diff --git a/.eslintrc.base.json b/.eslintrc.base.json index ccc0b847be..cc0dacb53d 100644 --- a/.eslintrc.base.json +++ b/.eslintrc.base.json @@ -8,8 +8,7 @@ "plugin:@typescript-eslint/recommended-requiring-type-checking", "plugin:import/errors", "plugin:import/warnings", - "plugin:import/typescript", - "prettier" + "plugin:import/typescript" ], "parser": "@typescript-eslint/parser", "parserOptions": { @@ -221,11 +220,14 @@ "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-parameter-properties": "off", + "@typescript-eslint/no-redundant-type-constituents": "off", "@typescript-eslint/no-this-alias": "off", "@typescript-eslint/no-unnecessary-condition": "off", "@typescript-eslint/no-unnecessary-type-assertion": "off", // TODO@eamodio revisit + "@typescript-eslint/no-unsafe-argument": "off", "@typescript-eslint/no-unsafe-assignment": "off", // TODO@eamodio revisit "@typescript-eslint/no-unsafe-call": "off", // TODO@eamodio revisit + "@typescript-eslint/no-unsafe-enum-comparison": "off", "@typescript-eslint/no-unsafe-member-access": "off", // TODO@eamodio revisit "@typescript-eslint/no-unsafe-return": "off", // TODO@eamodio revisit "@typescript-eslint/no-unused-expressions": ["warn", { "allowShortCircuit": true }], diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b685e95dcd..ab7b2028aa 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -5,11 +5,13 @@ about: Create a report to help us improve - + - Extension version: - VSCode Version: - OS: +- Repository Clone Configuration (single repository/fork of an upstream repository): +- Github Product (Github.com/Github Enterprise version x.x.x): Steps to Reproduce: diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..05e7278194 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run hygiene diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 88d17f1d7e..0000000000 --- a/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -.vscode-test -dist -**/@types/* - diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index f4654a36a2..0000000000 --- a/.prettierrc +++ /dev/null @@ -1,23 +0,0 @@ -{ - "arrowParens": "avoid", - "endOfLine": "lf", - "trailingComma": "all", - "printWidth": 120, - "singleQuote": true, - "tabWidth": 4, - "useTabs": true, - "overrides": [ - { - "files": ".prettierrc", - "options": { "parser": "json" } - }, - { - "files": "*.md", - "options": { "tabWidth": 2 } - }, - { - "files": "*.yml", - "options": { "tabWidth": 2 } - } - ] -} diff --git a/.readme/demo.gif b/.readme/demo.gif index 2e4c1f1ebc..7f127666a1 100644 Binary files a/.readme/demo.gif and b/.readme/demo.gif differ diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d0c9bfad48..93f54d0843 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,6 @@ { "recommendations": [ "dbaeumer.vscode-eslint", - "amodio.tsl-problem-matcher", - "esbenp.prettier-vscode" + "amodio.tsl-problem-matcher" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 5cfac4f123..1cae2e40e1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,12 +17,14 @@ "smartStep": true, "sourceMaps": true, "outFiles": [ - "${workspaceFolder}/dist/*.js" - ] + "${workspaceFolder}/dist/*.js", + "${workspaceFolder}" + ], + "debugWebviews": true }, { "name": "Launch Extension in Webworker", - "type": "pwa-extensionHost", + "type": "extensionHost", "request": "launch", "debugWebWorkerHost": true, "runtimeExecutable": "${execPath}", @@ -62,7 +64,7 @@ }, { "name": "Watch & Launch Extension in Webworker", - "type": "pwa-extensionHost", + "type": "extensionHost", "request": "launch", "debugWebWorkerHost": true, "runtimeExecutable": "${execPath}", @@ -83,24 +85,20 @@ }, { "name": "Extension Tests", - "type": "pwa-extensionHost", + "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", "args": [ "--extensionDevelopmentPath=${workspaceFolder}", "--extensionTestsPath=${workspaceFolder}/out/src/test", - "--disable-extension=GitHub.vscode-pull-request-github-insiders", - "--disable-extensions" + "--disable-extensions", ], "preLaunchTask": "npm: test:preprocess", "smartStep": true, - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/dist/test/*.js" - ] + "sourceMaps": true }, { - "type": "pwa-node", + "type": "node", "request": "launch", "name": "Attach Web Test", "program": "${workspaceFolder}/node_modules/vscode-test-web/out/index.js", @@ -118,7 +116,7 @@ } }, { - "type": "pwa-chrome", + "type": "chrome", "request": "launch", "name": "Launch Web Test", "skipFiles": [ diff --git a/.vscodeignore b/.vscodeignore index fb7da1db3a..4a58896050 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,4 +1,5 @@ .github +.husky .readme/** .vscode/** .vscode-test/** @@ -32,3 +33,4 @@ yarn.lock **/*.bak package.insiders.json README.insiders.md +tsfmt.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5acc31fce7..eb0859f3bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,536 @@ # Changelog +## 0.78.1 + +### Fixes + +- Files changed doesn't properly reflect changes against non base branch. https://github.com/microsoft/vscode-pull-request-github/issues/5545 +- Cannot review PRs with 0.78.0 / VSCode 1.85.0, "GraphQL error: Field 'mergeQueueEntry' doesn't exist. https://github.com/microsoft/vscode-pull-request-github/issues/5544 + +## 0.78.0 + +### Changes + +- Merge queues are now supported in the PR description and create view. + + ![Merge queues in PR description](/documentation/changelog/0.78.0/merge-queue.png) + +- The new setting `"githubPullRequests.allowFetch": false` will prevent `fetch` from being run. +- Projects are now cached for quicker assignment from the PR description. +- Merge commit message uses the message configured in the GitHub repository settings. +- Clicking on the filename of a comment in the PR description will open at the correct line. +- The repository name is shown in the "Changes in PR" view when there are PRs from multiple repositories shown in the view. + + ![Repository name in "Changes in PR" view](/documentation/changelog/0.78.0/repo-name-changes-view.png) + +### Fixes + +- Copy permalink uses wrong repository for submodules. https://github.com/microsoft/vscode-pull-request-github/issues/5181 +- Unable to select a repository when submodules are present. https://github.com/microsoft/vscode-pull-request-github/issues/3950. +- "We couldn't find commit" when submodules exist. https://github.com/microsoft/vscode-pull-request-github/issues/1499 +- Uses PR template from the wrong repo in multi-root workspace. https://github.com/microsoft/vscode-pull-request-github/issues/5489 +- At high contrast mode "Create with option" arrow button is not visible. https://github.com/microsoft/vscode-pull-request-github/issues/5480 +- Remove PR from "Waiting For My Review" list after I review it. https://github.com/microsoft/vscode-pull-request-github/issues/5379 + +**_Thank You_** + +* [@flpcury (Felipe Cury)](https://github.com/flpcury): Fix deprecation messages for createDraft and setAutoMerge [PR #5429](https://github.com/microsoft/vscode-pull-request-github/pull/5429) +* [@gjsjohnmurray (John Murray)](https://github.com/gjsjohnmurray): Treat `githubIssues.useBranchForIssues` setting description as markdown (fix #5506) [PR #5508](https://github.com/microsoft/vscode-pull-request-github/pull/5508) +* [@kurowski (Brandt Kurowski)](https://github.com/kurowski): add setting to never offer ignoring default branch pr [PR #5435](https://github.com/microsoft/vscode-pull-request-github/pull/5435) +* [@ThomsonTan (Tom Tan)](https://github.com/ThomsonTan): Iterate the diffs in each active PR in order [PR #5437](https://github.com/microsoft/vscode-pull-request-github/pull/5437) + +## 0.76.1 + +### Changes + +- Added telemetry for the acceptance rate of the generated PR title and description. + +## 0.76.0 + +### Changes + +- Integration with the GitHub Copilot Chat extension provides PR title and description generation. + + ![GitHub Copilot Chat integration](/documentation/changelog/0.76.0/github-copilot-title-description.gif) + +- "Project" can be set from the PR description webview. + + ![Project shown in PR description](/documentation/changelog/0.76.0/project-in-description.png) + +- Pull requests checked out using the GitHub CLI (`gh pr checkout`) are now recognized. +- The new `"none"` value for the setting `"githubPullRequests.pullRequestDescription"` will cause the title and description of the **Create** view to be empty by default. + +### Fixes + +- Could "Create a Pull Request" make fields within the create-pr view available faster?. https://github.com/microsoft/vscode-pull-request-github/issues/5399 +- Commits view is showing a commit with wrong author. https://github.com/microsoft/vscode-pull-request-github/issues/5352 +- Reviewer dropdown never hits cache. https://github.com/microsoft/vscode-pull-request-github/issues/5316 +- Settings option Pull Branch not honored. https://github.com/microsoft/vscode-pull-request-github/issues/5307 +- Comment locations error messages after deleting PR branch. https://github.com/microsoft/vscode-pull-request-github/issues/5281 + +## 0.74.1 + +### Fixes + +- Unable to Add Comments in PR on fork using GitHub Pull Requests Extension in VSCode. https://github.com/microsoft/vscode-pull-request-github/issues/5317 + +## 0.74.0 + +### Changes + +- Accessibility for reviewing PRs has been improved. See https://github.com/microsoft/vscode-pull-request-github/issues/5225 and https://github.com/microsoft/vscode/issues/192377 for a complete list of improvements. +- Commits are shown in the Create view even when the branch hasn't been published. +- The "Commits" node in the "Changes in Pull Request" tree now shows more than 30 commits. + +### Fixes + +- Using "Create an Issue" a 2nd time does not create a new issue, but a NewIssue.md with tons of numbers. https://github.com/microsoft/vscode-pull-request-github/issues/5253 +- Add +/- to added/deleted lines in PR description. https://github.com/microsoft/vscode-pull-request-github/issues/5224 +- Duplicate @mention suggestions. https://github.com/microsoft/vscode-pull-request-github/issues/5222 +- Don't require commit message for "Rebase and Merge". https://github.com/microsoft/vscode-pull-request-github/issues/5221 +- Focus in list of changes resets when opening file. https://github.com/microsoft/vscode-pull-request-github/issues/5173 + +**_Thank You_** + +* [@hsfzxjy (hsfzxjy)](https://github.com/hsfzxjy): Add a refresh button in the header of comment thread [PR #5229](https://github.com/microsoft/vscode-pull-request-github/pull/5229) + +## 0.72.0 + +### Changes + +- The pull request base in the "Create" view will use the upstream repo as the base if the current branch is a fork. +- There's a refresh button in the Comments view to immediately refresh comments. + +### Fixes + +- PR view comments should have a maximum width, with the code view using a horizontal scrollbar. https://github.com/microsoft/vscode-pull-request-github/issues/5155 +- Code suggestions in PRs are hard to differentiate. https://github.com/microsoft/vscode-pull-request-github/issues/5141 +- No way to remove Milestone. https://github.com/microsoft/vscode-pull-request-github/issues/5102 +- Progress feedback on PR description actions. https://github.com/microsoft/vscode-pull-request-github/issues/4954 + +**_Thank You_** + +* [@tobbbe (Tobbe)](https://github.com/tobbbe): Sanitize slashes from title [PR #5149](https://github.com/microsoft/vscode-pull-request-github/pull/5149) + +## 0.70.0 + +### Changes + +- The "Create" view has been updated to be less noisy and more useful. Aside from the purely visual changes, the following features have been added: + - We try to guess the best possible base branch for your PR instead of always using the default branch. + - You can add reviewers, assignees, labels, and milestones to your PR from the "Create" view. + - By default, your last "create option" will be remembered (ex. draft or auto merge) + - The view is much faster. + - You can view diffs before publishing your branch. + - Once the branch is published, you can also view commits (this is coming soon for unpublished branches). + + ![The new create view](/documentation/changelog/0.70.0/new-create-view.png) + +- If you work on a fork of a repository, but don't ever want to know about or make PRs to the parent, you can prevent the `upstream` remote from being added with the new setting `"githubPullRequests.upstreamRemote": "never"`. + +### Fixes + +- Quote reply missing for some comments. https://github.com/microsoft/vscode-pull-request-github/issues/5012 +- Accessibility of "suggest edits" new workflow and documentation. https://github.com/microsoft/vscode-pull-request-github/issues/4946 + +**_Thank You_** + +* [@mgyucht (Miles Yucht)](https://github.com/mgyucht): Correctly iterate backwards through diffs across files [PR #5036](https://github.com/microsoft/vscode-pull-request-github/pull/5036) + +## 0.68.1 + +### Fixes + +- Github Enterprise Doesn't Show Comments. https://github.com/microsoft/vscode-pull-request-github/issues/4995 +- Buffer is not defined when adding labels. https://github.com/microsoft/vscode-pull-request-github/issues/5009 + +## 0.68.0 + +### Changes + +- Avatars in tree views and comments are circles instead of squares + +![Circle avatar](/documentation/changelog/0.68.0/circle-avatar.png) + +- The old "Suggest Edit" command from the SCM view now directs you to "Suggest a Change" feature introduced in version 0.58.0. +- Up to 1000 (from the previous 100) comment threads can be loaded in a pull request. +- The new VS Code API proposal for a read-only message let's you check out a PR directly from an un-checked-out diff. + +![Read-only PR file message](/documentation/changelog/0.68.0/read-only-file-message.png) + +### Fixes + +- User hover shows null when writing the @username. https://github.com/microsoft/vscode-pull-request-github/issues/4891 +- Reverted PR remains visible in "Local Pull Request Branches" tab of sidebar. https://github.com/microsoft/vscode-pull-request-github/issues/4855 +- Order of workspaces in multi-root workspace is not what I expect. https://github.com/microsoft/vscode-pull-request-github/issues/4837 +- Reassigning same reviewers causes desync with GitHub. https://github.com/microsoft/vscode-pull-request-github/issues/4836 +- Re-request review from one reviewer will remove other reviewers. https://github.com/microsoft/vscode-pull-request-github/issues/4830 +- Don't reload entire DOM when getting data from GitHub. https://github.com/microsoft/vscode-pull-request-github/issues/4371 + +**_Thank You_** + +* [@SKPG-Tech (Salvijus K.)](https://github.com/SKPG-Tech): Fix null when no user name available [PR #4892](https://github.com/microsoft/vscode-pull-request-github/pull/4892) + +## 0.66.2 + +### Fixes + +- Use `supportHtml` for markdown that just cares about coloring spans for showing issue labels. [CVE-2023-36867](https://msrc.microsoft.com/update-guide/vulnerability/CVE-2023-36867) + +## 0.66.1 + +### Fixes + +- TypeError: Cannot read properties of undefined (reading 'number'). https://github.com/microsoft/vscode-pull-request-github/issues/4893 + +## 0.66.0 + +### Changes + +- We show the same welcome view as the git extension when you open a subfolder of a git repository. + +![Git subfolder welcome view](documentation/changelog/0.66.0/git-subfolder-welcome.png) + +- Improved performance of extension activation, particularly for multi-repo workspaces +- There are two new actions for viewing diffs of checked out PRs: **Compare Base With Pull Request Head (readonly)** and **Compare Pull Request Head with Local**. These actions are available from the PR changes context menu. + +![Compare changes with commands location](documentation/changelog/0.66.0/compare-changes-with-commands.png) + +- The new setting `"githubPullRequests.pullPullRequestBranchBeforeCheckout"` can be used to turn off pulling a previously checked out PR branch when checking out that same branch again. + +### Fixes + +- Bad/missing error handling when creating PR can lead to being rate limited. https://github.com/microsoft/vscode-pull-request-github/issues/4848 +- My vscode workspace sometimes shows a PR from vscode-cpptools. https://github.com/microsoft/vscode-pull-request-github/issues/4842 +- Improper `@mentions` in comments. https://github.com/microsoft/vscode-pull-request-github/issues/4810 +- Duplicated issues in tree. https://github.com/microsoft/vscode-pull-request-github/issues/4781 +- Element with id Local Pull Request Brancheshttps... is already registered. https://github.com/microsoft/vscode-pull-request-github/issues/4642 + +**_Thank You_** + +* [@kabel (Kevin Abel)](https://github.com/kabel): Simplify `AuthProvider` enum [PR #4779](https://github.com/microsoft/vscode-pull-request-github/pull/4779) +* [@SKPG-Tech (Salvijus K.)](https://github.com/SKPG-Tech): Add missing index in template [PR #4822](https://github.com/microsoft/vscode-pull-request-github/pull/4822) +* [@unknovvn (Andzej Korovacki)](https://github.com/unknovvn): Use git setting to fetch before checkout in checkoutExistingPullRequestBranch [PR #4759](https://github.com/microsoft/vscode-pull-request-github/pull/4759) + +## 0.64.0 + +### Changes + +- File level comments can be created from PR files. + +![File level comments](documentation/changelog/0.64.0/file-level-comments.gif) + +- We have an internal rate limit which should help prevent us from hitting GitHub's rate limit. +- All of the places where you can "Checkout default branch" respect the git setting `"git.pullBeforeCheckout"`. +- Team reviewers can be added as reviewers to PRs from the PR overview/description. Fetching team reviewers can be slow, so they are only fetched on demand and are then cached until you fetch them on demand again. + +![Show or refresh team reviewers button](documentation/changelog/0.64.0/get-team-reviewers.png) + +### Fixes + +- quickDiff setting is ignored. https://github.com/microsoft/vscode-pull-request-github/issues/4726 +- Overview shows closed instead of merged. https://github.com/microsoft/vscode-pull-request-github/issues/4721 +- 'Commit & Create Pull Request' automatically pushes when working on a PR. https://github.com/microsoft/vscode-pull-request-github/issues/4692 +- PRs for only one repo show in a multi root workspace. https://github.com/microsoft/vscode-pull-request-github/issues/4682 +- Publishing branch reset target branch to main. https://github.com/microsoft/vscode-pull-request-github/issues/4681 +- Old PR editors show error after revisiting. https://github.com/microsoft/vscode-pull-request-github/issues/4661 +- org in issue query causes crash. https://github.com/microsoft/vscode-pull-request-github/issues/4595 + +**_Thank You_** + +* [@Balastrong (Leonardo Montini)](https://github.com/Balastrong) + * Add x button to remove a label from a new PR [PR #4649](https://github.com/microsoft/vscode-pull-request-github/pull/4649) + * Change file mode for execute husky hook on MacOS [PR #4695](https://github.com/microsoft/vscode-pull-request-github/pull/4695) +* [@eastwood (Clinton Ryan)](https://github.com/eastwood): Gracefully handle errors where the SSH configuration file is corrupt or malformed [PR #4644](https://github.com/microsoft/vscode-pull-request-github/pull/4644) +* [@kabel (Kevin Abel)](https://github.com/kabel) + * Fix status checks rendering [PR #4542](https://github.com/microsoft/vscode-pull-request-github/pull/4542) + * Make the display of PR number in tree view configurable [PR #4576](https://github.com/microsoft/vscode-pull-request-github/pull/4576) + * Centralize all configuration strings into `settingKeys.ts` [PR #4577](https://github.com/microsoft/vscode-pull-request-github/pull/4577) + * Move `PullRequest` to a shared location for reviewing of types [PR #4578](https://github.com/microsoft/vscode-pull-request-github/pull/4578) +* [@ypresto (Yuya Tanaka)](https://github.com/ypresto): Fix wrong repo URL for nested repos in workspace (fix copy permalink) [PR #4711](https://github.com/microsoft/vscode-pull-request-github/pull/4711) + +## 0.62.0 + +### Changes + +- Pull requests can be opened on vscode.dev from the Pull Requests view. +- Collapse state is preserved in the Issues view. +- There's a new setting to check the "auto-merge" checkbox in the Create view: `githubPullRequests.setAutoMerge`. + +### Fixes + +- Cannot remove the last label. https://github.com/microsoft/vscode-pull-request-github/issues/4634 +- @type within code block rendering as link to GitHub user. https://github.com/microsoft/vscode-pull-request-github/issues/4611 + +**_Thank You_** + +* [@Balastrong (Leonardo Montini)](https://github.com/Balastrong) + * Allow empty labels array to be pushed to set-labels to remove all of them [PR #4637](https://github.com/microsoft/vscode-pull-request-github/pull/4637) + * Allow empty array to be pushed to remove the last label [PR #4648](https://github.com/microsoft/vscode-pull-request-github/pull/4648) + +## 0.60.0 + +### Changes + +- Permalinks are rendered better in both the comments widget and in the PR description. + +![Permalink in description](documentation/changelog/0.60.0/permalink-description.png) +![Permalink in comment widget](documentation/changelog/0.60.0/permalink-comment-widget.png) + +- The description has a button to re-request a review. + +![Re-request review](documentation/changelog/0.60.0/re-request-review.png) + +- Quick diffs are no longer experimental. You can turn on PR quick diffs with the setting `githubPullRequests.quickDiff`. + +![Pull request quick diff](documentation/changelog/0.60.0/quick-diff.png) + +- Extension logging log level is now controlled by the command "Developer: Set Log Level". The old setting for log level has been deprecated. + +### Fixes + +- Make a suggestion sometimes only works once. https://github.com/microsoft/vscode-pull-request-github/issues/4470 + +**_Thank You_** + +* [@joshuaobrien](https://github.com/joshuaobrien) + * Unify style of re-request review button [PR #4539](https://github.com/microsoft/vscode-pull-request-github/pull/4539) + * Ensure `re-request-review` command is handled in activityBarViewProvider [PR #4540](https://github.com/microsoft/vscode-pull-request-github/pull/4540) + * Prevent timestamp in comments overflowing [PR #4541](https://github.com/microsoft/vscode-pull-request-github/pull/4541) +* [@kabel (Kevin Abel)](https://github.com/kabel): Ignore more files from the vsix [PR #4530](https://github.com/microsoft/vscode-pull-request-github/pull/4530) + +## 0.58.2 + +### Fixes + +- "GitHub Pull Requests and Issues" plugin causing a large number of requests to github enterprise installation. https://github.com/microsoft/vscode-pull-request-github/issues/4523 + +## 0.58.1 + +### Fixes + +- Replacing a label with another appears to work in vscode but doesn't. https://github.com/microsoft/vscode-pull-request-github/issues/4492 + +## 0.58.0 + +### Changes + +- Changes can be suggested and accepted from within editor comments + +![Suggest a Change](documentation/changelog/0.58.0/suggest-a-change.gif) + +- The setting `githubPullRequests.defaultCommentType` controls whether the default comment type is a single comment or a review comment. +- `"githubPullRequests.postCreate": "checkoutDefaultBranch"` will cause the default branch to be checked out after creating a PR. +- Section headings (assignees, reviewers, lables, and milestones) are clickable in the PR overview. +- The commands pr.openModifiedFile pr.openDiffView can be executed with a keyboard shortcut on the active file. +- GitHub handles in comments are now linkified. +- Setting `"githubPullRequests.createDraft": true` will make created PRs default to drafts. +- Permalinks can be created for non-text, rendered, files. +- Labels can be added to PRs at creation time + +![Create a PR with labels](documentation/changelog/0.58.0/create-with-labels.png) + +- A progress notification shows during PR creation. +- Branches and remotes for PRs that are made from a fork and are checked out from the "Pull Requests" view will be automatically cleaned up when the default branch is checked out using the "Checkout default branch" button. +- An experimental setting `githubPullRequests.experimental.quickDiff` will show the quick diff widget in the editor gutter for changed lines in a checked out PR. + +### Fixes + +- Using the enter key while renaming a PR should save the title. https://github.com/microsoft/vscode-pull-request-github/issues/4402 +- JSDoc hover for @return shows GHPRI username hover. https://github.com/microsoft/vscode-pull-request-github/issues/4344 +- Some text is not visible in high contrast mode. https://github.com/microsoft/vscode-pull-request-github/issues/4287 +- Empty diff view after reloading. https://github.com/microsoft/vscode-pull-request-github/issues/4293 +- Error signing in to Github. Try Again doesn't try again. https://github.com/microsoft/vscode-pull-request-github/issues/4148 +- Other accessibility fixes. https://github.com/microsoft/vscode-pull-request-github/issues/4237 + +**_Thank You_** + +* [@eamodio (Eric Amodio)](https://github.com/eamodio): Updates TypeScript (released 4.2) and Octokit (to get fixed types), and a couple minor others [PR #2525](https://github.com/microsoft/vscode-pull-request-github/pull/2525) +* [@sravan1946 (sravan)](https://github.com/sravan1946): Remove unavailable badge from readme [PR #4393](https://github.com/microsoft/vscode-pull-request-github/pull/4393) +* [@Thomas1664](https://github.com/Thomas1664) + * Fix comment layout & use bin as delete icon [PR #4285](https://github.com/microsoft/vscode-pull-request-github/pull/4285) + * Colorize status badge [PR #4286](https://github.com/microsoft/vscode-pull-request-github/pull/4286) + * UI fixes for PR view [PR #4368](https://github.com/microsoft/vscode-pull-request-github/pull/4368) + * Use correct permission to show 'assign yourself' in PR view sidebar [PR #4369](https://github.com/microsoft/vscode-pull-request-github/pull/4369) + * Fix UI for PR draft status check entry [PR #4370](https://github.com/microsoft/vscode-pull-request-github/pull/4370) + + +## 0.56.0 + +### Changes + +- Most recent PR is selected when a branch has multiple PRs. +- Notebooks support for the permalink commands. +- Review status is shown in the PRs view. + +![Pull Requests view with status](documentation/changelog/0.56.0/pr-status-in-list.png) + +- PR links to vscode.dev can be copied from the Pull Request description page. + +![Copy vscode.dev link button](documentation/changelog/0.56.0/copy-vscode-dev-link.png) + +- The new "Go To Next Diff in Pull Request" command will navigate to the next diff in the pull request across files. +- The "Resolve" and "Unresolve" buttons are now always visible on comments, instead of only showing when the reply is expanded. + +![Always visible resolve button](documentation/changelog/0.56.0/visible-resolve-button.png) + +### Fixes + +- Still getting auto-fetching behavior when setting is off. https://github.com/microsoft/vscode-pull-request-github/issues/4202 + +**_Thank You_** + +* [@joshuaobrien (Joshua O'Brien)](https://github.com/joshuaobrien): Narrow types in TimelineEvent so that it may be treated as a tagged union [PR #4160](https://github.com/microsoft/vscode-pull-request-github/pull/4160) + +## 0.54.1 + +### Fixes + +- No Longer Prompted To Create PR after Pushing Feature Branch. https://github.com/microsoft/vscode-pull-request-github/issues/4171 + +## 0.54.0 + +### Changes + +- Pull Requests can be submitted from the "Create" view by doing `ctrl/cmd+enter` while your cursor is in the description input box. +- Keybindings are supported for "Mark File as Viewed" (`pr.markFileAsViewed`). When "Mark File as Viewed" is run from a command or from the editor toolbar the file will also be closed. Tip: Use with "Open All Diffs" for quickly going through a PR review. +- Checked-out pull requests with less than 20 files will have all the diffs pre-fetched for faster diff-opening times. +- Strings in VS Code UI have been configured for localization. Strings in webviews (such as the "Create" view and the PR description/overview) are still not localized. + +### Fixes + +- User completion in commit box is wrong when manually triggered. https://github.com/microsoft/vscode-pull-request-github/issues/4026 +- Extension periodically refreshes the file under review, resetting the view position. https://github.com/microsoft/vscode-pull-request-github/issues/4031 +- Does the GHPRI extension need to be * activated? https://github.com/microsoft/vscode-pull-request-github/issues/4046 +- "viewed" checkboxes don't always propagate. https://github.com/microsoft/vscode-pull-request-github/issues/3959 +- Block comments not rendering correctly. https://github.com/microsoft/vscode-pull-request-github/issues/4013 +- Can't Create an Issue without body. https://github.com/microsoft/vscode-pull-request-github/issues/4027 + +**_Thank You_** + +* [@hoontae24](https://github.com/hoontae24): feat: Add origin of upstream for github enterprise on copy head link [PR #4028](https://github.com/microsoft/vscode-pull-request-github/pull/4028) +* [@Thomas1664](https://github.com/Thomas1664): UI fixes for checks section [PR #4059](https://github.com/microsoft/vscode-pull-request-github/pull/4059) +* [@yin1999 (A1lo)](https://github.com/yin1999): fix: use ssh url for ssh protocol upstream [PR #3853](https://github.com/microsoft/vscode-pull-request-github/pull/3853) + +## 0.52.0 + +### Changes + +- Improved support for GitHub Enterprise starting with GitHub Enterprise version 3.1. This includes: + - PAT-less authentication courtesy of the VS Code built in GitHub Enterprise authentication provider. + - Automatic detection when you open a folder with an Enterprise repo and an on-ramp to get set up. + - Fixes for GitHub Enterprise bugs. _Note:_ If you find any issues with GitHub Enterprise please do file an issue! +- Checkboxes to mark files as viewed. This means you can mark whole folders as viewed now. +![Checkboxes to mark as viewed](documentation/changelog/0.52.0/tree-item-checkbox-state.png) +- When you use the "Checkout 'default branch'" button, the pull request overview and all associated diffs will close. +- Issues referenced by `#` in pull request titles are linked to the pull request. + +### Fixes + +- Multi-root workspaces with two projects checked out to branches with open PRs either shows errors or misleading information. https://github.com/microsoft/vscode-pull-request-github/issues/3490 +- Draft PR checkbox reverts to unchecked after typing description. https://github.com/microsoft/vscode-pull-request-github/issues/3977 + +**_Thank you_** + +* [@Thomas1664](https://github.com/Thomas1664): Add button to always pull on incoming changes [PR #3896](https://github.com/microsoft/vscode-pull-request-github/pull/3896) + +## 0.50.0 + +### Changes + +- By setting the `githubPullRequests.notifications` setting to `pullRequests` Pull Requests which have unread notifications will be highlighted. + + ![GitHub Notifications](documentation/changelog/0.50.0/githubNotifications.gif) + +- GitHub labels will render with the GitHub colors + + ![GitHub Label Colors](documentation/changelog/0.50.0/labelColors.png) + +- Review Comments can now be directly resolved/unresolved in the Pull Request Overview +- Creating an issue should never lose data. If the "new issue" editor is closed but the issue is not created, the data will be stored until VS Code is reloaded. +- When the local branch is out of date, a prompt to pull the branch will show when the "Refresh" button on the PR overview is clicked. The setting `githubPullRequests.pullBranch` also has a new `always` option. +- Renamed files have a tooltip that makes the rename clearer. + + ![Renamed file tooltip](documentation/changelog/0.50.0/renamed-tooltip.png) + +- The command "Reset Viewed Files" will reset all files to be unviewed. + +### Fixes + +- Fails to load Pull Requests on older GitHub Enterprise verisons https://github.com/microsoft/vscode-pull-request-github/issues/3829 +- Copy GitHub Permalink in LHS of a PR diff generates a link to the RHS. https://github.com/microsoft/vscode-pull-request-github/issues/3801 +- User and issue suggestions don't always show for some languages. https://github.com/microsoft/vscode-pull-request-github/issues/3874 +- Comment rendering distorted / missing linebreaks. https://github.com/microsoft/vscode-pull-request-github/issues/3776 + +## 0.48.0 + +### Changes + +- The changes since last review button will appear on PRs to which a commit has been pushed since the viewers review. Pressing it will only show the diffs for the commits since the review. + + ![Changes since last review](documentation/changelog/0.48.0/changesSinceReview.gif) + +- Milestones can be created directly from the add milestone dropdown. +- The setting `githubPullRequests.pullRequestDescription` has been brought back from being deprecated. +- `githubPullRequests.pullBranch` can be used to configure whether to be prompted to pull changes when a change in a PR is detected. +- The new **Commit & Create Pull Request** action in the git SCM view let's you commit and go to the "Create PR" view in one click. + + ![Commit and Create Pull Request action](documentation/changelog/0.48.0/commit-and-create-pr.png) + +### Fixes + +- Improve performance of expanding a PR in the "Pull Requests" view. https://github.com/microsoft/vscode-pull-request-github/issues/3684 +- Performance: Delay in showing Assignee quick open. https://github.com/microsoft/vscode-pull-request-github/issues/3728 +- Apply patch feature bugs. https://github.com/microsoft/vscode-pull-request-github/issues/3722 +- Show a notification when there are 2 signed in GitHub accounts. https://github.com/microsoft/vscode-pull-request-github/issues/3693 +- Only first 30 files per PR show in the "Pull Requests" view on vscode.dev. https://github.com/microsoft/vscode-pull-request-github/issues/3682 +- "Upgrade" Pull request diffs opened from the "Pull Requests" view after the PR is checked out. https://github.com/microsoft/vscode-pull-request-github/issues/3631 + +## 0.46.0 + +### Changes + +- Use the setting `"githubPullRequests.ignoredPullRequestBranches"` to ignore branches for pull requests. +- The setting `"githubPullRequests.overrideDefaultBranch"` lets you override the default branch from github.com locally. +- The "Publish branch?" dialog can be skipped when creating a PR using the setting `"githubPullRequests.pushBranch"`. +- The auto-merge checkbox is availabe in the "Overview" editor. + + ![Auto-merge in the overview](documentation/changelog/0.46.0/automerge-overview.png) + +### Fixes + +- "Exit Review Mode" changed to "Checkout default Branch". https://github.com/microsoft/vscode-pull-request-github/issues/3637 +- Comments showed when opening a PR despite having "comments.openView": "never" set. https://github.com/microsoft/vscode-pull-request-github/issues/3652 +- Can't comment on a new file (via github.dev web editor). https://github.com/microsoft/vscode-pull-request-github/issues/3646 +- Cannot view more than 100 and few files in PR. https://github.com/microsoft/vscode-pull-request-github/issues/3623 +- The Copy GitHub Permalink command copies wrong commit hash. https://github.com/microsoft/vscode-pull-request-github/issues/3566 + +**_Thank You_** + +* [@blindpirate (Bo Zhang)](https://github.com/blindpirate): Show pull request's close button for author [PR #3507](https://github.com/microsoft/vscode-pull-request-github/pull/3507) +* [@leopoldsedev (Christian Leopoldseder)](https://github.com/leopoldsedev): Implement quick self assign link as available on .com (#3382) [PR #3601](https://github.com/microsoft/vscode-pull-request-github/pull/3601) + + +## 0.44.0 + +### Changes + +- Auto-merge support from the "Create" view. + + ![Auto-merge from the create view](documentation/changelog/0.44.0/auto-merge-create-view.png) + +### Fixes + +- Creating a pull request doesn't use commit message for PR description when the base branch has more commits. https://github.com/microsoft/vscode-pull-request-github/issues/3350 +- Fails to activate with "Timed out waiting for authentication provider to register". https://github.com/microsoft/vscode-pull-request-github/issues/3469 +- Prompted about updates to PR after pushing to the PR branch. https://github.com/microsoft/vscode-pull-request-github/issues/3479 +- Pull requests created from a fork on a topic branch aren't discovered. https://github.com/microsoft/vscode-pull-request-github/issues/3511 +- Unable to create PR in web. https://github.com/microsoft/vscode-pull-request-github/issues/3528 + +**_Thank You_** + +* [@jpspringall](https://github.com/jpspringall): Issue #3371 | Updated getAuthSessionOptions in case of GitHub Enterprise AuthProvider [PR #3565](https://github.com/microsoft/vscode-pull-request-github/pull/3565) + ## 0.42.0 ### Changes diff --git a/README.md b/README.md index 8d8d36583f..a30bc22998 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -[![Build Status](https://rebornix.visualstudio.com/Pull%20Request/_apis/build/status/Pull%20Request%20Build?branchName=main)](https://rebornix.visualstudio.com/Pull%20Request/_build/latest?definitionId=5&branchName=main) +[![Build Status](https://dev.azure.com/vscode/vscode-pull-request-github/_apis/build/status/vscode-pull-request-github%20%28pr%29?branchName=main)](https://dev.azure.com/vscode/vscode-pull-request-github/_build?definitionId=44&branchName=main) > Review and manage your GitHub pull requests and issues directly in VS Code This extension allows you to review and manage GitHub pull requests and issues in Visual Studio Code. The support includes: -- Authenticating and connecting VS Code to GitHub. GitHub Enterprise is supported by the community, please see this [PR](https://github.com/microsoft/vscode/pull/115940) for how to set it up. +- Authenticating and connecting VS Code to GitHub and GitHub Enterprise. - Listing and browsing PRs from within VS Code. - Reviewing PRs from within VS Code with in-editor commenting. - Validating PRs from within VS Code with easy checkouts. @@ -30,6 +30,8 @@ It's easy to get started with GitHub Pull Requests for Visual Studio Code. Simpl 1. You may need to configure the `githubPullRequests.remotes` setting, by default the extension will look for PRs for `origin` and `upstream`. If you have different remotes, add them to the remotes list. 1. You should be good to go! +Check out https://www.youtube.com/watch?v=LdSwWxVzUpo for additional getting started tips! + # Configuring the extension There are several settings that can be used to configure the extension. @@ -75,8 +77,6 @@ See our [wiki](https://github.com/Microsoft/vscode-pull-request-github/wiki) for ## Contributing -[![Total alerts](https://img.shields.io/lgtm/alerts/g/Microsoft/vscode-pull-request-github.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/Microsoft/vscode-pull-request-github/alerts/) - If you're interested in contributing, or want to explore the source code of this extension yourself, see our [contributing guide](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing), which includes: - [How to Build and Run](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#build-and-run) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..f7b89984f0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ + + +## Security + +Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). + +If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). + +If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). + +You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). + +Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: + + * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) + * Full paths of source file(s) related to the manifestation of the issue + * The location of the affected source code (tag/branch/commit or direct URL) + * Any special configuration required to reproduce the issue + * Step-by-step instructions to reproduce the issue + * Proof-of-concept or exploit code (if possible) + * Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. + +## Preferred Languages + +We prefer all communications to be in English. + +## Policy + +Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). + + \ No newline at end of file diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt deleted file mode 100644 index 27b8255064..0000000000 --- a/ThirdPartyNotices.txt +++ /dev/null @@ -1,2261 +0,0 @@ -VS Code Extension for Managing GitHub Pull Requests - -NOTICES AND INFORMATION -Do Not Translate or Localize - -This software incorporates material from third parties. -Microsoft makes certain open source code available at https://3rdpartysource.microsoft.com, -or you may send a check or money order for US $5.00, including the product name, -the open source component name, platform, and version number, to: - -Source Code Compliance Team -Microsoft Corporation -One Microsoft Way -Redmond, WA 98052 -USA - -Notwithstanding any other terms, you may reverse engineer this software to the extent -required to debug changes to any libraries licensed under the GNU Lesser General Public License. - ---------------------------------------------------------- - -tslib 1.14.1 - 0BSD -https://www.typescriptlang.org/ - -Copyright (c) Microsoft Corporation. - -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -before-after-hook 2.2.0 - Apache-2.0 -https://github.com/gr2m/before-after-hook#readme - -Copyright 2018 Gregor Martynus and other contributors. - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2018 Gregor Martynus and other contributors. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -webidl-conversions 3.0.1 - BSD-2-Clause -https://github.com/jsdom/webidl-conversions#readme - -Copyright (c) 2014, Domenic Denicola - -# The BSD 2-Clause License - -Copyright (c) 2014, Domenic Denicola -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -deprecation 2.3.1 - ISC -https://github.com/gr2m/deprecation#readme - -Copyright (c) Gregor Martynus and contributors - -The ISC License - -Copyright (c) Gregor Martynus and contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -lru-cache 6.0.0 - ISC -https://github.com/isaacs/node-lru-cache#readme - -Copyright (c) Isaac Z. Schlueter and Contributors - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -once 1.4.0 - ISC -https://github.com/isaacs/once#readme - -Copyright (c) Isaac Z. Schlueter and Contributors - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -universal-user-agent 6.0.0 - ISC -https://github.com/gr2m/universal-user-agent#readme - -Copyright (c) 2018, Gregor Martynus (https://github.com/gr2m) - -# [ISC License](https://spdx.org/licenses/ISC) - -Copyright (c) 2018, Gregor Martynus (https://github.com/gr2m) - -Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -wrappy 1.0.2 - ISC -https://github.com/npm/wrappy - -Copyright (c) Isaac Z. Schlueter and Contributors - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -yallist 4.0.0 - ISC -https://github.com/isaacs/yallist#readme - -Copyright (c) Isaac Z. Schlueter and Contributors - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/auth-token 2.4.5 - MIT -https://github.com/octokit/auth-token.js#readme - -Copyright (c) 2019 Octokit - -The MIT License - -Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/core 3.3.1 - MIT -https://github.com/octokit/core.js#readme - -Copyright (c) 2019 Octokit - -The MIT License - -Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/endpoint 6.0.11 - MIT -https://github.com/octokit/endpoint.js#readme - -Copyright (c) 2018 Octokit -Copyright (c) 2012-2014, Bram Stein - -The MIT License - -Copyright (c) 2018 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/graphql 4.6.1 - MIT -https://github.com/octokit/graphql.js#readme - -Copyright (c) 2018 Octokit - -The MIT License - -Copyright (c) 2018 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/openapi-types 5.3.2 - MIT -https://github.com/octokit/openapi-types.ts#readme - - -Copyright 2020 Gregor Martynus - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/plugin-paginate-rest 2.13.2 - MIT -https://github.com/octokit/plugin-paginate-rest.js#readme - -Copyright (c) 2019 Octokit - -MIT License Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/plugin-request-log 1.0.3 - MIT -https://github.com/octokit/plugin-request-log.js#readme - - -MIT License Copyright (c) 2020 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/plugin-rest-endpoint-methods 4.12.0 - MIT -https://github.com/octokit/plugin-rest-endpoint-methods.js#readme - -Copyright (c) 2019 Octokit - -MIT License Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/request 5.4.14 - MIT -https://github.com/octokit/request.js#readme - -Copyright (c) 2018 Octokit - -The MIT License - -Copyright (c) 2018 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/request-error 2.0.5 - MIT -https://github.com/octokit/request-error.js#readme - -Copyright (c) 2019 Octokit - -The MIT License - -Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/rest 18.2.0 - MIT -https://github.com/octokit/rest.js#readme - -Copyright (c) 2017-2018 Octokit -Copyright (c) 2012 Cloud9 IDE, Inc. - -The MIT License - -Copyright (c) 2012 Cloud9 IDE, Inc. (Mike de Boer) -Copyright (c) 2017-2018 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/types 6.10.0 - MIT -https://github.com/octokit/types.ts#readme - -Copyright (c) 2019 Octokit - -MIT License Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@octokit/types 6.12.2 - MIT -https://github.com/octokit/types.ts#readme - - -MIT License Copyright (c) 2019 Octokit contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@types/node 14.14.35 - MIT - - -Copyright (c) Microsoft Corporation. - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -@types/zen-observable 0.8.2 - MIT - - -Copyright (c) Microsoft Corporation. - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -@wry/context 0.4.4 - MIT -https://github.com/benjamn/wryware - -Copyright (c) 2019 Ben Newman - -MIT License - -Copyright (c) 2019 Ben Newman - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -@wry/equality 0.1.11 - MIT -https://github.com/benjamn/wryware - -Copyright (c) 2019 Ben Newman - -MIT License - -Copyright (c) 2019 Ben Newman - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-boost 0.4.9 - MIT -https://github.com/apollographql/apollo-client#readme - -Copyright (c) 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-cache 1.3.5 - MIT -https://github.com/apollographql/apollo-client#readme - -Copyright (c) 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-cache-inmemory 1.6.6 - MIT -https://github.com/apollographql/apollo-client#readme - -Copyright (c) 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-client 2.6.10 - MIT -https://github.com/apollographql/apollo-client#readme - -Copyright (c) 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-link 1.2.14 - MIT -https://github.com/apollographql/apollo-link#readme - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-link-context 1.0.20 - MIT -https://github.com/apollographql/apollo-link#readme - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-link-error 1.1.13 - MIT -https://github.com/apollographql/apollo-link#readme - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-link-http 1.5.17 - MIT -https://github.com/apollographql/apollo-link#readme - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-link-http-common 0.2.16 - MIT -https://github.com/apollographql/apollo-link#readme - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2016 - 2017 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -apollo-utilities 1.3.4 - MIT -https://github.com/apollographql/apollo-client#readme - -Copyright (c) 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -axios 0.21.4 - MIT -https://axios-http.com/ - - -Copyright (c) 2014-present Matt Zabriskie - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -cross-fetch 3.1.5 - MIT -https://github.com/lquixada/cross-fetch - -Copyright (c) 2017 Leonardo Quixada -(c) Leonardo Quixada (https://twitter.com/lquixada/) -Copyright (c) 2010 Thomas Fuchs (http://script.aculo.us/thomas) - -The MIT License (MIT) - -Copyright (c) 2017 Leonardo Quixadá - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -dayjs 1.10.4 - MIT -https://day.js.org/ - -Copyright (c) 2018-present - -MIT License - -Copyright (c) 2018-present, iamkun - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -events 3.2.0 - MIT -https://github.com/Gozala/events#readme - -Copyright Joyent, Inc. and other Node contributors. - -MIT - -Copyright Joyent, Inc. and other Node contributors. - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to permit -persons to whom the Software is furnished to do so, subject to the -following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN -NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -fast-deep-equal 3.1.3 - MIT -https://github.com/epoberezkin/fast-deep-equal#readme - -Copyright (c) 2017 Evgeny Poberezkin - -MIT License - -Copyright (c) 2017 Evgeny Poberezkin - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -fast-json-stable-stringify 2.1.0 - MIT -https://github.com/epoberezkin/fast-json-stable-stringify - -Copyright (c) 2013 James Halliday -Copyright (c) 2017 Evgeny Poberezkin - -This software is released under the MIT license: - -Copyright (c) 2017 Evgeny Poberezkin -Copyright (c) 2013 James Halliday - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -follow-redirects 1.14.8 - MIT -https://github.com/follow-redirects/follow-redirects - -Copyright 2014-present Olivier Lalonde , James Talmage , Ruben Verborgh - -Copyright 2014–present Olivier Lalonde , James Talmage , Ruben Verborgh - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -graphql-tag 2.11.0 - MIT -https://github.com/apollographql/graphql-tag#readme - - -The MIT License (MIT) - -Copyright (c) 2020 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -is-plain-object 5.0.0 - MIT -https://github.com/jonschlinkert/is-plain-object - -Copyright (c) 2014-2017, Jon Schlinkert. -Copyright (c) 2019, Jon Schlinkert (https://github.com/jonschlinkert). - -The MIT License (MIT) - -Copyright (c) 2014-2017, Jon Schlinkert. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -js-tokens 4.0.0 - MIT -https://github.com/lydell/js-tokens#readme - -Copyright 2014, 2015, 2016, 2017, 2018 Simon Lydell -Copyright (c) 2014, 2015, 2016, 2017, 2018 Simon Lydell - -The MIT License (MIT) - -Copyright (c) 2014, 2015, 2016, 2017, 2018 Simon Lydell - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -loose-envify 1.4.0 - MIT -https://github.com/zertosh/loose-envify - -Copyright (c) 2015 Andres Suarez - -The MIT License (MIT) - -Copyright (c) 2015 Andres Suarez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -marked 4.0.10 - MIT -https://marked.js.org/ - -Copyright (c) 2011-2013, Christopher Jeffrey -Copyright (c) 2011-2014, Christopher Jeffrey -Copyright (c) 2011-2018, Christopher Jeffrey. -Copyright (c) 2004, John Gruber http://daringfireball.net -Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) -Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) - -# License information - -## Contribution License Agreement - -If you contribute code to this project, you are implicitly allowing your code -to be distributed under the MIT license. You are also implicitly verifying that -all code is your original work. `` - -## Marked - -Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) -Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -## Markdown - -Copyright © 2004, John Gruber -http://daringfireball.net/ -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. - -This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -node-fetch 2.6.7 - MIT -https://github.com/bitinn/node-fetch - -Copyright (c) 2016 David Frank - -The MIT License (MIT) - -Copyright (c) 2016 David Frank - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -object-assign 4.1.1 - MIT -https://github.com/sindresorhus/object-assign#readme - -(c) Sindre Sorhus -(c) Sindre Sorhus (https://sindresorhus.com) -Copyright (c) Sindre Sorhus (sindresorhus.com) - -The MIT License (MIT) - -Copyright (c) Sindre Sorhus (sindresorhus.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -optimism 0.10.3 - MIT -https://github.com/benjamn/optimism#readme - -Copyright (c) 2016 Ben Newman - -MIT License - -Copyright (c) 2016 Ben Newman - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -prop-types 15.7.2 - MIT -https://facebook.github.io/react/ - -(c) Sindre Sorhus -Copyright (c) 2013-present, Facebook, Inc. -Copyright (c) Facebook, Inc. and its affiliates. - -MIT License - -Copyright (c) 2013-present, Facebook, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -react 16.14.0 - MIT -https://reactjs.org/ - -(c) Sindre Sorhus -Copyright (c) 2013-present, Facebook, Inc. -Copyright (c) Facebook, Inc. and its affiliates. - -MIT License - -Copyright (c) Facebook, Inc. and its affiliates. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -react-dom 16.14.0 - MIT -https://reactjs.org/ - -Copyright (c) 2013-present, Facebook, Inc. -Copyright (c) Facebook, Inc. and its affiliates. - -MIT License - -Copyright (c) Facebook, Inc. and its affiliates. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -react-is 16.13.1 - MIT -https://reactjs.org/ - -Copyright (c) Facebook, Inc. and its affiliates. - -MIT License - -Copyright (c) Facebook, Inc. and its affiliates. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -scheduler 0.19.1 - MIT -https://reactjs.org/ - -Copyright (c) Facebook, Inc. and its affiliates. - -MIT License - -Copyright (c) Facebook, Inc. and its affiliates. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -ssh-config 2.0.0 - MIT -https://github.com/cyjake/ssh-config#readme - -Copyright (c) 2017 Chen Yangjian - -MIT License - -Copyright (c) 2017 Chen Yangjian - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -symbol-observable 1.2.0 - MIT -https://github.com/blesh/symbol-observable#readme - -Copyright (c) Ben Lesh -Copyright (c) Sindre Sorhus (sindresorhus.com) - -The MIT License (MIT) - -Copyright (c) Sindre Sorhus (sindresorhus.com) -Copyright (c) Ben Lesh - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -tas-client 0.1.16 - MIT - - -Copyright (c) Microsoft Corporation. - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -tr46 0.0.3 - MIT -https://github.com/Sebmaster/tr46.js#readme - - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -ts-invariant 0.4.4 - MIT -https://github.com/apollographql/invariant-packages - -Copyright (c) 2019 Apollo GraphQL - -MIT License - -Copyright (c) 2019 Apollo GraphQL - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -tunnel 0.0.6 - MIT -https://github.com/koichik/node-tunnel/ - -Copyright (c) 2012 Koichi Kobayashi - -The MIT License (MIT) - -Copyright (c) 2012 Koichi Kobayashi - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -uuid 8.3.2 - MIT -https://github.com/uuidjs/uuid#readme - -Copyright 2011, Sebastian Tschan https://blueimp.net -Copyright (c) Paul Johnston 1999 - 2009 Other contributors Greg Holt, Andrew Kepert, Ydnar, Lostinet - -The MIT License (MIT) - -Copyright (c) 2010-2020 Robert Kieffer and other contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -vscode-tas-client 0.1.17 - MIT - - -Copyright (c) Microsoft Corporation. - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -whatwg-url 5.0.0 - MIT -https://github.com/jsdom/whatwg-url#readme - -(c) extraPathPercentEncodeSet.has -Copyright (c) 2015-2016 Sebastian Mayr - -The MIT License (MIT) - -Copyright (c) 2015–2016 Sebastian Mayr - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -zen-observable 0.8.15 - MIT -https://github.com/zenparsing/zen-observable - -Copyright (c) 2018 - -Copyright (c) 2018 zenparsing (Kevin Smith) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -zen-observable-ts 0.8.21 - MIT -https://github.com/zenparsing/zen-observable - -Copyright (c) 2018 -Copyright (c) 2016 - 2018 Meteor Development Group, Inc. - -The MIT License (MIT) - -Copyright (c) 2018 zenparsing (Kevin Smith) -Copyright (c) 2016 - 2018 Meteor Development Group, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -vsls 0.3.1291 -https://aka.ms/vsls - -Copyright (c) Microsoft Corporation. - -MICROSOFT PRE-RELEASE SOFTWARE LICENSE TERMS - -MICROSOFT VISUAL STUDIO LIVE SHARE SOFTWARE - -These license terms are an agreement between Microsoft Corporation (or based on where you live, one of its affiliates) and you. They apply to the pre-release software named above. The terms also apply to any Microsoft services or updates for the software, except to the extent those have additional terms. - -IF YOU COMPLY WITH THESE LICENSE TERMS, YOU HAVE THE RIGHTS BELOW. - -1. INSTALLATION AND USE RIGHTS. You may install and use any number of copies of the software to evaluate it as you develop and test your software applications. You may use the software only with Microsoft Visual Studio or Visual Studio Code. The software works in tandem with an associated preview release service, as described below. - -2. PRE-RELEASE SOFTWARE. The software is a pre-release version. It may not work the way a final version of the software will. Microsoft may change it for the final, commercial version. We also may not release a commercial version. Microsoft is not obligated to provide maintenance, technical support or updates to you for the software. - -3. ASSOCIATED ONLINE SERVICES. - - a. Microsoft Azure Services. Some features of the software provide access to, or rely on, Azure online services, including an associated Azure online service to the software, Visual Studio Live Share (the “corresponding service”). The use of those services (but not the software) is governed by the separate terms and privacy policies in the agreement under which you obtained the Azure services at https://go.microsoft.com/fwLink/p/?LinkID=233178 (and, with respect to the corresponding service, the additional terms below). Please read them. The services may not be available in all regions. - - b. Limited Availability. The corresponding service is currently in “Preview,” and therefore, we may change or discontinue the corresponding service at any time without notice. Any changes or updates to the corresponding service may cause the software to stop working and may result in the deletion of any data stored on the corresponding service. You may not receive notice prior to these updates. - -4. Licenses for other components. The software may include third party components with separate legal notices or governed by other agreements, as described in the ThirdPartyNotices file accompanying the software. Even if such components are governed by other agreements, the disclaimers and the limitations on and exclusions of damages below also apply. - -5. DATA. - - a. Data Collection. The software may collect information about you and your use of the software, and send that to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may opt out of many of these scenarios, but not all, as described in the product documentation. In using the software, you must comply with applicable law. You can learn more about data collection and use in the help documentation and the privacy statement at http://go.microsoft.com/fwlink/?LinkId=398505. Your use of the software operates as your consent to these practices. - - b. Processing of Personal Data. To the extent Microsoft is a processor or subprocessor of personal data in connection with the software, Microsoft makes the commitments in the European Union General Data Protection Regulation Terms of the Online Services Terms to all customers effective May 25, 2018, at http://go.microsoft.com/?linkid=9840733. - -6. FEEDBACK. If you give feedback about the software to Microsoft, you give to Microsoft, without charge, the right to use, share and commercialize your feedback in any way and for any purpose. You will not give feedback that is subject to a license that requires Microsoft to license its software or documentation to third parties because we include your feedback in them. These rights survive this agreement. - -7. SCOPE OF LICENSE. The software is licensed, not sold. This agreement only gives you some rights to use the software. Microsoft reserves all other rights. Unless applicable law gives you more rights despite this limitation, you may use the software only as expressly permitted in this agreement. In doing so, you must comply with any technical limitations in the software that only allow you to use it in certain ways. For example, if Microsoft technically limits or disables extensibility for the software, you may not extend the software by, among other things, loading or injecting into the software any non-Microsoft add-ins, macros, or packages; modifying the software registry settings; or adding features or functionality equivalent to that found in other Visual Studio products. You may not: - - * work around any technical limitations in the software; - - * reverse engineer, decompile or disassemble the software, or attempt to do so, except and only to the extent required by third party licensing terms governing use of certain open source components that may be included with the software; - - * remove, minimize, block or modify any notices of Microsoft or its suppliers in the software; - - * use the software in any way that is against the law; or - - * share, publish, rent or lease the software, or provide the software as a stand-alone offering for others to use. - -8. UPDATES. The software may periodically check for updates and download and install them for you. You may obtain updates only from Microsoft or authorized sources. Microsoft may need to update your system to provide you with updates. You agree to receive these automatic updates without any additional notice. Updates may not include or support all existing software features, services, or peripheral devices. - -9. EXPORT RESTRICTIONS. You must comply with all domestic and international export laws and regulations that apply to the software, which include restrictions on destinations, end users and end use. For further information on export restrictions, visit (aka.ms/exporting). - -10. SUPPORT SERVICES. Because the software is “as is,” we may not provide support services for it. - -11. ENTIRE AGREEMENT. This agreement, and the terms for supplements, updates, Internet-based services and support services that you use, are the entire agreement for the software and support services. - -12. APPLICABLE LAW. If you acquired the software in the United States, Washington State law applies to interpretation of and claims for breach of this agreement, and the laws of the state where you live apply to all other claims. If you acquired the software in any other country, its laws apply. - -13. CONSUMER RIGHTS; REGIONAL VARIATIONS. This agreement describes certain legal rights. You may have other rights, including consumer rights, under the laws of your state or country. Separate and apart from your relationship with Microsoft, you may also have rights with respect to the party from which you acquired the software. This agreement does not change those other rights if the laws of your state or country do not permit it to do so. For example, if you acquired the software in one of the below regions, or mandatory country law applies, then the following provisions apply to you: - - a. Australia. You have statutory guarantees under the Australian Consumer Law and nothing in this agreement is intended to affect those rights. - - b. Canada. If you acquired the software in Canada, you may stop receiving updates by turning off the automatic update feature, disconnecting your device from the Internet (if and when you re-connect to the Internet, however, the software will resume checking for and installing updates), or uninstalling the software. The product documentation, if any, may also specify how to turn off updates for your specific device or software. - - c. Germany and Austria. - - (i) Warranty. The properly licensed software will perform substantially as described in any Microsoft materials that accompany the software. However, Microsoft gives no contractual guarantee in relation to the licensed software. - - (ii) Limitation of Liability. In case of intentional conduct, gross negligence, claims based on the Product Liability Act, as well as, in case of death or personal or physical injury, Microsoft is liable according to the statutory law. - - Subject to the foregoing clause (ii), Microsoft will only be liable for slight negligence if Microsoft is in breach of such material contractual obligations, the fulfillment of which facilitate the due performance of this agreement, the breach of which would endanger the purpose of this agreement and the compliance with which a party may constantly trust in (so-called "cardinal obligations"). In other cases of slight negligence, Microsoft will not be liable for slight negligence. - -14. LEGAL EFFECT. This agreement describes certain legal rights. You may have other rights under the laws of your country. You may also have rights with respect to the party from whom you acquired the software. This agreement does not change your rights under the laws of your country if the laws of your country do not permit it to do so. - -15. DISCLAIMER OF WARRANTY. THE SOFTWARE IS LICENSED “AS-IS.” YOU BEAR THE RISK OF USING IT. MICROSOFT GIVES NO EXPRESS WARRANTIES, GUARANTEES OR CONDITIONS. TO THE EXTENT PERMITTED UNDER YOUR LOCAL LAWS, MICROSOFT EXCLUDES THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. - -16. LIMITATION ON AND EXCLUSION OF DAMAGES. YOU CAN RECOVER FROM MICROSOFT AND ITS SUPPLIERS ONLY DIRECT DAMAGES UP TO U.S. $5.00. YOU CANNOT RECOVER ANY OTHER DAMAGES, INCLUDING CONSEQUENTIAL, LOST PROFITS, SPECIAL, INDIRECT OR INCIDENTAL DAMAGES. - - This limitation applies to (a) anything related to the software, services, content (including code) on third party Internet sites, or third party programs; and (b) claims for breach of contract, breach of warranty, guarantee or condition, strict liability, negligence, or other tort to the extent permitted by applicable law. - - It also applies even if Microsoft knew or should have known about the possibility of the damages. The above limitation or exclusion may not apply to you because your country may not allow the exclusion or limitation of incidental, consequential or other damages. - -Please note: As the software is distributed in Quebec, Canada, some of the clauses in this agreement are provided below in French. - -Remarque : Ce logiciel étant distribué au Québec, Canada, certaines des clauses dans ce contrat sont fournies ci-dessous en français. - -EXONÉRATION DE GARANTIE. Le logiciel visé par une licence est offert « tel quel ». Toute utilisation de ce logiciel est à votre seule risque et péril. Microsoft n’accorde aucune autre garantie expresse. Vous pouvez bénéficier de droits additionnels en vertu du droit local sur la protection des consommateurs, que ce contrat ne peut modifier. La ou elles sont permises par le droit locale, les garanties implicites de qualité marchande, d’adéquation à un usage particulier et d’absence de contrefaçon sont exclues. - -LIMITATION DES DOMMAGES-INTÉRÊTS ET EXCLUSION DE RESPONSABILITÉ POUR LES DOMMAGES. Vous pouvez obtenir de Microsoft et de ses fournisseurs une indemnisation en cas de dommages directs uniquement à hauteur de 5,00 $ US. Vous ne pouvez prétendre à aucune indemnisation pour les autres dommages, y compris les dommages spéciaux, indirects ou accessoires et pertes de bénéfices. - -Cette limitation concerne : - -* tout ce qui est relié au logiciel, aux services ou au contenu (y compris le code) figurant sur des sites Internet tiers ou dans des programmes tiers ; et - -* les réclamations au titre de violation de contrat ou de garantie, ou au titre de responsabilité stricte, de négligence ou d’une autre faute dans la limite autorisée par la loi en vigueur. - -Elle s’applique également, même si Microsoft connaissait ou devrait connaître l’éventualité d’un tel dommage. Si votre pays n’autorise pas l’exclusion ou la limitation de responsabilité pour les dommages indirects, accessoires ou de quelque nature que ce soit, il se peut que la limitation ou l’exclusion ci-dessus ne s’appliquera pas à votre égard. - -EFFET JURIDIQUE. Le présent contrat décrit certains droits juridiques. Vous pourriez avoir d’autres droits prévus par les lois de votre pays. Le présent contrat ne modifie pas les droits que vous confèrent les lois de votre pays si celles-ci ne le permettent pas. - - ---------------------------------------------------------- - diff --git a/azure-pipeline.nightly.yml b/azure-pipeline.nightly.yml index b2fb239154..2ca4813306 100644 --- a/azure-pipeline.nightly.yml +++ b/azure-pipeline.nightly.yml @@ -18,12 +18,40 @@ resources: ref: main endpoint: Monaco +parameters: + - name: publishExtension + displayName: 🚀 Publish Extension + type: boolean + default: false + extends: template: azure-pipelines/extension/pre-release.yml@templates parameters: + l10nSourcePaths: ./src + customNPMRegistry: '' + buildSteps: - - script: yarn install --frozen-lockfile + - script: yarn install --frozen-lockfile --check-files + displayName: Install dependencies + retryCountOnTaskFailure: 3 + + - script: yarn run bundle + displayName: Compile + + - script: > + node ./scripts/prepare-nightly-build.js + -v "$VERSION" + displayName: Generate package.json + + - script: | + mv ./package.json ./package.json.bak + mv ./package.insiders.json ./package.json + displayName: Override package.json + + testSteps: + - script: yarn install --frozen-lockfile --check-files displayName: Install dependencies + retryCountOnTaskFailure: 3 - script: yarn run bundle displayName: Compile @@ -39,27 +67,19 @@ extends: DISPLAY: ':99.0' TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/test-results.xml - - script: yarn 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=chromium + # displayName: Run test suite (chromium) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-chromium-test-results.xml - - script: yarn 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=firefox + # displayName: Run test suite (firefox) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-firefox-test-results.xml # - script: yarn run browsertest --browserType=webkit # displayName: Run test suite (webkit) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-webkit-test-results.xml - - script: > - node ./scripts/prepare-nightly-build.js - -v "$VERSION" - displayName: Generate package.json - - - script: | - mv ./package.json ./package.json.bak - mv ./package.insiders.json ./package.json - displayName: Override package.json + publishExtension: ${{ parameters.publishExtension }} diff --git a/azure-pipeline.pr.yml b/azure-pipeline.pr.yml index 9e7e5177c7..0b2c569a5f 100644 --- a/azure-pipeline.pr.yml +++ b/azure-pipeline.pr.yml @@ -2,7 +2,7 @@ jobs: - job: test_suite displayName: Test suite pool: - vmImage: 'macOS-10.15' + vmImage: 'macos-12' steps: - template: scripts/ci/common-setup.yml @@ -14,20 +14,20 @@ jobs: env: TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/test-results.xml - - script: yarn 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=chromium + # displayName: Run test suite (chromium) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-chromium-test-results.xml - - script: yarn 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=firefox + # displayName: Run test suite (firefox) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-firefox-test-results.xml - - script: yarn run browsertest --browserType=webkit - displayName: Run test suite (webkit) - env: - TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-webkit-test-results.xml + # - script: yarn run browsertest --browserType=webkit + # displayName: Run test suite (webkit) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-webkit-test-results.xml - task: PublishTestResults@2 displayName: Publish test results diff --git a/azure-pipeline.release.yml b/azure-pipeline.release.yml index 0f2539a98d..ea27a7935e 100644 --- a/azure-pipeline.release.yml +++ b/azure-pipeline.release.yml @@ -4,8 +4,6 @@ trigger: branches: include: - main - tags: - include: ['*'] pr: none resources: @@ -16,12 +14,31 @@ resources: ref: main endpoint: Monaco +parameters: + - name: publishExtension + # allow-any-unicode-next-line + displayName: 🚀 Publish Extension + type: boolean + default: false + extends: template: azure-pipelines/extension/stable.yml@templates parameters: + l10nSourcePaths: ./src + customNPMRegistry: '' + buildSteps: - - script: yarn install --frozen-lockfile + - script: yarn install --frozen-lockfile --check-files + displayName: Install dependencies + retryCountOnTaskFailure: 3 + + - script: yarn run bundle + displayName: Compile + + testSteps: + - script: yarn install --frozen-lockfile --check-files displayName: Install dependencies + retryCountOnTaskFailure: 3 - script: yarn run bundle displayName: Compile @@ -37,17 +54,32 @@ extends: DISPLAY: ':99.0' TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/test-results.xml - - script: yarn 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=chromium + # displayName: Run test suite (chromium) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-chromium-test-results.xml - - script: yarn 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=firefox + # displayName: Run test suite (firefox) + # env: + # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-firefox-test-results.xml # - script: yarn run browsertest --browserType=webkit # displayName: Run test suite (webkit) # env: # TEST_JUNIT_XML_PATH: $(Agent.HomeDirectory)/browser-webkit-test-results.xml + + tsa: + enabled: true + options: + codebaseName: 'devdiv_$(Build.Repository.Name)' + serviceTreeID: '1788a767-5861-45fb-973b-c686b67c5541' + instanceUrl: 'https://devdiv.visualstudio.com/defaultcollection' + projectName: 'DevDiv' + areaPath: "DevDiv\\VS Code (compliance tracking only)\\Visual Studio Code Web Extensions" + notificationAliases: + - 'stbatt@microsoft.com' + - 'lszomoru@microsoft.com' + - 'alros@microsoft.com' + + publishExtension: ${{ and(parameters.publishExtension, eq(variables['Build.Repository.Uri'], 'https://github.com/microsoft/vscode-pull-request-github.git')) }} \ No newline at end of file diff --git a/build/filters.js b/build/filters.js new file mode 100644 index 0000000000..f716c2bcc7 --- /dev/null +++ b/build/filters.js @@ -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. + *--------------------------------------------------------------------------------------------*/ + +// Largely copied from https://github.com/microsoft/vscode/blob/72208d7bbb0e151a54f012ffb382095f0f4c5ba4/build/filters.js + +/** + * Hygiene works by creating cascading subsets of all our files and + * passing them through a sequence of checks. Here are the current subsets, + * named according to the checks performed on them. + */ + +module.exports.all = [ + '*', + 'build/**/*', + 'common/**/*', + 'scripts/**/*', + 'src/**/*', + 'test/**/*', + 'webviews/**/*' +]; + +module.exports.unicodeFilter = [ + '**', + // except specific files + '!documentation/**/*', + '!**/ThirdPartyNotices.txt', + '!**/LICENSE.{txt,rtf}', + '!**/LICENSE', + '!*.yml' +]; + +module.exports.indentationFilter = [ + '**', + + // except specific files + '!CHANGELOG.md', + '!documentation/**/*', + '!**/ThirdPartyNotices.txt', + '!**/LICENSE.{txt,rtf}', + '!**/LICENSE', + '!**/*.yml', + + // except multiple specific files + '!**/package.json', + '!**/yarn.lock', + '!**/yarn-error.log' +]; + +module.exports.copyrightFilter = [ + '**', + '!documentation/**/*', + '!.readme/**/*', + '!.vscode/**/*', + '!.github/**/*', + '!.husky/**/*', + '!tsfmt.json', + '!**/queries*.gql', + '!**/*.yml', + '!**/*.md', + '!package.nls.json', + '!**/*.svg', + '!src/integrations/gitlens/gitlens.d.ts' +]; + +module.exports.tsFormattingFilter = [ + 'src/**/*.ts', + 'common/**/*.ts', + 'webviews/**/*.ts', +]; + diff --git a/build/hygiene.js b/build/hygiene.js new file mode 100644 index 0000000000..63c49b674a --- /dev/null +++ b/build/hygiene.js @@ -0,0 +1,276 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Largely copied from https://github.com/microsoft/vscode/blob/72208d7bbb0e151a54f012ffb382095f0f4c5ba4/build/hygiene.js + +const filter = require('gulp-filter'); +const es = require('event-stream'); +const VinylFile = require('vinyl'); +const vfs = require('vinyl-fs'); +const path = require('path'); +const fs = require('fs'); +const pall = require('p-all'); +const { all, tsFormattingFilter, copyrightFilter, unicodeFilter, indentationFilter } = require('./filters'); + +const copyrightHeaderLines = [ + '/*---------------------------------------------------------------------------------------------', + ' * Copyright (c) Microsoft Corporation. All rights reserved.', + ' * Licensed under the MIT License. See License.txt in the project root for license information.', + ' *--------------------------------------------------------------------------------------------*/', +]; + +function hygiene(some) { + const tsfmt = require('typescript-formatter'); + + let errorCount = 0; + + const unicode = es.through(function (file) { + const lines = file.contents.toString('utf8').split(/\r\n|\r|\n/); + file.__lines = lines; + + let skipNext = false; + lines.forEach((line, i) => { + if (/allow-any-unicode-next-line/.test(line)) { + skipNext = true; + return; + } + if (skipNext) { + skipNext = false; + return; + } + // Please do not add symbols that resemble ASCII letters! + const m = /([^\t\n\r\x20-\x7E⊃⊇✔︎✓🎯⚠️🛑🔴🚗🚙🚕🎉✨❗⇧⌥⌘×÷¦⋯…↑↓→→←↔⟷·•●◆▼⟪⟫┌└├⏎↩√φ]+)/g.exec(line); + if (m) { + console.error( + file.relative + `(${i + 1},${m.index + 1}): Unexpected unicode character: "${m[0]}" (charCode: ${m[0].charCodeAt(0)}). To suppress, use // allow-any-unicode-next-line` + ); + errorCount++; + } + }); + + this.emit('data', file); + }); + + const indentation = es.through(function (file) { + const lines = file.__lines; + + lines.forEach((line, i) => { + if (/^\s*$/.test(line)) { + // empty or whitespace lines are OK + } else if (/^[\t]*[^\s]/.test(line)) { + // good indent + } else if (/^[\t]* \*/.test(line)) { + // block comment using an extra space + } else { + console.error( + file.relative + '(' + (i + 1) + ',1): Bad whitespace indentation' + ); + errorCount++; + } + }); + + this.emit('data', file); + }); + + const copyrights = es.through(function (file) { + const lines = file.__lines; + + for (let i = 0; i < copyrightHeaderLines.length; i++) { + if (lines[i] !== copyrightHeaderLines[i]) { + console.error(file.relative + ': Missing or bad copyright statement'); + errorCount++; + break; + } + } + + this.emit('data', file); + }); + + const formatting = es.map(function (file, cb) { + tsfmt + .processString(file.path, file.contents.toString('utf8'), { + verify: false, + tsfmt: true, + // verbose: true, + // keep checkJS happy + editorconfig: undefined, + replace: undefined, + tsconfig: undefined, + tsconfigFile: undefined, + tsfmtFile: undefined, + vscode: undefined, + vscodeFile: undefined, + }) + .then( + (result) => { + const original = result.src.replace(/\r\n/gm, '\n'); + const formatted = result.dest.replace(/\r\n/gm, '\n'); + + if (original !== formatted) { + console.error( + `File not formatted. Run the 'Format Document' command to fix it:`, + file.relative + ); + errorCount++; + } + cb(null, file); + }, + (err) => { + cb(err); + } + ); + }); + + let input; + + if (Array.isArray(some) || typeof some === 'string' || !some) { + const options = { base: '.', follow: true, allowEmpty: true }; + if (some) { + input = vfs.src(some, options); + } else { + input = vfs.src(all, options); + } + } else { + input = some; + } + + const unicodeFilterStream = filter(unicodeFilter, { restore: true }); + + const result = input + .pipe(filter((f) => !f.stat.isDirectory())) + .pipe(unicodeFilterStream) + .pipe(unicode) + .pipe(unicodeFilterStream.restore) + .pipe(filter(indentationFilter)) + .pipe(indentation) + .pipe(filter(copyrightFilter)) + .pipe(copyrights); + + const streams = [ + result.pipe(filter(tsFormattingFilter)).pipe(formatting) + ]; + + let count = 0; + return es.merge(...streams).pipe( + es.through( + function (data) { + count++; + if (process.env['TRAVIS'] && count % 10 === 0) { + process.stdout.write('.'); + } + this.emit('data', data); + }, + function () { + process.stdout.write('\n'); + if (errorCount > 0) { + this.emit( + 'error', + 'Hygiene failed with ' + + errorCount + + ` errors. Check 'build / gulpfile.hygiene.js'.` + ); + } else { + this.emit('end'); + } + } + ) + ); +} + +module.exports.hygiene = hygiene; + +function createGitIndexVinyls(paths) { + const cp = require('child_process'); + const repositoryPath = process.cwd(); + + const fns = paths.map((relativePath) => () => + new Promise((c, e) => { + const fullPath = path.join(repositoryPath, relativePath); + + fs.stat(fullPath, (err, stat) => { + if (err && err.code === 'ENOENT') { + // ignore deletions + return c(null); + } else if (err) { + return e(err); + } + + cp.exec( + process.platform === 'win32' ? `git show :${relativePath}` : `git show ':${relativePath}'`, + { maxBuffer: 2000 * 1024, encoding: 'buffer' }, + (err, out) => { + if (err) { + return e(err); + } + + c( + new VinylFile({ + path: fullPath, + base: repositoryPath, + contents: out, + stat, + }) + ); + } + ); + }); + }) + ); + + return pall(fns, { concurrency: 4 }).then((r) => r.filter((p) => !!p)); +} + +// this allows us to run hygiene as a git pre-commit hook +if (require.main === module) { + const cp = require('child_process'); + + process.on('unhandledRejection', (reason, p) => { + console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); + process.exit(1); + }); + + if (process.argv.length > 2) { + hygiene(process.argv.slice(2)).on('error', (err) => { + console.error(); + console.error(err); + process.exit(1); + }); + } else { + cp.exec( + 'git diff --cached --name-only', + { maxBuffer: 2000 * 1024 }, + (err, out) => { + if (err) { + console.error(); + console.error(err); + process.exit(1); + } + + const some = out.split(/\r?\n/).filter((l) => !!l); + + if (some.length > 0) { + console.log('Reading git index versions...'); + + createGitIndexVinyls(some) + .then( + (vinyls) => + new Promise((c, e) => + hygiene(es.readArray(vinyls)) + .on('end', () => c()) + .on('error', e) + ) + ) + .catch((err) => { + console.error(); + console.error(err); + if (err.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') { + process.exit(1); + } + }); + } + } + ); + } +} diff --git a/common/views.ts b/common/views.ts index 8aef8e5cf6..a776749604 100644 --- a/common/views.ts +++ b/common/views.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IAccount, ILabel, IMilestone, ITeam, MergeMethod, MergeMethodsAvailability } from '../src/github/interface'; + export interface RemoteInfo { owner: string; repositoryName: string; @@ -27,11 +29,21 @@ export interface CreateParams { baseBranch?: string; compareRemote?: RemoteInfo; compareBranch?: string; - isDraft: boolean; + isDraftDefault: boolean; + isDraft?: boolean; + labels?: ILabel[]; + isDarkTheme?: boolean; validate?: boolean; showTitleValidationError?: boolean; createError?: string; + + autoMergeDefault: boolean; + autoMerge?: boolean; + autoMergeMethod?: MergeMethod; + allowAutoMerge?: boolean; + defaultMergeMethod?: MergeMethod; + mergeMethodsAvailability?: MergeMethodsAvailability; } export interface ScrollPosition { @@ -49,4 +61,101 @@ export interface CreatePullRequest { compareOwner: string; compareRepo: string; draft: boolean; -} \ No newline at end of file + autoMerge: boolean; + autoMergeMethod?: MergeMethod; + labels: ILabel[]; +} + +export interface CreatePullRequestNew { + title: string; + body: string; + owner: string; + repo: string; + base: string + compareBranch: string; + compareOwner: string; + compareRepo: string; + draft: boolean; + autoMerge: boolean; + autoMergeMethod?: MergeMethod; + labels: ILabel[]; + assignees: IAccount[]; + reviewers: (IAccount | ITeam)[]; + milestone?: IMilestone; +} + +// #region new create view + +export interface CreateParamsNew { + defaultBaseRemote?: RemoteInfo; + defaultBaseBranch?: string; + defaultCompareRemote?: RemoteInfo; + defaultCompareBranch?: string; + defaultTitle?: string; + defaultDescription?: string; + pendingTitle?: string; + pendingDescription?: string; + baseRemote?: RemoteInfo; + baseBranch?: string; + remoteCount?: number; + compareRemote?: RemoteInfo; + compareBranch?: string; + isDraftDefault: boolean; + isDraft?: boolean; + labels?: ILabel[]; + assignees?: IAccount[]; + reviewers?: (IAccount | ITeam)[]; + milestone?: IMilestone; + isDarkTheme?: boolean; + generateTitleAndDescriptionTitle: string | undefined; + initializeWithGeneratedTitleAndDescription: boolean; + + validate?: boolean; + showTitleValidationError?: boolean; + createError?: string; + + autoMergeDefault: boolean; + autoMerge?: boolean; + autoMergeMethod?: MergeMethod; + allowAutoMerge?: boolean; + defaultMergeMethod?: MergeMethod; + mergeMethodsAvailability?: MergeMethodsAvailability; + baseHasMergeQueue: boolean; + + creating: boolean; +} + +export interface ChooseRemoteAndBranchArgs { + currentRemote: RemoteInfo | undefined; + currentBranch: string | undefined; +} + +export interface ChooseBaseRemoteAndBranchResult { + baseRemote: RemoteInfo; + baseBranch: string; + defaultBaseBranch: string; + defaultMergeMethod: MergeMethod; + allowAutoMerge: boolean; + mergeMethodsAvailability: MergeMethodsAvailability; + autoMergeDefault: boolean; + baseHasMergeQueue: boolean; + defaultTitle: string; + defaultDescription: string; +} + +export interface ChooseCompareRemoteAndBranchResult { + compareRemote: RemoteInfo; + compareBranch: string; + defaultCompareBranch: string; +} + +export interface TitleAndDescriptionArgs { + useCopilot: boolean; +} + +export interface TitleAndDescriptionResult { + title: string | undefined; + description: string | undefined; +} + +// #endregion \ No newline at end of file diff --git a/documentation/changelog/0.44.0/auto-merge-create-view.png b/documentation/changelog/0.44.0/auto-merge-create-view.png new file mode 100644 index 0000000000..c9e2da5cd7 Binary files /dev/null and b/documentation/changelog/0.44.0/auto-merge-create-view.png differ diff --git a/documentation/changelog/0.46.0/automerge-overview.png b/documentation/changelog/0.46.0/automerge-overview.png new file mode 100644 index 0000000000..9aaaae73a5 Binary files /dev/null and b/documentation/changelog/0.46.0/automerge-overview.png differ diff --git a/documentation/changelog/0.48.0/changesSinceReview.gif b/documentation/changelog/0.48.0/changesSinceReview.gif new file mode 100644 index 0000000000..c0218c7512 Binary files /dev/null and b/documentation/changelog/0.48.0/changesSinceReview.gif differ diff --git a/documentation/changelog/0.48.0/commit-and-create-pr.png b/documentation/changelog/0.48.0/commit-and-create-pr.png new file mode 100644 index 0000000000..08a71eb6be Binary files /dev/null and b/documentation/changelog/0.48.0/commit-and-create-pr.png differ diff --git a/documentation/changelog/0.50.0/githubNotifications.gif b/documentation/changelog/0.50.0/githubNotifications.gif new file mode 100644 index 0000000000..e1b993c8f4 Binary files /dev/null and b/documentation/changelog/0.50.0/githubNotifications.gif differ diff --git a/documentation/changelog/0.50.0/labelColors.png b/documentation/changelog/0.50.0/labelColors.png new file mode 100644 index 0000000000..0ce6426698 Binary files /dev/null and b/documentation/changelog/0.50.0/labelColors.png differ diff --git a/documentation/changelog/0.50.0/renamed-tooltip.png b/documentation/changelog/0.50.0/renamed-tooltip.png new file mode 100644 index 0000000000..6922e9f9ae Binary files /dev/null and b/documentation/changelog/0.50.0/renamed-tooltip.png differ diff --git a/documentation/changelog/0.52.0/tree-item-checkbox-state.png b/documentation/changelog/0.52.0/tree-item-checkbox-state.png new file mode 100644 index 0000000000..c041eb9367 Binary files /dev/null and b/documentation/changelog/0.52.0/tree-item-checkbox-state.png differ diff --git a/documentation/changelog/0.56.0/copy-vscode-dev-link.png b/documentation/changelog/0.56.0/copy-vscode-dev-link.png new file mode 100644 index 0000000000..06a4d6dd3e Binary files /dev/null and b/documentation/changelog/0.56.0/copy-vscode-dev-link.png differ diff --git a/documentation/changelog/0.56.0/pr-status-in-list.png b/documentation/changelog/0.56.0/pr-status-in-list.png new file mode 100644 index 0000000000..4fef57958a Binary files /dev/null and b/documentation/changelog/0.56.0/pr-status-in-list.png differ diff --git a/documentation/changelog/0.56.0/visible-resolve-button.png b/documentation/changelog/0.56.0/visible-resolve-button.png new file mode 100644 index 0000000000..fda987f301 Binary files /dev/null and b/documentation/changelog/0.56.0/visible-resolve-button.png differ diff --git a/documentation/changelog/0.58.0/create-with-labels.png b/documentation/changelog/0.58.0/create-with-labels.png new file mode 100644 index 0000000000..a6e6b7a802 Binary files /dev/null and b/documentation/changelog/0.58.0/create-with-labels.png differ diff --git a/documentation/changelog/0.58.0/suggest-a-change.gif b/documentation/changelog/0.58.0/suggest-a-change.gif new file mode 100644 index 0000000000..3c180fb398 Binary files /dev/null and b/documentation/changelog/0.58.0/suggest-a-change.gif differ diff --git a/documentation/changelog/0.60.0/permalink-comment-widget.png b/documentation/changelog/0.60.0/permalink-comment-widget.png new file mode 100644 index 0000000000..c0c43a4138 Binary files /dev/null and b/documentation/changelog/0.60.0/permalink-comment-widget.png differ diff --git a/documentation/changelog/0.60.0/permalink-description.png b/documentation/changelog/0.60.0/permalink-description.png new file mode 100644 index 0000000000..7e711527ea Binary files /dev/null and b/documentation/changelog/0.60.0/permalink-description.png differ diff --git a/documentation/changelog/0.60.0/quick-diff.png b/documentation/changelog/0.60.0/quick-diff.png new file mode 100644 index 0000000000..7accc8114b Binary files /dev/null and b/documentation/changelog/0.60.0/quick-diff.png differ diff --git a/documentation/changelog/0.60.0/re-request-review.png b/documentation/changelog/0.60.0/re-request-review.png new file mode 100644 index 0000000000..cd7450484a Binary files /dev/null and b/documentation/changelog/0.60.0/re-request-review.png differ diff --git a/documentation/changelog/0.64.0/file-level-comments.gif b/documentation/changelog/0.64.0/file-level-comments.gif new file mode 100644 index 0000000000..6a81889d38 Binary files /dev/null and b/documentation/changelog/0.64.0/file-level-comments.gif differ diff --git a/documentation/changelog/0.64.0/get-team-reviewers.png b/documentation/changelog/0.64.0/get-team-reviewers.png new file mode 100644 index 0000000000..50327c51ec Binary files /dev/null and b/documentation/changelog/0.64.0/get-team-reviewers.png differ diff --git a/documentation/changelog/0.66.0/compare-changes-with-commands.png b/documentation/changelog/0.66.0/compare-changes-with-commands.png new file mode 100644 index 0000000000..d10c126e57 Binary files /dev/null and b/documentation/changelog/0.66.0/compare-changes-with-commands.png differ diff --git a/documentation/changelog/0.66.0/git-subfolder-welcome.png b/documentation/changelog/0.66.0/git-subfolder-welcome.png new file mode 100644 index 0000000000..1cae98e02d Binary files /dev/null and b/documentation/changelog/0.66.0/git-subfolder-welcome.png differ diff --git a/documentation/changelog/0.68.0/circle-avatar.png b/documentation/changelog/0.68.0/circle-avatar.png new file mode 100644 index 0000000000..c21f6be234 Binary files /dev/null and b/documentation/changelog/0.68.0/circle-avatar.png differ diff --git a/documentation/changelog/0.68.0/read-only-file-message.png b/documentation/changelog/0.68.0/read-only-file-message.png new file mode 100644 index 0000000000..cf00ad94fc Binary files /dev/null and b/documentation/changelog/0.68.0/read-only-file-message.png differ diff --git a/documentation/changelog/0.70.0/new-create-view.png b/documentation/changelog/0.70.0/new-create-view.png new file mode 100644 index 0000000000..2ec4a7cb49 Binary files /dev/null and b/documentation/changelog/0.70.0/new-create-view.png differ diff --git a/documentation/changelog/0.76.0/github-copilot-title-description.gif b/documentation/changelog/0.76.0/github-copilot-title-description.gif new file mode 100644 index 0000000000..b1bb41d770 Binary files /dev/null and b/documentation/changelog/0.76.0/github-copilot-title-description.gif differ diff --git a/documentation/changelog/0.76.0/project-in-description.png b/documentation/changelog/0.76.0/project-in-description.png new file mode 100644 index 0000000000..d6bd691d04 Binary files /dev/null and b/documentation/changelog/0.76.0/project-in-description.png differ diff --git a/documentation/changelog/0.78.0/merge-queue.png b/documentation/changelog/0.78.0/merge-queue.png new file mode 100644 index 0000000000..196b6b326a Binary files /dev/null and b/documentation/changelog/0.78.0/merge-queue.png differ diff --git a/documentation/changelog/0.78.0/repo-name-changes-view.png b/documentation/changelog/0.78.0/repo-name-changes-view.png new file mode 100644 index 0000000000..f1982e8466 Binary files /dev/null and b/documentation/changelog/0.78.0/repo-name-changes-view.png differ diff --git a/documentation/releasing.md b/documentation/releasing.md index cc9c0fb606..e76d24aace 100644 --- a/documentation/releasing.md +++ b/documentation/releasing.md @@ -5,29 +5,25 @@ **Until the marketplace supports semantic versioning, the minor version should always be an event number. Odd numbers are reserved for the pre-release version of the extension.** - (If necessary) Update vscode engine version - 2. Update [CHANGELOG.md](https://github.com/Microsoft/vscode-pull-request-github/blob/main/CHANGELOG.md) - In the **Changes** section, link to issues that were fixed or closed in the last sprint. Use a link to the pull request if there is no issue to reference. - In the **Thank You** section, @ mention users who contributed (if there were any). - -3. If there are new dependencies that have been added, update [ThirdPartyNotices.txt](https://github.com/microsoft/vscode-pull-request-github/commits/main/ThirdPartyNotices.txt). - - -4. Create PR with changes to `package.json` and `CHANGELOG.md` (and `ThirdPartyNotices.txt` when necessary) +3. Create PR with changes to `package.json` and `CHANGELOG.md` (`ThirdPartyNotices.txt` changes are not necessary as the pipeline creates the file) - Merge PR once changes are reviewed -5. If the minor version was increased, run the nightly build pipeline to ensure a new pre-release version with the increased version number is released +4. If the minor version was increased, run the nightly build pipeline to ensure a new pre-release version with the increased version number is released -6. Push a tag with the new version number to the appropriate commit (ex. `v0.5.0`). +5. Run the release pipeline with the `publishExtension` variable set to `true`. If needed, set the branch to the appropriate release branch (ex. `release/0.5`). -7. Wait for the release pipeline to finish running. +6. Wait for the release pipeline to finish running. -8. Draft new GitHub release +7. Draft new GitHub release - Go to: https://github.com/Microsoft/vscode-pull-request-github/releases - Tag should be the same as the extension version (ex. `v0.5.0`) - Set release title to the name of the version (ex. `0.5.0`) - Copy over contents from CHANGELOG.md - - Upload .vsix, which can be downloaded from the release pipeline - Preview release - **Publish** release + +8. If the nightly pre-release build was disable, re-enable in in https://github.com/microsoft/vscode-pull-request-github/blob/c6f00d59fb99c7807bfb963f55926505bdb723ef/azure-pipeline.nightly.yml diff --git a/documentation/suggestAChange.md b/documentation/suggestAChange.md new file mode 100644 index 0000000000..6d6e599073 --- /dev/null +++ b/documentation/suggestAChange.md @@ -0,0 +1,31 @@ +# Suggest a Change + +The "Suggest a Change" feature uses GitHub.com's mechanism for suggestion a change (as apposed to the old "Suggest an Edit" feature which used git patches to leave suggestsions). + +## Making a suggestion + +First, select the lines or place your cursor on the line you want to make a suggestion for. Then add a comment, either with the `+` in the editor or with the "Add Comment on Current Selection" command. From the comment, you can use the "Make a Suggestion" button, located below the comment input, to insert the suggestion template into the comment input. The "Make a Suggestion" button can be tabbed to in the comment widget. For example, if you want to leave a comment on this line: + +```ts +console.log('hello world'); +``` + +The following would be inserted into the comment input: + +```` +```suggestion + console.log('hello world'); +``` +```` + +You can then modify the contents of the `suggestion` block such that the code within demonstrates your suggestion. + +## Accepting a suggestion + +If a comment has a `suggestion` block in it as described above, the comment actions will include an "Apply Suggestion" button. This action can be tabbed to when you focus an existing comment. The suggestion is applied by replacing the lines that the comment targets with the contents of the suggestion. When you accept a suggestion, only the file is modified. To have the suggestion pushed to the pull request, you'll need to commit the file change and push the change to the remote branch. + +## Example + +This gif shows an example of how to make a suggestion and then apply it. + +![Example of how to suggest and accept a change in a PR](/documentation/changelog/0.58.0/suggest-a-change.gif) \ No newline at end of file diff --git a/package.json b/package.json index cce3ba21bc..377609848e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vscode-pull-request-github", - "displayName": "GitHub Pull Requests and Issues", - "description": "Pull Request and Issue Provider for GitHub", + "displayName": "%displayName%", + "description": "%description%", "icon": "resources/icons/github_logo.png", "repository": { "type": "git", @@ -12,25 +12,39 @@ }, "enabledApiProposals": [ "tokenInformation", - "commentsResolvedState" + "contribShareMenu", + "fileComments", + "commentReactor", + "contribCommentPeekContext", + "contribCommentThreadAdditionalMenu", + "codiconDecoration", + "diffCommand", + "contribCommentEditorActionsMenu", + "shareProvider", + "quickDiffProvider", + "readonlyMessage", + "treeViewMarkdownMessage" ], - "version": "0.40.0", + "version": "0.78.1", "publisher": "GitHub", "engines": { - "vscode": "^1.67.0" + "vscode": "^1.85.0" }, "categories": [ "Other" ], + "extensionDependencies": [ + "vscode.github-authentication" + ], "activationEvents": [ - "*", - "onCommand:github.api.preloadPullRequest", + "onStartupFinished", "onFileSystem:newIssue", "onFileSystem:pr", "onFileSystem:githubpr", "onFileSystem:review" ], "browser": "./dist/browser/extension", + "l10n": "./dist/browser/extension", "main": "./dist/extension", "capabilities": { "untrustedWorkspaces": { @@ -62,22 +76,44 @@ "description": "The title used when creating pull requests." }, "githubPullRequests.pullRequestDescription": { - "deprecationMessage": "The pull request description now uses the same defaults as GitHub, and can be edited before create.", "type": "string", "enum": [ "template", "commit", - "custom", - "ask" + "none", + "Copilot" ], "enumDescriptions": [ - "Use a pull request template, or use the commit description if no templates were found", - "Use the latest commit message", - "Specify a custom description", - "Ask which of the above methods to use" + "%githubPullRequests.pullRequestDescription.template%", + "%githubPullRequests.pullRequestDescription.commit%", + "%githubPullRequests.pullRequestDescription.none%", + "%githubPullRequests.pullRequestDescription.copilot%" ], "default": "template", - "description": "The description used when creating pull requests." + "description": "%githubPullRequests.pullRequestDescription.description%" + }, + "githubPullRequests.defaultCreateOption": { + "type":"string", + "enum": [ + "lastUsed", + "create", + "createDraft", + "createAutoMerge" + ], + "markdownEnumDescriptions": [ + "%githubPullRequests.defaultCreateOption.lastUsed%", + "%githubPullRequests.defaultCreateOption.create%", + "%githubPullRequests.defaultCreateOption.createDraft%", + "%githubPullRequests.defaultCreateOption.createAutoMerge%" + ], + "default": "lastUsed", + "description": "%githubPullRequests.defaultCreateOption.description%" + }, + "githubPullRequests.createDraft": { + "type": "boolean", + "default": false, + "deprecationMessage": "Use the setting 'githubPullRequests.defaultCreateOption' instead.", + "description": "%githubPullRequests.createDraft%" }, "githubPullRequests.logLevel": { "type": "string", @@ -87,7 +123,8 @@ "off" ], "default": "info", - "description": "Logging for GitHub Pull Request extension. The log is emitted to the output channel named as GitHub Pull Request." + "description": "%githubPullRequests.logLevel.description%", + "markdownDeprecationMessage": "%githubPullRequests.logLevel.markdownDeprecationMessage%" }, "githubPullRequests.remotes": { "type": "array", @@ -98,7 +135,7 @@ "items": { "type": "string" }, - "markdownDescription": "List of remotes, by name, to fetch pull requests from." + "markdownDescription": "%githubPullRequests.remotes.markdownDescription%" }, "githubPullRequests.includeRemotes": { "type": "string", @@ -117,27 +154,27 @@ "properties": { "label": { "type": "string", - "description": "The label to display for the query in the Pull Requests tree" + "description": "%githubPullRequests.queries.label.description%" }, "query": { "type": "string", - "description": "The query used for searching pull requests." + "description": "%githubPullRequests.queries.query.description%" } } }, "scope": "resource", - "markdownDescription": "Specifies what queries should be used in the GitHub Pull Requests tree. Each query object has a `label` that will be shown in the tree and a search `query` using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax). The variable `${user}` can be used to specify the logged in user within a search. By default these queries define the categories \"Waiting For My Review\", \"Assigned To Me\" and \"Created By Me\". If you want to preserve these, make sure they are still in the array when you modify the setting.", + "markdownDescription": "%githubPullRequests.queries.markdownDescription%", "default": [ { - "label": "Waiting For My Review", + "label": "%githubPullRequests.queries.waitingForMyReview%", "query": "is:open review-requested:${user}" }, { - "label": "Assigned To Me", + "label": "%githubPullRequests.queries.assignedToMe%", "query": "is:open assignee:${user}" }, { - "label": "Created By Me", + "label": "%githubPullRequests.queries.createdByMe%", "query": "is:open author:${user}" } ] @@ -150,7 +187,7 @@ "rebase" ], "default": "merge", - "description": "The method to use when merging pull requests." + "description": "%githubPullRequests.defaultMergeMethod.description%" }, "githubPullRequests.showInSCM": { "type": "boolean", @@ -158,6 +195,15 @@ "deprecationMessage": "This setting is deprecated. Views can now be dragged to any location.", "description": "When true, show GitHub Pull Requests within the SCM viewlet. Otherwise show a separate view container for them." }, + "githubPullRequests.notifications": { + "type": "string", + "enum": [ + "pullRequests", + "off" + ], + "default": "off", + "description": "%githubPullRequests.notifications.description%" + }, "githubPullRequests.fileListLayout": { "type": "string", "enum": [ @@ -165,17 +211,17 @@ "tree" ], "default": "tree", - "description": "The layout to use when displaying changed files list." + "description": "%githubPullRequests.fileListLayout.description%" }, "githubPullRequests.defaultDeletionMethod.selectLocalBranch": { "type": "boolean", "default": true, - "description": "When true, the option to delete the local branch will be selected by default when deleting a branch from a pull request." + "description": "%githubPullRequests.defaultDeletionMethod.selectLocalBranch.description%" }, "githubPullRequests.defaultDeletionMethod.selectRemote": { "type": "boolean", "default": true, - "description": "When true, the option to delete the remote will be selected by default when deleting a branch from a pull request." + "description": "%githubPullRequests.defaultDeletionMethod.selectRemote.description%" }, "githubPullRequests.terminalLinksHandler": { "type": "string", @@ -185,12 +231,12 @@ "ask" ], "enumDescriptions": [ - "Create the pull request on GitHub", - "Create the pull request in VS Code", - "Ask which method to use" + "%githubPullRequests.terminalLinksHandler.github%", + "%githubPullRequests.terminalLinksHandler.vscode%", + "%githubPullRequests.terminalLinksHandler.ask%" ], "default": "ask", - "description": "Default handler for terminal links." + "description": "%githubPullRequests.terminalLinksHandler.description%" }, "githubPullRequests.createOnPublishBranch": { "type": "string", @@ -199,11 +245,11 @@ "ask" ], "enumDescriptions": [ - "Never create a pull request when a branch is published.", - "Ask if you want to create a pull request when a branch is published." + "%githubPullRequests.createOnPublishBranch.never%", + "%githubPullRequests.createOnPublishBranch.ask%" ], "default": "ask", - "description": "Create a pull request when a branch is published." + "description": "%githubPullRequests.createOnPublishBranch.description%" }, "githubPullRequests.commentExpandState": { "type": "string", @@ -212,25 +258,25 @@ "collapseAll" ], "enumDescriptions": [ - "All unresolved comments will be expanded.", - "All comments will be collapsed" + "%githubPullRequests.commentExpandState.expandUnresolved%", + "%githubPullRequests.commentExpandState.collapseAll%" ], "default": "expandUnresolved", - "description": "Controls whether comments are expanded when a document with comments is opened." + "description": "%githubPullRequests.commentExpandState.description%" }, "githubPullRequests.useReviewMode": { "type": "object", - "description": "Choose which pull request states will use review mode. \"Open\" pull requests will always use review mode.", + "description": "%githubPullRequests.useReviewMode.description%", "additionalProperties": false, "properties": { "merged": { "type": "boolean", - "description": "Use review mode for merged pull requests.", - "default": true + "description": "%githubPullRequests.useReviewMode.merged%", + "default": false }, "closed": { "type": "boolean", - "description": "Use review mode for closed pull requests. Merged pull requests are not considered \"closed\".", + "description": "%githubPullRequests.useReviewMode.closed%", "default": false } }, @@ -239,20 +285,145 @@ "closed" ], "default": { - "merged": true, + "merged": false, "closed": false } }, + "githubPullRequests.assignCreated": { + "type": "string", + "description": "%githubPullRequests.assignCreated.description%" + }, + "githubPullRequests.pushBranch": { + "type": "string", + "enum": [ + "prompt", + "always" + ], + "default": "prompt", + "enumDescriptions": [ + "%githubPullRequests.pushBranch.prompt%", + "%githubPullRequests.pushBranch.always%" + ], + "description": "%githubPullRequests.pushBranch.description%" + }, + "githubPullRequests.pullBranch": { + "type": "string", + "enum": [ + "prompt", + "never", + "always" + ], + "default": "prompt", + "markdownEnumDescriptions": [ + "%githubPullRequests.pullBranch.prompt%", + "%githubPullRequests.pullBranch.never%", + "%githubPullRequests.pullBranch.always%" + ], + "description": "%githubPullRequests.pullBranch.description%" + }, + "githubPullRequests.allowFetch": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.allowFetch.description%" + }, + "githubPullRequests.ignoredPullRequestBranches": { + "type": "array", + "default": [], + "items": { + "type": "string", + "description": "%githubPullRequests.ignoredPullRequestBranches.items%" + }, + "description": "%githubPullRequests.ignoredPullRequestBranches.description%" + }, + "githubPullRequests.neverIgnoreDefaultBranch": { + "type": "boolean", + "description": "%githubPullRequests.neverIgnoreDefaultBranch.description%" + }, + "githubPullRequests.overrideDefaultBranch": { + "type": "string", + "description": "%githubPullRequests.overrideDefaultBranch.description%" + }, + "githubPullRequests.postCreate": { + "type": "string", + "enum": [ + "none", + "openOverview", + "checkoutDefaultBranch", + "checkoutDefaultBranchAndShow", + "checkoutDefaultBranchAndCopy" + ], + "description": "%githubPullRequests.postCreate.description%", + "default": "openOverview", + "enumDescriptions": [ + "%githubPullRequests.postCreate.none%", + "%githubPullRequests.postCreate.openOverview%", + "%githubPullRequests.postCreate.checkoutDefaultBranch%", + "%githubPullRequests.postCreate.checkoutDefaultBranchAndShow%", + "%githubPullRequests.postCreate.checkoutDefaultBranchAndCopy%" + ] + }, + "githubPullRequests.defaultCommentType": { + "type": "string", + "enum": [ + "single", + "review" + ], + "default": "single", + "description": "%githubPullRequests.defaultCommentType.description%", + "enumDescriptions": [ + "%githubPullRequests.defaultCommentType.single%", + "%githubPullRequests.defaultCommentType.review%" + ] + }, + "githubPullRequests.quickDiff": { + "type": "boolean", + "description": "Enables quick diff in the editor gutter for checked-out pull requests. Requires a reload to take effect", + "default": false + }, + "githubPullRequests.setAutoMerge": { + "type": "boolean", + "description": "%githubPullRequests.setAutoMerge.description%", + "deprecationMessage": "Use the setting 'githubPullRequests.defaultCreateOption' instead.", + "default": false + }, + "githubPullRequests.pullPullRequestBranchBeforeCheckout": { + "type": "boolean", + "description": "%githubPullRequests.pullPullRequestBranchBeforeCheckout.description%", + "default": true + }, + "githubPullRequests.upstreamRemote": { + "type": "string", + "enum": [ + "add", + "never" + ], + "markdownDescription": "%githubPullRequests.upstreamRemote.description%", + "markdownEnumDescriptions": [ + "%githubPullRequests.upstreamRemote.add%", + "%githubPullRequests.upstreamRemote.never%" + ], + "default": "add" + }, + "githubPullRequests.createDefaultBaseBranch": { + "type": "string", + "enum": ["repositoryDefault", "createdFromBranch"], + "markdownEnumDescriptions": [ + "%githubPullRequests.createDefaultBaseBranch.repositoryDefault%", + "%githubPullRequests.createDefaultBaseBranch.createdFromBranch%" + ], + "default": "createdFromBranch", + "markdownDescription": "%githubPullRequests.createDefaultBaseBranch.description%" + }, "githubIssues.ignoreMilestones": { "type": "array", "default": [], - "description": "An array of milestones titles to never show issues from." + "description": "%githubIssues.ignoreMilestones.description%" }, "githubIssues.createIssueTriggers": { "type": "array", "items": { "type": "string", - "description": "String that enables the 'Create issue from comment' code action. Should not contain whitespace." + "description": "%githubIssues.createIssueTriggers.items%" }, "default": [ "TODO", @@ -262,7 +433,7 @@ "ISSUE", "HACK" ], - "description": "Strings that will cause the 'Create issue from comment' code action to show." + "description": "%githubIssues.createIssueTriggers.description%" }, "githubIssues.createInsertFormat": { "type": "string", @@ -271,31 +442,29 @@ "url" ], "default": "number", - "description": "Controls whether an issue number (ex. #1234) or a full url (ex. https://github.com/owner/name/issues/1234) is inserted when the Create Issue code action is run." + "description": "%githubIssues.createInsertFormat.description%" }, "githubIssues.issueCompletions.enabled": { "type": "boolean", "default": true, - "description": "Controls whether completion suggestions are shown for issues." + "description": "%githubIssues.issueCompletions.enabled.description%" }, "githubIssues.userCompletions.enabled": { "type": "boolean", "default": true, - "description": "Controls whether completion suggestions are shown for users." + "description": "%githubIssues.userCompletions.enabled.description%" }, "githubIssues.ignoreCompletionTrigger": { "type": "array", "items": { "type": "string", - "description": "Language that issue completions should not trigger on '#'." + "description": "%githubIssues.ignoreCompletionTrigger.items%" }, "default": [ "coffeescript", "diff", "dockerfile", "dockercompose", - "git-commit", - "git-rebase", "ignore", "ini", "julia", @@ -308,23 +477,23 @@ "shellscript", "yaml" ], - "description": "Languages that the '#' character should not be used to trigger issue completion suggestions." + "description": "%githubIssues.ignoreCompletionTrigger.description%" }, "githubIssues.ignoreUserCompletionTrigger": { "type": "array", "items": { "type": "string", - "description": "Language that user completions should not trigger on '@'." + "description": "%githubIssues.ignoreUserCompletionTrigger.items%" }, "default": [ "python" ], - "description": "Languages that the '@' character should not be used to trigger user completion suggestions." + "description": "%githubIssues.ignoreUserCompletionTrigger.description%" }, "githubIssues.issueBranchTitle": { "type": "string", "default": "${user}/issue${issueNumber}", - "markdownDescription": "Advanced settings for the name of the branch that is created when you start working on an issue. \n- `${user}` will be replace with the currently logged in username \n- `${issueNumber}` will be replaced with the current issue number \n- `${sanitizedIssueTitle}` will be replaced with the issue title, with all spaces and unsupported characters removed" + "markdownDescription": "%githubIssues.issueBranchTitle.markdownDescription%" }, "githubIssues.useBranchForIssues": { "type": "string", @@ -334,22 +503,22 @@ "prompt" ], "enumDescriptions": [ - "A branch will always be checked out when you start working on an issue. If the branch doesn't exist, it will be created.", - "A branch will not be created when you start working on an issue. If you have worked on an issue before and a branch was created for it, that same branch will be checked out.", - "A prompt will show for setting the name of the branch that will be created and checked out." + "%githubIssues.useBranchForIssues.on%", + "%githubIssues.useBranchForIssues.off%", + "%githubIssues.useBranchForIssues.prompt%" ], "default": "on", - "description": "Determines whether a branch should be checked out when working on an issue. To configure the name of the branch, set `githubIssues.issueBranchTitle`." + "markdownDescription": "%githubIssues.useBranchForIssues.markdownDescription%" }, "githubIssues.issueCompletionFormatScm": { "type": "string", "default": "${issueTitle} ${issueNumberLabel}", - "markdownDescription": "Sets the format of issue completions in the SCM inputbox. \n- `${user}` will be replace with the currently logged in username \n- `${issueNumber}` will be replaced with the current issue number \n- `${issueNumberLabel}` will be replaced with a label formatted as #number or owner/repository#number, depending on whether the issue is in the current repository" + "markdownDescription": "%githubIssues.issueCompletionFormatScm.markdownDescription%" }, "githubIssues.workingIssueFormatScm": { "type": "string", "default": "${issueTitle} \nFixes ${issueNumberLabel}", - "markdownDescription": "Sets the format of the commit message that is set in the SCM inputbox when you **Start Working on an Issue**. Defaults to `${issueTitle} \nFixes #${issueNumber}`", + "markdownDescription": "%githubIssues.workingIssueFormatScm.markdownDescription%", "editPresentation": "multilineText" }, "githubIssues.queries": { @@ -359,27 +528,27 @@ "properties": { "label": { "type": "string", - "description": "The label to display for the query in the Issues tree." + "description": "%githubIssues.queries.label%" }, "query": { "type": "string", - "markdownDescription": "The search query using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax) with variables. The variable `${user}` can be used to specify the logged in user within a search. `${owner}` and `${repository}` can be used to specify the repository by using `repo:${owner}/${repository}`." + "markdownDescription": "%githubIssues.queries.query%" } } }, "scope": "resource", - "markdownDescription": "Specifies what queries should be used in the GitHub issues tree using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax) with variables. The first query listed will be expanded in the Issues view. The \"default\" query includes issues assigned to you by Milestone. If you want to preserve these, make sure they are still in the array when you modify the setting.", + "markdownDescription": "%githubIssues.queries.markdownDescription%", "default": [ { - "label": "My Issues", + "label": "%githubIssues.queries.default.myIssues%", "query": "default" }, { - "label": "Created Issues", + "label": "%githubIssues.queries.default.createdIssues%", "query": "author:${user} state:open repo:${owner}/${repository} sort:created-desc" }, { - "label": "Recent Issues", + "label": "%githubIssues.queries.default.recentIssues%", "query": "state:open repo:${owner}/${repository} sort:updated-desc" } ] @@ -387,12 +556,32 @@ "githubIssues.assignWhenWorking": { "type": "boolean", "default": true, - "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." + "description": "%githubIssues.assignWhenWorking.description%" }, "githubPullRequests.focusedMode": { + "properties": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "enum": [ + "firstDiff", + "overview", + "multiDiff", + false + ], + "default": "firstDiff", + "description": "%githubPullRequests.focusedMode.description%" + }, + "githubPullRequests.showPullRequestNumberInTree": { "type": "boolean", - "description": "Whether to enter focused mode when a pull request is checked out. This hides the issues and pull requests tree views.", - "default": true + "default": false, + "description": "%githubPullRequests.showPullRequestNumberInTree.description%" } } }, @@ -405,7 +594,7 @@ }, { "id": "github-pull-request", - "title": "GitHub Pull Request", + "title": "%view.github.pull.request.name%", "icon": "$(git-pull-request)" } ] @@ -414,53 +603,64 @@ "github-pull-requests": [ { "id": "github:login", - "name": "Login", + "name": "%view.github.login.name%", "when": "ReposManagerStateContext == NeedsAuthentication", "icon": "$(git-pull-request)" }, { "id": "pr:github", - "name": "Pull Requests", + "name": "%view.pr.github.name%", "when": "ReposManagerStateContext != NeedsAuthentication", "icon": "$(git-pull-request)" }, { "id": "issues:github", - "name": "Issues", + "name": "%view.issues.github.name%", "when": "ReposManagerStateContext != NeedsAuthentication", "icon": "$(issues)" } ], "github-pull-request": [ { - "id": "github:createPullRequest", + "id": "github:createPullRequestWebview", "type": "webview", - "name": "Create Pull Request", + "name": "%view.github.create.pull.request.name%", + "when": "github:createPullRequest", + "visibility": "visible", + "initialSize": 2 + }, + { + "id": "github:compareChangesFiles", + "name": "%view.github.compare.changes.name%", "when": "github:createPullRequest", - "visibility": "visible" + "visibility": "visible", + "initialSize": 1 }, { - "id": "github:compareChanges", - "name": "Compare Changes", + "id": "github:compareChangesCommits", + "name": "%view.github.compare.changesCommits.name%", "when": "github:createPullRequest", - "visibility": "visible" + "visibility": "visible", + "initialSize": 1 }, { "id": "prStatus:github", - "name": "Changes In Pull Request", - "when": "github:inReviewMode", + "name": "%view.pr.status.github.name%", + "when": "github:inReviewMode && !github:createPullRequest", "icon": "$(git-pull-request)", - "visibility": "visible" + "visibility": "visible", + "initialSize": 3 }, { "id": "github:activePullRequest", "type": "webview", - "name": "Active Pull Request", - "when": "github:inReviewMode && github:focusedReview" + "name": "%view.github.active.pull.request.name%", + "when": "github:inReviewMode && github:focusedReview && !github:createPullRequest && github:activePRCount <= 1", + "initialSize": 2 }, { "id": "github:activePullRequest:welcome", - "name": "Active Pull Request", + "name": "%view.github.active.pull.request.welcome.name%", "when": "!github:stateValidated && github:focusedReview" } ] @@ -469,458 +669,747 @@ { "command": "github.api.preloadPullRequest", "title": "Preload Pull Request", - "category": "GitHub Pull Requests" + "category": "%command.pull.request.category%" }, { "command": "pr.create", - "title": "Create Pull Request", + "title": "%command.pr.create.title%", "icon": "$(git-pull-request-create)", - "category": "GitHub Pull Requests" + "category": "%command.pull.request.category%" + }, + { + "command": "pr.pushAndCreate", + "title": "%command.pr.create.title%", + "icon": "$(git-pull-request-create)", + "category": "%command.pull.request.category%" }, { "command": "pr.pick", - "title": "Checkout Pull Request", - "category": "GitHub Pull Requests", + "title": "%command.pr.pick.title%", + "category": "%command.pull.request.category%", "icon": "$(arrow-right)" }, + { + "command": "pr.openChanges", + "title": "%command.pr.openChanges.title%", + "category": "%command.pull.request.category%", + "icon": "$(diff-multiple)" + }, + { + "command": "pr.pickOnVscodeDev", + "title": "%command.pr.pickOnVscodeDev.title%", + "category": "%command.pull.request.category%", + "icon": "$(globe)" + }, { "command": "pr.exit", - "title": "Exit Review Mode", - "category": "GitHub Pull Requests" + "title": "%command.pr.exit.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.dismissNotification", + "title": "%command.pr.dismissNotification.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.merge", - "title": "Merge Pull Request", - "category": "GitHub Pull Requests" + "title": "%command.pr.merge.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.readyForReview", - "title": "Mark Pull Request Ready For Review", - "category": "GitHub Pull Requests" + "title": "%command.pr.readyForReview.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.close", - "title": "Close Pull Request", - "category": "GitHub Pull Requests" + "title": "%command.pr.close.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.openPullRequestOnGitHub", - "title": "Open Pull Request on GitHub", - "category": "GitHub Pull Requests", + "title": "%command.pr.openPullRequestOnGitHub.title%", + "category": "%command.pull.request.category%", "icon": "$(globe)" }, { "command": "pr.openAllDiffs", - "title": "Open All Diffs", - "category": "GitHub Pull Requests" + "title": "%command.pr.openAllDiffs.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.refreshPullRequest", - "title": "Refresh Pull Request", - "category": "GitHub Pull Requests" + "title": "%command.pr.refreshPullRequest.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.openFileOnGitHub", - "title": "Open File on GitHub", - "category": "GitHub Pull Requests" + "title": "%command.pr.openFileOnGitHub.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.copyCommitHash", - "title": "Copy Commit Hash", - "category": "GitHub Pull Requests" + "title": "%command.pr.copyCommitHash.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.openOriginalFile", - "title": "Open Original File", - "category": "GitHub Pull Requests" + "title": "%command.pr.openOriginalFile.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.openModifiedFile", - "title": "Open Modified File", - "category": "GitHub Pull Requests" + "title": "%command.pr.openModifiedFile.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.openDiffView", - "title": "Open Diff View", - "category": "GitHub Pull Requests", + "title": "%command.pr.openDiffView.title%", + "category": "%command.pull.request.category%", "icon": "$(compare-changes)" }, + { + "command": "pr.openDiffViewFromEditor", + "title": "%command.pr.openDiffViewFromEditor.title%", + "category": "%command.pull.request.category%", + "icon": "$(git-pull-request)" + }, { "command": "pr.openDescription", - "title": "View Pull Request Description", - "category": "GitHub Pull Requests", + "title": "%command.pr.openDescription.title%", + "category": "%command.pull.request.category%", "when": "github:inReviewMode", "icon": "$(note)" }, { "command": "pr.openDescriptionToTheSide", - "title": "Open Pull Request Description to the Side", + "title": "%command.pr.openDescriptionToTheSide.title%", "icon": "$(split-horizontal)" }, { "command": "pr.refreshDescription", - "title": "Refresh Pull Request Description", - "category": "GitHub Pull Requests" + "title": "%command.pr.refreshDescription.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.showDiffSinceLastReview", + "title": "%command.pr.showDiffSinceLastReview.title%", + "icon": "$(git-pull-request-new-changes)" + }, + { + "command": "pr.showDiffAll", + "title": "%command.pr.showDiffAll.title%", + "icon": "$(git-pull-request-go-to-changes)" }, { "command": "pr.checkoutByNumber", - "title": "Checkout Pull Request by Number", - "category": "GitHub Pull Requests", + "title": "%command.pr.checkoutByNumber.title%", + "category": "%command.pull.request.category%", "icon": "$(symbol-numeric)" }, { "command": "review.openFile", - "title": "Open File", + "title": "%command.review.openFile.title%", + "icon": "$(go-to-file)" + }, + { + "command": "review.openLocalFile", + "title": "%command.review.openLocalFile.title%", "icon": "$(go-to-file)" }, { "command": "review.suggestDiff", - "title": "Suggest Edit", - "category": "GitHub Pull Requests" + "title": "%command.review.suggestDiff.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.refreshList", - "title": "Refresh Pull Requests List", + "title": "%command.pr.refreshList.title%", "icon": "$(refresh)", - "category": "GitHub Pull Requests" + "category": "%command.pull.request.category%" }, { "command": "pr.setFileListLayoutAsTree", - "title": "Toggle View Mode", + "title": "%command.pr.setFileListLayoutAsTree.title%", "icon": "$(list-tree)", - "category": "GitHub Pull Requests" + "category": "%command.pull.request.category%" }, { "command": "pr.setFileListLayoutAsFlat", - "title": "Toggle View Mode", + "title": "%command.pr.setFileListLayoutAsFlat.title%", "icon": "$(list-flat)", - "category": "GitHub Pull Requests" + "category": "%command.pull.request.category%" }, { "command": "pr.refreshChanges", - "title": "Refresh", + "title": "%command.pr.refreshChanges.title%", "icon": "$(refresh)", - "category": "GitHub Pull Requests" + "category": "%command.pull.request.category%" }, { "command": "pr.configurePRViewlet", - "title": "Configure...", - "category": "GitHub Pull Requests", + "title": "%command.pr.configurePRViewlet.title%", + "category": "%command.pull.request.category%", "icon": "$(gear)" }, { "command": "pr.deleteLocalBranch", - "title": "Delete Local Branch", - "category": "GitHub Pull Requests" + "title": "%command.pr.deleteLocalBranch.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.signin", - "title": "Sign in to GitHub", - "category": "GitHub Pull Requests" + "title": "%command.pr.signin.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.signinNoEnterprise", + "title": "%command.pr.signin.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.signinenterprise", + "title": "%command.pr.signinenterprise.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.deleteLocalBranchesNRemotes", - "title": "Delete local branches and remotes", - "category": "GitHub Pull Requests" + "title": "%command.pr.deleteLocalBranchesNRemotes.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.createComment", - "title": "Add Comment", - "category": "GitHub Pull Requests", + "title": "%command.pr.createComment.title%", + "category": "%command.pull.request.category%", "enablement": "!commentIsEmpty" }, { "command": "pr.createSingleComment", - "title": "Add Comment", - "category": "GitHub Pull Requests", + "title": "%command.pr.createSingleComment.title%", + "category": "%command.pull.request.category%", "enablement": "!commentIsEmpty" }, + { + "command": "pr.makeSuggestion", + "title": "%command.pr.makeSuggestion.title%", + "category": "%command.pull.request.category%" + }, { "command": "pr.startReview", - "title": "Start Review", - "category": "GitHub Pull Requests", + "title": "%command.pr.startReview.title%", + "category": "%command.pull.request.category%", "enablement": "!commentIsEmpty" }, { "command": "pr.editComment", - "title": "Edit Comment", - "category": "GitHub Pull Requests", - "icon": "$(edit)" + "title": "%command.pr.editComment.title%", + "category": "%command.pull.request.category%", + "icon": "$(edit)", + "enablement": "!(comment =~ /temporary/)" }, { "command": "pr.cancelEditComment", - "title": "Cancel", - "category": "GitHub Pull Requests" + "title": "%command.pr.cancelEditComment.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.saveComment", - "title": "Save", - "category": "GitHub Pull Requests", + "title": "%command.pr.saveComment.title%", + "category": "%command.pull.request.category%", "enablement": "!commentIsEmpty" }, { "command": "pr.deleteComment", - "title": "Delete Comment", - "category": "GitHub Pull Requests", - "icon": "$(trash)" + "title": "%command.pr.deleteComment.title%", + "category": "%command.pull.request.category%", + "icon": "$(trash)", + "enablement": "!(comment =~ /temporary/)" }, { "command": "pr.resolveReviewThread", - "title": "Resolve Conversation", - "category": "GitHub Pull Requests" + "title": "%command.pr.resolveReviewThread.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.unresolveReviewThread", - "title": "Unresolve Conversation", - "category": "GitHub Pull Requests" + "title": "%command.pr.unresolveReviewThread.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.signinAndRefreshList", - "title": "Sign in and Refresh", - "category": "GitHub Pull Requests" + "title": "%command.pr.signinAndRefreshList.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.configureRemotes", - "title": "Configure Remotes...", - "category": "GitHub Pull Requests" + "title": "%command.pr.configureRemotes.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.refreshActivePullRequest", - "title": "Refresh", - "category": "GitHub Pull Requests", + "title": "%command.pr.refreshActivePullRequest.title%", + "category": "%command.pull.request.category%", "icon": "$(refresh)" }, { "command": "pr.markFileAsViewed", - "title": "Mark File As Viewed", - "category": "GitHub Pull Requests", + "title": "%command.pr.markFileAsViewed.title%", + "category": "%command.pull.request.category%", "icon": "$(pass)" }, { "command": "pr.unmarkFileAsViewed", - "title": "Mark File As Not Viewed", - "category": "GitHub Pull Requests", + "title": "%command.pr.unmarkFileAsViewed.title%", + "category": "%command.pull.request.category%", "icon": "$(pass-filled)" }, { "command": "pr.openReview", - "title": "Go to Review", - "category": "GitHub Pull Requests" - }, - { - "command": "pr.expandAllComments", - "title": "Expand All Comments", - "category": "GitHub Pull Requests", - "icon": "$(expand-all)" + "title": "%command.pr.openReview.title%", + "category": "%command.pull.request.category%" }, { "command": "pr.collapseAllComments", - "title": "Collapse All Comments", - "category": "GitHub Pull Requests", + "title": "%command.pr.collapseAllComments.title%", + "category": "%command.comments.category%", "icon": "$(collapse-all)" }, { "command": "pr.editQuery", - "title": "Edit Query", - "category": "GitHub Pull Requests", + "title": "%command.pr.editQuery.title%", + "category": "%command.pull.request.category%", "icon": "$(edit)" }, { "command": "pr.openPullsWebsite", - "title": "Open on GitHub", - "category": "GitHub Pull Requests", + "title": "%command.pr.openPullsWebsite.title%", + "category": "%command.pull.request.category%", "icon": "$(globe)" }, + { + "command": "pr.resetViewedFiles", + "title": "%command.pr.resetViewedFiles.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.goToNextDiffInPr", + "title": "%command.pr.goToNextDiffInPr.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.goToPreviousDiffInPr", + "title": "%command.pr.goToPreviousDiffInPr.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.copyCommentLink", + "title": "%command.pr.copyCommentLink.title%", + "category": "%command.pull.request.category%", + "icon": "$(copy)", + "enablement": "!(comment =~ /temporary/)" + }, + { + "command": "pr.applySuggestion", + "title": "%command.pr.applySuggestion.title%", + "category": "%command.pull.request.category%", + "icon": "$(gift)" + }, + { + "command": "pr.addAssigneesToNewPr", + "title": "%command.pr.addAssigneesToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(account)" + }, + { + "command": "pr.addReviewersToNewPr", + "title": "%command.pr.addReviewersToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(feedback)" + }, + { + "command": "pr.addLabelsToNewPr", + "title": "%command.pr.addLabelsToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(tag)" + }, + { + "command": "pr.addMilestoneToNewPr", + "title": "%command.pr.addMilestoneToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(milestone)" + }, + { + "command": "pr.addFileComment", + "title": "%command.pr.addFileComment.title%", + "category": "%command.pull.request.category%", + "icon": "$(comment)" + }, + { + "command": "pr.checkoutFromReadonlyFile", + "title": "%command.pr.pick.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.diffWithPrHead", + "title": "%command.review.diffWithPrHead.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.diffLocalWithPrHead", + "title": "%command.review.diffLocalWithPrHead.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approve", + "title": "%command.review.approve.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.comment", + "title": "%command.review.comment.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChanges", + "title": "%command.review.requestChanges.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approveOnDotCom", + "title": "%command.review.approveOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChangesOnDotCom", + "title": "%command.review.requestChangesOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approveDescription", + "title": "%command.review.approve.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.commentDescription", + "title": "%command.review.comment.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChangesDescription", + "title": "%command.review.requestChanges.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.approveOnDotComDescription", + "title": "%command.review.approveOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "review.requestChangesOnDotComDescription", + "title": "%command.review.requestChangesOnDotCom.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuCreate", + "title": "%command.pr.createPrMenuCreate.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuDraft", + "title": "%command.pr.createPrMenuDraft.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "title": "%command.pr.createPrMenuMergeWhenReady.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuMerge", + "title": "%command.pr.createPrMenuMerge.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuSquash", + "title": "%command.pr.createPrMenuSquash.title%", + "category": "%command.pull.request.category%" + }, + { + "command": "pr.createPrMenuRebase", + "title": "%command.pr.createPrMenuRebase.title%", + "category": "%command.pull.request.category%" + }, { "command": "issue.createIssueFromSelection", - "title": "Create Issue From Selection", - "category": "GitHub Issues" + "title": "%command.issue.createIssueFromSelection.title%", + "category": "%command.issues.category%" }, { "command": "issue.createIssueFromClipboard", - "title": "Create Issue From Clipboard", - "category": "GitHub Issues" + "title": "%command.issue.createIssueFromClipboard.title%", + "category": "%command.issues.category%" + }, + { + "command": "pr.copyVscodeDevPrLink", + "title": "%command.pr.copyVscodeDevPrLink.title%", + "category": "%command.issues.category%" + }, + { + "command": "pr.refreshComments", + "title": "%command.pr.refreshComments.title%", + "category": "%command.pull.request.category%", + "icon": "$(refresh)" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.copyGithubDevLinkFile", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.copyGithubDevLink", + "title": "%command.issue.copyGithubDevLink.title%", + "category": "%command.issues.category%" }, { "command": "issue.copyGithubPermalink", - "title": "Copy GitHub Permalink", - "category": "GitHub Issues" + "title": "%command.issue.copyGithubPermalink.title%", + "category": "%command.issues.category%" }, { "command": "issue.copyGithubHeadLink", - "title": "Copy GitHub Head Link", - "category": "GitHub Issues" + "title": "%command.issue.copyGithubHeadLink.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.copyGithubPermalinkWithoutRange", + "title": "%command.issue.copyGithubPermalink.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "title": "%command.issue.copyGithubHeadLink.title%", + "category": "%command.issues.category%" }, { "command": "issue.copyMarkdownGithubPermalink", - "title": "Copy GitHub Permalink as Markdown", - "category": "GitHub Issues" + "title": "%command.issue.copyMarkdownGithubPermalink.title%", + "category": "%command.issues.category%" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "title": "%command.issue.copyMarkdownGithubPermalink.title%", + "category": "%command.issues.category%" }, { "command": "issue.openGithubPermalink", - "title": "Open Permalink on GitHub", - "category": "GitHub Issues" + "title": "%command.issue.openGithubPermalink.title%", + "category": "%command.issues.category%" }, { "command": "issue.openIssue", - "title": "Open Issue on GitHub", - "category": "GitHub Issues", + "title": "%command.issue.openIssue.title%", + "category": "%command.issues.category%", "icon": "$(globe)" }, { "command": "issue.copyIssueNumber", - "title": "Copy Number", - "category": "GitHub Issues" + "title": "%command.issue.copyIssueNumber.title%", + "category": "%command.issues.category%" }, { "command": "issue.copyIssueUrl", - "title": "Copy Url", - "category": "GitHub Issues" + "title": "%command.issue.copyIssueUrl.title%", + "category": "%command.issues.category%" }, { "command": "issue.refresh", - "title": "Refresh", - "category": "GitHub Issues", + "title": "%command.issue.refresh.title%", + "category": "%command.issues.category%", "icon": "$(refresh)" }, { "command": "issue.suggestRefresh", - "title": "Refresh Suggestions", - "category": "GitHub Issues" + "title": "%command.issue.suggestRefresh.title%", + "category": "%command.issues.category%" }, { "command": "issue.startWorking", - "title": "Start Working on Issue", - "category": "GitHub Issues", + "title": "%command.issue.startWorking.title%", + "category": "%command.issues.category%", "icon": "$(arrow-right)" }, { "command": "issue.startWorkingBranchDescriptiveTitle", - "title": "Start Working on Issue and Checkout Topic Branch", - "category": "GitHub Issues", + "title": "%command.issue.startWorkingBranchDescriptiveTitle.title%", + "category": "%command.issues.category%", "icon": "$(arrow-right)" }, { "command": "issue.continueWorking", - "title": "Continue Working on Issue", - "category": "GitHub Issues", + "title": "%command.issue.continueWorking.title%", + "category": "%command.issues.category%", "icon": "$(arrow-right)" }, { "command": "issue.startWorkingBranchPrompt", - "title": "Start Working and Set Branch...", - "category": "GitHub Issues" + "title": "%command.issue.startWorkingBranchPrompt.title%", + "category": "%command.issues.category%" }, { "command": "issue.stopWorking", - "title": "Stop Working on Issue", - "category": "GitHub Issues", + "title": "%command.issue.stopWorking.title%", + "category": "%command.issues.category%", "icon": "$(primitive-square)" }, { "command": "issue.stopWorkingBranchDescriptiveTitle", - "title": "Stop Working on Issue and Leave Topic Branch", - "category": "GitHub Issues", + "title": "%command.issue.stopWorkingBranchDescriptiveTitle.title%", + "category": "%command.issues.category%", "icon": "$(primitive-square)" }, { "command": "issue.statusBar", - "title": "Current Issue Options", - "category": "GitHub Issues" + "title": "%command.issue.statusBar.title%", + "category": "%command.issues.category%" }, { "command": "issue.getCurrent", - "title": "Get current issue", - "category": "GitHub Issues" + "title": "%command.issue.getCurrent.title%", + "category": "%command.issues.category%" }, { "command": "issue.editQuery", - "title": "Edit Query", - "category": "GitHub Issues", + "title": "%command.issue.editQuery.title%", + "category": "%command.issues.category%", "icon": "$(edit)" }, { "command": "issue.createIssue", - "title": "Create an Issue", - "category": "GitHub Issues", + "title": "%command.issue.createIssue.title%", + "category": "%command.issues.category%", "icon": "$(plus)" }, { "command": "issue.createIssueFromFile", - "title": "Create Issue", + "title": "%command.issue.createIssueFromFile.title%", "icon": "$(check)", "enablement": "!issues.creatingFromFile" }, { "command": "issue.issueCompletion", - "title": "Issue Completion Chosen" + "title": "%command.issue.issueCompletion.title%" }, { "command": "issue.userCompletion", - "title": "User Completion Chosen" + "title": "%command.issue.userCompletion.title%" }, { "command": "issue.signinAndRefreshList", - "title": "Sign in and Refresh", - "category": "GitHub Issues" + "title": "%command.issue.signinAndRefreshList.title%", + "category": "%command.issues.category%" }, { "command": "issue.goToLinkedCode", - "title": "Go to Linked Code", - "category": "GitHub Issues" + "title": "%command.issue.goToLinkedCode.title%", + "category": "%command.issues.category%" }, { "command": "issues.openIssuesWebsite", - "title": "Open on GitHub", - "category": "GitHub Pull Requests", + "title": "%command.issues.openIssuesWebsite.title%", + "category": "%command.pull.request.category%", "icon": "$(globe)" } ], "viewsWelcome": [ { "view": "github:login", - "when": "ReposManagerStateContext == NeedsAuthentication", - "contents": "You have not yet signed in with GitHub\n[Sign in](command:pr.signin)" + "when": "ReposManagerStateContext == NeedsAuthentication && github:hasGitHubRemotes", + "contents": "%welcome.github.login.contents%" + }, + { + "view": "pr:github", + "when": "gitNotInstalled", + "contents": "%welcome.github.noGit.contents%" + }, + { + "view": "github:login", + "when": "ReposManagerStateContext == NeedsAuthentication && !github:hasGitHubRemotes && gitOpenRepositoryCount", + "contents": "%welcome.github.loginNoEnterprise.contents%" }, { - "view": "github:compareChanges", - "when": "github:noUpstream", - "contents": "The comparing branch has no upstream remote\n[Publish branch](command:git.publish)" + "view": "github:login", + "when": "ReposManagerStateContext == NeedsAuthentication && !github:hasGitHubRemotes && gitOpenRepositoryCount", + "contents": "%welcome.github.loginWithEnterprise.contents%" }, { "view": "pr:github", "when": "git.state != initialized && !github:initialized && workspaceFolderCount > 0", - "contents": "Loading..." + "contents": "%welcome.pr.github.uninitialized.contents%" + }, + { + "view": "pr:github", + "when": "workspaceFolderCount > 0 && github:loadingPrsTree", + "contents": "%welcome.pr.github.uninitialized.contents%" + }, + { + "view": "pr:github", + "when": "workspaceFolderCount == 0", + "contents": "%welcome.pr.github.noFolder.contents%" }, { "view": "pr:github", - "when": "!github:initialized && workspaceFolderCount == 0", - "contents": "You have not yet opened a folder." + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 0", + "contents": "%welcome.pr.github.noRepo.contents%" }, { "view": "pr:github", - "when": "git.state == initialized && gitOpenRepositoryCount == 0", - "contents": "No git repositories found" + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 1", + "contents": "%welcome.pr.github.parentRepo.contents%" + }, + { + "view": "pr:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount > 1", + "contents": "%welcome.pr.github.parentRepo.contents%" }, { "view": "issues:github", "when": "git.state != initialized && !github:initialized && workspaceFolderCount > 0", - "contents": "Loading..." + "contents": "%welcome.issues.github.uninitialized.contents%" + }, + { + "view": "issues:github", + "when": "workspaceFolderCount > 0 && github:loadingPrsTree", + "contents": "%welcome.issues.github.uninitialized.contents%" + }, + { + "view": "issues:github", + "when": "workspaceFolderCount == 0", + "contents": "%welcome.issues.github.noFolder.contents%" + }, + { + "view": "issues:github", + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 0", + "contents": "%welcome.issues.github.noRepo.contents%" }, { "view": "issues:github", - "when": "!github:initialized && workspaceFolderCount == 0", - "contents": "You have not yet opened a folder." + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount == 1", + "contents": "%welcome.pr.github.parentRepo.contents%" }, { "view": "issues:github", - "when": "git.state == initialized && gitOpenRepositoryCount == 0", - "contents": "No git repositories found" + "when": "git.state == initialized && gitOpenRepositoryCount == 0 && workspaceFolderCount > 0 && git.parentRepositoryCount > 1", + "contents": "%welcome.pr.github.parentRepo.contents%" }, { "view": "github:activePullRequest:welcome", "when": "!github:stateValidated", - "contents": "Loading..." + "contents": "%welcome.github.activePullRequest.contents%" } ], "keybindings": [ @@ -934,6 +1423,12 @@ "mac": "cmd+s", "command": "issue.createIssueFromFile", "when": "resourceScheme == newIssue && config.files.autoSave != off" + }, + { + "key": "ctrl+enter", + "mac": "cmd+enter", + "command": "issue.createIssueFromFile", + "when": "resourceScheme == newIssue" } ], "menus": { @@ -954,14 +1449,34 @@ "command": "pr.pick", "when": "false" }, + { + "command": "pr.openChanges", + "when": "false" + }, + { + "command": "pr.pickOnVscodeDev", + "when": "false" + }, { "command": "pr.exit", + "when": "github:inReviewMode" + }, + { + "command": "pr.dismissNotification", "when": "false" }, + { + "command": "pr.resetViewedFiles", + "when": "github:inReviewMode" + }, { "command": "review.openFile", "when": "false" }, + { + "command": "review.openLocalFile", + "when": "false" + }, { "command": "pr.close", "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" @@ -970,6 +1485,10 @@ "command": "pr.create", "when": "gitHubOpenRepositoryCount != 0 && github:authenticated" }, + { + "command": "pr.pushAndCreate", + "when": "false" + }, { "command": "pr.merge", "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" @@ -1014,6 +1533,10 @@ "command": "pr.openDiffView", "when": "false" }, + { + "command": "pr.openDiffViewFromEditor", + "when": "false" + }, { "command": "pr.openDescriptionToTheSide", "when": "false" @@ -1023,63 +1546,127 @@ "when": "gitHubOpenRepositoryCount != 0 && github:inReviewMode" }, { - "command": "pr.refreshList", - "when": "gitHubOpenRepositoryCount != 0 && github:authenticated && github:hasGitHubRemotes" - }, - { - "command": "pr.setFileListLayoutAsTree", + "command": "pr.showDiffSinceLastReview", "when": "false" }, { - "command": "pr.setFileListLayoutAsFlat", + "command": "pr.showDiffAll", "when": "false" }, { - "command": "pr.refreshChanges", + "command": "review.suggestDiff", "when": "false" }, { - "command": "pr.signin", - "when": "gitHubOpenRepositoryCount != 0 && github:hasGitHubRemotes" - }, - { - "command": "pr.signinAndRefreshList", + "command": "review.approve", "when": "false" }, { - "command": "pr.copyCommitHash", + "command": "review.comment", "when": "false" }, { - "command": "pr.createComment", + "command": "review.requestChanges", "when": "false" }, { - "command": "pr.createSingleComment", + "command": "review.approveOnDotCom", "when": "false" }, { - "command": "pr.startReview", + "command": "review.requestChangesOnDotCom", "when": "false" }, { - "command": "pr.editComment", + "command": "review.approveDescription", "when": "false" }, { - "command": "pr.cancelEditComment", + "command": "review.commentDescription", "when": "false" }, { - "command": "pr.saveComment", + "command": "review.requestChangesDescription", "when": "false" }, { - "command": "pr.deleteComment", + "command": "review.approveOnDotComDescription", "when": "false" }, { - "command": "pr.openReview", + "command": "review.requestChangesOnDotComDescription", + "when": "false" + }, + { + "command": "pr.refreshList", + "when": "gitHubOpenRepositoryCount != 0 && github:authenticated && github:hasGitHubRemotes" + }, + { + "command": "pr.setFileListLayoutAsTree", + "when": "false" + }, + { + "command": "pr.setFileListLayoutAsFlat", + "when": "false" + }, + { + "command": "pr.refreshChanges", + "when": "false" + }, + { + "command": "pr.signin", + "when": "gitHubOpenRepositoryCount != 0 && github:hasGitHubRemotes" + }, + { + "command": "pr.signinNoEnterprise", + "when": "false" + }, + { + "command": "pr.signinenterprise", + "when": "gitHubOpenRepositoryCount != 0 && github:hasGitHubRemotes" + }, + { + "command": "pr.signinAndRefreshList", + "when": "false" + }, + { + "command": "pr.copyCommitHash", + "when": "false" + }, + { + "command": "pr.createComment", + "when": "false" + }, + { + "command": "pr.createSingleComment", + "when": "false" + }, + { + "command": "pr.makeSuggestion", + "when": "false" + }, + { + "command": "pr.startReview", + "when": "false" + }, + { + "command": "pr.editComment", + "when": "false" + }, + { + "command": "pr.cancelEditComment", + "when": "false" + }, + { + "command": "pr.saveComment", + "when": "false" + }, + { + "command": "pr.deleteComment", + "when": "false" + }, + { + "command": "pr.openReview", "when": "false" }, { @@ -1098,6 +1685,106 @@ "command": "pr.checkoutByNumber", "when": "gitHubOpenRepositoryCount != 0 && github:initialized && github:authenticated" }, + { + "command": "pr.collapseAllComments", + "when": "false" + }, + { + "command": "pr.copyVscodeDevPrLink", + "when": "github:inReviewMode && remoteName != codespaces && embedderIdentifier != github.dev" + }, + { + "command": "pr.goToNextDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.goToNextDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:viewedFiles" + }, + { + "command": "pr.goToPreviousDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.goToPreviousDiffInPr", + "when": "activeEditor == workbench.editors.textDiffEditor && resourcePath in github:viewedFiles" + }, + { + "command": "pr.copyCommentLink", + "when": "false" + }, + { + "command": "pr.addAssigneesToNewPr", + "when": "false" + }, + { + "command": "pr.addReviewersToNewPr", + "when": "false" + }, + { + "command": "pr.addLabelsToNewPr", + "when": "false" + }, + { + "command": "pr.addMilestoneToNewPr", + "when": "false" + }, + { + "command": "pr.addFileComment", + "when": "false" + }, + { + "command": "review.diffWithPrHead", + "when": "false" + }, + { + "command": "review.diffLocalWithPrHead", + "when": "false" + }, + { + "command": "pr.createPrMenuCreate", + "when": "false" + }, + { + "command": "pr.createPrMenuDraft", + "when": "false" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "when": "false" + }, + { + "command": "pr.createPrMenuMerge", + "when": "false" + }, + { + "command": "pr.createPrMenuSquash", + "when": "false" + }, + { + "command": "pr.createPrMenuRebase", + "when": "false" + }, + { + "command": "pr.refreshComments", + "when": "gitHubOpenRepositoryCount != 0" + }, + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.openGithubPermalink", + "when": "github:hasGitHubRemotes" + }, { "command": "issue.openIssue", "when": "false" @@ -1179,45 +1866,40 @@ "when": "false" }, { - "command": "pr.refreshActivePullRequest", + "command": "issue.copyGithubDevLinkWithoutRange", "when": "false" }, { - "command": "pr.openPullsWebsite", - "when": "github:hasGitHubRemotes" + "command": "issue.copyGithubDevLinkFile", + "when": "false" }, { - "command": "issues.openIssuesWebsite", - "when": "github:hasGitHubRemotes" - } - ], - "github.pullRequests.overflow": [ + "command": "issue.copyGithubDevLink", + "when": "false" + }, { - "command": "pr.openPullsWebsite", - "when": "gitHubOpenRepositoryCount != 0 && github:initialized", - "group": "navigation@1" + "command": "issue.copyGithubPermalinkWithoutRange", + "when": "false" }, { - "command": "pr.checkoutByNumber", - "when": "gitHubOpenRepositoryCount != 0 && github:initialized", - "group": "navigation@2" + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "false" }, { - "command": "pr.configurePRViewlet", - "when": "gitHubOpenRepositoryCount != 0 && github:initialized", - "group": "navigation@3" - } - ], - "github.issues.overflow": [ + "command": "issue.copyGithubHeadLinkWithoutRange", + "when": "false" + }, { - "command": "issues.openIssuesWebsite", - "when": "gitHubOpenRepositoryCount != 0 && github:initialized", - "group": "navigation@1" + "command": "pr.refreshActivePullRequest", + "when": "false" }, { - "command": "pr.configurePRViewlet", - "when": "gitHubOpenRepositoryCount != 0 && github:initialized", - "group": "navigation@2" + "command": "pr.openPullsWebsite", + "when": "github:hasGitHubRemotes" + }, + { + "command": "issues.openIssuesWebsite", + "when": "github:hasGitHubRemotes" } ], "view/title": [ @@ -1232,9 +1914,19 @@ "group": "navigation@2" }, { - "submenu": "github.pullRequests.overflow", + "command": "pr.openPullsWebsite", "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", - "group": "navigation@3" + "group": "overflow@1" + }, + { + "command": "pr.checkoutByNumber", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "overflow@2" + }, + { + "command": "pr.configurePRViewlet", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == pr:github", + "group": "overflow@3" }, { "command": "pr.refreshChanges", @@ -1262,9 +1954,14 @@ "group": "navigation@2" }, { - "submenu": "github.issues.overflow", + "command": "issues.openIssuesWebsite", "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == issues:github", - "group": "navigation@3" + "group": "overflow@1" + }, + { + "command": "pr.configurePRViewlet", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == issues:github", + "group": "overflow@2" }, { "command": "pr.refreshActivePullRequest", @@ -1280,33 +1977,83 @@ "command": "pr.openPullRequestOnGitHub", "when": "view == github:activePullRequest && github:hasGitHubRemotes", "group": "navigation@3" + }, + { + "command": "pr.addAssigneesToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@1" + }, + { + "command": "pr.addReviewersToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@2" + }, + { + "command": "pr.addLabelsToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@3" + }, + { + "command": "pr.addMilestoneToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@4" + }, + { + "command": "pr.refreshComments", + "when": "view == workbench.panel.comments", + "group": "navigation" } ], "view/item/context": [ { "command": "pr.pick", - "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive|description/", - "group": "pullrequest@1" + "when": "view == pr:github && viewItem =~ /(pullrequest(:local)?:nonactive)|(description:nonactive)/", + "group": "1_pullrequest@1" }, { "command": "pr.pick", - "when": "view == pr:github && viewItem =~ /description/", - "group": "inline" + "when": "view == pr:github && viewItem =~ /description:nonactive/", + "group": "inline@0" + }, + { + "command": "pr.openChanges", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description/ && config.multiDiffEditor.experimental.enabled", + "group": "inline@1" + }, + { + "command": "pr.showDiffSinceLastReview", + "group": "inline@1", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description:(active|nonactive):hasChangesSinceReview:showingAllChanges/" + }, + { + "command": "pr.showDiffAll", + "group": "inline@1", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description:(active|nonactive):hasChangesSinceReview:showingChangesSinceReview/" + }, + { + "command": "pr.openDescriptionToTheSide", + "group": "inline@2", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description/" }, { "command": "pr.exit", - "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:active|description/", - "group": "pullrequest@1" + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:active|description:active/", + "group": "1_pullrequest@1" + }, + { + "command": "pr.pickOnVscodeDev", + "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive|description/ && (!isWeb || remoteName != codespaces && virtualWorkspace != vscode-vfs)", + "group": "1_pullrequest@2" }, { "command": "pr.refreshPullRequest", "when": "view == pr:github && viewItem =~ /pullrequest|description/", - "group": "pullrequest@2" + "group": "pullrequest@1" }, { "command": "pr.openPullRequestOnGitHub", "when": "view == pr:github && viewItem =~ /pullrequest|description/", - "group": "pullrequest@3" + "group": "1_pullrequest@3" }, { "command": "pr.deleteLocalBranch", @@ -1314,18 +2061,14 @@ "group": "pullrequest@4" }, { - "command": "pr.openFileOnGitHub", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange/" + "command": "pr.dismissNotification", + "when": "view == pr:github && viewItem =~ /pullrequest(.*):notification/", + "group": "pullrequest@5" }, { "command": "pr.copyCommitHash", "when": "view == prStatus:github && viewItem =~ /commit/" }, - { - "command": "pr.openDescriptionToTheSide", - "group": "inline", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description/" - }, { "command": "review.openFile", "group": "inline@0", @@ -1337,22 +2080,29 @@ "when": "!openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" }, { - "command": "pr.markFileAsViewed", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange(.*):unviewed/", - "group": "inline@1" - }, - { - "command": "pr.unmarkFileAsViewed", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange(.*):viewed/", - "group": "inline@1" + "command": "pr.openFileOnGitHub", + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange/", + "group": "0_open@0" }, { "command": "pr.openOriginalFile", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/" + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/", + "group": "0_open@1" }, { "command": "pr.openModifiedFile", - "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/" + "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /filechange:MODIFY/", + "group": "0_open@2" + }, + { + "command": "review.diffWithPrHead", + "group": "1_diff@0", + "when": "openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" + }, + { + "command": "review.diffLocalWithPrHead", + "group": "1_diff@1", + "when": "openDiffOnClick && view == prStatus:github && viewItem =~ /filechange(?!:DELETE)/" }, { "command": "pr.editQuery", @@ -1453,7 +2203,12 @@ { "command": "review.openFile", "group": "navigation", - "when": "resourceScheme =~ /^review$/" + "when": "resourceScheme =~ /^review$/ && isInDiffEditor" + }, + { + "command": "review.openLocalFile", + "group": "navigation", + "when": "resourceScheme =~ /^review$/ && !isInDiffEditor" }, { "command": "issue.createIssueFromFile", @@ -1469,6 +2224,21 @@ "command": "pr.unmarkFileAsViewed", "group": "navigation", "when": "resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:viewedFiles" + }, + { + "command": "pr.openDiffViewFromEditor", + "group": "navigation", + "when": "!isInDiffEditor && resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:unviewedFiles" + }, + { + "command": "pr.openDiffViewFromEditor", + "group": "navigation", + "when": "!isInDiffEditor && resourceScheme != pr && resourceScheme != review && resourceScheme != filechange && resourcePath in github:viewedFiles" + }, + { + "command": "pr.addFileComment", + "group": "navigation", + "when": "(resourceScheme == pr) || (resourcePath in github:viewedFiles) || (resourcePath in github:unviewedFiles)" } ], "scm/title": [ @@ -1487,43 +2257,66 @@ { "command": "pr.createComment", "group": "inline@1", - "when": "commentController =~ /^github-browse/ && prInDraft" - }, - { - "command": "pr.createComment", - "group": "inline@1", - "when": "commentController =~ /^github-review/ && reviewInDraftMode" + "when": "(commentController =~ /^github-browse/ && prInDraft) || (commentController =~ /^github-review/ && reviewInDraftMode)" }, { "command": "pr.createSingleComment", "group": "inline@1", - "when": "commentController =~ /^github-browse/ && !prInDraft" + "when": "config.githubPullRequests.defaultCommentType != review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" }, { - "command": "pr.createSingleComment", + "command": "pr.startReview", "group": "inline@1", - "when": "commentController =~ /^github-review/ && !reviewInDraftMode" + "when": "config.githubPullRequests.defaultCommentType == review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" }, { "command": "pr.startReview", "group": "inline@2", - "when": "commentController =~ /^github-browse/ && !prInDraft" + "when": "config.githubPullRequests.defaultCommentType != review && ((commentController =~ /^github-browse/ && !prInDraft) || (commentController =~ /^github-review/ && !reviewInDraftMode))" }, { - "command": "pr.startReview", + "command": "pr.createSingleComment", "group": "inline@2", - "when": "commentController =~ /^github-review/ && !reviewInDraftMode" - }, + "when": "config.githubPullRequests.defaultCommentType == review && ((commentController =~ /^github-browse/ && !prInDraft) || commentController =~ /^github-review/ && !reviewInDraftMode)" + } + ], + "comments/comment/editorActions": [ { - "command": "pr.openReview", + "command": "pr.makeSuggestion", "group": "inline@3", - "when": "commentController =~ /^github-browse/ && prInDraft" + "when": "commentController =~ /^github-(browse|review)/" + } + ], + "comments/commentThread/additionalActions": [ + { + "command": "pr.resolveReviewThread", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread == canResolve" + }, + { + "command": "pr.unresolveReviewThread", + "group": "inline@1", + "when": "commentController =~ /^github-(browse|review)/ && commentThread == canUnresolve" }, { "command": "pr.openReview", + "group": "inline@2", + "when": "(commentController =~ /^github-browse/ && prInDraft) || (commentController =~ /^github-review/ && reviewInDraftMode)" + } + ], + "comments/commentThread/title/context": [ + { + "command": "pr.resolveReviewThread", "group": "inline@3", - "when": "commentController =~ /^github-review/ && reviewInDraftMode" + "when": "commentController =~ /^github-(browse|review)/ && commentThread == canResolve" }, + { + "command": "pr.unresolveReviewThread", + "group": "inline@3", + "when": "commentController =~ /^github-(browse|review)/ && commentThread == canUnresolve" + } + ], + "comments/commentThread/comment/context": [ { "command": "pr.resolveReviewThread", "group": "inline@3", @@ -1537,31 +2330,46 @@ ], "comments/comment/title": [ { - "command": "pr.editComment", + "command": "pr.copyCommentLink", "group": "inline@1", "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canEdit/" }, { - "command": "pr.deleteComment", + "command": "pr.applySuggestion", + "group": "inline@0", + "when": "commentController =~ /^github-review/ && comment =~ /hasSuggestion/" + }, + { + "command": "pr.editComment", "group": "inline@2", + "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canEdit/" + }, + { + "command": "pr.deleteComment", + "group": "inline@3", "when": "commentController =~ /^github-(browse|review)/ && comment =~ /canDelete/" } ], "comments/commentThread/title": [ + { + "command": "pr.refreshComments", + "group": "0_refresh@0", + "when": "commentController =~ /^github-(browse|review)/" + }, { "command": "pr.collapseAllComments", - "group": "collapse@0", + "group": "1_collapse@0", "when": "commentController =~ /^github-(browse|review)/" } ], "comments/comment/context": [ { - "command": "pr.cancelEditComment", + "command": "pr.saveComment", "group": "inline@1", "when": "commentController =~ /^github-(browse|review)/" }, { - "command": "pr.saveComment", + "command": "pr.cancelEditComment", "group": "inline@2", "when": "commentController =~ /^github-(browse|review)/" } @@ -1569,49 +2377,143 @@ "editor/context/copy": [ { "command": "issue.copyGithubPermalink", - "when": "gitHubOpenRepositoryCount != 0" + "when": "github:hasGitHubRemotes", + "group": "3_githubPullRequests@0" }, { "command": "issue.copyMarkdownGithubPermalink", - "when": "gitHubOpenRepositoryCount != 0" + "when": "github:hasGitHubRemotes", + "group": "3_githubPullRequests@1" }, { "command": "issue.copyGithubHeadLink", - "when": "gitHubOpenRepositoryCount != 0" + "when": "github:hasGitHubRemotes", + "group": "3_githubPullRequests@2" + } + ], + "editor/context/share": [ + { + "command": "issue.copyGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@0" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@1" + }, + { + "command": "issue.copyGithubHeadLink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@2" + }, + { + "command": "issue.copyGithubDevLink", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" } ], - "editor/title/context": [ + "file/share": [ { "command": "issue.copyGithubPermalink", - "when": "gitHubOpenRepositoryCount != 0", - "group": "1_cutcopypaste@10" + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@0" + }, + { + "command": "pr.copyVscodeDevPrLink", + "when": "github:hasGitHubRemotes && github:inReviewMode && remoteName != codespaces && embedderIdentifier != github.dev", + "group": "1_githubPullRequests@1" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@2" }, { "command": "issue.copyGithubHeadLink", - "when": "gitHubOpenRepositoryCount != 0", - "group": "1_cutcopypaste@11" + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@3" + }, + { + "command": "issue.copyGithubDevLinkFile", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" } ], - "explorer/context": [ + "editor/lineNumber/context": [ { "command": "issue.copyGithubPermalink", - "when": "gitHubOpenRepositoryCount != 0", - "group": "5_cutcopypaste@10" + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@3" + }, + { + "command": "issue.copyMarkdownGithubPermalink", + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@4" }, { "command": "issue.copyGithubHeadLink", - "when": "gitHubOpenRepositoryCount != 0", - "group": "5_cutcopypaste@11" + "when": "github:hasGitHubRemotes && activeEditor == workbench.editors.files.textFileEditor && config.editor.lineNumbers == on", + "group": "1_cutcopypaste@5" + }, + { + "command": "issue.copyGithubDevLink", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "1_cutcopypaste@0" + } + ], + "editor/title/context/share": [ + { + "command": "issue.copyGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@10" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@11" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "1_githubPullRequests@12" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" + } + ], + "explorer/context/share": [ + { + "command": "issue.copyGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "5_githubPulLRequests@10" + }, + { + "command": "issue.copyMarkdownGithubPermalinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "5_githubPulLRequests@11" + }, + { + "command": "issue.copyGithubHeadLinkWithoutRange", + "when": "github:hasGitHubRemotes", + "group": "5_githubPulLRequests@12" + }, + { + "command": "issue.copyGithubDevLinkWithoutRange", + "when": "github:hasGitHubRemotes && remoteName == codespaces && isWeb || github:hasGitHubRemotes && embedderIdentifier == github.dev", + "group": "0_vscode@0" } ], "menuBar/edit/copy": [ { "command": "issue.copyGithubPermalink", - "when": "gitHubOpenRepositoryCount != 0" + "when": "github:hasGitHubRemotes" }, { "command": "issue.copyMarkdownGithubPermalink", - "when": "gitHubOpenRepositoryCount != 0" + "when": "github:hasGitHubRemotes" } ], "remoteHub/pullRequest": [ @@ -1620,20 +2522,80 @@ "when": "scmProvider =~ /^remoteHub:github/", "group": "1_modification@0" } + ], + "webview/context": [ + { + "command": "pr.createPrMenuCreate", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu", + "group": "0_create@0" + }, + { + "command": "pr.createPrMenuDraft", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuDraft", + "group": "0_create@1" + }, + { + "command": "pr.createPrMenuMergeWhenReady", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuMergeWhenReady", + "group": "1_create@0" + }, + { + "command": "pr.createPrMenuMerge", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuMerge", + "group": "1_create@0" + }, + { + "command": "pr.createPrMenuSquash", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuSquash", + "group": "1_create@1" + }, + { + "command": "pr.createPrMenuRebase", + "when": "webviewId == 'github:createPullRequestWebview' && github:createPrMenu && github:createPrMenuRebase", + "group": "1_create@2" + }, + { + "command": "review.approve", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentApprove" + }, + { + "command": "review.comment", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentComment" + }, + { + "command": "review.requestChanges", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentRequestChanges" + }, + { + "command": "review.approveOnDotCom", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentApproveOnDotCom" + }, + { + "command": "review.requestChangesOnDotCom", + "when": "webviewId == 'github:activePullRequest' && github:reviewCommentMenu && github:reviewCommentRequestChangesOnDotCom" + }, + { + "command": "review.approveDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentApprove" + }, + { + "command": "review.commentDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentComment" + }, + { + "command": "review.requestChangesDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentRequestChanges" + }, + { + "command": "review.approveOnDotComDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentApproveOnDotCom" + }, + { + "command": "review.requestChangesOnDotComDescription", + "when": "webviewId == PullRequestOverview && github:reviewCommentMenu && github:reviewCommentRequestChangesOnDotCom" + } ] }, - "submenus": [ - { - "id": "github.pullRequests.overflow", - "label": "More actions...", - "icon": "$(ellipsis)" - }, - { - "id": "github.issues.overflow", - "label": "More actions...", - "icon": "$(ellipsis)" - } - ], "colors": [ { "id": "issues.newIssueDecoration", @@ -1648,8 +2610,8 @@ { "id": "issues.open", "defaults": { - "dark": "#22863a", - "light": "#22863a", + "dark": "#3FB950", + "light": "#3FB950", "highContrast": "editor.foreground", "highContrastLight": "editor.foreground" }, @@ -1664,6 +2626,56 @@ "highContrastLight": "editor.foreground" }, "description": "The color used for indicating that an issue is closed." + }, + { + "id": "pullRequests.merged", + "defaults": { + "dark": "#8957e5", + "light": "#8957e5", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is merged." + }, + { + "id": "pullRequests.draft", + "defaults": { + "dark": "#6e7681", + "light": "#6e7681", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is a draft." + }, + { + "id": "pullRequests.open", + "defaults": { + "dark": "issues.open", + "light": "issues.open", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is open." + }, + { + "id": "pullRequests.closed", + "defaults": { + "dark": "issues.closed", + "light": "issues.closed", + "highContrast": "editor.background", + "highContrastLight": "editor.background" + }, + "description": "The color used for indicating that a pull request is closed." + }, + { + "id": "pullRequests.notification", + "defaults": { + "dark": "notificationsInfoIcon.foreground", + "light": "notificationsInfoIcon.foreground", + "highContrast": "editor.foreground", + "highContrastLight": "editor.foreground" + }, + "description": "The color used for indicating a notification on a pull request" } ], "resourceLabelFormatters": [ @@ -1691,16 +2703,17 @@ "lint": "eslint --fix --cache --config .eslintrc.json --ignore-pattern src/env/browser/**/* \"{src,webviews}/**/*.{ts,tsx}\"", "lint:browser": "eslint --fix --cache --cache-location .eslintcache.browser --config .eslintrc.browser.json --ignore-pattern src/env/node/**/* \"{src,webviews}/**/*.{ts,tsx}\"", "package": "npx vsce package --yarn", - "pretty": "prettier --config .prettierrc --loglevel warn --write .", "test": "yarn run test:preprocess && node ./out/src/test/runTests.js", "test:preprocess": "yarn run compile:test && yarn run test:preprocess-gql && yarn run test:preprocess-svg", "browsertest:preprocess": "tsc ./src/test/browser/runTests.ts --outDir ./dist/browser/test --rootDir ./src/test/browser --target es6 --module commonjs", "browsertest": "yarn run browsertest:preprocess && node ./dist/browser/test/runTests.js", - "test:preprocess-gql": "node scripts/preprocess-gql --in src/github/queries.gql --out out/src/github/queries.gql", + "test:preprocess-gql": "node scripts/preprocess-gql --in src/github/queries.gql --out out/src/github/queries.gql && node scripts/preprocess-gql --in src/github/queriesExtra.gql --out out/src/github/queriesExtra.gql && node scripts/preprocess-gql --in src/github/queriesShared.gql --out out/src/github/queriesShared.gql && node scripts/preprocess-gql --in src/github/queriesLimited.gql --out out/src/github/queriesLimited.gql", "test:preprocess-svg": "node scripts/preprocess-svg --in ../resources/ --out out/resources", "update-dts": "cd \"src/@types\" && npx vscode-dts main && npx vscode-dts dev", "watch": "webpack --watch --mode development --env esbuild", - "watch:web": "webpack --watch --mode development --config-name extension:webworker --config-name webviews" + "watch:web": "webpack --watch --mode development --config-name extension:webworker --config-name webviews", + "hygiene": "node ./build/hygiene.js", + "prepare": "husky install" }, "devDependencies": { "@types/chai": "^4.1.4", @@ -1713,12 +2726,12 @@ "@types/react-dom": "^16.8.2", "@types/sinon": "7.0.11", "@types/temp": "0.8.34", - "@types/vscode": "1.58.0", + "@types/vscode": "1.79.0", "@types/webpack-env": "^1.16.0", - "@typescript-eslint/eslint-plugin": "4.18.0", - "@typescript-eslint/parser": "4.18.0", - "@vscode/test-electron": "^1.6.1", - "@vscode/test-web": "^0.0.22", + "@typescript-eslint/eslint-plugin": "6.10.0", + "@typescript-eslint/parser": "6.10.0", + "@vscode/test-electron": "^2.3.8", + "@vscode/test-web": "^0.0.29", "assert": "^2.0.0", "buffer": "^6.0.3", "constants-browserify": "^1.0.0", @@ -1727,15 +2740,17 @@ "esbuild-loader": "2.10.0", "eslint": "7.22.0", "eslint-cli": "1.1.1", - "eslint-config-prettier": "8.1.0", "eslint-plugin-import": "2.22.1", + "event-stream": "^4.0.1", "fork-ts-checker-webpack-plugin": "6.1.1", "glob": "7.1.6", "graphql": "15.5.0", "graphql-tag": "2.11.0", - "jsdom": "16.4.0", + "gulp-filter": "^7.0.0", + "husky": "^8.0.1", + "jsdom": "19.0.0", "jsdom-global": "3.0.2", - "json5": "2.2.0", + "json5": "2.2.2", "merge-options": "3.0.4", "minimist": "^1.2.6", "mkdirp": "1.0.4", @@ -1743,8 +2758,8 @@ "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", - "prettier": "2.2.1", "process": "^0.11.10", "raw-loader": "4.0.2", "react-testing-library": "7.0.1", @@ -1758,29 +2773,34 @@ "timers-browserify": "^2.0.12", "ts-loader": "8.0.18", "tty": "1.0.1", - "typescript": "4.2.3", - "webpack": "5.68.0", + "typescript": "4.5.5", + "typescript-formatter": "^7.2.2", + "vinyl-fs": "^3.0.3", + "webpack": "5.76.0", "webpack-cli": "4.2.0" }, "dependencies": { - "@octokit/rest": "18.2.0", - "@octokit/types": "6.10.0", + "@octokit/rest": "18.2.1", + "@octokit/types": "6.10.1", + "@vscode/extension-telemetry": "0.7.5", "apollo-boost": "^0.4.9", "apollo-link-context": "1.0.20", + "cockatiel": "^3.1.1", "cross-fetch": "3.1.5", "dayjs": "1.10.4", + "debounce": "^1.2.1", "events": "3.2.0", "fast-deep-equal": "^3.1.3", "lru-cache": "6.0.0", "marked": "^4.0.10", "react": "^16.12.0", "react-dom": "^16.12.0", - "ssh-config": "^2.0.0-alpha.3", + "ssh-config": "4.1.1", "tunnel": "0.0.6", + "url-search-params-polyfill": "^8.1.1", "uuid": "8.3.2", - "vscode-extension-telemetry": "0.4.5", - "vscode-tas-client": "^0.1.17", + "vscode-tas-client": "^0.1.75", "vsls": "^0.3.967" }, "license": "MIT" -} \ No newline at end of file +} diff --git a/package.nls.json b/package.nls.json new file mode 100644 index 0000000000..b47bdfc13b --- /dev/null +++ b/package.nls.json @@ -0,0 +1,308 @@ +{ + "displayName": "GitHub Pull Requests and Issues", + "description": "Pull Request and Issue Provider for GitHub", + "githubPullRequests.pullRequestDescription.description": "The description used when creating pull requests.", + "githubPullRequests.pullRequestDescription.template": "Use a pull request template and commit description, or just use the commit description if no templates were found", + "githubPullRequests.pullRequestDescription.commit": "Use the latest commit message only", + "githubPullRequests.pullRequestDescription.none": "Do not have a default description", + "githubPullRequests.pullRequestDescription.copilot": "Generate a pull request title and description from GitHub Copilot. Requires that the GitHub Copilot extension is installed and authenticated. Will fall back to `commit` if Copilot is not set up.", + "githubPullRequests.defaultCreateOption.description": "The create option that the \"Create\" button will default to when creating a pull request.", + "githubPullRequests.defaultCreateOption.lastUsed": "The most recently used create option.", + "githubPullRequests.defaultCreateOption.create": "The pull request will be created.", + "githubPullRequests.defaultCreateOption.createDraft": "The pull request will be created as a draft.", + "githubPullRequests.defaultCreateOption.createAutoMerge": "The pull request will be created with auto-merge enabled. The merge method selected will be the default for the repo or the value of `githubPullRequests.defaultMergeMethod` if set.", + "githubPullRequests.createDraft": "Whether the \"Draft\" checkbox will be checked by default when creating a pull request.", + "githubPullRequests.logLevel.description": "Logging for GitHub Pull Request extension. The log is emitted to the output channel named as GitHub Pull Request.", + "githubPullRequests.logLevel.markdownDeprecationMessage":{ + "message": "Log level is now controlled by the [Developer: Set Log Level...](command:workbench.action.setLogLevel) command. You can set the log level for the current session and also the default log level from there.", + "comment" : [ + "Do not translate what's inside of (...). It is link syntax.", + "{Locked='](command:workbench.action.setLogLevel)'}" + ] + }, + "githubPullRequests.remotes.markdownDescription": "List of remotes, by name, to fetch pull requests from.", + "githubPullRequests.queries.markdownDescription": "Specifies what queries should be used in the GitHub Pull Requests tree. All queries are made against **the currently opened repos**. Each query object has a `label` that will be shown in the tree and a search `query` using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax). The variable `${user}` can be used to specify the logged in user within a search. By default these queries define the categories \"Waiting For My Review\", \"Assigned To Me\" and \"Created By Me\". If you want to preserve these, make sure they are still in the array when you modify the setting.", + "githubPullRequests.queries.label.description": "The label to display for the query in the Pull Requests tree", + "githubPullRequests.queries.query.description": "The query used for searching pull requests.", + "githubPullRequests.queries.waitingForMyReview": "Waiting For My Review", + "githubPullRequests.queries.assignedToMe": "Assigned To Me", + "githubPullRequests.queries.createdByMe": "Created By Me", + "githubPullRequests.defaultMergeMethod.description": "The method to use when merging pull requests.", + "githubPullRequests.notifications.description": "If GitHub notifications should be shown to the user.", + "githubPullRequests.fileListLayout.description": "The layout to use when displaying changed files list.", + "githubPullRequests.defaultDeletionMethod.selectLocalBranch.description": "When true, the option to delete the local branch will be selected by default when deleting a branch from a pull request.", + "githubPullRequests.defaultDeletionMethod.selectRemote.description": "When true, the option to delete the remote will be selected by default when deleting a branch from a pull request.", + "githubPullRequests.terminalLinksHandler.description": "Default handler for terminal links.", + "githubPullRequests.terminalLinksHandler.github": "Create the pull request on GitHub", + "githubPullRequests.terminalLinksHandler.vscode": "Create the pull request in VS Code", + "githubPullRequests.terminalLinksHandler.ask": "Ask which method to use", + "githubPullRequests.createOnPublishBranch.description": "Create a pull request when a branch is published.", + "githubPullRequests.createOnPublishBranch.never": "Never create a pull request when a branch is published.", + "githubPullRequests.createOnPublishBranch.ask": "Ask if you want to create a pull request when a branch is published.", + "githubPullRequests.commentExpandState.description": "Controls whether comments are expanded when a document with comments is opened.", + "githubPullRequests.commentExpandState.expandUnresolved": "All unresolved comments will be expanded.", + "githubPullRequests.commentExpandState.collapseAll": "All comments will be collapsed", + "githubPullRequests.useReviewMode.description": "Choose which pull request states will use review mode. \"Open\" pull requests will always use review mode.", + "githubPullRequests.useReviewMode.merged": "Use review mode for merged pull requests.", + "githubPullRequests.useReviewMode.closed": "Use review mode for closed pull requests. Merged pull requests are not considered \"closed\".", + "githubPullRequests.assignCreated.description": { + "message": "All pull requests created with this extension will be assigned to this user. To assign to yourself, use the '${user}' variable.", + "comment": [ + "{Locked='${user}'}", + "Do not translate what's inside of the '${..}'. It is an internal syntax for the extension" + ] + }, + "githubPullRequests.pushBranch.description": "Push the \"from\" branch when creating a PR and the \"from\" branch is not available on the remote.", + "githubPullRequests.pushBranch.prompt": "Prompt to push the branch when creating a PR and the \"from\" branch is not available on the remote.", + "githubPullRequests.pushBranch.always": "Always push the branch when creating a PR and the \"from\" branch is not available on the remote.", + "githubPullRequests.pullBranch.description": "Pull changes from the remote when a PR branch is checked out locally. Changes are detected when the PR is manually refreshed and during periodic background updates.", + "githubPullRequests.pullBranch.prompt": "Prompt to pull a PR branch when changes are detected in the PR.", + "githubPullRequests.pullBranch.never": "Never pull a PR branch when changes are detected in the PR.", + "githubPullRequests.pullBranch.always": "Always pull a PR branch when changes are detected in the PR. When `\"git.autoStash\": true` this will instead `prompt` to prevent unexpected file changes.", + "githubPullRequests.allowFetch.description": "Allows `git fetch` to be run for checked-out pull request branches when checking for updates to the pull request.", + "githubPullRequests.ignoredPullRequestBranches.description": "Prevents branches that are associated with a pull request from being automatically detected. This will prevent review mode from being entered on these branches.", + "githubPullRequests.ignoredPullRequestBranches.items": "Branch name", + "githubPullRequests.neverIgnoreDefaultBranch.description": "Never offer to ignore a pull request associated with the default branch of a repository.", + "githubPullRequests.overrideDefaultBranch.description": "The default branch for a repository is set on github.com. With this setting, you can override that default with another branch.", + "githubPullRequests.postCreate.description": "The action to take after creating a pull request.", + "githubPullRequests.postCreate.none": "No action", + "githubPullRequests.postCreate.openOverview": "Open the overview page of the pull request", + "githubPullRequests.postCreate.checkoutDefaultBranch": "Checkout the default branch of the repository", + "githubPullRequests.postCreate.checkoutDefaultBranchAndShow": "Checkout the default branch of the repository and show the pull request in the Pull Requests view", + "githubPullRequests.postCreate.checkoutDefaultBranchAndCopy": "Checkout the default branch of the repository and copy a link to the pull request to the clipboard", + "githubPullRequests.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": "Pulls pull request before checkout", + "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", + "githubIssues.ignoreMilestones.description": "An array of milestones titles to never show issues from.", + "githubIssues.createIssueTriggers.description": "Strings that will cause the 'Create issue from comment' code action to show.", + "githubIssues.createIssueTriggers.items": "String that enables the 'Create issue from comment' code action. Should not contain whitespace.", + "githubIssues.createInsertFormat.description": "Controls whether an issue number (ex. #1234) or a full url (ex. https://github.com/owner/name/issues/1234) is inserted when the Create Issue code action is run.", + "githubIssues.issueCompletions.enabled.description": "Controls whether completion suggestions are shown for issues.", + "githubIssues.userCompletions.enabled.description": "Controls whether completion suggestions are shown for users.", + "githubIssues.ignoreCompletionTrigger.description": "Languages that the '#' character should not be used to trigger issue completion suggestions.", + "githubIssues.ignoreCompletionTrigger.items": "Language that issue completions should not trigger on '#'.", + "githubIssues.ignoreUserCompletionTrigger.description": "Languages that the '@' character should not be used to trigger user completion suggestions.", + "githubIssues.ignoreUserCompletionTrigger.items": "Language that user completions should not trigger on '@'.", + "githubIssues.issueBranchTitle.markdownDescription": { + "message": "Advanced settings for the name of the branch that is created when you start working on an issue. \n- `${user}` will be replace with the currently logged in username \n- `${issueNumber}` will be replaced with the current issue number \n- `${sanitizedIssueTitle}` will be replaced with the issue title, with all spaces and unsupported characters (https://git-scm.com/docs/git-check-ref-format) removed", + "comment": [ + "{Locked='${...}'}", + "Do not translate what's inside of the '${..}'. It is an internal syntax for the extension" + ] + }, + "githubIssues.useBranchForIssues.markdownDescription": { + "message": "Determines whether a branch should be checked out when working on an issue. To configure the name of the branch, set `#githubIssues.issueBranchTitle#`.", + "comment": [ + "{Locked='`#githubIssues.issueBranchTitle#`'}", + "Do not translate what's inside of the `...`. It is a setting id." + ] + }, + "githubIssues.useBranchForIssues.on": "A branch will always be checked out when you start working on an issue. If the branch doesn't exist, it will be created.", + "githubIssues.useBranchForIssues.off": "A branch will not be created when you start working on an issue. If you have worked on an issue before and a branch was created for it, that same branch will be checked out.", + "githubIssues.useBranchForIssues.prompt": "A prompt will show for setting the name of the branch that will be created and checked out.", + "githubIssues.issueCompletionFormatScm.markdownDescription": { + "message": "Sets the format of issue completions in the SCM inputbox. \n- `${user}` will be replace with the currently logged in username \n- `${issueNumber}` will be replaced with the current issue number \n- `${issueNumberLabel}` will be replaced with a label formatted as #number or owner/repository#number, depending on whether the issue is in the current repository", + "comment": [ + "Do not translate what's inside of the ${...}. It is an internal syntax for the extension." + ] + }, + "githubIssues.workingIssueFormatScm.markdownDescription": { + "message": "Sets the format of the commit message that is set in the SCM inputbox when you **Start Working on an Issue**. Defaults to `${issueTitle} \nFixes #${issueNumber}`", + "comment": [ + "Do not translate what's inside of the ${...}. It is an internal syntax for the extension." + ] + }, + "githubIssues.queries.markdownDescription": "Specifies what queries should be used in the GitHub issues tree using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax) with variables. The first query listed will be expanded in the Issues view. The \"default\" query includes issues assigned to you by Milestone. If you want to preserve these, make sure they are still in the array when you modify the setting.", + "githubIssues.queries.label": "The label to display for the query in the Issues tree.", + "githubIssues.queries.query": "The search query using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax) with variables. The variable `${user}` can be used to specify the logged in user within a search. `${owner}` and `${repository}` can be used to specify the repository by using `repo:${owner}/${repository}`.", + "githubIssues.queries.default.myIssues": "My Issues", + "githubIssues.queries.default.createdIssues": "Created Issues", + "githubIssues.queries.default.recentIssues": "Recent Issues", + "githubIssues.assignWhenWorking.description": "Assigns the issue you're working on to you. Only applies when the issue you're working on is in a repo you currently have open.", + "githubPullRequests.focusedMode.description": "The layout to use when a pull request is checked out. Set to false to prevent layout changes.", + "githubPullRequests.showPullRequestNumberInTree.description": "Shows the pull request number in the tree view.", + "view.github.pull.request.name": "GitHub Pull Request", + "view.github.login.name": "Login", + "view.pr.github.name": "Pull Requests", + "view.issues.github.name": "Issues", + "view.github.create.pull.request.name": "Create", + "view.github.compare.changes.name": "Files Changed", + "view.github.compare.changesCommits.name": "Commits", + "view.pr.status.github.name": "Changes In Pull Request", + "view.github.active.pull.request.name": "Active Pull Request", + "view.github.active.pull.request.welcome.name": "Active Pull Request", + "command.pull.request.category": "GitHub Pull Requests", + "command.pr.create.title": "Create Pull Request", + "command.pr.pick.title": "Checkout Pull Request", + "command.pr.openChanges.title": "Open Changes", + "command.pr.pickOnVscodeDev.title": "Checkout Pull Request on vscode.dev", + "command.pr.exit.title": "Checkout Default Branch", + "command.pr.dismissNotification.title": "Dismiss Notification", + "command.pr.merge.title": "Merge Pull Request", + "command.pr.readyForReview.title": "Mark Pull Request Ready For Review", + "command.pr.close.title": "Close Pull Request", + "command.pr.openPullRequestOnGitHub.title": "Open Pull Request on GitHub", + "command.pr.openAllDiffs.title": "Open All Diffs", + "command.pr.refreshPullRequest.title": "Refresh Pull Request", + "command.pr.openFileOnGitHub.title": "Open File on GitHub", + "command.pr.copyCommitHash.title": "Copy Commit Hash", + "command.pr.openOriginalFile.title": "Open Original File", + "command.pr.openModifiedFile.title": "Open Modified File", + "command.pr.openDiffView.title": "Open Diff View", + "command.pr.openDiffViewFromEditor.title": "Open Pull Request Diff View", + "command.pr.openDescription.title": "View Pull Request Description", + "command.pr.openDescriptionToTheSide.title": "Open Pull Request Description to the Side", + "command.pr.refreshDescription.title": "Refresh Pull Request Description", + "command.pr.showDiffSinceLastReview.title": "Show Changes Since Last Review", + "command.pr.showDiffAll.title": "Show All Changes", + "command.pr.checkoutByNumber.title": "Checkout Pull Request by Number", + "command.review.openFile.title": "Open File", + "command.review.openLocalFile.title": "Open File", + "command.review.suggestDiff.title": "Suggest Edit", + "command.review.approve.title": "Approve", + "command.review.comment.title": "Comment", + "command.review.requestChanges.title": "Request Changes", + "command.review.approveOnDotCom.title": "Approve on github.com", + "command.review.requestChangesOnDotCom.title": "Request changes on github.com", + "command.pr.refreshList.title": "Refresh Pull Requests List", + "command.pr.setFileListLayoutAsTree.title": "View as Tree", + "command.pr.setFileListLayoutAsFlat.title": "View as List", + "command.pr.refreshChanges.title": "Refresh", + "command.pr.configurePRViewlet.title": "Configure...", + "command.pr.deleteLocalBranch.title": "Delete Local Branch", + "command.pr.signin.title": "Sign in to GitHub", + "command.pr.signinenterprise.title": "Sign in to GitHub Enterprise", + "command.pr.deleteLocalBranchesNRemotes.title": "Delete local branches and remotes", + "command.pr.createComment.title": "Add Review Comment", + "command.pr.createSingleComment.title": "Add Comment", + "command.pr.makeSuggestion.title": "Make a Suggestion", + "command.pr.startReview.title": "Start Review", + "command.pr.editComment.title": "Edit Comment", + "command.pr.cancelEditComment.title": "Cancel", + "command.pr.saveComment.title": "Save", + "command.pr.deleteComment.title": "Delete Comment", + "command.pr.resolveReviewThread.title": "Resolve Conversation", + "command.pr.unresolveReviewThread.title": "Unresolve Conversation", + "command.pr.signinAndRefreshList.title": "Sign in and Refresh", + "command.pr.configureRemotes.title": "Configure Remotes...", + "command.pr.refreshActivePullRequest.title": "Refresh", + "command.pr.markFileAsViewed.title": "Mark File As Viewed", + "command.pr.unmarkFileAsViewed.title": "Mark File As Not Viewed", + "command.pr.openReview.title": "Go to Review", + "command.pr.collapseAllComments.title": "Collapse All Comments", + "command.comments.category": "Comments", + "command.pr.editQuery.title": "Edit Query", + "command.pr.openPullsWebsite.title": "Open on GitHub", + "command.pr.resetViewedFiles.title": "Reset Viewed Files", + "command.pr.goToNextDiffInPr.title": "Go to Next Diff in Pull Request", + "command.pr.goToPreviousDiffInPr.title": "Go to Previous Diff in Pull Request", + "command.pr.copyCommentLink.title": "Copy Comment Link", + "command.pr.applySuggestion.title": "Apply Suggestion", + "command.pr.addAssigneesToNewPr.title": "Add Assignees", + "command.pr.addReviewersToNewPr.title": "Add Reviewers", + "command.pr.addLabelsToNewPr.title": "Apply Labels", + "command.pr.addMilestoneToNewPr.title": "Set Milestone", + "command.pr.addFileComment.title": "Add File Comment", + "command.review.diffWithPrHead.title": "Compare Base With Pull Request Head (readonly)", + "command.review.diffLocalWithPrHead.title": "Compare Pull Request Head with Local", + "command.issues.category": "GitHub Issues", + "command.issue.createIssueFromSelection.title": "Create Issue From Selection", + "command.issue.createIssueFromClipboard.title": "Create Issue From Clipboard", + "command.pr.copyVscodeDevPrLink.title": "Copy vscode.dev Pull Request Link", + "command.pr.createPrMenuCreate.title": "Create", + "command.pr.createPrMenuDraft.title": "Create Draft", + "command.pr.createPrMenuSquash.title": "Create + Auto-Squash", + "command.pr.createPrMenuMergeWhenReady.title": "Create + Merge When Ready", + "command.pr.createPrMenuMerge.title": "Create + Auto-Merge", + "command.pr.createPrMenuRebase.title": "Create + Auto-Rebase", + "command.pr.refreshComments.title": "Refresh Pull Request Comments", + "command.issue.copyGithubDevLink.title": "Copy github.dev Link", + "command.issue.copyGithubPermalink.title": "Copy GitHub Permalink", + "command.issue.copyGithubHeadLink.title": "Copy GitHub Head Link", + "command.issue.copyMarkdownGithubPermalink.title": "Copy GitHub Permalink as Markdown", + "command.issue.openGithubPermalink.title": "Open Permalink on GitHub", + "command.issue.openIssue.title": "Open Issue on GitHub", + "command.issue.copyIssueNumber.title": "Copy Issue Number", + "command.issue.copyIssueUrl.title": "Copy Issue Link", + "command.issue.refresh.title": "Refresh", + "command.issue.suggestRefresh.title": "Refresh Suggestions", + "command.issue.startWorking.title": "Start Working on Issue", + "command.issue.startWorkingBranchDescriptiveTitle.title": "Start Working on Issue and Checkout Topic Branch", + "command.issue.continueWorking.title": "Continue Working on Issue", + "command.issue.startWorkingBranchPrompt.title": "Start Working and Set Branch...", + "command.issue.stopWorking.title": "Stop Working on Issue", + "command.issue.stopWorkingBranchDescriptiveTitle.title": "Stop Working on Issue and Leave Topic Branch", + "command.issue.statusBar.title": "Current Issue Options", + "command.issue.getCurrent.title": "Get current issue", + "command.issue.editQuery.title": "Edit Query", + "command.issue.createIssue.title": "Create an Issue", + "command.issue.createIssueFromFile.title": "Create Issue", + "command.issue.issueCompletion.title": "Issue Completion Chosen", + "command.issue.userCompletion.title": "User Completion Chosen", + "command.issue.signinAndRefreshList.title": "Sign in and Refresh", + "command.issue.goToLinkedCode.title": "Go to Linked Code", + "command.issues.openIssuesWebsite.title": "Open on GitHub", + "welcome.github.login.contents": { + "message": "You have not yet signed in with GitHub\n[Sign in](command:pr.signin)", + "comment" : [ + "Do not translate what's inside of (...). It is link syntax.", + "{Locked='](command:pr.signin)'}" + ] + }, + "welcome.github.noGit.contents": "Git is not installed or otherwise not available. Install git or fix your git installation and then reload.", + "welcome.github.loginNoEnterprise.contents": { + "message": "You have not yet signed in with GitHub\n[Sign in](command:pr.signinNoEnterprise)", + "comment" : [ + "Do not translate what's inside of (...). It is link syntax.", + "{Locked='](command:pr.signinNoEnterprise)'}" + ] + }, + "welcome.github.loginWithEnterprise.contents": { + "message": "[Sign in with GitHub Enterprise](command:pr.signinenterprise)", + "comment": [ + "Do not translate what's inside of (...). It is link syntax.", + "{Locked='](command:pr.signinenterprise)'}" + ] + }, + "welcome.pr.github.uninitialized.contents": "Loading...", + "welcome.pr.github.noFolder.contents": { + "message": "You have not yet opened a folder.\n[Open Folder](command:workbench.action.files.openFolder)", + "comment" : [ + "Do not translate what's inside of (...). It is link syntax.", + "{Locked='](command:workbench.action.files.openFolder)'}" + ] + }, + "welcome.pr.github.noRepo.contents": "No git repositories found", + "welcome.pr.github.parentRepo.contents": { + "message": "A git repository was found in the parent folders of the workspace or the open file(s).\n[Open Repository](command:git.openRepositoriesInParentFolders)\nUse the [git.openRepositoryInParentFolders](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D) setting to control whether git repositories in parent folders of workspaces or open files are opened. To learn more [read our docs](https://aka.ms/vscode-git-repository-in-parent-folders).", + "comment": [ + "{Locked='](command:git.openRepositoriesInParentFolders'}", + "{Locked='](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D'}", + "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", + "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" + ] + }, + "welcome.pr.github.parentRepos.contents": { + "message": "Git repositories were found in the parent folders of the workspace or the open file(s).\n[Open Repository](command:git.openRepositoriesInParentFolders)\nUse the [git.openRepositoryInParentFolders](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D) setting to control whether git repositories in parent folders of workspace or open files are opened. To learn more [read our docs](https://aka.ms/vscode-git-repository-in-parent-folders).", + "comment": [ + "{Locked='](command:git.openRepositoriesInParentFolders'}", + "{Locked='](command:workbench.action.openSettings?%5B%22git.openRepositoryInParentFolders%22%5D'}", + "Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code", + "Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links" + ] + }, + "welcome.issues.github.uninitialized.contents": "Loading...", + "welcome.issues.github.noFolder.contents": "You have not yet opened a folder.", + "welcome.issues.github.noRepo.contents": "No git repositories found", + "welcome.github.activePullRequest.contents": "Loading...", + "submenu.github.pullRequests.overflow.label": "More actions...", + "submenu.github.issues.overflow.label": "More actions..." +} \ No newline at end of file diff --git a/resources/icons/assignee.svg b/resources/icons/assignee.svg new file mode 100644 index 0000000000..2bb8758146 --- /dev/null +++ b/resources/icons/assignee.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/resources/icons/chevron_down.svg b/resources/icons/chevron_down.svg new file mode 100644 index 0000000000..d369b3dc9d --- /dev/null +++ b/resources/icons/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/close.svg b/resources/icons/close.svg new file mode 100644 index 0000000000..f8af265cc4 --- /dev/null +++ b/resources/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/commit_icon.svg b/resources/icons/commit_icon.svg index 63d67b4dd6..dc1d10c63f 100644 --- a/resources/icons/commit_icon.svg +++ b/resources/icons/commit_icon.svg @@ -1,5 +1,3 @@ - - - + diff --git a/resources/icons/delete.svg b/resources/icons/delete.svg index f8af265cc4..4bebdd27c4 100644 --- a/resources/icons/delete.svg +++ b/resources/icons/delete.svg @@ -1,3 +1,3 @@ - + diff --git a/resources/icons/diff.svg b/resources/icons/diff.svg deleted file mode 100644 index 17faabf478..0000000000 --- a/resources/icons/diff.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/resources/icons/dot.svg b/resources/icons/dot.svg index 788f8f7ed7..0394588aec 100644 --- a/resources/icons/dot.svg +++ b/resources/icons/dot.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/resources/icons/gear.svg b/resources/icons/gear.svg new file mode 100644 index 0000000000..d4b13f9797 --- /dev/null +++ b/resources/icons/gear.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/git_compare.svg b/resources/icons/git_compare.svg deleted file mode 100644 index 43ff676544..0000000000 --- a/resources/icons/git_compare.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/icons/label.svg b/resources/icons/label.svg new file mode 100644 index 0000000000..06c7fb55ea --- /dev/null +++ b/resources/icons/label.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/merge_method.svg b/resources/icons/merge_method.svg new file mode 100644 index 0000000000..b8596218f1 --- /dev/null +++ b/resources/icons/merge_method.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/milestone.svg b/resources/icons/milestone.svg new file mode 100644 index 0000000000..58ac83a825 --- /dev/null +++ b/resources/icons/milestone.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/pr.svg b/resources/icons/pr.svg new file mode 100644 index 0000000000..6d59036b31 --- /dev/null +++ b/resources/icons/pr.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/pr_base.svg b/resources/icons/pr_base.svg new file mode 100644 index 0000000000..862a547917 --- /dev/null +++ b/resources/icons/pr_base.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/pr_closed.svg b/resources/icons/pr_closed.svg new file mode 100644 index 0000000000..4fb8a73d1f --- /dev/null +++ b/resources/icons/pr_closed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/icons/pr_draft.svg b/resources/icons/pr_draft.svg new file mode 100644 index 0000000000..d0cf9b3008 --- /dev/null +++ b/resources/icons/pr_draft.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resources/icons/pr_merge.svg b/resources/icons/pr_merge.svg new file mode 100644 index 0000000000..cf26950f84 --- /dev/null +++ b/resources/icons/pr_merge.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/repo.svg b/resources/icons/repo.svg deleted file mode 100644 index b472902721..0000000000 --- a/resources/icons/repo.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 new file mode 100644 index 0000000000..c412bb8546 --- /dev/null +++ b/resources/icons/request_changes.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/resources/icons/reviewer.svg b/resources/icons/reviewer.svg new file mode 100644 index 0000000000..e83e580fd1 --- /dev/null +++ b/resources/icons/reviewer.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/settings.svg b/resources/icons/settings.svg new file mode 100644 index 0000000000..4e7e022bcf --- /dev/null +++ b/resources/icons/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/sparkle.svg b/resources/icons/sparkle.svg new file mode 100644 index 0000000000..442e6cc389 --- /dev/null +++ b/resources/icons/sparkle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/stop.svg b/resources/icons/stop.svg new file mode 100644 index 0000000000..9a63e2191f --- /dev/null +++ b/resources/icons/stop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/sync.svg b/resources/icons/sync.svg new file mode 100644 index 0000000000..63c0090a6c --- /dev/null +++ b/resources/icons/sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/ci/common-setup.yml b/scripts/ci/common-setup.yml index f8cd1f2b0a..cad46bba53 100644 --- a/scripts/ci/common-setup.yml +++ b/scripts/ci/common-setup.yml @@ -2,7 +2,8 @@ steps: - task: NodeTool@0 displayName: Upgrade Node inputs: - versionSpec: '14.x' + versionSpec: '16.x' - - script: yarn install --frozen-lockfile + - script: yarn install --frozen-lockfile --check-files displayName: Install dependencies + retryCountOnTaskFailure: 3 diff --git a/scripts/prepare-nightly-build.js b/scripts/prepare-nightly-build.js index 90a8166e07..e8564c52fc 100644 --- a/scripts/prepare-nightly-build.js +++ b/scripts/prepare-nightly-build.js @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + const fs = require('fs'); const argv = require('minimist')(process.argv.slice(2)); @@ -15,6 +20,7 @@ function prependZero(number) { // update name, publisher and description // calculate version +// If the format of the patch version is ever changed, the isPreRelease utility function should be updated. let patch = argv['v']; if (typeof patch !== 'string') { const date = new Date(); diff --git a/src/@types/git.d.ts b/src/@types/git.d.ts index 7a62d3950b..b3d6fbceaf 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 } from 'vscode'; +import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken } from 'vscode'; export { ProviderResult } from 'vscode'; export interface Git { @@ -103,7 +103,6 @@ export interface Change { export interface RepositoryState { readonly HEAD: Branch | undefined; - readonly refs: Ref[]; readonly remotes: Remote[]; readonly submodules: Submodule[]; readonly rebaseCommit: Commit | undefined; @@ -137,6 +136,15 @@ export interface CommitOptions { empty?: boolean; noVerify?: boolean; requireUserConfig?: boolean; + useEditor?: boolean; + verbose?: boolean; + /** + * string - execute the specified command after the commit operation + * undefined - execute the command specified in git.postCommitCommand + * after the commit operation + * null - do not execute any command after the commit operation + */ + postCommitCommand?: string | null; } export interface FetchOptions { @@ -147,11 +155,15 @@ export interface FetchOptions { depth?: number; } -export interface BranchQuery { - readonly remote?: boolean; - readonly pattern?: string; - readonly count?: number; +export interface RefQuery { readonly contains?: string; + readonly count?: number; + readonly pattern?: string; + readonly sort?: 'alphabetically' | 'committerdate'; +} + +export interface BranchQuery extends RefQuery { + readonly remote?: boolean; } export interface Repository { @@ -172,6 +184,8 @@ export interface Repository { show(ref: string, path: string): Promise; getCommit(ref: string): Promise; + add(paths: string[]): Promise; + revert(paths: string[]): Promise; clean(paths: string[]): Promise; apply(patch: string, reverse?: boolean): Promise; @@ -193,11 +207,16 @@ export interface Repository { createBranch(name: string, checkout: boolean, ref?: string): Promise; deleteBranch(name: string, force?: boolean): Promise; getBranch(name: string): Promise; - getBranches(query: BranchQuery): Promise; + getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise; setBranchUpstream(name: string, upstream: string): Promise; + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; + getMergeBase(ref1: string, ref2: string): Promise; + tag(name: string, upstream: string): Promise; + deleteTag(name: string): Promise; + status(): Promise; checkout(treeish: string): Promise; @@ -231,6 +250,12 @@ export interface RemoteSourceProvider { publishRepository?(repository: Repository): Promise; } +export interface RemoteSourcePublisher { + readonly name: string; + readonly icon?: string; // codicon name + publishRepository(repository: Repository): Promise; +} + export interface Credentials { readonly username: string; readonly password: string; @@ -240,6 +265,10 @@ export interface CredentialsProvider { getCredentials(host: Uri): ProviderResult; } +export interface PostCommitCommandsProvider { + getCommands(repository: Repository): Command[]; +} + export interface PushErrorHandler { handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise; } @@ -265,8 +294,10 @@ export interface GitAPI { init(root: Uri): 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; } @@ -278,7 +309,7 @@ export interface GitExtension { /** * Returns a specific API version. * - * Throws error if git extension is disabled. You can listed to the + * Throws error if git extension is disabled. You can listen to the * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event * to know when the extension becomes enabled/disabled. * @@ -324,4 +355,8 @@ export const enum GitErrorCodes { PatchDoesNotApply = 'PatchDoesNotApply', NoPathFound = 'NoPathFound', UnknownPath = 'UnknownPath', + EmptyCommitMessage = 'EmptyCommitMessage', + BranchFastForwardRejected = 'BranchFastForwardRejected', + BranchNotYetBorn = 'BranchNotYetBorn', + TagConflict = 'TagConflict' } diff --git a/src/@types/vscode.proposed.codiconDecoration.d.ts b/src/@types/vscode.proposed.codiconDecoration.d.ts new file mode 100644 index 0000000000..2a0fc4578b --- /dev/null +++ b/src/@types/vscode.proposed.codiconDecoration.d.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/135591 @alexr00 + + // export interface FileDecorationProvider { + // provideFileDecoration(uri: Uri, token: CancellationToken): ProviderResult; + // } + + /** + * A file decoration represents metadata that can be rendered with a file. + */ + export class FileDecoration2 { + /** + * A very short string that represents this decoration. + */ + badge?: string | ThemeIcon; + + /** + * A human-readable tooltip for this decoration. + */ + tooltip?: string; + + /** + * The color of this decoration. + */ + color?: ThemeColor; + + /** + * A flag expressing that this decoration should be + * propagated to its parents. + */ + propagate?: boolean; + + /** + * Creates a new decoration. + * + * @param badge A letter that represents the decoration. + * @param tooltip The tooltip of the decoration. + * @param color The color of the decoration. + */ + constructor(badge?: string | ThemeIcon, tooltip?: string, color?: ThemeColor); + } +} diff --git a/src/@types/vscode.proposed.commentTimestamp.d.ts b/src/@types/vscode.proposed.commentReactor.d.ts similarity index 65% rename from src/@types/vscode.proposed.commentTimestamp.d.ts rename to src/@types/vscode.proposed.commentReactor.d.ts index 2867dcbfea..7356d025dd 100644 --- a/src/@types/vscode.proposed.commentTimestamp.d.ts +++ b/src/@types/vscode.proposed.commentReactor.d.ts @@ -1,14 +1,10 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - export interface Comment { - /** - * An optional timestamp that will be displayed in comments. - * The date will be formatted according to the user's locale and settings. - */ - timestamp?: Date; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + export interface CommentReaction { + readonly reactors?: readonly string[]; + } +} diff --git a/src/@types/vscode.proposed.contribCommentEditorActionsMenu.d.ts b/src/@types/vscode.proposed.contribCommentEditorActionsMenu.d.ts new file mode 100644 index 0000000000..9b24bcc32e --- /dev/null +++ b/src/@types/vscode.proposed.contribCommentEditorActionsMenu.d.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder declaration for the `comments/comment/editorActions` menu diff --git a/src/@types/vscode.proposed.contribCommentPeekContext.d.ts b/src/@types/vscode.proposed.contribCommentPeekContext.d.ts new file mode 100644 index 0000000000..251df53c3a --- /dev/null +++ b/src/@types/vscode.proposed.contribCommentPeekContext.d.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder for comment peek context menus + +// https://github.com/microsoft/vscode/issues/151533 @alexr00 + + diff --git a/src/@types/vscode.proposed.contribCommentThreadAdditionalMenu.d.ts b/src/@types/vscode.proposed.contribCommentThreadAdditionalMenu.d.ts new file mode 100644 index 0000000000..2e71d90a25 --- /dev/null +++ b/src/@types/vscode.proposed.contribCommentThreadAdditionalMenu.d.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder for comment thread additional menus + +// https://github.com/microsoft/vscode/issues/163281 diff --git a/src/@types/vscode.proposed.contribShareMenu.d.ts b/src/@types/vscode.proposed.contribShareMenu.d.ts new file mode 100644 index 0000000000..e308029d4e --- /dev/null +++ b/src/@types/vscode.proposed.contribShareMenu.d.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder declaration for the `file/share`-submenu contribution point +// https://github.com/microsoft/vscode/issues/176316 diff --git a/src/@types/vscode.proposed.diffCommand.d.ts b/src/@types/vscode.proposed.diffCommand.d.ts new file mode 100644 index 0000000000..84f4328e07 --- /dev/null +++ b/src/@types/vscode.proposed.diffCommand.d.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/84899 + + /** + * The contiguous set of modified lines in a diff. + */ + export interface LineChange { + readonly originalStartLineNumber: number; + readonly originalEndLineNumber: number; + readonly modifiedStartLineNumber: number; + readonly modifiedEndLineNumber: number; + } + + export namespace commands { + + /** + * Registers a diff information command that can be invoked via a keyboard shortcut, + * a menu item, an action, or directly. + * + * Diff information commands are different from ordinary {@link commands.registerCommand commands} as + * they only execute when there is an active diff editor when the command is called, and the diff + * information has been computed. Also, the command handler of an editor command has access to + * the diff information. + * + * @param command A unique identifier for the command. + * @param callback A command handler function with access to the {@link LineChange diff information}. + * @param thisArg The `this` context used when invoking the handler function. + * @return Disposable which unregisters this command on disposal. + */ + export function registerDiffInformationCommand(command: string, callback: (diff: LineChange[], ...args: any[]) => any, thisArg?: any): Disposable; + } +} diff --git a/src/@types/vscode.proposed.fileComments.d.ts b/src/@types/vscode.proposed.fileComments.d.ts new file mode 100644 index 0000000000..09c729145f --- /dev/null +++ b/src/@types/vscode.proposed.fileComments.d.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. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export interface CommentThread2 { + /** + * The uri of the document the thread has been created on. + */ + readonly uri: Uri; + + /** + * The range the comment thread is located within the document. The thread icon will be shown + * at the last line of the range. + */ + range: Range | undefined; + + /** + * The ordered comments of the thread. + */ + comments: readonly Comment[]; + + /** + * Whether the thread should be collapsed or expanded when opening the document. + * Defaults to Collapsed. + */ + collapsibleState: CommentThreadCollapsibleState; + + /** + * Whether the thread supports reply. + * Defaults to true. + */ + canReply: boolean; + + /** + * Context value of the comment thread. This can be used to contribute thread specific actions. + * For example, a comment thread is given a context value as `editable`. When contributing actions to `comments/commentThread/title` + * using `menus` extension point, you can specify context value for key `commentThread` in `when` expression like `commentThread == editable`. + * ```json + * "contributes": { + * "menus": { + * "comments/commentThread/title": [ + * { + * "command": "extension.deleteCommentThread", + * "when": "commentThread == editable" + * } + * ] + * } + * } + * ``` + * This will show action `extension.deleteCommentThread` only for comment threads with `contextValue` is `editable`. + */ + contextValue?: string; + + /** + * The optional human-readable label describing the {@link CommentThread Comment Thread} + */ + label?: string; + + /** + * The optional state of a comment thread, which may affect how the comment is displayed. + */ + state?: CommentThreadState; + + /** + * Dispose this comment thread. + * + * Once disposed, this comment thread will be removed from visible editors and Comment Panel when appropriate. + */ + dispose(): void; + } + + export interface CommentController { + createCommentThread(uri: Uri, range: Range | undefined, comments: readonly Comment[]): CommentThread | CommentThread2; + } + + export interface CommentingRangeProvider2 { + /** + * Provide a list of ranges which allow new comment threads creation or null for a given document + */ + provideCommentingRanges(document: TextDocument, token: CancellationToken): ProviderResult; + } +} diff --git a/src/@types/vscode.proposed.commentsResolvedState.d.ts b/src/@types/vscode.proposed.quickDiffProvider.d.ts similarity index 53% rename from src/@types/vscode.proposed.commentsResolvedState.d.ts rename to src/@types/vscode.proposed.quickDiffProvider.d.ts index 95fb288dd2..3778e7092e 100644 --- a/src/@types/vscode.proposed.commentsResolvedState.d.ts +++ b/src/@types/vscode.proposed.quickDiffProvider.d.ts @@ -5,20 +5,13 @@ declare module 'vscode' { - // https://github.com/microsoft/vscode/issues/127473 + // https://github.com/microsoft/vscode/issues/169012 - /** - * The state of a comment thread. - */ - export enum CommentThreadState { - Unresolved = 0, - Resolved = 1 + export namespace window { + export function registerQuickDiffProvider(selector: DocumentSelector, quickDiffProvider: QuickDiffProvider, label: string, rootUri?: Uri): Disposable; } - export interface CommentThread { - /** - * The optional state of a comment thread, which may affect how the comment is displayed. - */ - state?: CommentThreadState; + interface QuickDiffProvider { + label?: string; } } diff --git a/src/@types/vscode.proposed.readonlyMessage.d.ts b/src/@types/vscode.proposed.readonlyMessage.d.ts new file mode 100644 index 0000000000..03bad3a664 --- /dev/null +++ b/src/@types/vscode.proposed.readonlyMessage.d.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://github.com/microsoft/vscode/issues/166971 + +declare module 'vscode' { + + export namespace workspace { + + export function registerFileSystemProvider(scheme: string, provider: FileSystemProvider, options?: { readonly isCaseSensitive?: boolean; readonly isReadonly?: boolean | MarkdownString }): Disposable; + } +} diff --git a/src/@types/vscode.proposed.shareProvider.d.ts b/src/@types/vscode.proposed.shareProvider.d.ts new file mode 100644 index 0000000000..8c432341a7 --- /dev/null +++ b/src/@types/vscode.proposed.shareProvider.d.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://github.com/microsoft/vscode/issues/176316 @joyceerhl + +declare module 'vscode' { + + /** + * Data about an item which can be shared. + */ + export interface ShareableItem { + /** + * A resource in the workspace that can be shared. + */ + resourceUri: Uri; + + /** + * If present, a selection within the `resourceUri`. + */ + selection?: Range; + } + + /** + * A provider which generates share links for resources in the editor. + */ + export interface ShareProvider { + + /** + * A unique ID for the provider. + * This will be used to activate specific extensions contributing share providers if necessary. + */ + readonly id: string; + + /** + * A label which will be used to present this provider's options in the UI. + */ + readonly label: string; + + /** + * The order in which the provider should be listed in the UI when there are multiple providers. + */ + readonly priority: number; + + /** + * + * @param item Data about an item which can be shared. + * @param token A cancellation token. + * @returns A {@link Uri} representing an external link or sharing text. The provider result + * will be copied to the user's clipboard and presented in a confirmation dialog. + */ + provideShare(item: ShareableItem, token: CancellationToken): ProviderResult; + } + + export namespace window { + + /** + * Register a share provider. An extension may register multiple share providers. + * There may be multiple share providers for the same {@link ShareableItem}. + * @param selector A document selector to filter whether the provider should be shown for a {@link ShareableItem}. + * @param provider A share provider. + */ + export function registerShareProvider(selector: DocumentSelector, provider: ShareProvider): Disposable; + } + + export interface TreeItem { + + /** + * An optional property which, when set, inlines a `Share` option in the context menu for this tree item. + */ + shareableItem?: ShareableItem; + } +} diff --git a/src/@types/vscode.proposed.treeViewMarkdownMessage.d.ts b/src/@types/vscode.proposed.treeViewMarkdownMessage.d.ts new file mode 100644 index 0000000000..ad4655d9bc --- /dev/null +++ b/src/@types/vscode.proposed.treeViewMarkdownMessage.d.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export interface TreeView2 extends Disposable { + readonly onDidExpandElement: Event>; + readonly onDidCollapseElement: Event>; + readonly selection: readonly T[]; + readonly onDidChangeSelection: Event>; + readonly visible: boolean; + readonly onDidChangeVisibility: Event; + readonly onDidChangeCheckboxState: Event>; + title?: string; + description?: string; + badge?: ViewBadge | undefined; + reveal(element: T, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }): Thenable; + + /** + * An optional human-readable message that will be rendered in the view. + * Only a subset of markdown is supported. + * Setting the message to null, undefined, or empty string will remove the message from the view. + */ + message?: string | MarkdownString; + } +} diff --git a/src/api/api.d.ts b/src/api/api.d.ts index 8893504b2a..98c8fb8d2e 100644 --- a/src/api/api.d.ts +++ b/src/api/api.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, Event, Uri } from 'vscode'; +import { CancellationToken, Disposable, Event, Uri } from 'vscode'; import { APIState, PublishEvent } from '../@types/git'; export interface InputBox { @@ -53,27 +53,7 @@ export interface Remote { readonly isReadOnly: boolean; } -export const enum Status { - INDEX_MODIFIED, - INDEX_ADDED, - INDEX_DELETED, - INDEX_RENAMED, - INDEX_COPIED, - - MODIFIED, - DELETED, - UNTRACKED, - IGNORED, - INTENT_TO_ADD, - - ADDED_BY_US, - ADDED_BY_THEM, - DELETED_BY_US, - DELETED_BY_THEM, - BOTH_ADDED, - BOTH_DELETED, - BOTH_MODIFIED, -} +export { Status } from './api1'; export interface Change { /** @@ -89,7 +69,6 @@ export interface Change { export interface RepositoryState { readonly HEAD: Branch | undefined; - readonly refs: Ref[]; readonly remotes: Remote[]; readonly submodules: Submodule[]; readonly rebaseCommit: Commit | undefined; @@ -122,11 +101,15 @@ export interface FetchOptions { depth?: number; } -export interface BranchQuery { - readonly remote?: boolean; - readonly pattern?: string; - readonly count?: number; +export interface RefQuery { readonly contains?: string; + readonly count?: number; + readonly pattern?: string; + readonly sort?: 'alphabetically' | 'committerdate'; +} + +export interface BranchQuery extends RefQuery { + readonly remote?: boolean; } export interface Repository { @@ -201,6 +184,8 @@ export interface Repository { getBranch(name: string): Promise; getBranches(query: BranchQuery): Promise; setBranchUpstream(name: string, upstream: string): Promise; + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; + getMergeBase(ref1: string, ref2: string): Promise; status(): Promise; @@ -219,6 +204,7 @@ export interface Repository { log(options?: LogOptions): Promise; commit(message: string, opts?: CommitOptions): Promise; + add(paths: string[]): Promise; } /** @@ -228,6 +214,12 @@ export interface LogOptions { /** Max number of log entries to retrieve. If not specified, the default is 32. */ readonly maxEntries?: number; readonly path?: string; + /** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */ + readonly range?: string; +} + +export interface PostCommitCommandsProvider { + getCommands(repository: Repository): Command[]; } export { GitErrorCodes } from './api1'; @@ -241,6 +233,13 @@ export interface IGit { readonly state?: APIState; readonly onDidChangeState?: Event; readonly onDidPublish?: Event; + + registerPostCommitCommandsProvider?(provider: PostCommitCommandsProvider): Disposable; +} + +export interface TitleAndDescriptionProvider { + provideTitleAndDescription(commitMessages: string[], patches: string[], token: CancellationToken): Promise<{ title: string, description?: string } | undefined>; + provideTitleAndDescription(context: { commitMessages: string[], patches: string[], issues?: { reference: string, content: string }[] }, token: CancellationToken): Promise<{ title: string, description?: string } | undefined>; } export interface API { @@ -256,4 +255,9 @@ export interface API { * @return A git provider or `undefined` */ getGitProvider(uri: Uri): IGit | undefined; + + /** + * Register a PR title and description provider. + */ + registerTitleAndDescriptionProvider(title: string, provider: TitleAndDescriptionProvider): Disposable; } diff --git a/src/api/api1.ts b/src/api/api1.ts index f769114925..84450bff55 100644 --- a/src/api/api1.ts +++ b/src/api/api1.ts @@ -7,7 +7,7 @@ import * as vscode from 'vscode'; import { APIState, PublishEvent } from '../@types/git'; import Logger from '../common/logger'; import { TernarySearchTree } from '../common/utils'; -import { API, IGit, Repository } from './api'; +import { API, IGit, PostCommitCommandsProvider, Repository, TitleAndDescriptionProvider } from './api'; export const enum RefType { Head, @@ -50,6 +50,28 @@ export const enum GitErrorCodes { PatchDoesNotApply = 'PatchDoesNotApply', } +export const enum Status { + INDEX_MODIFIED, + INDEX_ADDED, + INDEX_DELETED, + INDEX_RENAMED, + INDEX_COPIED, + + MODIFIED, + DELETED, + UNTRACKED, + IGNORED, + INTENT_TO_ADD, + + ADDED_BY_US, + ADDED_BY_THEM, + DELETED_BY_US, + DELETED_BY_THEM, + BOTH_ADDED, + BOTH_DELETED, + BOTH_MODIFIED, +} + export class GitApiImpl implements API, IGit, vscode.Disposable { private static _handlePool: number = 0; private _providers = new Map(); @@ -150,6 +172,18 @@ export class GitApiImpl implements API, IGit, vscode.Disposable { return foldersMap.findSubstr(uri); } + registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): vscode.Disposable { + const disposables = Array.from(this._providers.values()).map(gitProvider => { + if (gitProvider.registerPostCommitCommandsProvider) { + return gitProvider.registerPostCommitCommandsProvider(provider); + } + return { dispose: () => { } }; + }); + return { + dispose: () => disposables.forEach(disposable => disposable.dispose()) + }; + } + private _nextHandle(): number { return GitApiImpl._handlePool++; } @@ -157,4 +191,28 @@ export class GitApiImpl implements API, IGit, vscode.Disposable { dispose() { this._disposables.forEach(disposable => disposable.dispose()); } + + private _titleAndDescriptionProviders: Set<{ title: string, provider: TitleAndDescriptionProvider }> = new Set(); + registerTitleAndDescriptionProvider(title: string, provider: TitleAndDescriptionProvider): vscode.Disposable { + const registeredValue = { title, provider }; + this._titleAndDescriptionProviders.add(registeredValue); + const disposable = { + dispose: () => this._titleAndDescriptionProviders.delete(registeredValue) + }; + this._disposables.push(disposable); + return disposable; + } + + getTitleAndDescriptionProvider(searchTerm?: string): { title: string, provider: TitleAndDescriptionProvider } | undefined { + if (!searchTerm) { + return this._titleAndDescriptionProviders.size > 0 ? this._titleAndDescriptionProviders.values().next().value : undefined; + } else { + for (const provider of this._titleAndDescriptionProviders) { + if (provider.title.toLowerCase().includes(searchTerm.toLowerCase())) { + return provider; + } + } + } + } + } diff --git a/src/authentication/configuration.ts b/src/authentication/configuration.ts index e505b1bc84..0f48622c76 100644 --- a/src/authentication/configuration.ts +++ b/src/authentication/configuration.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; export interface IHostConfiguration { @@ -15,12 +20,13 @@ export const HostHelper = class { return vscode.Uri.parse(testEnv); } + const yes = vscode.l10n.t('Yes'); const result = await vscode.window.showInformationMessage( - `The 'GITHUB_TEST_SERVER' environment variable is set to '${testEnv}'. Use this as the GitHub API endpoint?`, + vscode.l10n.t('The \'GITHUB_TEST_SERVER\' environment variable is set to \'{0}\'. Use this as the GitHub API endpoint?', testEnv), { modal: true }, - 'Yes', + yes, ); - if (result === 'Yes') { + if (result === yes) { USE_TEST_SERVER = true; return vscode.Uri.parse(testEnv); } diff --git a/src/authentication/githubServer.ts b/src/authentication/githubServer.ts index 1d70f349cd..6f9dd03ffa 100644 --- a/src/authentication/githubServer.ts +++ b/src/authentication/githubServer.ts @@ -1,36 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import fetch from 'cross-fetch'; import * as vscode from 'vscode'; +import { GitHubServerType } from '../common/authentication'; import Logger from '../common/logger'; import { agent } from '../env/node/net'; +import { getEnterpriseUri } from '../github/utils'; import { HostHelper } from './configuration'; export class GitHubManager { - private _servers: Map = new Map().set('github.com', true).set('ssh.github.com', true); + private static readonly _githubDotComServers = new Set().add('github.com').add('ssh.github.com'); + private static readonly _neverGitHubServers = new Set().add('bitbucket.org').add('gitlab.com'); + private _servers: Map = new Map(Array.from(GitHubManager._githubDotComServers.keys()).map(key => [key, GitHubServerType.GitHubDotCom])); + + public static isGithubDotCom(host: string): boolean { + return this._githubDotComServers.has(host); + } + + public static isNeverGitHub(host: string): boolean { + return this._neverGitHubServers.has(host); + } - public async isGitHub(host: vscode.Uri): Promise { + public async isGitHub(host: vscode.Uri): Promise { if (host === null) { - return false; + return GitHubServerType.None; } // .wiki/.git repos are not supported if (host.path.endsWith('.wiki') || host.authority.match(/gist[.]github[.]com/)) { - return false; + return GitHubServerType.None; + } + + if (GitHubManager.isGithubDotCom(host.authority)) { + return GitHubServerType.GitHubDotCom; + } + + const knownEnterprise = getEnterpriseUri(); + if ((host.authority.toLowerCase() === knownEnterprise?.authority.toLowerCase()) && (!this._servers.has(host.authority) || (this._servers.get(host.authority) === GitHubServerType.None))) { + return GitHubServerType.Enterprise; } if (this._servers.has(host.authority)) { - return !!this._servers.get(host.authority); + return this._servers.get(host.authority) ?? GitHubServerType.None; } const [uri, options] = await GitHubManager.getOptions(host, 'HEAD', '/rate_limit'); - let isGitHub = false; + let isGitHub = GitHubServerType.None; try { const response = await fetch(uri.toString(), options); + const otherGitHubHeaders: string[] = []; + response.headers.forEach((_value, header) => { + otherGitHubHeaders.push(header); + }); + Logger.debug(`All headers: ${otherGitHubHeaders.join(', ')}`, 'GitHubServer'); const gitHubHeader = response.headers.get('x-github-request-id'); - isGitHub = ((gitHubHeader !== undefined) && (gitHubHeader !== null)); + const gitHubEnterpriseHeader = response.headers.get('x-github-enterprise-version'); + if (!gitHubHeader && !gitHubEnterpriseHeader) { + const [uriFallBack] = await GitHubManager.getOptions(host, 'HEAD', '/status'); + const response = await fetch(uriFallBack.toString()); + const responseText = await response.text(); + if (responseText.startsWith('GitHub lives!')) { + // We've made it this far so it's not github.com + // It's very likely enterprise. + isGitHub = GitHubServerType.Enterprise; + } else { + // Check if we got an enterprise-looking needs auth response: + // { message: 'Must authenticate to access this API.', documentation_url: 'https://docs.github.com/enterprise/3.3/rest'} + Logger.appendLine(`Received fallback response from the server: ${responseText}`, 'GitHubServer'); + const parsedResponse = JSON.parse(responseText); + if (parsedResponse.documentation_url && (parsedResponse.documentation_url as string).startsWith('https://docs.github.com/enterprise')) { + isGitHub = GitHubServerType.Enterprise; + } + } + } else { + isGitHub = ((gitHubHeader !== undefined) && (gitHubHeader !== null)) ? (gitHubEnterpriseHeader ? GitHubServerType.Enterprise : GitHubServerType.GitHubDotCom) : GitHubServerType.None; + } return isGitHub; } catch (ex) { - Logger.appendLine(`No response from host ${host}: ${ex.message}`, 'GitHubServer'); + Logger.warn(`No response from host ${host}: ${ex.message}`, 'GitHubServer'); return isGitHub; } finally { Logger.debug(`Host ${host} is associated with GitHub: ${isGitHub}`, 'GitHubServer'); diff --git a/src/commands.ts b/src/commands.ts index b7c698029e..725a2b995a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -6,26 +6,29 @@ import * as pathLib from 'path'; import * as vscode from 'vscode'; +import { Repository } from './api/api'; import { GitErrorCodes } from './api/api1'; import { CommentReply, resolveCommentHandler } from './commentHandlerResolver'; import { IComment } from './common/comment'; import Logger from './common/logger'; -import { SessionState } from './common/sessionState'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; import { ITelemetry } from './common/telemetry'; -import { asImageDataURI } from './common/uri'; +import { asTempStorageURI, fromPRUri, fromReviewUri, Schemes, toPRUri } from './common/uri'; import { formatError } from './common/utils'; import { EXTENSION_ID } from './constants'; -import { CredentialStore } from './github/credentials'; import { FolderRepositoryManager } from './github/folderRepositoryManager'; import { GitHubRepository } from './github/githubRepository'; import { PullRequest } from './github/interface'; -import { GHPRComment, TemporaryComment } from './github/prComment'; +import { NotificationProvider } from './github/notifications'; +import { GHPRComment, GHPRCommentThread, TemporaryComment } from './github/prComment'; import { PullRequestModel } from './github/pullRequestModel'; import { PullRequestOverviewPanel } from './github/pullRequestOverview'; import { RepositoriesManager } from './github/repositoriesManager'; -import { getIssuesUrl, getPullsUrl, isInCodespaces } from './github/utils'; +import { getIssuesUrl, getPullsUrl, isInCodespaces, vscodeDevPrLink } from './github/utils'; import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; +import { ReviewCommentController } from './view/reviewCommentController'; import { ReviewManager } from './view/reviewManager'; +import { ReviewsManager } from './view/reviewsManager'; import { CategoryTreeNode } from './view/treeNodes/categoryNode'; import { CommitNode } from './view/treeNodes/commitNode'; import { DescriptionNode } from './view/treeNodes/descriptionNode'; @@ -37,6 +40,7 @@ import { RemoteFileChangeNode, } from './view/treeNodes/fileChangeNode'; import { PRNode } from './view/treeNodes/pullRequestNode'; +import { RepositoryChangesNode } from './view/treeNodes/repositoryChangesNode'; const _onDidUpdatePR = new vscode.EventEmitter(); export const onDidUpdatePR: vscode.Event = _onDidUpdatePR.event; @@ -45,7 +49,7 @@ function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode | Pull // If the command is called from the command palette, no arguments are passed. if (!pr) { if (!folderRepoManager.activePullRequest) { - vscode.window.showErrorMessage('Unable to find current pull request.'); + vscode.window.showErrorMessage(vscode.l10n.t('Unable to find current pull request.')); throw new Error('Unable to find current pull request.'); } @@ -61,11 +65,20 @@ export async function openDescription( pullRequestModel: PullRequestModel, descriptionNode: DescriptionNode | undefined, folderManager: FolderRepositoryManager, + revealNode: boolean, + preserveFocus: boolean = true, + notificationProvider?: NotificationProvider ) { const pullRequest = ensurePR(folderManager, pullRequestModel); - descriptionNode?.reveal(descriptionNode, { select: true, focus: true }); + if (revealNode) { + descriptionNode?.reveal(descriptionNode, { select: true, focus: true }); + } // Create and show a new webview - await PullRequestOverviewPanel.createOrShow(context.extensionUri, folderManager, pullRequest); + await PullRequestOverviewPanel.createOrShow(context.extensionUri, folderManager, pullRequest, undefined, preserveFocus); + + if (notificationProvider?.hasNotification(pullRequest)) { + notificationProvider.markPrNotificationsAsRead(pullRequest); + } /* __GDPR__ "pr.openDescription" : {} @@ -101,19 +114,17 @@ export async function openPullRequestOnGitHub(e: PRNode | DescriptionNode | Pull } /** __GDPR__ - "pr.openInGitHub" : {} + "pr.openInGitHub" : {} */ telemetry.sendTelemetryEvent('pr.openInGitHub'); } export function registerCommands( context: vscode.ExtensionContext, - sessionState: SessionState, reposManager: RepositoriesManager, - reviewManagers: ReviewManager[], + reviewsManager: ReviewsManager, telemetry: ITelemetry, - credentialStore: CredentialStore, - tree: PullRequestsTreeDataProvider, + tree: PullRequestsTreeDataProvider ) { context.subscriptions.push( vscode.commands.registerCommand( @@ -164,7 +175,7 @@ export function registerCommands( } const { folderManager } = activePullRequestAndFolderManager; - const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewManagers, folderManager); + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); if (!reviewManager) { return; @@ -178,6 +189,17 @@ export function registerCommands( context.subscriptions.push( vscode.commands.registerCommand('review.suggestDiff', async e => { + const hasShownMessageKey = 'githubPullRequest.suggestDiffMessage'; + const hasShownMessage = context.globalState.get(hasShownMessageKey, false); + if (!hasShownMessage) { + await context.globalState.update(hasShownMessageKey, true); + const documentation = vscode.l10n.t('Open documentation'); + const result = await vscode.window.showInformationMessage(vscode.l10n.t('You can now make suggestions from review comments, just like on GitHub.com. See the documentation for more details.'), + { modal: true }, documentation); + if (result === documentation) { + return vscode.env.openExternal(vscode.Uri.parse('https://github.com/microsoft/vscode-pull-request-github/blob/main/documentation/suggestAChange.md')); + } + } try { const folderManager = await chooseItem( reposManager.folderManagers, @@ -191,25 +213,26 @@ export function registerCommands( if (!indexChanges.length) { if (workingTreeChanges.length) { + const yes = vscode.l10n.t('Yes'); const stageAll = await vscode.window.showWarningMessage( - 'There are no staged changes to suggest.\n\nWould you like to automatically stage all your of changes and suggest them?', + vscode.l10n.t('There are no staged changes to suggest.\n\nWould you like to automatically stage all your of changes and suggest them?'), { modal: true }, - 'Yes', + yes, ); - if (stageAll === 'Yes') { + if (stageAll === yes) { await vscode.commands.executeCommand('git.stageAll'); } else { return; } } else { - vscode.window.showInformationMessage('There are no changes to suggest.'); + vscode.window.showInformationMessage(vscode.l10n.t('There are no changes to suggest.')); return; } } const diff = await folderManager.repository.diff(true); - let suggestEditMessage = 'Suggested edit:\n'; + let suggestEditMessage = vscode.l10n.t('Suggested edit:\n'); if (e && e.inputBox && e.inputBox.value) { suggestEditMessage = `${e.inputBox.value}\n`; e.inputBox.value = ''; @@ -234,8 +257,8 @@ export function registerCommands( await vscode.workspace.fs.delete(tempUri); } catch (err) { const moreError = `${err}${err.stderr ? `\n${err.stderr}` : ''}`; - Logger.appendLine(`Applying patch failed: ${moreError}`); - vscode.window.showErrorMessage(`Applying patch failed: ${formatError(err)}`); + Logger.error(`Applying patch failed: ${moreError}`); + vscode.window.showErrorMessage(vscode.l10n.t('Applying patch failed: {0}', formatError(err))); } }), ); @@ -244,15 +267,15 @@ export function registerCommands( vscode.commands.registerCommand('pr.openFileOnGitHub', async (e: GitFileChangeNode | RemoteFileChangeNode) => { if (e instanceof RemoteFileChangeNode) { const choice = await vscode.window.showInformationMessage( - `${e.fileName} can't be opened locally. Do you want to open it on GitHub?`, - 'Open', + vscode.l10n.t('{0} can\'t be opened locally. Do you want to open it on GitHub?', e.changeModel.fileName), + vscode.l10n.t('Open'), ); if (!choice) { return; } } - if (e.blobUrl) { - return vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.blobUrl)); + if (e.changeModel.blobUrl) { + return vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.changeModel.blobUrl)); } }), ); @@ -268,27 +291,72 @@ export function registerCommands( // if this is an image, encode it as a base64 data URI const folderManager = reposManager.getManagerForIssueModel(e.pullRequest); if (folderManager) { - const imageDataURI = await asImageDataURI(e.parentFilePath, folderManager.repository); - vscode.commands.executeCommand('vscode.open', imageDataURI || e.parentFilePath); + const imageDataURI = await asTempStorageURI(e.changeModel.parentFilePath, folderManager.repository); + vscode.commands.executeCommand('vscode.open', imageDataURI || e.changeModel.parentFilePath); } }), ); context.subscriptions.push( - vscode.commands.registerCommand('pr.openModifiedFile', (e: GitFileChangeNode) => { - vscode.commands.executeCommand('vscode.open', e.filePath); + vscode.commands.registerCommand('pr.openModifiedFile', (e: GitFileChangeNode | undefined) => { + let uri: vscode.Uri | undefined; + const tab = vscode.window.tabGroups.activeTabGroup.activeTab; + + if (e) { + uri = e.changeModel.filePath; + } else { + if (tab?.input instanceof vscode.TabInputTextDiff) { + uri = tab.input.modified; + } + } + if (uri) { + vscode.commands.executeCommand('vscode.open', uri, tab?.group.viewColumn); + } }), ); + async function openDiffView(fileChangeNode: GitFileChangeNode | InMemFileChangeNode | vscode.Uri | undefined) { + if (fileChangeNode && !(fileChangeNode instanceof vscode.Uri)) { + const folderManager = reposManager.getManagerForIssueModel(fileChangeNode.pullRequest); + if (!folderManager) { + return; + } + return fileChangeNode.openDiff(folderManager); + } else if (fileChangeNode || vscode.window.activeTextEditor) { + const editor = fileChangeNode instanceof vscode.Uri ? vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === fileChangeNode.toString())! : vscode.window.activeTextEditor!; + const visibleRanges = editor.visibleRanges; + const folderManager = reposManager.getManagerForFile(editor.document.uri); + if (!folderManager?.activePullRequest) { + return; + } + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + if (!reviewManager) { + return; + } + const change = reviewManager.reviewModel.localFileChanges.find(change => change.resourceUri.with({ query: '' }).toString() === editor.document.uri.toString()); + await change?.openDiff(folderManager); + const tabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input; + const diffEditor = (tabInput instanceof vscode.TabInputTextDiff && tabInput.modified.toString() === editor.document.uri.toString()) ? vscode.window.activeTextEditor : undefined; + if (diffEditor) { + diffEditor.revealRange(visibleRanges[0]); + } + } + } + context.subscriptions.push( vscode.commands.registerCommand( 'pr.openDiffView', - (fileChangeNode: GitFileChangeNode | InMemFileChangeNode) => { - const folderManager = reposManager.getManagerForIssueModel(fileChangeNode.pullRequest); - if (!folderManager) { - return; - } - fileChangeNode.openDiff(folderManager); + (fileChangeNode: GitFileChangeNode | InMemFileChangeNode | undefined) => { + return openDiffView(fileChangeNode); + }, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.openDiffViewFromEditor', + (uri: vscode.Uri) => { + return openDiffView(uri); }, ), ); @@ -300,7 +368,7 @@ export function registerCommands( return; } const pullRequestModel = ensurePR(folderManager, e); - const DELETE_BRANCH_FORCE = 'delete branch (even if not merged)'; + const DELETE_BRANCH_FORCE = 'Delete Unmerged Branch'; let error = null; try { @@ -308,7 +376,7 @@ export function registerCommands( } catch (e) { if (e.gitErrorCode === GitErrorCodes.BranchNotFullyMerged) { const action = await vscode.window.showErrorMessage( - `The branch '${pullRequestModel.localBranchName}' is not fully merged, are you sure you want to delete it? `, + vscode.l10n.t('The local branch \'{0}\' is not fully merged. Are you sure you want to delete it?', pullRequestModel.localBranchName ?? 'unknown branch'), DELETE_BRANCH_FORCE, ); @@ -346,27 +414,27 @@ export function registerCommands( function chooseReviewManager(repoPath?: string) { if (repoPath) { const uri = vscode.Uri.file(repoPath).toString(); - for (const mgr of reviewManagers) { + for (const mgr of reviewsManager.reviewManagers) { if (mgr.repository.rootUri.toString() === uri) { return mgr; } } } return chooseItem( - reviewManagers, + reviewsManager.reviewManagers, itemValue => pathLib.basename(itemValue.repository.rootUri.fsPath), - { placeHolder: 'Choose a repository to create a pull request in', ignoreFocusOut: true }, + { placeHolder: vscode.l10n.t('Choose a repository to create a pull request in'), ignoreFocusOut: true }, ); } - function isSourceControl(x: any): x is { rootUri: vscode.Uri } { + function isSourceControl(x: any): x is Repository { return !!x?.rootUri; } context.subscriptions.push( vscode.commands.registerCommand( 'pr.create', - async (args?: { repoPath: string; compareBranch: string } | { rootUri: vscode.Uri }) => { + async (args?: { repoPath: string; compareBranch: string } | Repository) => { // The arguments this is called with are either from the SCM view, or manually passed. if (isSourceControl(args)) { (await chooseReviewManager(args.rootUri.fsPath))?.createPullRequest(); @@ -377,12 +445,49 @@ export function registerCommands( ), ); + context.subscriptions.push( + vscode.commands.registerCommand( + 'pr.pushAndCreate', + async (args?: any | Repository) => { + if (isSourceControl(args)) { + const reviewManager = await chooseReviewManager(args.rootUri.fsPath); + const folderManager = reposManager.getManagerForFile(args.rootUri); + let create = true; + if (folderManager?.activePullRequest) { + const push = vscode.l10n.t('Push'); + const result = await vscode.window.showInformationMessage(vscode.l10n.t('You already have a pull request for this branch. Do you want to push your changes to the remote branch?'), { modal: true }, push); + if (result !== push) { + return; + } + create = false; + } + if (reviewManager) { + if (args.state.HEAD?.upstream) { + await args.push(); + } + if (create) { + reviewManager.createPullRequest(); + } + } + } + }, + ), + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.pick', async (pr: PRNode | DescriptionNode | PullRequestModel) => { + if (pr === undefined) { + // This is unexpected, but has happened a few times. + Logger.error('Unexpectedly received undefined when picking a PR.'); + return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.')); + } + let pullRequestModel: PullRequestModel; + let repository: Repository | undefined; if (pr instanceof PRNode || pr instanceof DescriptionNode) { pullRequestModel = pr.pullRequestModel; + repository = pr.repository; } else { pullRequestModel = pr; } @@ -398,20 +503,80 @@ export function registerCommands( return vscode.window.withProgress( { location: vscode.ProgressLocation.SourceControl, - title: `Switching to Pull Request #${pullRequestModel.number}`, + title: vscode.l10n.t('Switching to Pull Request #{0}', pullRequestModel.number), }, async () => { await ReviewManager.getReviewManagerForRepository( - reviewManagers, + reviewsManager.reviewManagers, pullRequestModel.githubRepository, + repository )?.switch(pullRequestModel); }, ); }), ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.openChanges', async (pr: PRNode | DescriptionNode | PullRequestModel) => { + if (pr === undefined) { + // This is unexpected, but has happened a few times. + Logger.error('Unexpectedly received undefined when picking a PR.'); + 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 DescriptionNode) { + pullRequestModel = pr.pullRequestModel; + } else { + pullRequestModel = pr; + } + + const folderReposManager = reposManager.getManagerForIssueModel(pullRequestModel); + if (!folderReposManager) { + return; + } + return PullRequestModel.openChanges(folderReposManager, pullRequestModel); + }), + ); + + let isCheckingOutFromReadonlyFile = false; + context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutFromReadonlyFile', async () => { + const uri = vscode.window.activeTextEditor?.document.uri; + if (uri?.scheme !== Schemes.Pr) { + return; + } + const prUriPropserties = fromPRUri(uri); + if (prUriPropserties === undefined) { + return; + } + let githubRepository: GitHubRepository | undefined; + const folderManager = reposManager.folderManagers.find(folderManager => { + githubRepository = folderManager.gitHubRepositories.find(githubRepo => githubRepo.remote.remoteName === prUriPropserties.remoteName); + return !!githubRepository; + }); + if (!folderManager || !githubRepository) { + return; + } + const prModel = await vscode.window.withProgress({ location: vscode.ProgressLocation.Window }, () => folderManager.fetchById(githubRepository!, Number(prUriPropserties.prNumber))); + if (prModel && !isCheckingOutFromReadonlyFile) { + isCheckingOutFromReadonlyFile = true; + try { + await ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager)?.switch(prModel); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to check out pull request from read-only file: {0}', e instanceof Error ? e.message : 'unknown')); + } + isCheckingOutFromReadonlyFile = false; + } + })); context.subscriptions.push( - vscode.commands.registerCommand('pr.exit', async (pr: PRNode | DescriptionNode | PullRequestModel) => { + vscode.commands.registerCommand('pr.pickOnVscodeDev', async (pr: PRNode | DescriptionNode | PullRequestModel) => { + if (pr === undefined) { + // This is unexpected, but has happened a few times. + Logger.error('Unexpectedly received undefined when picking a PR.'); + 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 DescriptionNode) { @@ -420,6 +585,30 @@ export function registerCommands( pullRequestModel = pr; } + return vscode.env.openExternal(vscode.Uri.parse(vscodeDevPrLink(pullRequestModel))); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.exit', async (pr: PRNode | DescriptionNode | PullRequestModel | undefined) => { + let pullRequestModel: PullRequestModel | undefined; + + if (pr instanceof PRNode || pr instanceof DescriptionNode) { + pullRequestModel = pr.pullRequestModel; + } else if (pr === undefined) { + pullRequestModel = await chooseItem(reposManager.folderManagers + .map(folderManager => folderManager.activePullRequest!) + .filter(activePR => !!activePR), + itemValue => `${itemValue.number}: ${itemValue.title}`, + { placeHolder: vscode.l10n.t('Choose the pull request to exit') }); + } else { + pullRequestModel = pr; + } + + if (!pullRequestModel) { + return; + } + const fromDescriptionPage = pr instanceof PullRequestModel; /* __GDPR__ "pr.exit" : { @@ -431,13 +620,17 @@ export function registerCommands( return vscode.window.withProgress( { location: vscode.ProgressLocation.SourceControl, - title: `Exiting Pull Request`, + title: vscode.l10n.t('Exiting Pull Request'), }, async () => { - const branch = await pullRequestModel.githubRepository.getDefaultBranch(); + const branch = await pullRequestModel!.githubRepository.getDefaultBranch(); const manager = reposManager.getManagerForIssueModel(pullRequestModel); if (manager) { - manager.checkoutDefaultBranch(branch); + const prBranch = manager.repository.state.HEAD?.name; + await manager.checkoutDefaultBranch(branch); + if (prBranch) { + await manager.cleanupAfterPullRequest(prBranch, pullRequestModel!); + } } }, ); @@ -463,15 +656,16 @@ export function registerCommands( return openPullRequestOnGitHub(pullRequest, telemetry); } + const yes = vscode.l10n.t('Yes'); return vscode.window .showWarningMessage( - `Are you sure you want to merge this pull request on GitHub?`, + vscode.l10n.t('Are you sure you want to merge this pull request on GitHub?'), { modal: true }, - 'Yes', + yes, ) .then(async value => { let newPR; - if (value === 'Yes') { + if (value === yes) { try { newPR = await folderManager.mergePullRequest(pullRequest); return newPR; @@ -491,15 +685,16 @@ export function registerCommands( return; } const pullRequest = ensurePR(folderManager, pr); + const yes = vscode.l10n.t('Yes'); return vscode.window .showWarningMessage( - `Are you sure you want to mark this pull request as ready to review on GitHub?`, + vscode.l10n.t('Are you sure you want to mark this pull request as ready to review on GitHub?'), { modal: true }, - 'Yes', + yes, ) .then(async value => { let isDraft; - if (value === 'Yes') { + if (value === yes) { try { isDraft = await pullRequest.setReadyForReview(); vscode.commands.executeCommand('pr.refreshList'); @@ -527,22 +722,23 @@ export function registerCommands( pullRequestModel = await chooseItem( activePullRequests, itemValue => `${itemValue.number}: ${itemValue.title}`, - { placeHolder: 'Pull request to close' }, + { placeHolder: vscode.l10n.t('Pull request to close') }, ); } if (!pullRequestModel) { return; } const pullRequest: PullRequestModel = pullRequestModel; + const yes = vscode.l10n.t('Yes'); return vscode.window .showWarningMessage( - `Are you sure you want to close this pull request on GitHub? This will close the pull request without merging.`, + vscode.l10n.t('Are you sure you want to close this pull request on GitHub? This will close the pull request without merging.'), { modal: true }, - 'Yes', - 'No', + yes, + vscode.l10n.t('No'), ) .then(async value => { - if (value === 'Yes') { + if (value === yes) { try { let newComment: IComment | undefined = undefined; if (message) { @@ -564,6 +760,17 @@ export function registerCommands( }), ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.dismissNotification', node => { + if (node instanceof PRNode) { + tree.notificationProvider.markPrNotificationsAsRead(node.pullRequestModel).then( + () => tree.refresh(node) + ); + + } + }), + ); + context.subscriptions.push( vscode.commands.registerCommand( 'pr.openDescription', @@ -597,7 +804,7 @@ export function registerCommands( if (argument instanceof DescriptionNode) { descriptionNode = argument; } else { - const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewManagers, folderManager); + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); if (!reviewManager) { return; } @@ -605,7 +812,7 @@ export function registerCommands( descriptionNode = reviewManager.changesInPrDataProvider.getDescriptionNode(folderManager); } - await openDescription(context, telemetry, pullRequestModel, descriptionNode, folderManager); + await openDescription(context, telemetry, pullRequestModel, descriptionNode, folderManager, !(argument instanceof DescriptionNode), !(argument instanceof RepositoryChangesNode), tree.notificationProvider); }, ), ); @@ -637,12 +844,36 @@ export function registerCommands( }), ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.showDiffSinceLastReview', async (descriptionNode: DescriptionNode) => { + descriptionNode.pullRequestModel.showChangesSinceReview = true; + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.showDiffAll', async (descriptionNode: DescriptionNode) => { + descriptionNode.pullRequestModel.showChangesSinceReview = false; + }), + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.signin', async () => { await reposManager.authenticate(); }), ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.signinNoEnterprise', async () => { + await reposManager.authenticate(false); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.signinenterprise', async () => { + await reposManager.authenticate(true); + }), + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.deleteLocalBranchesNRemotes', async () => { for (const folderManager of reposManager.folderManagers) { @@ -680,43 +911,61 @@ export function registerCommands( ); context.subscriptions.push( - vscode.commands.registerCommand('pr.openReview', async (reply: CommentReply) => { + vscode.commands.registerCommand('pr.openReview', async (thread: GHPRCommentThread) => { /* __GDPR__ "pr.openReview" : {} */ telemetry.sendTelemetryEvent('pr.openReview'); - const handler = resolveCommentHandler(reply.thread); + const handler = resolveCommentHandler(thread); if (handler) { - await handler.openReview(reply.thread); + await handler.openReview(thread); } }), ); + function threadAndText(commentLike: CommentReply | GHPRCommentThread | GHPRComment | any): { thread: GHPRCommentThread, text: string } { + let thread: GHPRCommentThread; + let text: string = ''; + if (commentLike instanceof GHPRComment) { + thread = commentLike.parent; + } else if (CommentReply.is(commentLike)) { + thread = commentLike.thread; + } else if (GHPRCommentThread.is(commentLike?.thread)) { + thread = commentLike.thread; + } else { + thread = commentLike; + } + return { thread, text }; + } + context.subscriptions.push( - vscode.commands.registerCommand('pr.resolveReviewThread', async (reply: CommentReply) => { + vscode.commands.registerCommand('pr.resolveReviewThread', async (commentLike: CommentReply | GHPRCommentThread | GHPRComment) => { /* __GDPR__ "pr.resolveReviewThread" : {} */ telemetry.sendTelemetryEvent('pr.resolveReviewThread'); - const handler = resolveCommentHandler(reply.thread); + const { thread, text } = threadAndText(commentLike); + const handler = resolveCommentHandler(thread); if (handler) { - await handler.resolveReviewThread(reply.thread, reply.text); + await handler.resolveReviewThread(thread, text); } }), ); context.subscriptions.push( - vscode.commands.registerCommand('pr.unresolveReviewThread', async (reply: CommentReply) => { + vscode.commands.registerCommand('pr.unresolveReviewThread', async (commentLike: CommentReply | GHPRCommentThread | GHPRComment) => { /* __GDPR__ "pr.unresolveReviewThread" : {} */ telemetry.sendTelemetryEvent('pr.unresolveReviewThread'); - const handler = resolveCommentHandler(reply.thread); + const { thread, text } = threadAndText(commentLike); + + const handler = resolveCommentHandler(thread); if (handler) { - await handler.unresolveReviewThread(reply.thread, reply.text); + await handler.unresolveReviewThread(thread, text); } }), ); @@ -749,6 +998,31 @@ export function registerCommands( }), ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.makeSuggestion', async (reply: CommentReply | GHPRComment) => { + const thread = reply instanceof GHPRComment ? reply.parent : reply.thread; + if (!thread.range) { + return; + } + const commentEditor = vscode.window.activeTextEditor?.document.uri.scheme === Schemes.Comment ? vscode.window.activeTextEditor + : vscode.window.visibleTextEditors.find(visible => (visible.document.uri.scheme === Schemes.Comment) && (visible.document.uri.query === '')); + if (!commentEditor) { + Logger.error('No comment editor visible for making a suggestion.'); + vscode.window.showErrorMessage(vscode.l10n.t('No available comment editor to make a suggestion in.')); + return; + } + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === thread.uri.toString()); + const contents = editor?.document.getText(new vscode.Range(thread.range.start.line, 0, thread.range.end.line, editor.document.lineAt(thread.range.end.line).text.length)); + const position = commentEditor.document.lineAt(commentEditor.selection.end.line).range.end; + return commentEditor.edit((editBuilder) => { + editBuilder.insert(position, ` +\`\`\`suggestion +${contents} +\`\`\``); + }); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.editComment', async (comment: GHPRComment | TemporaryComment) => { /* __GDPR__ @@ -760,7 +1034,7 @@ export function registerCommands( ); context.subscriptions.push( - vscode.commands.registerCommand('pr.editQuery', (query: CategoryTreeNode) => { + vscode.commands.registerCommand('pr.editQuery', (query: CategoryTreeNode) => { /* __GDPR__ "pr.editQuery" : {} */ @@ -799,10 +1073,10 @@ export function registerCommands( "pr.deleteComment" : {} */ telemetry.sendTelemetryEvent('pr.deleteComment'); + const deleteOption = vscode.l10n.t('Delete'); + const shouldDelete = await vscode.window.showWarningMessage(vscode.l10n.t('Delete comment?'), { modal: true }, deleteOption); - const shouldDelete = await vscode.window.showWarningMessage('Delete comment?', { modal: true }, 'Delete'); - - if (shouldDelete === 'Delete') { + if (shouldDelete === deleteOption) { const handler = resolveCommentHandler(comment.parent); if (handler) { @@ -819,9 +1093,19 @@ export function registerCommands( }), ); + context.subscriptions.push( + vscode.commands.registerCommand('review.openLocalFile', (value: vscode.Uri) => { + const { path, rootPath } = fromReviewUri(value.query); + const localUri = vscode.Uri.joinPath(vscode.Uri.file(rootPath), path); + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === value.toString()); + const command = openFileCommand(localUri, editor ? { selection: editor.selection } : undefined); + vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); + }), + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.refreshChanges', _ => { - reviewManagers.forEach(reviewManager => { + reviewsManager.reviewManagers.forEach(reviewManager => { reviewManager.updateComments(); PullRequestOverviewPanel.refresh(); reviewManager.changesInPrDataProvider.refresh(); @@ -831,13 +1115,13 @@ export function registerCommands( context.subscriptions.push( vscode.commands.registerCommand('pr.setFileListLayoutAsTree', _ => { - vscode.workspace.getConfiguration('githubPullRequests').update('fileListLayout', 'tree', true); + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(FILE_LIST_LAYOUT, 'tree', true); }), ); context.subscriptions.push( vscode.commands.registerCommand('pr.setFileListLayoutAsFlat', _ => { - vscode.workspace.getConfiguration('githubPullRequests').update('fileListLayout', 'flat', true); + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(FILE_LIST_LAYOUT, 'flat', true); }), ); @@ -845,7 +1129,7 @@ export function registerCommands( vscode.commands.registerCommand('pr.refreshPullRequest', (prNode: PRNode) => { const folderManager = reposManager.getManagerForIssueModel(prNode.pullRequestModel); if (folderManager && prNode.pullRequestModel.equals(folderManager?.activePullRequest)) { - ReviewManager.getReviewManagerForFolderManager(reviewManagers, folderManager)?.updateComments(); + ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager)?.updateComments(); } PullRequestOverviewPanel.refresh(); @@ -854,18 +1138,33 @@ export function registerCommands( ); context.subscriptions.push( - vscode.commands.registerCommand('pr.markFileAsViewed', async (treeNode: FileChangeNode | vscode.Uri) => { + vscode.commands.registerCommand('pr.markFileAsViewed', async (treeNode: FileChangeNode | vscode.Uri | undefined) => { try { + if (treeNode === undefined) { + // Use the active editor to enable keybindings + treeNode = vscode.window.activeTextEditor?.document.uri; + } + if (treeNode instanceof FileChangeNode) { - await treeNode.pullRequest.markFileAsViewed(treeNode.fileName); - const manager = reposManager.getManagerForFile(treeNode.filePath); - if (treeNode.pullRequest === manager?.activePullRequest) { - treeNode.pullRequest.setFileViewedContext(); + await treeNode.markFileAsViewed(false); + } else if (treeNode) { + // When the argument is a uri it came from the editor menu and we should also close the file + // Do the close first to improve perceived performance of marking as viewed. + const tab = vscode.window.tabGroups.activeTabGroup.activeTab; + if (tab) { + let compareUri: vscode.Uri | undefined = undefined; + if (tab.input instanceof vscode.TabInputTextDiff) { + compareUri = tab.input.modified; + } else if (tab.input instanceof vscode.TabInputText) { + compareUri = tab.input.uri; + } + if (compareUri && treeNode.toString() === compareUri.toString()) { + vscode.window.tabGroups.close(tab); + } } - } else { const manager = reposManager.getManagerForFile(treeNode); - await manager?.activePullRequest?.markFileAsViewed(treeNode.path); - manager?.activePullRequest?.setFileViewedContext(); + await manager?.activePullRequest?.markFiles([treeNode.path], true, 'viewed'); + manager?.setFileViewedContext(); } } catch (e) { vscode.window.showErrorMessage(`Marked file as viewed failed: ${e}`); @@ -874,18 +1173,19 @@ export function registerCommands( ); context.subscriptions.push( - vscode.commands.registerCommand('pr.unmarkFileAsViewed', async (treeNode: FileChangeNode | vscode.Uri) => { + vscode.commands.registerCommand('pr.unmarkFileAsViewed', async (treeNode: FileChangeNode | vscode.Uri | undefined) => { try { + if (treeNode === undefined) { + // Use the active editor to enable keybindings + treeNode = vscode.window.activeTextEditor?.document.uri; + } + if (treeNode instanceof FileChangeNode) { - await treeNode.pullRequest.unmarkFileAsViewed(treeNode.fileName); - const manager = reposManager.getManagerForFile(treeNode.filePath); - if (treeNode.pullRequest === manager?.activePullRequest) { - treeNode.pullRequest.setFileViewedContext(); - } - } else { + treeNode.unmarkFileAsViewed(false); + } else if (treeNode) { const manager = reposManager.getManagerForFile(treeNode); - await manager?.activePullRequest?.unmarkFileAsViewed(treeNode.path); - manager?.activePullRequest?.setFileViewedContext(); + await manager?.activePullRequest?.markFiles([treeNode.path], true, 'unviewed'); + manager?.setFileViewedContext(); } } catch (e) { vscode.window.showErrorMessage(`Marked file as not viewed failed: ${e}`); @@ -894,37 +1194,69 @@ export function registerCommands( ); context.subscriptions.push( - vscode.commands.registerCommand('pr.expandAllComments', () => { - sessionState.commentsExpandState = true; - })); + vscode.commands.registerCommand('pr.resetViewedFiles', async () => { + try { + return reposManager.folderManagers.map(async (manager) => { + await manager.activePullRequest?.unmarkAllFilesAsViewed(); + manager.setFileViewedContext(); + }); + } catch (e) { + vscode.window.showErrorMessage(`Marked file as not viewed failed: ${e}`); + } + }), + ); context.subscriptions.push( vscode.commands.registerCommand('pr.collapseAllComments', () => { - sessionState.commentsExpandState = false; + return vscode.commands.executeCommand('workbench.action.collapseAllComments'); + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.copyCommentLink', (comment) => { + if (comment instanceof GHPRComment) { + return vscode.env.clipboard.writeText(comment.rawComment.htmlUrl); + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.copyVscodeDevPrLink', async () => { + const activePullRequests: PullRequestModel[] = reposManager.folderManagers + .map(folderManager => folderManager.activePullRequest!) + .filter(activePR => !!activePR); + const pr = await chooseItem( + activePullRequests, + itemValue => `${itemValue.number}: ${itemValue.title}`, + { placeHolder: vscode.l10n.t('Pull request to create a link for') }, + ); + if (pr) { + return vscode.env.clipboard.writeText(vscodeDevPrLink(pr)); + } })); context.subscriptions.push( vscode.commands.registerCommand('pr.checkoutByNumber', async () => { const githubRepositories: { manager: FolderRepositoryManager, repo: GitHubRepository }[] = []; - reposManager.folderManagers.forEach(manager => { - githubRepositories.push(...(manager.gitHubRepositories.map(repo => { return { manager, repo }; }))); - }); + for (const manager of reposManager.folderManagers) { + const remotes = await manager.getActiveGitHubRemotes(await manager.getGitHubRemotes()); + const activeGitHubRepos = manager.gitHubRepositories.filter(repo => remotes.find(remote => remote.remoteName === repo.remote.remoteName)); + githubRepositories.push(...(activeGitHubRepos.map(repo => { return { manager, repo }; }))); + } const githubRepo = await chooseItem<{ manager: FolderRepositoryManager, repo: GitHubRepository }>( githubRepositories, itemValue => `${itemValue.repo.remote.owner}/${itemValue.repo.remote.repositoryName}`, - { placeHolder: 'Which GitHub repository do you want to checkout the pull request from?' } + { placeHolder: vscode.l10n.t('Which GitHub repository do you want to checkout the pull request from?') } ); if (!githubRepo) { return; } const prNumberMatcher = /^#?(\d*)$/; const prNumber = await vscode.window.showInputBox({ - ignoreFocusOut: true, prompt: 'Enter the pull request number', + ignoreFocusOut: true, prompt: vscode.l10n.t('Enter the pull request number'), validateInput: (input: string) => { const matches = input.match(prNumberMatcher); if (!matches || (matches.length !== 2) || Number.isNaN(Number(matches[1]))) { - return 'Value must be a number'; + return vscode.l10n.t('Value must be a number'); } return undefined; } @@ -934,33 +1266,193 @@ export function registerCommands( } const prModel = await githubRepo.manager.fetchById(githubRepo.repo, Number(prNumber.match(prNumberMatcher)![1])); if (prModel) { - return ReviewManager.getReviewManagerForFolderManager(reviewManagers, githubRepo.manager)?.switch(prModel); + return ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, githubRepo.manager)?.switch(prModel); } })); - function chooseRepoToOpen() { - const githubRepositories: GitHubRepository[] = []; - reposManager.folderManagers.forEach(manager => { - githubRepositories.push(...(manager.gitHubRepositories)); - }); - return chooseItem( - githubRepositories, - itemValue => `${itemValue.remote.owner}/${itemValue.remote.repositoryName}`, - { placeHolder: 'Which GitHub repository do you want to open?' } - ); + function chooseRepoToOpen() { + const githubRepositories: GitHubRepository[] = []; + reposManager.folderManagers.forEach(manager => { + githubRepositories.push(...(manager.gitHubRepositories)); + }); + return chooseItem( + githubRepositories, + itemValue => `${itemValue.remote.owner}/${itemValue.remote.repositoryName}`, + { placeHolder: vscode.l10n.t('Which GitHub repository do you want to open?') } + ); + } + context.subscriptions.push( + vscode.commands.registerCommand('pr.openPullsWebsite', async () => { + const githubRepo = await chooseRepoToOpen(); + if (githubRepo) { + vscode.env.openExternal(getPullsUrl(githubRepo)); + } + })); + context.subscriptions.push( + vscode.commands.registerCommand('issues.openIssuesWebsite', async () => { + const githubRepo = await chooseRepoToOpen(); + if (githubRepo) { + vscode.env.openExternal(getIssuesUrl(githubRepo)); + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.applySuggestion', async (comment: GHPRComment) => { + /* __GDPR__ + "pr.applySuggestion" : {} + */ + telemetry.sendTelemetryEvent('pr.applySuggestion'); + + const handler = resolveCommentHandler(comment.parent); + + if (handler instanceof ReviewCommentController) { + handler.applySuggestion(comment); + } + })); + + context.subscriptions.push( + vscode.commands.registerCommand('pr.addFileComment', async () => { + return vscode.commands.executeCommand('workbench.action.addComment', { fileComment: true }); + })); + + context.subscriptions.push( + vscode.commands.registerCommand('review.diffWithPrHead', async (fileChangeNode: GitFileChangeNode) => { + const fileName = fileChangeNode.fileName; + let parentURI = toPRUri( + fileChangeNode.resourceUri, + fileChangeNode.pullRequest, + fileChangeNode.pullRequest.base.sha, + fileChangeNode.pullRequest.head.sha, + fileChangeNode.fileName, + true, + fileChangeNode.status); + let headURI = toPRUri( + fileChangeNode.resourceUri, + fileChangeNode.pullRequest, + fileChangeNode.pullRequest.base.sha, + fileChangeNode.pullRequest.head.sha, + fileChangeNode.fileName, + false, + fileChangeNode.status); + return vscode.commands.executeCommand('vscode.diff', parentURI, headURI, `${fileName} (Pull Request Compare Base with Head)`); + })); + + context.subscriptions.push( + vscode.commands.registerCommand('review.diffLocalWithPrHead', async (fileChangeNode: GitFileChangeNode) => { + const fileName = fileChangeNode.fileName; + let headURI = toPRUri( + fileChangeNode.resourceUri, + fileChangeNode.pullRequest, + fileChangeNode.pullRequest.base.sha, + fileChangeNode.pullRequest.head.sha, + fileChangeNode.fileName, + false, + fileChangeNode.status); + return vscode.commands.executeCommand('vscode.diff', headURI, fileChangeNode.resourceUri, `${fileName} (Pull Request Compare Head with Local)`); + })); + + async function goToNextPrevDiff(diffs: vscode.LineChange[], next: boolean) { + const tab = vscode.window.tabGroups.activeTabGroup.activeTab; + const input = tab?.input; + if (!(input instanceof vscode.TabInputTextDiff)) { + return vscode.window.showErrorMessage(vscode.l10n.t('Current editor isn\'t a diff editor.')); + } + + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === input.modified.toString()); + if (!editor) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unexpectedly unable to find the current modified editor.')); + } + + const editorUri = editor.document.uri; + if (input.original.scheme !== Schemes.Review) { + return vscode.window.showErrorMessage(vscode.l10n.t('Current file isn\'t a pull request diff.')); } - context.subscriptions.push( - vscode.commands.registerCommand('pr.openPullsWebsite', async () => { - const githubRepo = await chooseRepoToOpen(); - if (githubRepo) { - vscode.env.openExternal(getPullsUrl(githubRepo)); + + // Find the next diff in the current file to scroll to + const cursorPosition = editor.selection.active; + const iterateThroughDiffs = next ? diffs : diffs.reverse(); + for (const diff of iterateThroughDiffs) { + const practicalModifiedEndLineNumber = (diff.modifiedEndLineNumber > diff.modifiedStartLineNumber) ? diff.modifiedEndLineNumber : diff.modifiedStartLineNumber as number + 1; + const diffRange = new vscode.Range(diff.modifiedStartLineNumber ? diff.modifiedStartLineNumber - 1 : diff.modifiedStartLineNumber, 0, practicalModifiedEndLineNumber, 0); + + // cursorPosition.line is 0-based, diff.modifiedStartLineNumber is 1-based + if (next && cursorPosition.line + 1 < diff.modifiedStartLineNumber) { + editor.revealRange(diffRange); + editor.selection = new vscode.Selection(diffRange.start, diffRange.start); + return; + } else if (!next && cursorPosition.line + 1 > diff.modifiedStartLineNumber) { + editor.revealRange(diffRange); + editor.selection = new vscode.Selection(diffRange.start, diffRange.start); + return; + } + } + + // There is no new range to reveal, time to go to the next file. + const folderManager = reposManager.getManagerForFile(editorUri); + if (!folderManager) { + return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find a repository for pull request.')); + } + + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + if (!reviewManager) { + return vscode.window.showErrorMessage(vscode.l10n.t('Cannot find active pull request.')); + } + + if (!reviewManager.reviewModel.hasLocalFileChanges || (reviewManager.reviewModel.localFileChanges.length === 0)) { + return vscode.window.showWarningMessage(vscode.l10n.t('Pull request data is not yet complete, please try again in a moment.')); + } + + for (let i = 0; i < reviewManager.reviewModel.localFileChanges.length; i++) { + const index = next ? i : reviewManager.reviewModel.localFileChanges.length - 1 - i; + const localFileChange = reviewManager.reviewModel.localFileChanges[index]; + if (localFileChange.changeModel.filePath.toString() === editorUri.toString()) { + const nextIndex = next ? index + 1 : index - 1; + if (reviewManager.reviewModel.localFileChanges.length > nextIndex) { + await reviewManager.reviewModel.localFileChanges[nextIndex].openDiff(folderManager); + // if going backwards, we now need to go to the last diff in the file + if (!next) { + const editor = vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === reviewManager.reviewModel.localFileChanges[nextIndex].changeModel.filePath.toString()); + if (editor) { + const diffs = await reviewManager.reviewModel.localFileChanges[nextIndex].changeModel.diffHunks(); + const diff = diffs[diffs.length - 1]; + const diffNewEndLine = diff.newLineNumber + diff.newLength; + const practicalModifiedEndLineNumber = (diffNewEndLine > diff.newLineNumber) ? diffNewEndLine : diff.newLineNumber as number + 1; + const diffRange = new vscode.Range(diff.newLineNumber ? diff.newLineNumber - 1 : diff.newLineNumber, 0, practicalModifiedEndLineNumber, 0); + editor.revealRange(diffRange); + editor.selection = new vscode.Selection(diffRange.start, diffRange.start); + } + } + return; } - })); - context.subscriptions.push( - vscode.commands.registerCommand('issues.openIssuesWebsite', async () => { - const githubRepo = await chooseRepoToOpen(); - if (githubRepo) { - vscode.env.openExternal(getIssuesUrl(githubRepo)); + } + } + // No further files in PR. + const goInCircle = next ? vscode.l10n.t('Go to first diff') : vscode.l10n.t('Go to last diff'); + return vscode.window.showInformationMessage(vscode.l10n.t('There are no more diffs in this pull request.'), goInCircle).then(result => { + if (result === goInCircle) { + return reviewManager.reviewModel.localFileChanges[next ? 0 : reviewManager.reviewModel.localFileChanges.length - 1].openDiff(folderManager); + } + }); + } + + context.subscriptions.push( + vscode.commands.registerDiffInformationCommand('pr.goToNextDiffInPr', async (diffs: vscode.LineChange[]) => { + goToNextPrevDiff(diffs, true); + })); + context.subscriptions.push( + vscode.commands.registerDiffInformationCommand('pr.goToPreviousDiffInPr', async (diffs: vscode.LineChange[]) => { + goToNextPrevDiff(diffs, false); + })); + + context.subscriptions.push(vscode.commands.registerCommand('pr.refreshComments', async () => { + for (const folderManager of reposManager.folderManagers) { + for (const githubRepository of folderManager.gitHubRepositories) { + for (const pullRequest of githubRepository.pullRequestModels) { + if (pullRequest[1].isResolved() && pullRequest[1].reviewThreadsCacheReady) { + pullRequest[1].initializeReviewThreadCache(); + } } - })); + } + } + })); } diff --git a/src/commentHandlerResolver.ts b/src/commentHandlerResolver.ts index 2bb86ec219..4018ca8be2 100644 --- a/src/commentHandlerResolver.ts +++ b/src/commentHandlerResolver.ts @@ -28,6 +28,12 @@ export interface CommentReply { text: string; } +export namespace CommentReply { + export function is(commentReply: any): commentReply is CommentReply { + return commentReply && commentReply.thread && (commentReply.text !== undefined); + } +} + const commentHandlers = new Map(); export function registerCommentHandler(key: string, commentHandler: CommentHandler) { @@ -45,7 +51,7 @@ export function resolveCommentHandler(commentThread: GHPRCommentThread): Comment } } - Logger.appendLine(`Unable to find handler for comment thread ${commentThread.gitHubThreadId}`); + Logger.warn(`Unable to find handler for comment thread ${commentThread.gitHubThreadId}`); return; } diff --git a/src/common/async.ts b/src/common/async.ts index 87e1ddd024..35488704b6 100644 --- a/src/common/async.ts +++ b/src/common/async.ts @@ -37,10 +37,12 @@ export function throttle(fn: () => Promise): () => Promise { } export function debounce(fn: () => any, delay: number): () => void { - let timer: any; + let timer: NodeJS.Timeout | undefined; return () => { - clearTimeout(timer); + if (timer) { + clearTimeout(timer); + } timer = setTimeout(() => fn(), delay); }; } diff --git a/src/common/authentication.ts b/src/common/authentication.ts index 4c8153ec47..3a761193b7 100644 --- a/src/common/authentication.ts +++ b/src/common/authentication.ts @@ -1,3 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export enum GitHubServerType { + None, + GitHubDotCom, + Enterprise +} + +export enum AuthProvider { + github = 'github', + githubEnterprise = 'github-enterprise' +} + export class AuthenticationError extends Error { name: string; stack?: string; @@ -6,6 +22,6 @@ export class AuthenticationError extends Error { } } -export function isSamlError(e: {message?: string}): boolean { +export function isSamlError(e: { message?: string }): boolean { return !!e.message?.startsWith('Resource protected by organization SAML enforcement.'); } diff --git a/src/common/comment.ts b/src/common/comment.ts index fdcf8e86e2..4bdcfdc6a1 100644 --- a/src/common/comment.ts +++ b/src/common/comment.ts @@ -23,10 +23,17 @@ export interface Reaction { count: number; icon?: vscode.Uri; viewerHasReacted: boolean; + reactors: readonly string[]; +} + +export enum SubjectType { + LINE = 'LINE', + FILE = 'FILE' } export interface IReviewThread { id: string; + prReviewDatabaseId?: number; isResolved: boolean; viewerCanResolve: boolean; viewerCanUnresolve: boolean; @@ -38,6 +45,7 @@ export interface IReviewThread { originalEndLine: number; isOutdated: boolean; comments: IComment[]; + subjectType: SubjectType; } export interface IComment { diff --git a/src/common/commentingRanges.ts b/src/common/commentingRanges.ts index 8c576c0a51..f2083ccedf 100644 --- a/src/common/commentingRanges.ts +++ b/src/common/commentingRanges.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import { DiffChangeType, DiffHunk } from './diffHunk'; import { getZeroBased } from './diffPositionMapping'; +import Logger from './logger'; /** * For the base file, the only commentable areas are deleted lines. For the modified file, @@ -13,7 +14,11 @@ import { getZeroBased } from './diffPositionMapping'; * @param diffHunks The diff hunks of the file * @param isBase Whether the commenting ranges are calculated for the base or modified file */ -export function getCommentingRanges(diffHunks: DiffHunk[], isBase: boolean): vscode.Range[] { +export function getCommentingRanges(diffHunks: DiffHunk[], isBase: boolean, logId: string = 'GetCommentingRanges'): vscode.Range[] { + if (diffHunks.length === 0) { + Logger.debug('No commenting ranges: File contains no diffs.', logId); + } + const ranges: vscode.Range[] = []; for (let i = 0; i < diffHunks.length; i++) { @@ -44,15 +49,20 @@ export function getCommentingRanges(diffHunks: DiffHunk[], isBase: boolean): vsc ranges.push(new vscode.Range(startingLine, 0, endingLine, 0)); startingLine = undefined; endingLine = undefined; + } else if (ranges.length === 0) { + Logger.debug('No commenting ranges: Diff is in base and none of the diff hunks could be added.', logId); } } else { if (diffHunk.newLineNumber) { startingLine = getZeroBased(diffHunk.newLineNumber); length = getZeroBased(diffHunk.newLength); ranges.push(new vscode.Range(startingLine, 0, startingLine + length, 0)); + } else { + Logger.debug('No commenting ranges: Diff is not base and newLineNumber is undefined.', logId); } } } + Logger.debug(`Found ${ranges.length} commenting ranges.`, logId); return ranges; } diff --git a/src/common/diffHunk.ts b/src/common/diffHunk.ts index 5d949383f2..6fa94e3dda 100644 --- a/src/common/diffHunk.ts +++ b/src/common/diffHunk.ts @@ -7,7 +7,6 @@ * Inspired by and includes code from GitHub/VisualStudio project, obtained from https://github.com/github/VisualStudio/blob/master/src/GitHub.Exports/Models/DiffLine.cs */ -import { Repository } from '../api/api'; import { IRawFileChange } from '../github/interface'; import { GitChangeType, InMemFileChange, SlimFileChange } from './file'; @@ -98,7 +97,7 @@ export function* LineReader(text: string): IterableIterator { } export function* parseDiffHunk(diffHunkPatch: string): IterableIterator { - const lineReader = LineReader(diffHunkPatch); + const lineReader: Iterator = LineReader(diffHunkPatch); let itr = lineReader.next(); let diffHunk: DiffHunk | undefined = undefined; @@ -262,7 +261,6 @@ export function getGitChangeType(status: string): GitChangeType { export async function parseDiff( reviews: IRawFileChange[], - repository: Repository, parentCommit: string, ): Promise<(InMemFileChange | SlimFileChange)[]> { const fileChanges: (InMemFileChange | SlimFileChange)[] = []; @@ -271,7 +269,9 @@ export async function parseDiff( const review = reviews[i]; const gitChangeType = getGitChangeType(review.status); - if (!review.patch) { + if (!review.patch && + // We don't need to make a SlimFileChange for empty file adds. + !((gitChangeType === GitChangeType.ADD) && (review.additions === 0))) { fileChanges.push( new SlimFileChange( parentCommit, @@ -284,30 +284,7 @@ export async function parseDiff( continue; } - let originalFileExist = false; - - switch (gitChangeType) { - case GitChangeType.DELETE: - case GitChangeType.MODIFY: - try { - await repository.getObjectDetails(parentCommit, review.filename); - originalFileExist = true; - } catch (err) { - /* noop */ - } - break; - case GitChangeType.RENAME: - try { - await repository.getObjectDetails(parentCommit, review.previous_filename!); - originalFileExist = true; - } catch (err) { - /* noop */ - } - break; - } - - const diffHunks = parsePatch(review.patch); - const isPartial = !originalFileExist && gitChangeType !== GitChangeType.ADD; + const diffHunks = review.patch ? parsePatch(review.patch) : []; fileChanges.push( new InMemFileChange( parentCommit, @@ -316,7 +293,6 @@ export async function parseDiff( review.previous_filename, review.patch, diffHunks, - isPartial, review.blob_url, ), ); diff --git a/src/common/diffPositionMapping.ts b/src/common/diffPositionMapping.ts index a8d4f69804..fa582ebd6d 100644 --- a/src/common/diffPositionMapping.ts +++ b/src/common/diffPositionMapping.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DiffHunk, DiffLine, parseDiffHunk } from './diffHunk'; +import { DiffChangeType, DiffHunk, DiffLine, parseDiffHunk } from './diffHunk'; /** * Line position in a git diff is 1 based, except for the case when the original or changed file have @@ -44,7 +44,17 @@ export function mapOldPositionToNew(patch: string, line: number): number { } else if (diffHunk.oldLineNumber + diffHunk.oldLength - 1 < line) { delta += diffHunk.newLength - diffHunk.oldLength; } else { - delta += diffHunk.newLength - diffHunk.oldLength; + // Part of the hunk is before line, part is after. + for (const diffLine of diffHunk.diffLines) { + if (diffLine.oldLineNumber > line) { + return line + delta; + } + if (diffLine.type === DiffChangeType.Add) { + delta++; + } else if (diffLine.type === DiffChangeType.Delete) { + delta--; + } + } return line + delta; } @@ -67,7 +77,17 @@ export function mapNewPositionToOld(patch: string, line: number): number { } else if (diffHunk.newLineNumber + diffHunk.newLength - 1 < line) { delta += diffHunk.oldLength - diffHunk.newLength; } else { - delta += diffHunk.oldLength - diffHunk.newLength; + // Part of the hunk is before line, part is after. + for (const diffLine of diffHunk.diffLines) { + if (diffLine.newLineNumber > line) { + return line + delta; + } + if (diffLine.type === DiffChangeType.Add) { + delta--; + } else if (diffLine.type === DiffChangeType.Delete) { + delta++; + } + } return line + delta; } diff --git a/src/common/executeCommands.ts b/src/common/executeCommands.ts index f04df72ff4..9e1290e190 100644 --- a/src/common/executeCommands.ts +++ b/src/common/executeCommands.ts @@ -11,6 +11,10 @@ export namespace contexts { export const IN_REVIEW_MODE = 'github:inReviewMode'; export const REPOS_NOT_IN_REVIEW_MODE = 'github:reposNotInReviewMode'; export const REPOS_IN_REVIEW_MODE = 'github:reposInReviewMode'; + export const ACTIVE_PR_COUNT = 'github:activePRCount'; + export const LOADING_PRS_TREE = 'github:loadingPrsTree'; + export const LOADING_ISSUES_TREE = 'github:loadingIssuesTree'; + export const CREATE_PR_PERMISSIONS = 'github:createPrPermissions'; } export namespace commands { diff --git a/src/common/file.ts b/src/common/file.ts index 13c21de4e9..c352a24137 100644 --- a/src/common/file.ts +++ b/src/common/file.ts @@ -31,7 +31,6 @@ export class InMemFileChange implements SimpleFileChange { public readonly previousFileName: string | undefined, public readonly patch: string, public readonly diffHunks: DiffHunk[], - public readonly isPartial: boolean, public readonly blobUrl: string, ) {} } diff --git a/src/common/githubRef.ts b/src/common/githubRef.ts index dff9bf55d6..eb6847443b 100644 --- a/src/common/githubRef.ts +++ b/src/common/githubRef.ts @@ -7,7 +7,8 @@ import { Protocol } from './protocol'; export class GitHubRef { public repositoryCloneUrl: Protocol; - constructor(public ref: string, public label: string, public sha: string, repositoryCloneUrl: string) { + constructor(public ref: string, public label: string, public sha: string, repositoryCloneUrl: string, + public readonly owner: string, public readonly name: string, public readonly isInOrganization: boolean) { this.repositoryCloneUrl = new Protocol(repositoryCloneUrl); } } diff --git a/src/common/logger.ts b/src/common/logger.ts index 4cc5d3edcd..57bfb0ccd2 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -1,62 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; -const enum LogLevel { - Info, - Debug, - Off, -} - -const SETTINGS_NAMESPACE = 'githubPullRequests'; -const LOG_LEVEL_SETTING = 'logLevel'; export const PR_TREE = 'PullRequestTree'; class Log { - private _outputChannel: vscode.OutputChannel; - private _logLevel: LogLevel; + private _outputChannel: vscode.LogOutputChannel; private _disposable: vscode.Disposable; private _activePerfMarkers: Map = new Map(); constructor() { - this._outputChannel = vscode.window.createOutputChannel('GitHub Pull Request'); - this._disposable = vscode.workspace.onDidChangeConfiguration(() => { - this.getLogLevel(); - }); - this.getLogLevel(); + this._outputChannel = vscode.window.createOutputChannel('GitHub Pull Request', { log: true }); } public startPerfMarker(marker: string) { - const startTime = (new Date()).getTime(); + const startTime = performance.now(); this._outputChannel.appendLine(`PERF_MARKER> Start ${marker}`); this._activePerfMarkers.set(marker, startTime); } public endPerfMarker(marker: string) { - const endTime = (new Date()).getTime(); + const endTime = performance.now(); this._outputChannel.appendLine(`PERF_MARKER> End ${marker}: ${endTime - this._activePerfMarkers.get(marker)!} ms`); this._activePerfMarkers.delete(marker); } - public appendLine(message: string, component?: string) { - switch (this._logLevel) { - case LogLevel.Off: - return; - case LogLevel.Debug: - const hrtime = new Date().getTime() / 1000; - const timeStamp = `${hrtime}s`; - const info = component ? `${component}> ${message}` : `${message}`; - this._outputChannel.appendLine(`[Debug ${timeStamp}] ${info}`); - return; - case LogLevel.Info: - default: - this._outputChannel.appendLine(`[Info] ` + (component ? `${component}> ${message}` : `${message}`)); - return; + private logString(message: any, component?: string): string { + if (typeof message !== 'string') { + if (message instanceof Error) { + message = message.message; + } else if ('toString' in message) { + message = message.toString(); + } else { + message = JSON.stringify(message); + } } + return component ? `${component}> ${message}` : message; } - public debug(message: string, component: string) { - if (this._logLevel === LogLevel.Debug) { - this.appendLine(message, component); - } + public trace(message: any, component: string) { + this._outputChannel.trace(this.logString(message, component)); + } + + public debug(message: any, component: string) { + this._outputChannel.debug(this.logString(message, component)); + } + + public appendLine(message: any, component?: string) { + this._outputChannel.info(this.logString(message, component)); + } + + public warn(message: any, component?: string) { + this._outputChannel.warn(this.logString(message, component)); + } + + public error(message: any, component?: string) { + this._outputChannel.error(this.logString(message, component)); } public dispose() { @@ -64,22 +65,6 @@ class Log { this._disposable.dispose(); } } - - private getLogLevel() { - const logLevel = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(LOG_LEVEL_SETTING); - switch (logLevel) { - case 'debug': - this._logLevel = LogLevel.Debug; - break; - case 'off': - this._logLevel = LogLevel.Off; - break; - case 'info': - default: - this._logLevel = LogLevel.Info; - break; - } - } } const Logger = new Log(); diff --git a/src/common/protocol.ts b/src/common/protocol.ts index 0729818825..a3b074f91f 100644 --- a/src/common/protocol.ts +++ b/src/common/protocol.ts @@ -44,9 +44,9 @@ export class Protocol { this.owner = this.getOwnerName(this.url.path) || ''; } } catch (e) { - Logger.appendLine(`Failed to parse '${uriString}'`); + Logger.error(`Failed to parse '${uriString}'`); vscode.window.showWarningMessage( - `Unable to parse remote '${uriString}'. Please check that it is correctly formatted.`, + vscode.l10n.t('Unable to parse remote \'{0}\'. Please check that it is correctly formatted.', uriString) ); } } diff --git a/src/common/remote.ts b/src/common/remote.ts index 49392909db..cdfab74a12 100644 --- a/src/common/remote.ts +++ b/src/common/remote.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Repository } from '../api/api'; -import { AuthProvider } from '../github/credentials'; -import { getEnterpriseUri } from '../github/utils'; +import { getEnterpriseUri, isEnterprise } from '../github/utils'; +import { AuthProvider, GitHubServerType } from './authentication'; import { Protocol } from './protocol'; export class Remote { @@ -25,14 +25,18 @@ export class Remote { } public get authProviderId(): AuthProvider { - return this.host === getEnterpriseUri()?.authority ? AuthProvider['github-enterprise'] : AuthProvider.github; + return this.host === getEnterpriseUri()?.authority ? AuthProvider.githubEnterprise : AuthProvider.github; + } + + public get isEnterprise(): boolean { + return isEnterprise(this.authProviderId); } constructor( public readonly remoteName: string, public readonly url: string, public readonly gitProtocol: Protocol, - ) {} + ) { } equals(remote: Remote): boolean { if (this.remoteName !== remote.remoteName) { @@ -41,10 +45,10 @@ export class Remote { if (this.host !== remote.host) { return false; } - if (this.owner !== remote.owner) { + if (this.owner.toLocaleLowerCase() !== remote.owner.toLocaleLowerCase()) { return false; } - if (this.repositoryName !== remote.repositoryName) { + if (this.repositoryName.toLocaleLowerCase() !== remote.repositoryName.toLocaleLowerCase()) { return false; } @@ -73,7 +77,7 @@ export function parseRemote(remoteName: string, url: string, originalProtocol?: export function parseRepositoryRemotes(repository: Repository): Remote[] { const remotes: Remote[] = []; for (const r of repository.state.remotes) { - const urls: string[] =[]; + const urls: string[] = []; if (r.fetchUrl) { urls.push(r.fetchUrl); } @@ -89,3 +93,18 @@ export function parseRepositoryRemotes(repository: Repository): Remote[] { } return remotes; } + +export class GitHubRemote extends Remote { + static remoteAsGitHub(remote: Remote, githubServerType: GitHubServerType): GitHubRemote { + return new GitHubRemote(remote.remoteName, remote.url, remote.gitProtocol, githubServerType); + } + + constructor( + remoteName: string, + url: string, + gitProtocol: Protocol, + public readonly githubServerType: GitHubServerType + ) { + super(remoteName, url, gitProtocol); + } +} \ No newline at end of file diff --git a/src/common/sessionState.ts b/src/common/sessionState.ts deleted file mode 100644 index 2732d6ce77..0000000000 --- a/src/common/sessionState.ts +++ /dev/null @@ -1,38 +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 { SETTINGS_NAMESPACE } from '../github/folderRepositoryManager'; -import { COMMENT_EXPAND_STATE_EXPAND_VALUE, COMMENT_EXPAND_STATE_SETTING } from '../github/utils'; - -export interface ISessionState { - onDidChangeCommentsExpandState: vscode.Event; - commentsExpandState: boolean; -} - -export class SessionState implements ISessionState { - private _commentsExpandState: boolean; - private _onDidChangeCommentsExpandState: vscode.EventEmitter = new vscode.EventEmitter(); - public onDidChangeCommentsExpandState = this._onDidChangeCommentsExpandState.event; - get commentsExpandState(): boolean { - return this._commentsExpandState; - } - set commentsExpandState(expand: boolean) { - this._commentsExpandState = expand; - this._onDidChangeCommentsExpandState.fire(this._commentsExpandState); - } - - constructor(context: vscode.ExtensionContext) { - const config = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE)?.get(COMMENT_EXPAND_STATE_SETTING); - this._commentsExpandState = config === COMMENT_EXPAND_STATE_EXPAND_VALUE; - context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration(event => { - if (event.affectsConfiguration(`${SETTINGS_NAMESPACE}.${COMMENT_EXPAND_STATE_SETTING}`)) { - const config = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE)?.get(COMMENT_EXPAND_STATE_SETTING); - this.commentsExpandState = config === COMMENT_EXPAND_STATE_EXPAND_VALUE; - } - })); - } -} \ No newline at end of file diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts index f5bcdc4c8e..8ab9f20fdb 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -8,3 +8,66 @@ export const TERMINAL_LINK_HANDLER = 'terminalLinksHandler'; export const BRANCH_PUBLISH = 'createOnPublishBranch'; export const USE_REVIEW_MODE = 'useReviewMode'; export const FILE_LIST_LAYOUT = 'fileListLayout'; +export const ASSIGN_TO = 'assignCreated'; +export const PUSH_BRANCH = 'pushBranch'; +export const IGNORE_PR_BRANCHES = 'ignoredPullRequestBranches'; +export const NEVER_IGNORE_DEFAULT_BRANCH = 'neverIgnoreDefaultBranch'; +export const OVERRIDE_DEFAULT_BRANCH = 'overrideDefaultBranch'; +export const PULL_BRANCH = 'pullBranch'; +export const PULL_REQUEST_DESCRIPTION = 'pullRequestDescription'; +export const NOTIFICATION_SETTING = 'notifications'; +export const POST_CREATE = 'postCreate'; +export const QUERIES = 'queries'; +export const FOCUSED_MODE = 'focusedMode'; +export const CREATE_DRAFT = 'createDraft'; +export const QUICK_DIFF = 'quickDiff'; +export const SET_AUTO_MERGE = 'setAutoMerge'; +export const SHOW_PULL_REQUEST_NUMBER_IN_TREE = 'showPullRequestNumberInTree'; +export const DEFAULT_MERGE_METHOD = 'defaultMergeMethod'; +export const DEFAULT_DELETION_METHOD = 'defaultDeletionMethod'; +export const SELECT_LOCAL_BRANCH = 'selectLocalBranch'; +export const SELECT_REMOTE = 'selectRemote'; +export const REMOTES = 'remotes'; +export const PULL_PR_BRANCH_BEFORE_CHECKOUT = 'pullPullRequestBranchBeforeCheckout'; +export const UPSTREAM_REMOTE = 'upstreamRemote'; +export const DEFAULT_CREATE_OPTION = 'defaultCreateOption'; +export const CREATE_BASE_BRANCH = 'createDefaultBaseBranch'; + +export const ISSUES_SETTINGS_NAMESPACE = 'githubIssues'; +export const ASSIGN_WHEN_WORKING = 'assignWhenWorking'; +export const ISSUE_COMPLETIONS = 'issueCompletions'; +export const USER_COMPLETIONS = 'userCompletions'; +export const ENABLED = 'enabled'; +export const IGNORE_USER_COMPLETION_TRIGGER = 'ignoreUserCompletionTrigger'; +export const CREATE_INSERT_FORMAT = 'createInsertFormat'; +export const ISSUE_BRANCH_TITLE = 'issueBranchTitle'; +export const USE_BRANCH_FOR_ISSUES = 'useBranchForIssues'; +export const WORKING_ISSUE_FORMAT_SCM = 'workingIssueFormatScm'; +export const IGNORE_COMPLETION_TRIGGER = 'ignoreCompletionTrigger'; +export const ISSUE_COMPLETION_FORMAT_SCM = 'issueCompletionFormatScm'; +export const CREATE_ISSUE_TRIGGERS = 'createIssueTriggers'; +export const DEFAULT = 'default'; +export const IGNORE_MILESTONES = 'ignoreMilestones'; +export const ALLOW_FETCH = 'allowFetch'; + +// git +export const GIT = 'git'; +export const PULL_BEFORE_CHECKOUT = 'pullBeforeCheckout'; +export const OPEN_DIFF_ON_CLICK = 'openDiffOnClick'; +export const AUTO_STASH = 'autoStash'; + +// GitHub Enterprise +export const GITHUB_ENTERPRISE = 'github-enterprise'; +export const URI = 'uri'; + +// Editor +export const EDITOR = 'editor'; +export const WORD_WRAP = 'wordWrap'; + +// Comments +export const COMMENTS = 'comments'; +export const OPEN_VIEW = 'openView'; + +// Explorer +export const EXPLORER = 'explorer'; +export const AUTO_REVEAL = 'autoReveal'; diff --git a/src/common/temporaryState.ts b/src/common/temporaryState.ts index c740f7d1c2..c73288317f 100644 --- a/src/common/temporaryState.ts +++ b/src/common/temporaryState.ts @@ -4,12 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; import Logger from './logger'; +import { dispose } from './utils'; let tempState: TemporaryState | undefined; export class TemporaryState extends vscode.Disposable { private readonly SUBPATH = 'temp'; private readonly disposables: vscode.Disposable[] = []; + private readonly persistInSessionDisposables: vscode.Disposable[] = []; constructor(private _storageUri: vscode.Uri) { super(() => this.disposables.forEach(disposable => disposable.dispose())); @@ -19,15 +21,24 @@ export class TemporaryState extends vscode.Disposable { return vscode.Uri.joinPath(this._storageUri, this.SUBPATH); } - private addDisposable(disposable: vscode.Disposable) { - if (this.disposables.length > 30) { - const oldDisposable = this.disposables.shift(); - oldDisposable?.dispose(); + dispose() { + dispose(this.disposables); + dispose(this.persistInSessionDisposables); + } + + private addDisposable(disposable: vscode.Disposable, persistInSession: boolean) { + if (persistInSession) { + this.persistInSessionDisposables.push(disposable); + } else { + if (this.disposables.length > 30) { + const oldDisposable = this.disposables.shift(); + oldDisposable?.dispose(); + } + this.disposables.push(disposable); } - this.disposables.push(disposable); } - private async writeState(subpath: string, filename: string, contents: Uint8Array): Promise { + private async writeState(subpath: string, filename: string, contents: Uint8Array, persistInSession: boolean): Promise { let filePath: vscode.Uri = this.path; const workspace = (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) ? vscode.workspace.workspaceFolders[0].name : undefined; @@ -45,13 +56,30 @@ export class TemporaryState extends vscode.Disposable { const dispose = { dispose: () => { - return vscode.workspace.fs.delete(file, { recursive: true }); + try { + return vscode.workspace.fs.delete(file, { recursive: true }); + } catch (e) { + // No matter the error, we do not want to throw in dispose. + } } }; - this.addDisposable(dispose); + this.addDisposable(dispose, persistInSession); return file; } + private async readState(subpath: string, filename: string): Promise { + let filePath: vscode.Uri = this.path; + const workspace = (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) + ? vscode.workspace.workspaceFolders[0].name : undefined; + + if (workspace) { + filePath = vscode.Uri.joinPath(filePath, workspace); + } + filePath = vscode.Uri.joinPath(filePath, subpath); + const file = vscode.Uri.joinPath(filePath, filename); + return vscode.workspace.fs.readFile(file); + } + static async init(context: vscode.ExtensionContext): Promise { if (context.globalStorageUri && !tempState) { tempState = new TemporaryState(context.globalStorageUri); @@ -70,11 +98,19 @@ export class TemporaryState extends vscode.Disposable { } } - static async write(subpath: string, filename: string, contents: Uint8Array): Promise { + static async write(subpath: string, filename: string, contents: Uint8Array, persistInSession: boolean = false): Promise { + if (!tempState) { + return; + } + + return tempState.writeState(subpath, filename, contents, persistInSession); + } + + static async read(subpath: string, filename: string): Promise { if (!tempState) { return; } - return tempState.writeState(subpath, filename, contents); + return tempState.readState(subpath, filename); } } \ No newline at end of file diff --git a/src/common/timelineEvent.ts b/src/common/timelineEvent.ts index 737a4bc2b4..ba59aeffe7 100644 --- a/src/common/timelineEvent.ts +++ b/src/common/timelineEvent.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IAccount } from '../github/interface'; +import { IAccount, IActor } from '../github/interface'; import { IComment } from './comment'; export enum EventType { @@ -12,6 +12,7 @@ export enum EventType { Subscribed, Commented, Reviewed, + NewCommitsSinceReview, Labeled, Milestoned, Assigned, @@ -28,19 +29,28 @@ export interface Committer { export interface CommentEvent { id: number; + graphNodeId: string; htmlUrl: string; body: string; bodyHTML?: string; user: IAccount; - event: EventType; + event: EventType.Commented; canEdit?: boolean; canDelete?: boolean; createdAt: string; } +export interface ReviewResolveInfo { + threadId: string; + canResolve: boolean; + canUnresolve: boolean; + isResolved: boolean; +} + export interface ReviewEvent { id: number; - event: EventType; + reviewThread?: ReviewResolveInfo + event: EventType.Reviewed; comments: IComment[]; submittedAt: string; body: string; @@ -54,7 +64,7 @@ export interface ReviewEvent { export interface CommitEvent { id: string; author: IAccount; - event: EventType; + event: EventType.Committed; sha: string; htmlUrl: string; message: string; @@ -62,55 +72,36 @@ export interface CommitEvent { authoredDate: Date; } +export interface NewCommitsSinceReviewEvent { + id: string; + event: EventType.NewCommitsSinceReview; +} + export interface MergedEvent { id: string; graphNodeId: string; - user: IAccount; + user: IActor; createdAt: string; mergeRef: string; sha: string; commitUrl: string; - event: EventType; + event: EventType.Merged; url: string; } export interface AssignEvent { id: number; - event: EventType; + event: EventType.Assigned; user: IAccount; - actor: IAccount; + actor: IActor; } export interface HeadRefDeleteEvent { id: string; - event: EventType; - actor: IAccount; + event: EventType.HeadRefDeleted; + actor: IActor; createdAt: string; headRef: string; } -export type TimelineEvent = CommitEvent | ReviewEvent | CommentEvent | MergedEvent | AssignEvent | HeadRefDeleteEvent; - -export function isReviewEvent(event: TimelineEvent): event is ReviewEvent { - return event.event === EventType.Reviewed; -} - -export function isCommitEvent(event: TimelineEvent): event is CommitEvent { - return event.event === EventType.Committed; -} - -export function isCommentEvent(event: TimelineEvent): event is CommentEvent { - return event.event === EventType.Commented; -} - -export function isMergedEvent(event: TimelineEvent): event is MergedEvent { - return event.event === EventType.Merged; -} - -export function isAssignEvent(event: TimelineEvent): event is AssignEvent { - return event.event === EventType.Assigned; -} - -export function isHeadDeleteEvent(event: TimelineEvent): event is HeadRefDeleteEvent { - return event.event === EventType.HeadRefDeleted; -} +export type TimelineEvent = CommitEvent | ReviewEvent | CommentEvent | NewCommitsSinceReviewEvent | MergedEvent | AssignEvent | HeadRefDeleteEvent; diff --git a/src/common/uri.ts b/src/common/uri.ts index 6ff787fb1d..30b31d404d 100644 --- a/src/common/uri.ts +++ b/src/common/uri.ts @@ -5,11 +5,15 @@ 'use strict'; +import { Buffer } from 'buffer'; import * as pathUtils from 'path'; +import fetch from 'cross-fetch'; import * as vscode from 'vscode'; import { Repository } from '../api/api'; +import { IAccount, ITeam, reviewerId } from '../github/interface'; import { PullRequestModel } from '../github/pullRequestModel'; import { GitChangeType } from './file'; +import Logger from './logger'; import { TemporaryState } from './temporaryState'; export interface ReviewUriParams { @@ -33,6 +37,7 @@ export interface PRUriParams { prNumber: number; status: GitChangeType; remoteName: string; + previousFileName?: string; } export function fromPRUri(uri: vscode.Uri): PRUriParams | undefined { @@ -41,6 +46,16 @@ export function fromPRUri(uri: vscode.Uri): PRUriParams | undefined { } catch (e) { } } +export interface PRNodeUriParams { + prIdentifier: string +} + +export function fromPRNodeUri(uri: vscode.Uri): PRNodeUriParams | undefined { + try { + return JSON.parse(uri.query) as PRNodeUriParams; + } catch (e) { } +} + export interface GitHubUriParams { fileName: string; branch: string; @@ -60,7 +75,7 @@ export interface GitUriOptions { const ImageMimetypes = ['image/png', 'image/gif', 'image/jpeg', 'image/webp', 'image/tiff', 'image/bmp']; // Known media types that VS Code can handle: https://github.com/microsoft/vscode/blob/a64e8e5673a44e5b9c2d493666bde684bd5a135c/src/vs/base/common/mime.ts#L33-L84 -const KnownMediaExtensions =[ +export const KnownMediaExtensions = [ '.aac', '.avi', '.bmp', @@ -117,9 +132,9 @@ export const EMPTY_IMAGE_URI = vscode.Uri.parse( `data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==`, ); -export async function asImageDataURI(uri: vscode.Uri, repository: Repository): Promise { +export async function asTempStorageURI(uri: vscode.Uri, repository: Repository): Promise { try { - const { commit, baseCommit, headCommit, isBase, path } = JSON.parse(uri.query); + const { commit, baseCommit, headCommit, isBase, path }: { commit: string, baseCommit: string, headCommit: string, isBase: string, path: string } = JSON.parse(uri.query); const ext = pathUtils.extname(path); if (!KnownMediaExtensions.includes(ext)) { return; @@ -141,6 +156,125 @@ export async function asImageDataURI(uri: vscode.Uri, repository: Repository): P } } +export namespace DataUri { + const iconsFolder = 'userIcons'; + + function iconFilename(user: IAccount | ITeam): string { + return `${reviewerId(user)}.jpg`; + } + + function cacheLocation(context: vscode.ExtensionContext): vscode.Uri { + return vscode.Uri.joinPath(context.globalStorageUri, iconsFolder); + } + + function fileCacheUri(context: vscode.ExtensionContext, user: IAccount | ITeam): vscode.Uri { + return vscode.Uri.joinPath(cacheLocation(context), iconFilename(user)); + } + + function cacheLogUri(context: vscode.ExtensionContext): vscode.Uri { + return vscode.Uri.joinPath(cacheLocation(context), 'cache.log'); + } + + async function writeAvatarToCache(context: vscode.ExtensionContext, user: IAccount | ITeam, contents: Uint8Array): Promise { + await vscode.workspace.fs.createDirectory(cacheLocation(context)); + const file = fileCacheUri(context, user); + await vscode.workspace.fs.writeFile(file, contents); + return file; + } + + async function readAvatarFromCache(context: vscode.ExtensionContext, user: IAccount | ITeam): Promise { + try { + const file = fileCacheUri(context, user); + return vscode.workspace.fs.readFile(file); + } catch (e) { + return; + } + } + + export function asImageDataURI(contents: Buffer): vscode.Uri { + return vscode.Uri.parse( + `data:image/svg+xml;size:${contents.byteLength};base64,${contents.toString('base64')}` + ); + } + + export async function avatarCirclesAsImageDataUris(context: vscode.ExtensionContext, users: (IAccount | ITeam)[], height: number, width: number, localOnly?: boolean): Promise<(vscode.Uri | undefined)[]> { + let cacheLogOrder: string[]; + const cacheLog = cacheLogUri(context); + try { + const log = await vscode.workspace.fs.readFile(cacheLog); + cacheLogOrder = JSON.parse(log.toString()); + } catch (e) { + cacheLogOrder = []; + } + const startingCacheSize = cacheLogOrder.length; + + const results = await Promise.all(users.map(async (user) => { + + const imageSourceUrl = user.avatarUrl; + if (imageSourceUrl === undefined) { + return undefined; + } + let innerImageContents: Buffer | undefined; + let cacheMiss: boolean = false; + try { + const fileContents = await readAvatarFromCache(context, user); + if (!fileContents) { + throw new Error('Temporary state not initialized'); + } + innerImageContents = Buffer.from(fileContents); + } catch (e) { + if (localOnly) { + return; + } + cacheMiss = true; + const doFetch = async () => { + const response = await fetch(imageSourceUrl.toString()); + const buffer = await response.arrayBuffer(); + await writeAvatarToCache(context, user, new Uint8Array(buffer)); + innerImageContents = Buffer.from(buffer); + }; + try { + await doFetch(); + } catch (e) { + // We retry once. + await doFetch(); + } + } + if (!innerImageContents) { + return undefined; + } + if (cacheMiss) { + const icon = iconFilename(user); + cacheLogOrder.push(icon); + } + const innerImageEncoded = `data:image/jpeg;size:${innerImageContents.byteLength};base64,${innerImageContents.toString('base64')}`; + const contentsString = ` + + `; + const contents = Buffer.from(contentsString); + const finalDataUri = asImageDataURI(contents); + return finalDataUri; + })); + + const maxCacheSize = Math.max(users.length, 200); + if (cacheLogOrder.length > startingCacheSize && startingCacheSize > 0 && cacheLogOrder.length > maxCacheSize) { + // The cache is getting big, we should clean it up. + const toDelete = cacheLogOrder.splice(0, 50); + await Promise.all(toDelete.map(async (id) => { + try { + await vscode.workspace.fs.delete(vscode.Uri.joinPath(cacheLocation(context), id)); + } catch (e) { + Logger.error(`Failed to delete avatar from cache: ${e}`); + } + })); + } + + await vscode.workspace.fs.writeFile(cacheLog, Buffer.from(JSON.stringify(cacheLogOrder))); + + return results; + } +} + export function toReviewUri( uri: vscode.Uri, filePath: string | undefined, @@ -175,14 +309,16 @@ export function toReviewUri( export interface FileChangeNodeUriParams { prNumber: number; fileName: string; + previousFileName?: string; status?: GitChangeType; } -export function toResourceUri(uri: vscode.Uri, prNumber: number, fileName: string, status: GitChangeType) { - const params = { - prNumber: prNumber, - fileName: fileName, - status: status, +export function toResourceUri(uri: vscode.Uri, prNumber: number, fileName: string, status: GitChangeType, previousFileName?: string) { + const params: FileChangeNodeUriParams = { + prNumber, + fileName, + status, + previousFileName }; return uri.with({ @@ -205,6 +341,7 @@ export function toPRUri( fileName: string, base: boolean, status: GitChangeType, + previousFileName?: string ): vscode.Uri { const params: PRUriParams = { baseCommit: baseCommit, @@ -214,6 +351,7 @@ export function toPRUri( prNumber: pullRequestModel.number, status: status, remoteName: pullRequestModel.githubRepository.remote.remoteName, + previousFileName }; const path = uri.path; @@ -225,13 +363,44 @@ export function toPRUri( }); } +export function createPRNodeIdentifier(pullRequest: PullRequestModel | { remote: string, prNumber: number } | string) { + let identifier: string; + if (pullRequest instanceof PullRequestModel) { + identifier = `${pullRequest.remote.url}:${pullRequest.number}`; + } else if (typeof pullRequest === 'string') { + identifier = pullRequest; + } else { + identifier = `${pullRequest.remote}:${pullRequest.prNumber}`; + } + return identifier; +} + +export function createPRNodeUri( + pullRequest: PullRequestModel | { remote: string, prNumber: number } | string +): vscode.Uri { + const identifier = createPRNodeIdentifier(pullRequest); + const params: PRNodeUriParams = { + prIdentifier: identifier, + }; + + const uri = vscode.Uri.parse(`PRNode:${identifier}`); + + return uri.with({ + scheme: Schemes.PRNode, + query: JSON.stringify(params) + }); +} + export enum Schemes { File = 'file', Review = 'review', Pr = 'pr', + PRNode = 'prnode', FileChange = 'filechange', GithubPr = 'githubpr', - VscodeVfs = 'vscode-vfs' // Remote Repository + GitPr = 'gitpr', + VscodeVfs = 'vscode-vfs', // Remote Repository + Comment = 'comment' // Comments from the VS Code comment widget } export function resolvePath(from: vscode.Uri, to: string) { diff --git a/src/common/user.ts b/src/common/user.ts new file mode 100644 index 0000000000..71b3d7f5a7 --- /dev/null +++ b/src/common/user.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// 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']; + +// 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']; \ No newline at end of file diff --git a/src/common/utils.ts b/src/common/utils.ts index c1cd1cf1c3..f836c4c91b 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -8,7 +8,8 @@ import { sep } from 'path'; import dayjs from 'dayjs'; import * as relativeTime from 'dayjs/plugin/relativeTime'; import * as updateLocale from 'dayjs/plugin/updateLocale'; -import { Disposable, Event, Uri } from 'vscode'; +import type { Disposable, Event, ExtensionContext, Uri } from 'vscode'; +// TODO: localization for webview needed dayjs.extend(relativeTime.default, { thresholds: [ @@ -90,12 +91,12 @@ export function anyEvent(...events: Event[]): Event { } export function filterEvent(event: Event, filter: (e: T) => boolean): Event { - return (listener, thisArgs = null, disposables?) => + return (listener, thisArgs = null, disposables?: Disposable[]) => event(e => filter(e) && listener.call(thisArgs, e), null, disposables); } export function onceEvent(event: Event): Event { - return (listener, thisArgs = null, disposables?) => { + return (listener, thisArgs = null, disposables?: Disposable[]) => { const result = event( e => { result.dispose(); @@ -139,6 +140,12 @@ export function groupBy(arr: T[], fn: (el: T) => string): { [key: string]: T[ }, Object.create(null)); } +export class UnreachableCaseError extends Error { + constructor(val: never) { + super(`Unreachable case: ${val}`); + } +} + interface HookError extends Error { errors: any; } @@ -185,6 +192,8 @@ export function formatError(e: HookError | any): string { return `Value "${error.value}" cannot be set for field ${error.field} (code: ${error.code})`; }) .join(', '); + } else if (e.message.startsWith('Validation Failed:')) { + return e.message; } else if (isHookError(e) && e.errors) { return e.errors .map((error: any) => { @@ -207,43 +216,20 @@ export interface PromiseAdapter { (value: T, resolve: (value?: U | PromiseLike) => void, reject: (reason: any) => void): any; } -const passthrough = (value: any, resolve: (value?: any) => void) => resolve(value); - -/** - * Return a promise that resolves with the next emitted event, or with some future - * event as decided by an adapter. - * - * If specified, the adapter is a function that will be called with - * `(event, resolve, reject)`. It will be called once per event until it resolves or - * rejects. - * - * The default adapter is the passthrough function `(value, resolve) => resolve(value)`. - * - * @param {Event} event the event - * @param {PromiseAdapter?} adapter controls resolution of the returned promise - * @returns {Promise} a promise that resolves or rejects as specified by the adapter - */ -export async function promiseFromEvent(event: Event, adapter: PromiseAdapter = passthrough): Promise { - let subscription: Disposable; - return new Promise( - (resolve, reject) => - (subscription = event((value: T) => { - try { - Promise.resolve(adapter(value, resolve as any, reject)).catch(reject); - } catch (error) { - reject(error); - } - })), - ).then( - (result: U) => { - subscription.dispose(); - return result; - }, - error => { - subscription.dispose(); - throw error; - }, - ); +// Copied from https://github.com/microsoft/vscode/blob/cfd9d25826b5b5bc3b06677521660b4f1ba6639a/extensions/vscode-api-tests/src/utils.ts#L135-L136 +export async function asPromise(event: Event): Promise { + return new Promise((resolve) => { + const sub = event(e => { + sub.dispose(); + resolve(e); + }); + }); +} + +export async function promiseWithTimeout(promise: Promise, ms: number): Promise { + return Promise.race([promise, new Promise(resolve => { + setTimeout(() => resolve(undefined), ms); + })]); } export function dateFromNow(date: Date | string): string { @@ -260,6 +246,138 @@ export function dateFromNow(date: Date | string): string { return `on ${djs.format('MMM D, YYYY')}`; } + +export function gitHubLabelColor(hexColor: string, isDark: boolean, markDown: boolean = false): { textColor: string, backgroundColor: string, borderColor: string } { + if (hexColor.startsWith('#')) { + hexColor = hexColor.substring(1); + } + const rgbColor = hexToRgb(hexColor); + + if (isDark) { + const hslColor = rgbToHsl(rgbColor.r, rgbColor.g, rgbColor.b); + + const lightnessThreshold = 0.6; + const backgroundAlpha = 0.18; + const borderAlpha = 0.3; + + const perceivedLightness = (rgbColor.r * 0.2126 + rgbColor.g * 0.7152 + rgbColor.b * 0.0722) / 255; + const lightnessSwitch = Math.max(0, Math.min((perceivedLightness - lightnessThreshold) * -1000, 1)); + + const lightenBy = (lightnessThreshold - perceivedLightness) * 100 * lightnessSwitch; + const rgbBorder = hexToRgb(hslToHex(hslColor.h, hslColor.s, hslColor.l + lightenBy)); + + const textColor = `#${hslToHex(hslColor.h, hslColor.s, hslColor.l + lightenBy)}`; + const backgroundColor = !markDown ? + `rgba(${rgbColor.r},${rgbColor.g},${rgbColor.b},${backgroundAlpha})` : + `#${rgbToHex({ ...rgbColor, a: backgroundAlpha })}`; + const borderColor = !markDown ? + `rgba(${rgbBorder.r},${rgbBorder.g},${rgbBorder.b},${borderAlpha})` : + `#${rgbToHex({ ...rgbBorder, a: borderAlpha })}`; + + return { textColor: textColor, backgroundColor: backgroundColor, borderColor: borderColor }; + } + else { + return { textColor: `#${contrastColor(rgbColor)}`, backgroundColor: `#${hexColor}`, borderColor: `#${hexColor}` }; + } +} + +const rgbToHex = (color: { r: number, g: number, b: number, a?: number }) => { + const colors = [color.r, color.g, color.b]; + if (color.a) { + colors.push(Math.floor(color.a * 255)); + } + return colors.map((digit) => { + return digit.toString(16).padStart(2, '0'); + }).join(''); +}; + +function hexToRgb(color: string) { + const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color); + + if (result) { + return { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + }; + } + return { + r: 0, + g: 0, + b: 0, + }; +} + +function rgbToHsl(r: number, g: number, b: number) { + // Source: https://css-tricks.com/converting-color-spaces-in-javascript/ + // Make r, g, and b fractions of 1 + r /= 255; + g /= 255; + b /= 255; + + // Find greatest and smallest channel values + let cmin = Math.min(r, g, b), + cmax = Math.max(r, g, b), + delta = cmax - cmin, + h = 0, + s = 0, + l = 0; + + // Calculate hue + // No difference + if (delta == 0) + h = 0; + // Red is max + else if (cmax == r) + h = ((g - b) / delta) % 6; + // Green is max + else if (cmax == g) + h = (b - r) / delta + 2; + // Blue is max + else + h = (r - g) / delta + 4; + + h = Math.round(h * 60); + + // Make negative hues positive behind 360 deg + if (h < 0) + h += 360; + + // Calculate lightness + l = (cmax + cmin) / 2; + + // Calculate saturation + s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + + // Multiply l and s by 100 + s = +(s * 100).toFixed(1); + l = +(l * 100).toFixed(1); + + return { h: h, s: s, l: l }; +} + +function hslToHex(h: number, s: number, l: number): string { + // source https://www.jameslmilner.com/posts/converting-rgb-hex-hsl-colors/ + const hDecimal = l / 100; + const a = (s * Math.min(hDecimal, 1 - hDecimal)) / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = hDecimal - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + + // Convert to Hex and prefix with "0" if required + return Math.round(255 * color) + .toString(16) + .padStart(2, '0'); + }; + return `${f(0)}${f(8)}${f(4)}`; +} + +function contrastColor(rgbColor: { r: number, g: number, b: number }) { + // Color algorithm from https://stackoverflow.com/questions/1855884/determine-font-color-based-on-background-color + const luminance = (0.299 * rgbColor.r + 0.587 * rgbColor.g + 0.114 * rgbColor.b) / 255; + return luminance > 0.5 ? '000000' : 'ffffff'; +} + export interface Predicate { (input: T): boolean; } @@ -598,6 +716,19 @@ export class UriIterator implements IKeyIterator { } } +export function isPreRelease(context: ExtensionContext): boolean { + const uri = context.extensionUri; + const path = uri.path; + const lastIndexOfDot = path.lastIndexOf('.'); + if (lastIndexOfDot === -1) { + return false; + } + const patchVersion = path.substr(lastIndexOfDot + 1); + // The patch version of release versions should never be more than 1 digit since it is only used for recovery releases. + // The patch version of pre-release is the date + time. + return patchVersion.length > 1; +} + class TernarySearchTreeNode { segment!: string; value: V | undefined; @@ -854,3 +985,15 @@ export class TernarySearchTree { } } } + +export async function stringReplaceAsync(str: string, regex: RegExp, asyncFn: (substring: string, ...args: any[]) => Promise): Promise { + const promises: Promise[] = []; + str.replace(regex, (match, ...args) => { + const promise = asyncFn(match, ...args); + promises.push(promise); + return ''; + }); + const data = await Promise.all(promises); + let offset = 0; + return str.replace(regex, () => data[offset++]); +} diff --git a/src/common/webview.ts b/src/common/webview.ts index 08d687cbc9..102da03e35 100644 --- a/src/common/webview.ts +++ b/src/common/webview.ts @@ -6,6 +6,8 @@ import * as vscode from 'vscode'; import { commands } from './executeCommands'; +export const PULL_REQUEST_OVERVIEW_VIEW_TYPE = 'PullRequestOverview'; + export interface IRequestMessage { req: string; command: string; @@ -48,7 +50,7 @@ export class WebviewBase { public initialize(): void { const disposable = this._webview?.onDidReceiveMessage( async message => { - await this._onDidReceiveMessage(message); + await this._onDidReceiveMessage(message as IRequestMessage); }, null, this._disposables, @@ -85,9 +87,9 @@ export class WebviewBase { this._webview?.postMessage(reply); } - protected async _throwError(originalMessage: IRequestMessage, error: any) { + protected async _throwError(originalMessage: IRequestMessage | undefined, error: any) { const reply: IReplyMessage = { - seq: originalMessage.req, + seq: originalMessage?.req, err: error, }; this._webview?.postMessage(reply); diff --git a/src/env/browser/ssh.ts b/src/env/browser/ssh.ts index be65dbd24e..511b33ac45 100644 --- a/src/env/browser/ssh.ts +++ b/src/env/browser/ssh.ts @@ -3,15 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { parse as parseConfig } from 'ssh-config'; +import Logger from '../../common/logger'; const SSH_URL_RE = /^(?:([^@:]+)@)?([^:/]+):?(.+)$/; -const URL_SCHEME_RE = /^([a-z-]+):\/\//; +const URL_SCHEME_RE = /^([a-z-+]+):\/\//; export const sshParse = (url: string): Config | undefined => { const urlMatch = URL_SCHEME_RE.exec(url); if (urlMatch) { const [fullSchemePrefix, scheme] = urlMatch; - if (scheme === 'ssh') { + if (scheme.includes('ssh')) { url = url.slice(fullSchemePrefix.length); } else { return; @@ -51,12 +52,12 @@ export const sshParse = (url: string): Config | undefined => { * @param {ConfigResolver?} resolveConfig ssh config resolver (default: from ~/.ssh/config) * @returns {Config} */ - export const resolve = (url: string, resolveConfig = Resolvers.current) => { +export const resolve = (url: string, resolveConfig = Resolvers.current) => { const config = sshParse(url); return config && resolveConfig(config); }; -export function baseResolver(config: Config) { +export function baseResolver(config: Config): Config { return { ...config, Hostname: config.Host, @@ -83,13 +84,20 @@ export type ConfigResolver = (config: Config) => Config; export function chainResolvers(...chain: (ConfigResolver | undefined)[]): ConfigResolver { const resolvers = chain.filter(x => !!x) as ConfigResolver[]; return (config: Config) => - resolvers.reduce( - (resolved, next) => ({ - ...resolved, - ...next(resolved), - }), - config, - ); + resolvers.reduce((resolved, next) => { + try { + return { + ...resolved, + ...next(resolved), + }; + } catch (err) { + // We cannot trust that some resolvers are not going to throw (i.e user has malformed .ssh/config file). + // Since we can't guarantee that ssh-config package won't throw and we're reducing over the entire chain of resolvers, + // we'll skip erroneous resolvers for now and log. Potentially can validate + Logger.warn(`Failed to parse config for '${config.Host}, this can occur when the extension configurations is invalid or system ssh config files are malformed. Skipping erroneous resolver for now.'`); + return resolved; + } + }, config); } export function resolverFromConfig(text: string): ConfigResolver { diff --git a/src/env/node/net.ts b/src/env/node/net.ts index af650217eb..ec1f6fd5ea 100644 --- a/src/env/node/net.ts +++ b/src/env/node/net.ts @@ -6,7 +6,7 @@ import { Agent, globalAgent } from 'https'; import { URL } from 'url'; import { httpsOverHttp } from 'tunnel'; -import { window } from 'vscode'; +import { l10n, window } from 'vscode'; export const agent = getAgent(); @@ -26,7 +26,7 @@ function getAgent(url: string | undefined = process.env.HTTPS_PROXY): Agent { const auth = username && password && `${username}:${password}`; return httpsOverHttp({ proxy: { host: hostname, port, proxyAuth: auth } }); } catch (e) { - window.showErrorMessage(`HTTPS_PROXY environment variable ignored: ${e.message}`); + window.showErrorMessage(l10n.t('HTTPS_PROXY environment variable ignored: {0}', (e as Error).message)); return globalAgent; } } diff --git a/src/env/node/ssh.ts b/src/env/node/ssh.ts index c242e69041..848b63085b 100644 --- a/src/env/node/ssh.ts +++ b/src/env/node/ssh.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { readFileSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; @@ -50,6 +55,6 @@ function resolverFromConfigFile(configPath = join(homedir(), '.ssh', 'config')): const config = readFileSync(configPath).toString(); return resolverFromConfig(config); } catch (error) { - Logger.appendLine(`${configPath}: ${error.message}`); + Logger.warn(`${configPath}: ${error.message}`); } } diff --git a/src/experimentationService.ts b/src/experimentationService.ts index 0bb018fd17..a792b4e768 100644 --- a/src/experimentationService.ts +++ b/src/experimentationService.ts @@ -3,8 +3,8 @@ * 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 TelemetryReporter from 'vscode-extension-telemetry'; import { getExperimentationService, IExperimentationService, @@ -79,6 +79,7 @@ function getTargetPopulation(): TargetPopulation { class NullExperimentationService implements IExperimentationService { readonly initializePromise: Promise = Promise.resolve(); + readonly initialFetch: Promise = Promise.resolve(); isFlightEnabled(_flight: string): boolean { return false; @@ -110,17 +111,17 @@ export async function createExperimentationService( ): Promise { const id = context.extension.id; const name = context.extension.packageJSON['name']; - const version = context.extension.packageJSON['version']; + const version: string = context.extension.packageJSON['version']; const targetPopulation = getTargetPopulation(); // We only create a real experimentation service for the stable version of the extension, not insiders. return name === 'vscode-pull-request-github' ? getExperimentationService( - id, - version, - targetPopulation, - experimentationTelemetry, - context.globalState, - ) + id, + version, + targetPopulation, + experimentationTelemetry, + context.globalState, + ) : new NullExperimentationService(); } diff --git a/src/extension.ts b/src/extension.ts index 957b93215c..fe6d8a2156 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,38 +4,41 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; +import TelemetryReporter from '@vscode/extension-telemetry'; import * as vscode from 'vscode'; -import TelemetryReporter from 'vscode-extension-telemetry'; import { LiveShare } from 'vsls/vscode.js'; -import { Repository } from './api/api'; +import { PostCommitCommandsProvider, Repository } from './api/api'; import { GitApiImpl } from './api/api1'; import { registerCommands } from './commands'; import { commands } from './common/executeCommands'; import Logger from './common/logger'; import * as PersistentState from './common/persistentState'; +import { parseRepositoryRemotes } from './common/remote'; import { Resource } from './common/resources'; -import { SessionState } from './common/sessionState'; -import { BRANCH_PUBLISH, FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; +import { BRANCH_PUBLISH, FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; import { TemporaryState } from './common/temporaryState'; import { Schemes, handler as uriHandler } from './common/uri'; import { EXTENSION_ID, FOCUS_REVIEW_MODE } from './constants'; import { createExperimentationService, ExperimentationTelemetry } from './experimentationService'; -import { setSyncedKeys } from './extensionState'; import { CredentialStore } from './github/credentials'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from './github/folderRepositoryManager'; +import { FolderRepositoryManager } from './github/folderRepositoryManager'; import { RepositoriesManager } from './github/repositoriesManager'; import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitProviders/api'; import { GitHubContactServiceProvider } from './gitProviders/GitHubContactServiceProvider'; import { GitLensIntegration } from './integrations/gitlens/gitlensImpl'; import { IssueFeatureRegistrar } from './issues/issueFeatureRegistrar'; +import { CompareChanges } from './view/compareChangesTreeDataProvider'; +import { CreatePullRequestHelper } from './view/createPullRequestHelper'; import { FileTypeDecorationProvider } from './view/fileTypeDecorationProvider'; import { getInMemPRFileSystemProvider } from './view/inMemPRContentProvider'; import { PullRequestChangesTreeDataProvider } from './view/prChangesTreeDataProvider'; +import { PRNotificationDecorationProvider } from './view/prNotificationDecorationProvider'; import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; import { ReviewManager, ShowPullRequest } from './view/reviewManager'; import { ReviewsManager } from './view/reviewsManager'; +import { WebviewViewCoordinator } from './view/webviewViewCoordinator'; -const aiKey = 'AIF-d9b70cd4-b9f9-4d70-929b-a071c400b217'; +const ingestionKey = '0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255'; let telemetry: ExperimentationTelemetry; @@ -49,7 +52,8 @@ async function init( repositories: Repository[], tree: PullRequestsTreeDataProvider, liveshareApiPromise: Promise, - showPRController: ShowPullRequest + showPRController: ShowPullRequest, + reposManager: RepositoriesManager, ): Promise { context.subscriptions.push(Logger); Logger.appendLine('Git repository found, initializing review manager and pr tree view.'); @@ -57,8 +61,8 @@ async function init( vscode.authentication.onDidChangeSessions(async e => { if (e.provider.id === 'github') { await reposManager.clearCredentialCache(); - if (reviewManagers) { - reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); + if (reviewsManager) { + reviewsManager.reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); } } }); @@ -74,14 +78,14 @@ async function init( return; } - const reviewManager = reviewManagers.find( + const reviewManager = reviewsManager.reviewManagers.find( manager => manager.repository.rootUri.toString() === e.repository.rootUri.toString(), ); if (reviewManager?.isCreatingPullRequest) { return; } - const folderManager = folderManagers.find( + const folderManager = reposManager.folderManagers.find( manager => manager.repository.rootUri.toString() === e.repository.rootUri.toString()); if (!folderManager || folderManager.gitHubRepositories.length === 0) { @@ -93,10 +97,10 @@ async function init( return; } - const create = 'Create Pull Request...'; - const dontShowAgain = "Don't Show Again"; + const create = vscode.l10n.t('Create Pull Request...'); + const dontShowAgain = vscode.l10n.t('Don\'t Show Again'); const result = await vscode.window.showInformationMessage( - `Would you like to create a Pull Request for branch '${e.branch}'?`, + vscode.l10n.t('Would you like to create a Pull Request for branch \'{0}\'?', e.branch), create, dontShowAgain, ); @@ -130,15 +134,6 @@ async function init( }); } - const sessionState = new SessionState(context); - const folderManagers = repositories.map( - repository => new FolderRepositoryManager(context, repository, telemetry, git, credentialStore, sessionState), - ); - context.subscriptions.push(...folderManagers); - - const reposManager = new RepositoriesManager(folderManagers, credentialStore, telemetry, sessionState); - context.subscriptions.push(reposManager); - liveshareApiPromise.then(api => { if (api) { // register the pull request provider to suggest PR contacts @@ -149,42 +144,51 @@ async function init( const changesTree = new PullRequestChangesTreeDataProvider(context, git, reposManager); context.subscriptions.push(changesTree); - const reviewManagers = folderManagers.map( - folderManager => new ReviewManager(context, folderManager.repository, folderManager, telemetry, changesTree, showPRController, sessionState), + const activePrViewCoordinator = new WebviewViewCoordinator(context); + context.subscriptions.push(activePrViewCoordinator); + const createPrHelper = new CreatePullRequestHelper(); + context.subscriptions.push(createPrHelper); + let reviewManagerIndex = 0; + const reviewManagers = reposManager.folderManagers.map( + folderManager => new ReviewManager(reviewManagerIndex++, context, folderManager.repository, folderManager, telemetry, changesTree, tree, showPRController, activePrViewCoordinator, createPrHelper, git), ); - context.subscriptions.push(new FileTypeDecorationProvider(reposManager, reviewManagers)); + context.subscriptions.push(new FileTypeDecorationProvider(reposManager)); const reviewsManager = new ReviewsManager(context, reposManager, reviewManagers, tree, changesTree, telemetry, credentialStore, git); context.subscriptions.push(reviewsManager); git.onDidChangeState(() => { Logger.appendLine(`Git initialization state changed: state=${git.state}`); - reviewManagers.forEach(reviewManager => reviewManager.updateState(true)); + 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.getManagerForFile(repo.rootUri); + const existing = reposManager.folderManagers.find(manager => manager.repository.rootUri.toString() === repo.rootUri.toString()); if (existing) { Logger.appendLine(`Repo ${repo.rootUri} has already been setup.`); return; } - const newFolderManager = new FolderRepositoryManager(context, repo, telemetry, git, credentialStore, sessionState); + const newFolderManager = new FolderRepositoryManager(reposManager.folderManagers.length, context, repo, telemetry, git, credentialStore); reposManager.insertFolderManager(newFolderManager); const newReviewManager = new ReviewManager( + reviewManagerIndex++, context, newFolderManager.repository, newFolderManager, telemetry, changesTree, + tree, showPRController, - sessionState + activePrViewCoordinator, + createPrHelper, + git ); reviewsManager.addReviewManager(newReviewManager); - tree.refresh(); } addRepo(); + tree.notificationProvider.refreshOrLaunchPolling(); const disposable = repo.state.onDidChange(() => { Logger.appendLine(`Repo state for ${repo.rootUri} changed.`); addRepo(); @@ -195,18 +199,19 @@ async function init( git.onDidCloseRepository(repo => { reposManager.removeRepo(repo); reviewsManager.removeReviewManager(repo); - tree.refresh(); + tree.notificationProvider.refreshOrLaunchPolling(); }); - tree.initialize(reposManager); + tree.initialize(reviewsManager.reviewManagers.map(manager => manager.reviewModel), credentialStore); + + context.subscriptions.push(new PRNotificationDecorationProvider(tree.notificationProvider)); - setSyncedKeys(context); - registerCommands(context, sessionState, reposManager, reviewManagers, telemetry, credentialStore, tree); + registerCommands(context, reposManager, reviewsManager, telemetry, tree); - const layout = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); + 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, reviewManagers, context, telemetry); + const issuesFeatures = new IssueFeatureRegistrar(git, reposManager, reviewsManager, context, telemetry); context.subscriptions.push(issuesFeatures); await issuesFeatures.initialize(); @@ -214,10 +219,9 @@ async function init( await vscode.commands.executeCommand('setContext', 'github:initialized', true); - const experimentationService = await createExperimentationService(context, telemetry); - await experimentationService.initializePromise; - await experimentationService.isCachedFlightEnabled('githubaa'); - + registerPostCommitCommandsProvider(reposManager, git); + // Make sure any compare changes tabs, which come from the create flow, are closed. + CompareChanges.closeTabs(); /* __GDPR__ "startup" : {} */ @@ -225,6 +229,7 @@ async function init( } export async function activate(context: vscode.ExtensionContext): Promise { + Logger.appendLine(`Extension version: ${vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.version}`, 'Activation'); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore if (EXTENSION_ID === 'GitHub.vscode-pull-request-github-insiders') { @@ -242,7 +247,7 @@ export async function activate(context: vscode.ExtensionContext): Promise prev + curr.gitHubRepositories.length, 0)} GitHub repositories.`, componentId); + const repoRemotes = parseRepositoryRemotes(repository); + + const found = reposManager.folderManagers.find(folderManager => folderManager.findRepo(githubRepo => { + return !!repoRemotes.find(remote => { + return remote.equals(githubRepo.remote); + }); + })); + Logger.debug(`Found ${found ? 'a repo' : 'no repos'} when getting post commit commands.`, componentId); + return found ? [{ + command: 'pr.pushAndCreate', + title: vscode.l10n.t('{0} Commit & Create Pull Request', '$(git-pull-request-create)'), + tooltip: vscode.l10n.t('Commit & Create Pull Request') + }] : []; + } + } + + function hasGitHubRepos(): boolean { + return reposManager.folderManagers.some(folderManager => folderManager.gitHubRepositories.length > 0); + } + function tryRegister(): boolean { + Logger.debug('Trying to register post commit commands.', 'GitPostCommitCommands'); + if (hasGitHubRepos()) { + Logger.debug('GitHub remote(s) found, registering post commit commands.', componentId); + git.registerPostCommitCommandsProvider(new Provider()); + return true; + } + return false; + } + + if (!tryRegister()) { + const reposDisposable = reposManager.onDidLoadAnyRepositories(() => { + if (tryRegister()) { + reposDisposable.dispose(); + } + }); + } +} + +async function deferredActivateRegisterBuiltInGitProvider(context: vscode.ExtensionContext, apiImpl: GitApiImpl, credentialStore: CredentialStore) { + Logger.debug('Registering built in git provider.', 'Activation'); + if (!(await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl))) { + const extensionsChangedDisposable = vscode.extensions.onDidChange(async () => { + if (await doRegisterBuiltinGitProvider(context, credentialStore, apiImpl)) { + extensionsChangedDisposable.dispose(); + } + }); + context.subscriptions.push(extensionsChangedDisposable); + } +} + async function deferredActivate(context: vscode.ExtensionContext, apiImpl: GitApiImpl, showPRController: ShowPullRequest) { Logger.debug('Initializing state.', 'Activation'); PersistentState.init(context); @@ -278,19 +338,15 @@ async function deferredActivate(context: vscode.ExtensionContext, apiImpl: GitAp } TemporaryState.init(context); Logger.debug('Creating credential store.', 'Activation'); - const credentialStore = new CredentialStore(telemetry); + const credentialStore = new CredentialStore(telemetry, context); context.subscriptions.push(credentialStore); - await credentialStore.create({ silent: true }); + const experimentationService = await createExperimentationService(context, telemetry); + await experimentationService.initializePromise; + await experimentationService.isCachedFlightEnabled('githubaa'); + const showBadge = (vscode.env.appHost === 'desktop'); + await credentialStore.create(showBadge ? undefined : { silent: true }); - 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); - } + deferredActivateRegisterBuiltInGitProvider(context, apiImpl, credentialStore); Logger.debug('Registering live share git provider.', 'Activation'); const liveshareGitProvider = registerLiveShareGitProvider(apiImpl); @@ -300,16 +356,30 @@ async function deferredActivate(context: vscode.ExtensionContext, apiImpl: GitAp context.subscriptions.push(apiImpl); Logger.debug('Creating tree view.', 'Activation'); - const prTree = new PullRequestsTreeDataProvider(telemetry); - context.subscriptions.push(prTree); - context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.Pr, getInMemPRFileSystemProvider(), { isReadonly: true })); + const reposManager = new RepositoriesManager(credentialStore, telemetry); + context.subscriptions.push(reposManager); + const prTree = new PullRequestsTreeDataProvider(telemetry, context, reposManager); + context.subscriptions.push(prTree); Logger.appendLine('Looking for git repository'); - const repositories = apiImpl.repositories; Logger.appendLine(`Found ${repositories.length} repositories during activation`); - await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController); + let folderManagerIndex = 0; + const folderManagers = repositories.map( + repository => new FolderRepositoryManager(folderManagerIndex++, context, repository, telemetry, apiImpl, credentialStore), + ); + 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 })); + + await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager); } export async function deactivate() { diff --git a/src/extensionState.ts b/src/extensionState.ts index 85c01cd61b..e45ddecea0 100644 --- a/src/extensionState.ts +++ b/src/extensionState.ts @@ -6,10 +6,12 @@ import * as vscode from 'vscode'; import { IAccount } from './github/interface'; // Synced keys +// This key is deprecated in favor of the setting githubPullRequests.pullBranch. export const NEVER_SHOW_PULL_NOTIFICATION = 'github.pullRequest.pullNotification.show'; // Not synced keys export const REPO_KEYS = 'github.pullRequest.repos'; +export const PREVIOUS_CREATE_METHOD = 'github.pullRequest.previousCreateMethod'; export interface RepoState { mentionableUsers?: IAccount[]; diff --git a/src/gitExtensionIntegration.ts b/src/gitExtensionIntegration.ts index 2eadf399ce..88ee9cc940 100644 --- a/src/gitExtensionIntegration.ts +++ b/src/gitExtensionIntegration.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { RemoteSource, RemoteSourceProvider } from './@types/git'; +import { AuthProvider } from './common/authentication'; import { OctokitCommon } from './github/common'; -import { AuthProvider, CredentialStore, GitHub } from './github/credentials'; +import { CredentialStore, GitHub } from './github/credentials'; +import { isEnterprise } from './github/utils'; interface Repository { readonly full_name: string; @@ -38,7 +40,7 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { private userReposCache: RemoteSource[] = []; constructor(private readonly credentialStore: CredentialStore, private readonly authProviderId: AuthProvider = AuthProvider.github) { - if (authProviderId === AuthProvider['github-enterprise']) { + if (isEnterprise(authProviderId)) { this.name = 'GitHub Enterprise'; } } @@ -62,7 +64,7 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { private async getUserRemoteSources(hub: GitHub, query?: string): Promise { if (!query) { - const res = await hub.octokit.repos.listForAuthenticatedUser({ sort: 'pushed', per_page: 100 }); + const res = await hub.octokit.call(hub.octokit.api.repos.listForAuthenticatedUser, { sort: 'pushed', per_page: 100 }); this.userReposCache = res.data.map(asRemoteSource); } @@ -74,7 +76,7 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { return []; } - const raw = await hub.octokit.search.repos({ q: query, sort: 'updated' }); + const raw = await hub.octokit.call(hub.octokit.api.search.repos, { q: query, sort: 'updated' }); return raw.data.items.map(repoResponseAsRemoteSource); } } diff --git a/src/gitProviders/GitHubContactServiceProvider.ts b/src/gitProviders/GitHubContactServiceProvider.ts index d0854c6e22..ad7f11308c 100644 --- a/src/gitProviders/GitHubContactServiceProvider.ts +++ b/src/gitProviders/GitHubContactServiceProvider.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; import { IAccount } from '../github/interface'; import { RepositoriesManager } from '../github/repositoriesManager'; @@ -120,7 +125,7 @@ export class GitHubContactServiceProvider implements ContactServiceProvider { } const origin = await this.pullRequestManager.folderManagers[0]?.getOrigin(); if (origin) { - const currentUser = origin.hub.currentUser; + const currentUser = origin.hub.currentUser ? await origin.hub.currentUser : undefined; if (currentUser) { return currentUser.login; } diff --git a/src/gitProviders/builtinGit.ts b/src/gitProviders/builtinGit.ts index 54dcecae40..43603ec367 100644 --- a/src/gitProviders/builtinGit.ts +++ b/src/gitProviders/builtinGit.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import { APIState, GitAPI, GitExtension, PublishEvent } from '../@types/git'; import { IGit, Repository } from '../api/api'; +import { commands } from '../common/executeCommands'; export class BuiltinGitProvider implements IGit, vscode.Disposable { get repositories(): Repository[] { @@ -35,7 +36,7 @@ export class BuiltinGitProvider implements IGit, vscode.Disposable { this._gitAPI = gitExtension.getAPI(1); } catch (e) { // The git extension will throw if a git model cannot be found, i.e. if git is not installed. - vscode.window.showErrorMessage('Activating the Pull Requests and Issues extension failed. Please make sure you have git installed.'); + commands.setContext('gitNotInstalled', true); throw e; } @@ -55,6 +56,15 @@ export class BuiltinGitProvider implements IGit, vscode.Disposable { return undefined; } + registerPostCommitCommandsProvider?(provider: any): vscode.Disposable { + if (this._gitAPI.registerPostCommitCommandsProvider) { + return this._gitAPI.registerPostCommitCommandsProvider(provider); + } + return { + dispose: () => { } + }; + } + dispose() { this._disposables.forEach(disposable => disposable.dispose()); } diff --git a/src/test/mocks/mockSessionState.ts b/src/gitProviders/gitCommands.ts similarity index 62% rename from src/test/mocks/mockSessionState.ts rename to src/gitProviders/gitCommands.ts index 3b6ddd414e..3110f7b42e 100644 --- a/src/test/mocks/mockSessionState.ts +++ b/src/gitProviders/gitCommands.ts @@ -1,11 +1,17 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; -import { ISessionState } from "../../common/sessionState"; - -export class MockSessionState implements ISessionState { - commentsExpandState: true; - onDidChangeCommentsExpandState = new vscode.EventEmitter().event; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; + +export namespace git { + + export async function checkout(): Promise { + try { + await vscode.commands.executeCommand('git.checkout'); + } catch (e) { + await vscode.commands.executeCommand('remoteHub.switchToBranch'); + } + } + } \ No newline at end of file diff --git a/src/gitProviders/vslsguest.ts b/src/gitProviders/vslsguest.ts index edcdbd2772..271db4abd6 100644 --- a/src/gitProviders/vslsguest.ts +++ b/src/gitProviders/vslsguest.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import { LiveShare, SharedServiceProxy } from 'vsls/vscode.js'; -import { Branch, Change, Commit, Ref, Remote, RepositoryState, Submodule } from '../@types/git'; +import { Branch, Change, Commit, Remote, RepositoryState, Submodule } from '../@types/git'; import { IGit, Repository } from '../api/api'; import { VSLS_GIT_PR_SESSION_NAME, @@ -129,7 +129,7 @@ export class VSLSGuest implements IGit, vscode.Disposable { } class LiveShareRepositoryProxyHandler { - constructor() {} + constructor() { } get(obj: any, prop: any) { if (prop in obj) { @@ -144,7 +144,6 @@ class LiveShareRepositoryProxyHandler { class LiveShareRepositoryState implements RepositoryState { HEAD: Branch | undefined; - refs: Ref[]; remotes: Remote[]; submodules: Submodule[] = []; rebaseCommit: Commit | undefined; @@ -157,13 +156,11 @@ class LiveShareRepositoryState implements RepositoryState { constructor(state: RepositoryState) { this.HEAD = state.HEAD; this.remotes = state.remotes; - this.refs = state.refs; } public update(state: RepositoryState) { this.HEAD = state.HEAD; this.remotes = state.remotes; - this.refs = state.refs; this._onDidChange.fire(); } @@ -173,7 +170,7 @@ class LiveShareRepository { rootUri: vscode.Uri | undefined; state: LiveShareRepositoryState | undefined; - constructor(public workspaceFolder: vscode.WorkspaceFolder, public proxy: SharedServiceProxy) {} + constructor(public workspaceFolder: vscode.WorkspaceFolder, public proxy: SharedServiceProxy) { } public async initialize() { const result = await this.proxy.request(VSLS_REQUEST_NAME, [ diff --git a/src/github/activityBarViewProvider.ts b/src/github/activityBarViewProvider.ts index 986b6b9254..ad02943937 100644 --- a/src/github/activityBarViewProvider.ts +++ b/src/github/activityBarViewProvider.ts @@ -7,14 +7,16 @@ import * as vscode from 'vscode'; import { onDidUpdatePR, openPullRequestOnGitHub } from '../commands'; import { IComment } from '../common/comment'; import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent'; -import { formatError } from '../common/utils'; +import { dispose, formatError } from '../common/utils'; import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; +import { ReviewManager } from '../view/reviewManager'; import { FolderRepositoryManager } from './folderRepositoryManager'; -import { GithubItemStateEnum, MergeMethod, ReviewEvent, ReviewState } from './interface'; +import { GithubItemStateEnum, IAccount, isTeam, reviewerId, ReviewEvent, ReviewState } from './interface'; import { PullRequestModel } from './pullRequestModel'; import { getDefaultMergeMethod } from './pullRequestOverview'; import { PullRequestView } from './pullRequestOverviewCommon'; import { isInCodespaces, parseReviewers } from './utils'; +import { PullRequest, ReviewType } from './views'; export class PullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider { public readonly viewType = 'github:activePullRequest'; @@ -24,6 +26,7 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W constructor( extensionUri: vscode.Uri, private readonly _folderRepositoryManager: FolderRepositoryManager, + private readonly _reviewManager: ReviewManager, private _item: PullRequestModel, ) { super(extensionUri); @@ -49,6 +52,16 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W state: GithubItemStateEnum.Merged, }); })); + + this._disposables.push(vscode.commands.registerCommand('review.approve', (e: { body: string }) => this.approvePullRequestCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.comment', (e: { body: string }) => this.submitReviewCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.requestChanges', (e: { body: string }) => this.requestChangesCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.approveOnDotCom', () => { + return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + })); + this._disposables.push(vscode.commands.registerCommand('review.requestChangesOnDotCom', () => { + return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + })); } public resolveWebviewView( @@ -78,41 +91,105 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W return this.createComment(message); case 'pr.merge': return this.mergePullRequest(message); + case 'pr.open-create': + return this.create(); case 'pr.deleteBranch': return this.deleteBranch(message); case 'pr.readyForReview': return this.setReadyForReview(message); case 'pr.approve': - return this.approvePullRequest(message); + return this.approvePullRequestMessage(message); case 'pr.request-changes': - return this.requestChanges(message); + return this.requestChangesMessage(message); case 'pr.submit': - return this.submitReview(message); + return this.submitReviewMessage(message); case 'pr.openOnGitHub': return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); case 'pr.checkout-default-branch': return this.checkoutDefaultBranch(message); + case 'pr.re-request-review': + return this.reRequestReview(message); } } private async checkoutDefaultBranch(message: IRequestMessage): Promise { try { const defaultBranch = await this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(this._item); + const prBranch = this._folderRepositoryManager.repository.state.HEAD?.name; await this._folderRepositoryManager.checkoutDefaultBranch(defaultBranch); + if (prBranch) { + await this._folderRepositoryManager.cleanupAfterPullRequest(prBranch, this._item); + } } finally { // Complete webview promise so that button becomes enabled again this._replyMessage(message, {}); } } + private reRequestReview(message: IRequestMessage): void { + let targetReviewer: ReviewState | undefined; + const userReviewers: string[] = []; + const teamReviewers: string[] = []; + + for (const reviewer of this._existingReviewers) { + let id: string | undefined; + let reviewerArray: string[] | undefined; + if (reviewer && isTeam(reviewer.reviewer)) { + id = reviewer.reviewer.id; + reviewerArray = teamReviewers; + } else if (reviewer && !isTeam(reviewer.reviewer)) { + id = reviewer.reviewer.id; + reviewerArray = userReviewers; + } + if (reviewerArray && id && ((reviewer.state === 'REQUESTED') || (id === message.args))) { + reviewerArray.push(id); + if (id === message.args) { + targetReviewer = reviewer; + } + } + } + this._item.requestReview(userReviewers, teamReviewers).then(() => { + if (targetReviewer) { + targetReviewer.state = 'REQUESTED'; + } + this._replyMessage(message, { + reviewers: this._existingReviewers, + }); + }); + } + public async refresh(): Promise { await this.updatePullRequest(this._item); } + 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; + } + + private _prDisposables: vscode.Disposable[] | undefined = undefined; + private registerPrSpecificListeners(pullRequestModel: PullRequestModel) { + if (this._prDisposables !== undefined) { + dispose(this._prDisposables); + } + this._prDisposables = []; + this._prDisposables.push(pullRequestModel.onDidInvalidate(() => this.updatePullRequest(pullRequestModel))); + this._prDisposables.push(pullRequestModel.onDidChangePendingReviewState(() => this.updatePullRequest(pullRequestModel))); + } + + private _updatePendingVisibility: vscode.Disposable | undefined = undefined; public async updatePullRequest(pullRequestModel: PullRequestModel): Promise { - if (!this._prChangeListener || (pullRequestModel.number !== this._item.number)) { - this._prChangeListener?.dispose(); - this._prChangeListener = pullRequestModel.onDidInvalidate(() => this.updatePullRequest(pullRequestModel)); + if (this._view && !this._view.visible) { + this._updatePendingVisibility?.dispose(); + this._updatePendingVisibility = this._view.onDidChangeVisibility(async () => { + this.updatePullRequest(pullRequestModel); + this._updatePendingVisibility?.dispose(); + }); + } + + if ((this._prDisposables === undefined) || (pullRequestModel.number !== this._item.number)) { + this.registerPrSpecificListeners(pullRequestModel); } this._item = pullRequestModel; return Promise.all([ @@ -125,9 +202,13 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W pullRequestModel.getTimelineEvents(), pullRequestModel.getReviewRequests(), this._folderRepositoryManager.getBranchNameForPullRequest(pullRequestModel), + this._folderRepositoryManager.getPullRequestRepositoryDefaultBranch(pullRequestModel), + this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository), + pullRequestModel.canEdit(), + pullRequestModel.validateDraftMode() ]) .then(result => { - const [pullRequest, repositoryAccess, timelineEvents, requestedReviewers, branchInfo] = result; + const [pullRequest, repositoryAccess, timelineEvents, requestedReviewers, branchInfo, defaultBranch, currentUser, viewerCanEdit, hasReviewDraft] = result; if (!pullRequest) { throw new Error( `Fail to resolve Pull Request #${pullRequestModel.number} in ${pullRequestModel.remote.owner}/${pullRequestModel.remote.repositoryName}`, @@ -140,17 +221,17 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W return; } - this._view.title = `${pullRequest.title} #${pullRequestModel.number.toString()}`; + try { + this._view.title = `${pullRequest.title} #${pullRequestModel.number.toString()}`; + } catch (e) { + // If we ry to set the title of the webview too early it will throw an error. + } const isCurrentlyCheckedOut = pullRequestModel.equals(this._folderRepositoryManager.activePullRequest); const hasWritePermission = repositoryAccess!.hasWritePermission; const mergeMethodsAvailability = repositoryAccess!.mergeMethodsAvailability; - const canEdit = hasWritePermission || this._item.canEdit(); - const preferredMergeMethod = vscode.workspace - .getConfiguration('githubPullRequests') - .get('defaultMergeMethod'); - const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability, preferredMergeMethod); - const currentUser = this._folderRepositoryManager.getCurrentUser(this._item); + const canEdit = hasWritePermission || viewerCanEdit; + const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability); this._existingReviewers = parseReviewers( requestedReviewers ?? [], timelineEvents ?? [], @@ -162,44 +243,55 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W pullRequest.head && !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); - const continueOnGitHub = isCrossRepository && isInCodespaces(); + const continueOnGitHub = !!(isCrossRepository && isInCodespaces()); + const reviewState = this.getCurrentUserReviewState(this._existingReviewers, currentUser); + + const context: Partial = { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.html_url, + createdAt: pullRequest.createdAt, + body: pullRequest.body, + bodyHTML: pullRequest.bodyHTML, + labels: pullRequest.item.labels, + author: { + login: pullRequest.author.login, + name: pullRequest.author.name, + avatarUrl: pullRequest.userAvatar, + url: pullRequest.author.url, + email: pullRequest.author.email, + id: pullRequest.author.id + }, + state: pullRequest.state, + isCurrentlyCheckedOut: isCurrentlyCheckedOut, + isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, + base: pullRequest.base.label, + isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, + isLocalHeadDeleted: !branchInfo, + head: pullRequest.head?.label ?? '', + canEdit: canEdit, + hasWritePermission, + mergeable: pullRequest.item.mergeable, + isDraft: pullRequest.isDraft, + status: null, + reviewRequirement: null, + events: [], + mergeMethodsAvailability, + defaultMergeMethod, + repositoryDefaultBranch: defaultBranch, + isIssue: false, + isAuthor: currentUser.login === pullRequest.author.login, + reviewers: this._existingReviewers, + continueOnGitHub, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, + isEnterprise: pullRequest.githubRepository.remote.isEnterprise, + hasReviewDraft, + currentUserReviewState: reviewState + }; this._postMessage({ command: 'pr.initialize', - pullrequest: { - number: pullRequest.number, - title: pullRequest.title, - url: pullRequest.html_url, - createdAt: pullRequest.createdAt, - body: pullRequest.body, - bodyHTML: pullRequest.bodyHTML, - labels: pullRequest.item.labels, - author: { - login: pullRequest.author.login, - name: pullRequest.author.name, - avatarUrl: pullRequest.userAvatar, - url: pullRequest.author.url, - }, - state: pullRequest.state, - isCurrentlyCheckedOut: isCurrentlyCheckedOut, - isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, - base: pullRequest.base.label, - isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, - isLocalHeadDeleted: !branchInfo, - head: pullRequest.head?.label ?? '', - canEdit: canEdit, - hasWritePermission, - mergeable: pullRequest.item.mergeable, - isDraft: pullRequest.isDraft, - status: { statuses: [] }, - events: [], - mergeMethodsAvailability, - defaultMergeMethod, - isIssue: false, - isAuthor: currentUser.login === pullRequest.author.login, - reviewers: this._existingReviewers, - continueOnGitHub, - }, + pullrequest: context, }); }) .catch(e => { @@ -217,6 +309,10 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W }); } + private create() { + this._reviewManager.createPullRequest(); + } + private createComment(message: IRequestMessage) { this._item.createIssueComment(message.args).then(comment => { this._replyMessage(message, { @@ -228,7 +324,7 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W private updateReviewers(review?: CommonReviewEvent): void { if (review) { const existingReviewer = this._existingReviewers.find( - reviewer => review.user.login === reviewer.reviewer.login, + reviewer => review.user.login === reviewerId(reviewer.reviewer), ); if (existingReviewer) { existingReviewer.state = review.state; @@ -241,55 +337,77 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W } } - private approvePullRequest(message: IRequestMessage): void { - this._item.approve(message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - //refresh the pr list as this one is approved - vscode.commands.executeCommand('pr.refreshList'); - }, - e => { - vscode.window.showErrorMessage(`Approving pull request failed. ${formatError(e)}`); + private 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 = { + command: 'pr.append-review', + review, + reviewers: this._existingReviewers + }; + 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' }); + } + } - this._throwError(message, `${formatError(e)}`); - }, - ); + private async doReviewMessage(message: IRequestMessage, action: (body) => Promise) { + try { + const review = await action(message.args); + this.updateReviewers(review); + this._replyMessage(message, { + review: review, + reviewers: this._existingReviewers, + }); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); + this._throwError(message, `${formatError(e)}`); + } } - private requestChanges(message: IRequestMessage): void { - this._item.requestChanges(message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - }, - e => { - vscode.window.showErrorMessage(`Requesting changes failed. ${formatError(e)}`); - this._throwError(message, `${formatError(e)}`); - }, - ); + private approvePullRequest(body: string): Promise { + return this._item.approve(this._folderRepositoryManager.repository, body); } - private submitReview(message: IRequestMessage): void { - this._item.submitReview(ReviewEvent.Comment, message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - }, - e => { - vscode.window.showErrorMessage(`Submitting review failed. ${formatError(e)}`); - this._throwError(message, `${formatError(e)}`); - }, - ); + private approvePullRequestMessage(message: IRequestMessage): Promise { + return this.doReviewMessage(message, (body) => this.approvePullRequest(body)); + } + + private approvePullRequestCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.Approve, (body) => this.approvePullRequest(body)); + } + + private requestChanges(body: string): Promise { + return this._item.requestChanges(body); + } + + private requestChangesCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body)); + } + + private requestChangesMessage(message: IRequestMessage): Promise { + return this.doReviewMessage(message, (body) => this.requestChanges(body)); + } + + private submitReview(body: string): Promise { + return this._item.submitReview(ReviewEvent.Comment, body); + } + + private submitReviewCommand(context: { body: string }) { + return this.doReviewCommand(context, ReviewType.Comment, (body) => this.submitReview(body)); + } + + private submitReviewMessage(message: IRequestMessage) { + return this.doReviewMessage(message, (body) => this.submitReview(body)); } private async deleteBranch(message: IRequestMessage) { @@ -310,7 +428,7 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W this._replyMessage(message, { isDraft }); }) .catch(e => { - vscode.window.showErrorMessage(`Unable to set PR ready for review. ${formatError(e)}`); + vscode.window.showErrorMessage(vscode.l10n.t('Unable to set PR ready for review. {0}', formatError(e))); this._throwError(message, {}); }); } @@ -319,12 +437,13 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W message: IRequestMessage<{ title: string; description: string; method: 'merge' | 'squash' | 'rebase' }>, ): Promise { const { title, description, method } = message.args; + const yes = vscode.l10n.t('Yes'); const confirmation = await vscode.window.showInformationMessage( - 'Merge this pull request?', + vscode.l10n.t('Merge this pull request?'), { modal: true }, - 'Yes', + yes, ); - if (confirmation !== 'Yes') { + if (confirmation !== yes) { this._replyMessage(message, { state: GithubItemStateEnum.Open }); return; } @@ -335,7 +454,7 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W vscode.commands.executeCommand('pr.refreshList'); if (!result.merged) { - vscode.window.showErrorMessage(`Merging PR failed: ${result.message}`); + vscode.window.showErrorMessage(vscode.l10n.t('Merging PR failed: {0}', result?.message ?? '')); } this._replyMessage(message, { @@ -343,7 +462,7 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W }); }) .catch(e => { - vscode.window.showErrorMessage(`Unable to merge pull request. ${formatError(e)}`); + vscode.window.showErrorMessage(vscode.l10n.t('Unable to merge pull request. {0}', formatError(e))); this._throwError(message, {}); }); } diff --git a/src/github/common.ts b/src/github/common.ts index 9b545fccc3..9a6a2fffe0 100644 --- a/src/github/common.ts +++ b/src/github/common.ts @@ -1,22 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ import * as OctokitRest from '@octokit/rest'; import { Endpoints } from '@octokit/types'; export namespace OctokitCommon { export type IssuesAssignParams = OctokitRest.RestEndpointMethodTypes['issues']['addAssignees']['parameters']; export type IssuesCreateParams = OctokitRest.RestEndpointMethodTypes['issues']['create']['parameters']; - export type IssuesCreateResponseData = Endpoints['POST /repos/{owner}/{repo}/issues']['response']['data']; - export type IssuesListCommentsResponseData = Endpoints['GET /repos/{owner}/{repo}/issues/{issue_number}/comments']['response']['data']; + export type IssuesCreateResponseData = OctokitRest.RestEndpointMethodTypes['issues']['create']['response']['data']; + export type IssuesListCommentsResponseData = OctokitRest.RestEndpointMethodTypes['issues']['listComments']['response']['data']; export type IssuesListEventsForTimelineResponseData = Endpoints['GET /repos/{owner}/{repo}/issues/{issue_number}/timeline']['response']['data']; export type IssuesListEventsForTimelineResponseItemActor = IssuesListEventsForTimelineResponseData[0]['actor']; export type PullsCreateParams = OctokitRest.RestEndpointMethodTypes['pulls']['create']['parameters']; - export type PullsCreateResponseData = Endpoints['POST /repos/{owner}/{repo}/pulls']['response']['data']; export type PullsCreateReviewResponseData = Endpoints['POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews']['response']['data']; export type PullsCreateReviewCommentResponseData = Endpoints['POST /repos/{owner}/{repo}/pulls/{pull_number}/comments']['response']['data']; - export type PullsGetResponseData = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}']['response']['data']; + export type PullsGetResponseData = OctokitRest.RestEndpointMethodTypes['pulls']['get']['response']['data']; export type PullsGetResponseUser = Exclude; export type PullsListCommitsResponseData = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/commits']['response']['data']; export type PullsListRequestedReviewersResponseData = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers']['response']['data']; - export type PullsListResponseItem = Endpoints['GET /repos/{owner}/{repo}/pulls']['response']['data'][0]; + export type PullsListResponseItem = OctokitRest.RestEndpointMethodTypes['pulls']['list']['response']['data'][0]; export type PullsListResponseItemHead = PullsListResponseItem['head']; export type PullsListResponseItemBase = PullsListResponseItem['base']; export type PullsListResponseItemHeadRepo = PullsListResponseItemHead['repo']; @@ -32,7 +35,7 @@ export namespace OctokitCommon { export type PullsListReviewRequestsResponseTeamsItem = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers']['response']['data']['teams'][0]; export type PullsListResponseItemHeadRepoTemplateRepository = PullsListResponseItem['head']['repo']['template_repository']; export type PullsListCommitsResponseItem = Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/commits']['response']['data'][0]; - export type ReposCompareCommitsResponseData = Endpoints['GET /repos/{owner}/{repo}/compare/{base}...{head}']['response']['data']; + export type ReposCompareCommitsResponseData = OctokitRest.RestEndpointMethodTypes['repos']['compareCommits']['response']['data']; export type ReposGetCombinedStatusForRefResponseStatusesItem = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}/status']['response']['data']['statuses'][0]; export type ReposGetCommitResponseData = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']; export type ReposGetCommitResponseFiles = Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']['files']; @@ -41,4 +44,19 @@ export namespace OctokitCommon { export type ReposGetResponseOrganization = ReposGetResponseData['organization']; export type ReposListBranchesResponseData = Endpoints['GET /repos/{owner}/{repo}/branches']['response']['data']; export type SearchReposResponseItem = Endpoints['GET /search/repositories']['response']['data']['items'][0]; + export type CompareCommits = Endpoints['GET /repos/{owner}/{repo}/compare/{base}...{head}']['response']['data']; + export type Commit = CompareCommits['commits'][0]; + export type CommitFile = CompareCommits['files'][0]; } + +export type Schema = { [key: string]: any, definitions: any[]; }; +export function mergeQuerySchemaWithShared(sharedSchema: Schema, schema: Schema) { + const sharedSchemaDefinitions = sharedSchema.definitions; + const schemaDefinitions = schema.definitions; + const mergedDefinitions = schemaDefinitions.concat(sharedSchemaDefinitions); + return { + ...schema, + ...sharedSchema, + definitions: mergedDefinitions + }; +} \ No newline at end of file diff --git a/src/github/createPRLinkProvider.ts b/src/github/createPRLinkProvider.ts index cabebc5484..9c689c9e77 100644 --- a/src/github/createPRLinkProvider.ts +++ b/src/github/createPRLinkProvider.ts @@ -16,7 +16,7 @@ export class GitHubCreatePullRequestLinkProvider implements vscode.TerminalLinkP constructor( private readonly reviewManager: ReviewManager, private readonly folderRepositoryManager: FolderRepositoryManager, - ) {} + ) { } private static getSettingsValue() { return vscode.workspace @@ -64,7 +64,7 @@ export class GitHubCreatePullRequestLinkProvider implements vscode.TerminalLinkP { startIndex, length: context.line.length - startIndex, - tooltip: 'Create a Pull Request', + tooltip: vscode.l10n.t('Create a Pull Request'), url, }, ]; @@ -92,7 +92,7 @@ export class GitHubCreatePullRequestLinkProvider implements vscode.TerminalLinkP } const yes = 'Yes'; - const neverShow = 'Don\'t show again'; + const neverShow = 'Don\'t Show Again'; vscode.window .showInformationMessage( diff --git a/src/github/createPRViewProvider.ts b/src/github/createPRViewProvider.ts deleted file mode 100644 index 58c2b0e418..0000000000 --- a/src/github/createPRViewProvider.ts +++ /dev/null @@ -1,471 +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 { CreateParams, CreatePullRequest, RemoteInfo } from '../../common/views'; -import type { Branch } from '../api/api'; -import Logger from '../common/logger'; -import { Protocol } from '../common/protocol'; -import { Remote } from '../common/remote'; -import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; -import { - byRemoteName, - DetachedHeadError, - FolderRepositoryManager, - PullRequestDefaults, - titleAndBodyFrom, -} from './folderRepositoryManager'; -import { PullRequestGitHelper } from './pullRequestGitHelper'; -import { PullRequestModel } from './pullRequestModel'; - -export class CreatePullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider { - public readonly viewType = 'github:createPullRequest'; - - private _onDone = new vscode.EventEmitter(); - readonly onDone: vscode.Event = this._onDone.event; - - private _onDidChangeBaseRemote = new vscode.EventEmitter(); - readonly onDidChangeBaseRemote: vscode.Event = this._onDidChangeBaseRemote.event; - - private _onDidChangeBaseBranch = new vscode.EventEmitter(); - readonly onDidChangeBaseBranch: vscode.Event = this._onDidChangeBaseBranch.event; - - private _onDidChangeCompareRemote = new vscode.EventEmitter(); - readonly onDidChangeCompareRemote: vscode.Event = this._onDidChangeCompareRemote.event; - - private _onDidChangeCompareBranch = new vscode.EventEmitter(); - readonly onDidChangeCompareBranch: vscode.Event = this._onDidChangeCompareBranch.event; - - private _compareBranch: string; - private _baseBranch: string; - - private _firstLoad: boolean = true; - - constructor( - extensionUri: vscode.Uri, - private readonly _folderRepositoryManager: FolderRepositoryManager, - private readonly _pullRequestDefaults: PullRequestDefaults, - compareBranch: Branch, - ) { - super(extensionUri); - - this._defaultCompareBranch = compareBranch; - } - - public resolveWebviewView( - webviewView: vscode.WebviewView, - _context: vscode.WebviewViewResolveContext, - _token: vscode.CancellationToken, - ) { - super.resolveWebviewView(webviewView, _context, _token); - webviewView.webview.html = this._getHtmlForWebview(); - - if (this._firstLoad) { - this._firstLoad = false; - // Reset any stored state. - // TODO @RMacfarlane Clear stored state on extension deactivation instead. - this.initializeParams(true); - } else { - this.initializeParams(); - } - } - - private _defaultCompareBranch: Branch; - get defaultCompareBranch() { - return this._defaultCompareBranch; - } - - set defaultCompareBranch(compareBranch: Branch | undefined) { - if ( - compareBranch && - (compareBranch?.name !== this._defaultCompareBranch.name || - compareBranch?.upstream?.remote !== this._defaultCompareBranch.upstream?.remote) - ) { - this._defaultCompareBranch = compareBranch; - void this.initializeParams(); - this._onDidChangeCompareBranch.fire(this._defaultCompareBranch.name!); - } - } - - public show(compareBranch?: Branch): void { - if (compareBranch) { - this.defaultCompareBranch = compareBranch; - } - - super.show(); - } - - private async getTotalCommits(compareBranch: Branch, baseBranchName: string): Promise { - const origin = await this._folderRepositoryManager.getOrigin(compareBranch); - - if (compareBranch.upstream) { - const headRepo = this._folderRepositoryManager.findRepo(byRemoteName(compareBranch.upstream.remote)); - - if (headRepo) { - const headBranch = `${headRepo.remote.owner}:${compareBranch.name ?? ''}`; - const baseBranch = `${this._pullRequestDefaults.owner}:${baseBranchName}`; - const { total_commits } = await origin.compareCommits(baseBranch, headBranch); - - return total_commits; - } - } else if (compareBranch.commit) { - // We can use the git API instead of the GitHub API - const baseBranch = await this._folderRepositoryManager.repository.getBranch(baseBranchName); - if (baseBranch.commit) { - const changes = await this._folderRepositoryManager.repository.diffBetween(baseBranch.commit, compareBranch.commit); - return changes.length; - } - } - - return 0; - } - - private async getTitle(compareBranch: Branch, baseBranch: string): Promise { - // Use same default as GitHub, if there is only one commit, use the commit, otherwise use the branch name, as long as it is not the default branch. - // By default, the base branch we use for comparison is the base branch of origin. Compare this to the - // compare branch if it has a GitHub remote. - const origin = await this._folderRepositoryManager.getOrigin(compareBranch); - - let useBranchName = this._pullRequestDefaults.base === compareBranch.name; - Logger.debug(`Compare branch name: ${compareBranch.name}, Base branch name: ${this._pullRequestDefaults.base}`, 'CreatePullRequestViewProvider'); - try { - const totalCommits = await this.getTotalCommits(compareBranch, baseBranch); - Logger.debug(`Total commits: ${totalCommits}`, 'CreatePullRequestViewProvider'); - if (totalCommits > 1) { - const defaultBranch = await origin.getDefaultBranch(); - useBranchName = defaultBranch !== compareBranch.name; - } - } catch (e) { - // Ignore and fall back to commit message - Logger.debug(`Error while getting total commits: ${e}`, 'CreatePullRequestViewProvider'); - } - - if (useBranchName) { - const name = compareBranch.name; - return name - ? `${name.charAt(0).toUpperCase()}${name.slice(1)}` - : ''; - } else { - return compareBranch.name - ? titleAndBodyFrom(await this._folderRepositoryManager.getTipCommitMessage(compareBranch.name)) - .title - : ''; - } - } - - private async getPullRequestTemplate(): Promise { - const templateUris = await this._folderRepositoryManager.getPullRequestTemplates(); - if (templateUris[0]) { - try { - const templateContent = await vscode.workspace.fs.readFile(templateUris[0]); - return new TextDecoder('utf-8').decode(templateContent); - } catch (e) { - Logger.appendLine(`Reading pull request template failed: ${e}`); - return undefined; - } - } - - return undefined; - } - - private async getDescription(compareBranch: Branch, baseBranch: string): Promise { - // Try to match github's default, first look for template, then use commit body if available. - let commitMessage: string | undefined; - try { - const totalCommits = await this.getTotalCommits(compareBranch, baseBranch); - - // If there's just a single commit - if (totalCommits === 1 && compareBranch.name) { - commitMessage = titleAndBodyFrom(await this._folderRepositoryManager.getTipCommitMessage(compareBranch.name)).body; - } - } catch (e) { - // Ignore and show nothing for the commit message. - } - - const pullRequestTemplate = await this.getPullRequestTemplate(); - if (pullRequestTemplate && commitMessage) { - return `${commitMessage}\n\n${pullRequestTemplate}`; - } else if (pullRequestTemplate) { - return pullRequestTemplate; - } else if (commitMessage && (this._pullRequestDefaults.base !== compareBranch.name)) { - return commitMessage; - } else { - return ''; - } - } - - public async initializeParams(reset: boolean = false): Promise { - if (!this.defaultCompareBranch) { - throw new DetachedHeadError(this._folderRepositoryManager.repository); - } - - const defaultBaseRemote: RemoteInfo = { - owner: this._pullRequestDefaults.owner, - repositoryName: this._pullRequestDefaults.repo, - }; - - const defaultOrigin = await this._folderRepositoryManager.getOrigin(this.defaultCompareBranch); - const defaultCompareRemote: RemoteInfo = { - owner: defaultOrigin.remote.owner, - repositoryName: defaultOrigin.remote.repositoryName, - }; - - const defaultBaseBranch = this._pullRequestDefaults.base; - - const [configuredGitHubRemotes, allGitHubRemotes, branchesForRemote, defaultTitle, defaultDescription] = await Promise.all([ - this._folderRepositoryManager.getGitHubRemotes(), - this._folderRepositoryManager.getAllGitHubRemotes(), - defaultOrigin.listBranches(this._pullRequestDefaults.owner, this._pullRequestDefaults.repo), - this.getTitle(this.defaultCompareBranch, defaultBaseBranch), - this.getDescription(this.defaultCompareBranch, defaultBaseBranch), - ]); - - const configuredRemotes: RemoteInfo[] = configuredGitHubRemotes.map(remote => { - return { - owner: remote.owner, - repositoryName: remote.repositoryName, - }; - }); - - const allRemotes: RemoteInfo[] = allGitHubRemotes.map(remote => { - return { - owner: remote.owner, - repositoryName: remote.repositoryName, - }; - }); - - // Ensure default into branch is in the remotes list - if (!branchesForRemote.includes(this._pullRequestDefaults.base)) { - branchesForRemote.push(this._pullRequestDefaults.base); - branchesForRemote.sort(); - } - - let branchesForCompare = branchesForRemote; - if (defaultCompareRemote.owner !== defaultBaseRemote.owner) { - branchesForCompare = await defaultOrigin.listBranches( - defaultCompareRemote.owner, - defaultCompareRemote.repositoryName, - ); - } - - // Ensure default from branch is in the remotes list - if (this.defaultCompareBranch.name && !branchesForCompare.includes(this.defaultCompareBranch.name)) { - branchesForCompare.push(this.defaultCompareBranch.name); - branchesForCompare.sort(); - } - - const params: CreateParams = { - availableBaseRemotes: configuredRemotes, - availableCompareRemotes: allRemotes, - defaultBaseRemote, - defaultBaseBranch, - defaultCompareRemote, - defaultCompareBranch: this.defaultCompareBranch.name ?? '', - branchesForRemote, - branchesForCompare, - defaultTitle, - defaultDescription, - isDraft: false, - }; - - this._compareBranch = this.defaultCompareBranch.name ?? ''; - this._baseBranch = defaultBaseBranch; - - this._postMessage({ - command: reset ? 'reset' : 'pr.initialize', - params, - }); - } - - private async changeRemote( - message: IRequestMessage<{ owner: string; repositoryName: string }>, - isBase: boolean, - ): Promise { - const { owner, repositoryName } = message.args; - - let githubRepository = this._folderRepositoryManager.findRepo( - repo => owner === repo.remote.owner && repositoryName === repo.remote.repositoryName, - ); - - if (!githubRepository) { - githubRepository = this._folderRepositoryManager.createGitHubRepositoryFromOwnerName(owner, repositoryName); - } - if (!githubRepository) { - throw new Error('No matching GitHub repository found.'); - } - - const defaultBranch = await githubRepository.getDefaultBranch(); - const newBranches = await githubRepository.listBranches(owner, repositoryName); - - if (!isBase && this.defaultCompareBranch?.name && !newBranches.includes(this.defaultCompareBranch.name)) { - newBranches.push(this.defaultCompareBranch.name); - newBranches.sort(); - } - - let newBranch: string | undefined; - if (isBase) { - newBranch = defaultBranch; - this._baseBranch = defaultBranch; - this._onDidChangeBaseRemote.fire({ owner, repositoryName }); - this._onDidChangeBaseBranch.fire(defaultBranch); - } else { - if (this.defaultCompareBranch?.name) { - newBranch = this.defaultCompareBranch?.name; - this._compareBranch = this.defaultCompareBranch?.name; - } - this._onDidChangeCompareRemote.fire({ owner, repositoryName }); - } - - return this._replyMessage(message, { branches: newBranches, defaultBranch: newBranch }); - } - - private async create(message: IRequestMessage): Promise { - try { - const compareOwner = message.args.compareOwner; - const compareRepositoryName = message.args.compareRepo; - const compareBranchName = message.args.compareBranch; - const compareGithubRemoteName = `${compareOwner}/${compareRepositoryName}`; - const compareBranch = await this._folderRepositoryManager.repository.getBranch(compareBranchName); - let headRepo = compareBranch.upstream ? this._folderRepositoryManager.findRepo((githubRepo) => { - return (githubRepo.remote.owner === compareOwner) && (githubRepo.remote.repositoryName === compareRepositoryName); - }) : undefined; - let existingCompareUpstream = headRepo?.remote; - - if (!existingCompareUpstream - || (existingCompareUpstream.owner !== compareOwner) - || (existingCompareUpstream.repositoryName !== compareRepositoryName)) { - // We assume this happens only when the compare branch is based on the current branch. - const shouldPushUpstream = await vscode.window.showInformationMessage( - `There is no upstream branch for '${compareBranchName}'.\n\nDo you want to publish it and then create the pull request?`, - { modal: true }, - 'Publish branch', - ); - if (shouldPushUpstream === 'Publish branch') { - let createdPushRemote: Remote | undefined; - const pushRemote = this._folderRepositoryManager.repository.state.remotes.find(localRemote => { - if (!localRemote.pushUrl) { - return false; - } - const testRemote = new Remote(localRemote.name, localRemote.pushUrl, new Protocol(localRemote.pushUrl)); - if ((testRemote.owner === compareOwner) && (testRemote.repositoryName === compareRepositoryName)) { - createdPushRemote = testRemote; - return true; - } - return false; - }); - - if (pushRemote && createdPushRemote) { - await this._folderRepositoryManager.repository.push(pushRemote.name, compareBranchName, true); - existingCompareUpstream = createdPushRemote; - headRepo = this._folderRepositoryManager.findRepo(byRemoteName(existingCompareUpstream.remoteName)); - } else { - this._throwError(message, `The current repository does not have a push remote for ${compareGithubRemoteName}`); - } - } - } - if (!existingCompareUpstream) { - this._throwError(message, 'No upstream for the compare branch.'); - return; - } - - if (!headRepo) { - throw new Error(`Unable to find GitHub repository matching '${existingCompareUpstream.remoteName}'. You can add '${existingCompareUpstream.remoteName}' to the setting "githubPullRequests.remotes" to ensure '${existingCompareUpstream.remoteName}' is found.`); - } - - const head = `${headRepo.remote.owner}:${compareBranchName}`; - const createdPR = await this._folderRepositoryManager.createPullRequest({ ...message.args, head }); - - // Create was cancelled - if (!createdPR) { - this._throwError(message, 'There must be a difference in commits to create a pull request.'); - } else { - await this._replyMessage(message, {}); - await PullRequestGitHelper.associateBranchWithPullRequest( - this._folderRepositoryManager.repository, - createdPR, - compareBranchName, - ); - this._onDone.fire(createdPR); - } - } catch (e) { - this._throwError(message, e.message); - } - } - - private async changeBranch(message: IRequestMessage, isBase: boolean): Promise { - const newBranch = (typeof message.args === 'string') ? message.args : message.args.name; - let compareBranch: Branch | undefined; - if (isBase) { - this._baseBranch = newBranch; - this._onDidChangeBaseBranch.fire(newBranch); - } else { - try { - compareBranch = await this._folderRepositoryManager.repository.getBranch(newBranch); - this._onDidChangeCompareBranch.fire(compareBranch.name!); - } catch (e) { - vscode.window.showErrorMessage('Branch does not exist locally.'); - } - } - - compareBranch = compareBranch ?? await this._folderRepositoryManager.repository.getBranch(this._compareBranch); - const title = await this.getTitle(compareBranch, this._baseBranch); - const description = await this.getDescription(compareBranch, this._baseBranch); - return this._replyMessage(message, { title, description }); - } - - protected async _onDidReceiveMessage(message: IRequestMessage) { - const result = await super._onDidReceiveMessage(message); - if (result !== this.MESSAGE_UNHANDLED) { - return; - } - - switch (message.command) { - case 'pr.cancelCreate': - vscode.commands.executeCommand('setContext', 'github:createPullRequest', false); - this._onDone.fire(undefined); - return this._replyMessage(message, undefined); - - case 'pr.create': - return this.create(message); - - case 'pr.changeBaseRemote': - return this.changeRemote(message, true); - - case 'pr.changeBaseBranch': - return this.changeBranch(message, true); - - case 'pr.changeCompareRemote': - return this.changeRemote(message, false); - - case 'pr.changeCompareBranch': - return this.changeBranch(message, false); - - default: - // Log error - vscode.window.showErrorMessage('Unsupported webview message'); - } - } - - private _getHtmlForWebview() { - const nonce = getNonce(); - - const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-create-pr-view.js'); - - return ` - - - - - - - Create Pull Request - - -
- - -`; - } -} diff --git a/src/github/createPRViewProviderNew.ts b/src/github/createPRViewProviderNew.ts new file mode 100644 index 0000000000..4775192211 --- /dev/null +++ b/src/github/createPRViewProviderNew.ts @@ -0,0 +1,1081 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, RemoteInfo, TitleAndDescriptionArgs } from '../../common/views'; +import type { Branch, Ref } from '../api/api'; +import { GitHubServerType } from '../common/authentication'; +import { commands, contexts } from '../common/executeCommands'; +import Logger from '../common/logger'; +import { Protocol } from '../common/protocol'; +import { GitHubRemote } from '../common/remote'; +import { + ASSIGN_TO, + CREATE_BASE_BRANCH, + DEFAULT_CREATE_OPTION, + PR_SETTINGS_NAMESPACE, + PULL_REQUEST_DESCRIPTION, + PUSH_BRANCH +} 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 { CreatePullRequestDataModel } from '../view/createPullRequestDataModel'; +import { + byRemoteName, + DetachedHeadError, + FolderRepositoryManager, + PullRequestDefaults, + titleAndBodyFrom, +} from './folderRepositoryManager'; +import { GitHubRepository } from './githubRepository'; +import { IAccount, ILabel, IMilestone, isTeam, ITeam, MergeMethod, RepoAccessAndMergeMethods } from './interface'; +import { PullRequestGitHelper } from './pullRequestGitHelper'; +import { PullRequestModel } from './pullRequestModel'; +import { getDefaultMergeMethod } from './pullRequestOverview'; +import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, reviewersQuickPick } from './quickPicks'; +import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils'; + +const ISSUE_CLOSING_KEYWORDS = new RegExp('closes|closed|close|fixes|fixed|fix|resolves|resolved|resolve\s$', 'i'); // https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword + +export class CreatePullRequestViewProviderNew extends WebviewViewBase implements vscode.WebviewViewProvider, vscode.Disposable { + private static readonly ID = 'CreatePullRequestViewProvider'; + public readonly viewType = 'github:createPullRequestWebview'; + + private _onDone = new vscode.EventEmitter(); + readonly onDone: vscode.Event = this._onDone.event; + + private _onDidChangeBaseRemote = new vscode.EventEmitter(); + readonly onDidChangeBaseRemote: vscode.Event = this._onDidChangeBaseRemote.event; + + private _onDidChangeBaseBranch = new vscode.EventEmitter(); + readonly onDidChangeBaseBranch: vscode.Event = this._onDidChangeBaseBranch.event; + + private _onDidChangeCompareRemote = new vscode.EventEmitter(); + readonly onDidChangeCompareRemote: vscode.Event = this._onDidChangeCompareRemote.event; + + private _onDidChangeCompareBranch = new vscode.EventEmitter(); + readonly onDidChangeCompareBranch: vscode.Event = this._onDidChangeCompareBranch.event; + + private _compareBranch: string; + private _baseBranch: string; + private _baseRemote: RemoteInfo; + + private _firstLoad: boolean = true; + + constructor( + private readonly telemetry: ITelemetry, + private readonly model: CreatePullRequestDataModel, + extensionUri: vscode.Uri, + private readonly _folderRepositoryManager: FolderRepositoryManager, + private readonly _pullRequestDefaults: PullRequestDefaults, + compareBranch: Branch, + ) { + super(extensionUri); + + this._defaultCompareBranch = compareBranch; + } + + public resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + super.resolveWebviewView(webviewView, _context, _token); + webviewView.webview.html = this._getHtmlForWebview(); + + if (this._firstLoad) { + this._firstLoad = false; + // Reset any stored state. + return this.initializeParams(true); + } else { + return this.initializeParams(); + } + } + + private _defaultCompareBranch: Branch; + get defaultCompareBranch() { + return this._defaultCompareBranch; + } + + set defaultCompareBranch(compareBranch: Branch | undefined) { + const branchChanged = compareBranch && (compareBranch.name !== this._defaultCompareBranch.name); + const branchRemoteChanged = compareBranch && (compareBranch.upstream?.remote !== this._defaultCompareBranch.upstream?.remote); + const commitChanged = compareBranch && (compareBranch.commit !== this._defaultCompareBranch.commit); + if (branchChanged || branchRemoteChanged || commitChanged) { + this._defaultCompareBranch = compareBranch!; + this.changeBranch(compareBranch!.name!, false).then(titleAndDescription => { + const params: Partial = { + defaultTitle: titleAndDescription.title, + defaultDescription: titleAndDescription.description, + compareBranch: compareBranch?.name, + defaultCompareBranch: compareBranch?.name + }; + if (!branchRemoteChanged) { + return this._postMessage({ + command: 'pr.initialize', + params, + }); + } + }); + + if (branchChanged) { + this._onDidChangeCompareBranch.fire(this._defaultCompareBranch.name!); + } + } + } + + public show(compareBranch?: Branch): void { + if (compareBranch) { + this.defaultCompareBranch = compareBranch; + } + + super.show(); + } + + public static withProgress(task: (progress: vscode.Progress<{ message?: string; increment?: number }>, token: vscode.CancellationToken) => Thenable) { + return vscode.window.withProgress({ location: { viewId: 'github:createPullRequestWebview' } }, task); + } + + private async getTotalGitHubCommits(compareBranch: Branch, baseBranchName: string): Promise<{ commit: { message: string }; parents: { sha: string }[] }[] | undefined> { + const origin = await this._folderRepositoryManager.getOrigin(compareBranch); + + if (compareBranch.upstream) { + const headRepo = this._folderRepositoryManager.findRepo(byRemoteName(compareBranch.upstream.remote)); + + if (headRepo) { + const headBranch = `${headRepo.remote.owner}:${compareBranch.name ?? ''}`; + const baseBranch = `${this._pullRequestDefaults.owner}:${baseBranchName}`; + const compareResult = await origin.compareCommits(baseBranch, headBranch); + + return compareResult?.commits; + } + } + + return undefined; + } + + private 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') { + return { title, description }; + } + + // Use same default as GitHub, if there is only one commit, use the commit, otherwise use the branch name, as long as it is not the default branch. + // By default, the base branch we use for comparison is the base branch of origin. Compare this to the + // compare branch if it has a GitHub remote. + const origin = await this._folderRepositoryManager.getOrigin(compareBranch); + + let useBranchName = this._pullRequestDefaults.base === compareBranch.name; + Logger.debug(`Compare branch name: ${compareBranch.name}, Base branch name: ${this._pullRequestDefaults.base}`, CreatePullRequestViewProviderNew.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' ? await this.getPullRequestTemplate() : undefined + ]); + const totalNonMergeCommits = totalCommits?.filter(commit => commit.parents.length < 2); + + Logger.debug(`Total commits: ${totalNonMergeCommits?.length}`, CreatePullRequestViewProviderNew.ID); + if (totalNonMergeCommits === undefined) { + // There is no upstream branch. Use the last commit as the title and description. + useBranchName = false; + } else if (totalNonMergeCommits && totalNonMergeCommits.length > 1) { + const defaultBranch = await origin.getDefaultBranch(); + useBranchName = defaultBranch !== compareBranch.name; + } + + // Set title + if (useBranchName && name) { + title = `${name.charAt(0).toUpperCase()}${name.slice(1)}`; + } else if (name && lastCommit) { + title = lastCommit.title; + } + + // Set description + if (pullRequestTemplate && lastCommit?.body) { + description = `${lastCommit.body}\n\n${pullRequestTemplate}`; + } else if (pullRequestTemplate) { + description = pullRequestTemplate; + } else if (lastCommit?.body && (this._pullRequestDefaults.base !== compareBranch.name)) { + description = lastCommit.body; + } + + // If the description is empty, check to see if the title of the PR contains something that looks like an issue + if (!description) { + const issueExpMatch = title.match(ISSUE_EXPRESSION); + const match = parseIssueExpressionOutput(issueExpMatch); + if (match?.issueNumber && !match.name && !match.owner) { + description = `#${match.issueNumber}`; + const prefix = title.substr(0, title.indexOf(issueExpMatch![0])); + + const keyWordMatch = prefix.match(ISSUE_CLOSING_KEYWORDS); + if (keyWordMatch) { + description = `${keyWordMatch[0]} ${description}`; + } + } + } + } catch (e) { + // Ignore and fall back to commit message + Logger.debug(`Error while getting total commits: ${e}`, CreatePullRequestViewProviderNew.ID); + } + return { title, description }; + } + + private async getPullRequestTemplate(): Promise { + Logger.debug(`Pull request template - enter`, CreatePullRequestViewProviderNew.ID); + const templateUris = await this._folderRepositoryManager.getPullRequestTemplatesWithCache(); + let template: string | undefined; + if (templateUris[0]) { + try { + const templateContent = await vscode.workspace.fs.readFile(templateUris[0]); + template = new TextDecoder('utf-8').decode(templateContent); + } catch (e) { + Logger.warn(`Reading pull request template failed: ${e}`); + return undefined; + } + } + Logger.debug(`Pull request template - done`, CreatePullRequestViewProviderNew.ID); + return template; + } + + private async getMergeConfiguration(owner: string, name: string, refetch: boolean = false): Promise { + const repo = await this._folderRepositoryManager.createGitHubRepositoryFromOwnerName(owner, name); + return repo.getRepoAccessAndMergeMethods(refetch); + } + + private initializeWhenVisibleDisposable: vscode.Disposable | undefined; + public async initializeParams(reset: boolean = false): Promise { + if (this._view?.visible === false && this.initializeWhenVisibleDisposable === undefined) { + this.initializeWhenVisibleDisposable = this._view?.onDidChangeVisibility(() => { + this.initializeWhenVisibleDisposable?.dispose(); + this.initializeWhenVisibleDisposable = undefined; + void this.initializeParams(); + }); + return; + } + + if (reset) { + // First clear all state ASAP + this._postMessage({ command: 'reset' }); + } + await this.initializeParamsPromise(); + } + + private _alreadyInitializing: Promise | undefined; + private async initializeParamsPromise(): Promise { + if (!this._alreadyInitializing) { + this._alreadyInitializing = this.doInitializeParams(); + this._alreadyInitializing.then(() => { + this._alreadyInitializing = undefined; + }); + } + return this._alreadyInitializing; + } + + private async doInitializeParams(): Promise { + if (!this.defaultCompareBranch) { + throw new DetachedHeadError(this._folderRepositoryManager.repository); + } + + const defaultCompareBranch = this.defaultCompareBranch.name ?? ''; + const [detectedBaseMetadata, remotes, defaultOrigin] = await Promise.all([ + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'repositoryDefault' | 'createdFromBranch'>(CREATE_BASE_BRANCH) === 'createdFromBranch' ? PullRequestGitHelper.getMatchingBaseBranchMetadataForBranch(this._folderRepositoryManager.repository, defaultCompareBranch) : undefined, + this._folderRepositoryManager.getGitHubRemotes(), + this._folderRepositoryManager.getOrigin(this.defaultCompareBranch)]); + + const defaultBaseRemote: RemoteInfo = { + owner: detectedBaseMetadata?.owner ?? this._pullRequestDefaults.owner, + repositoryName: detectedBaseMetadata?.repositoryName ?? this._pullRequestDefaults.repo, + }; + if (defaultBaseRemote.owner !== this._pullRequestDefaults.owner || defaultBaseRemote.repositoryName !== this._pullRequestDefaults.repo) { + this._onDidChangeBaseRemote.fire(defaultBaseRemote); + } + + const defaultCompareRemote: RemoteInfo = { + owner: defaultOrigin.remote.owner, + repositoryName: defaultOrigin.remote.repositoryName, + }; + + const defaultBaseBranch = detectedBaseMetadata?.branch ?? this._pullRequestDefaults.base; + if (defaultBaseBranch !== this._pullRequestDefaults.base) { + this._onDidChangeBaseBranch.fire(defaultBaseBranch); + } + + const [defaultTitleAndDescription, mergeConfiguration, viewerPermission, mergeQueueMethodForBranch] = await Promise.all([ + this.getTitleAndDescription(this.defaultCompareBranch, defaultBaseBranch), + this.getMergeConfiguration(defaultBaseRemote.owner, defaultBaseRemote.repositoryName), + defaultOrigin.getViewerPermission(), + this._folderRepositoryManager.mergeQueueMethodForBranch(defaultBaseBranch, defaultBaseRemote.owner, defaultBaseRemote.repositoryName) + ]); + + const defaultCreateOption = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'lastUsed' | 'create' | 'createDraft' | 'createAutoMerge'>(DEFAULT_CREATE_OPTION, 'lastUsed'); + const lastCreateMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } | undefined = this._folderRepositoryManager.context.workspaceState.get<{ autoMerge: boolean, mergeMethod: MergeMethod, isDraft } | undefined>(PREVIOUS_CREATE_METHOD, undefined); + const repoMergeMethod = getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability); + + // default values are for 'create' + let defaultMergeMethod: MergeMethod = repoMergeMethod; + let isDraftDefault: boolean = false; + let autoMergeDefault: boolean = false; + defaultMergeMethod = (defaultCreateOption === 'lastUsed' && lastCreateMethod?.mergeMethod) ? lastCreateMethod?.mergeMethod : repoMergeMethod; + + if (defaultCreateOption === 'lastUsed') { + defaultMergeMethod = lastCreateMethod?.mergeMethod ?? repoMergeMethod; + isDraftDefault = !!lastCreateMethod?.isDraft; + autoMergeDefault = mergeConfiguration.viewerCanAutoMerge && !!lastCreateMethod?.autoMerge; + } else if (defaultCreateOption === 'createDraft') { + isDraftDefault = true; + } else if (defaultCreateOption === 'createAutoMerge') { + autoMergeDefault = mergeConfiguration.viewerCanAutoMerge; + } + commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); + + const useCopilot: boolean = !!this._folderRepositoryManager.getTitleAndDescriptionProvider('Copilot') && (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION) === 'Copilot'); + const defaultTitleAndDescriptionProvider = this._folderRepositoryManager.getTitleAndDescriptionProvider()?.title; + if (defaultTitleAndDescriptionProvider) { + /* __GDPR__ + "pr.defaultTitleAndDescriptionProvider" : { + "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.defaultTitleAndDescriptionProvider', { providerTitle: defaultTitleAndDescriptionProvider }); + } + + const params: CreateParamsNew = { + defaultBaseRemote, + defaultBaseBranch, + defaultCompareRemote, + defaultCompareBranch, + defaultTitle: defaultTitleAndDescription.title, + defaultDescription: defaultTitleAndDescription.description, + defaultMergeMethod, + baseHasMergeQueue: !!mergeQueueMethodForBranch, + remoteCount: remotes.length, + allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, + mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, + autoMergeDefault, + createError: '', + labels: this.labels, + isDraftDefault, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, + generateTitleAndDescriptionTitle: defaultTitleAndDescriptionProvider, + creating: false, + initializeWithGeneratedTitleAndDescription: useCopilot + }; + + Logger.appendLine(`Initializing "create" view: ${JSON.stringify(params)}`, CreatePullRequestViewProviderNew.ID); + + this._compareBranch = this.defaultCompareBranch.name ?? ''; + this._baseBranch = defaultBaseBranch; + this._baseRemote = defaultBaseRemote; + + this._postMessage({ + command: 'pr.initialize', + params, + }); + return params; + } + + + private async remotePicks(isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo })[]> { + const remotes = isBase ? await this._folderRepositoryManager.getActiveGitHubRemotes(await this._folderRepositoryManager.getGitHubRemotes()) : this._folderRepositoryManager.gitHubRepositories.map(repo => repo.remote); + return remotes.map(remote => { + return { + iconPath: new vscode.ThemeIcon('repo'), + label: `${remote.owner}/${remote.repositoryName}`, + remote: { + owner: remote.owner, + repositoryName: remote.repositoryName, + } + }; + }); + } + + private async 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()]); + + commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); + let chooseResult: ChooseBaseRemoteAndBranchResult | ChooseCompareRemoteAndBranchResult; + if (isBase) { + const baseRemoteChanged = this._baseRemote !== result.remote; + const baseBranchChanged = baseRemoteChanged || this._baseBranch !== result.branch; + this._baseBranch = result.branch; + this._baseRemote = result.remote; + const compareBranch = await this._folderRepositoryManager.repository.getBranch(this._compareBranch); + const [mergeConfiguration, titleAndDescription, mergeQueueMethodForBranch] = await Promise.all([ + this.getMergeConfiguration(result.remote.owner, result.remote.repositoryName), + this.getTitleAndDescription(compareBranch, this._baseBranch), + this._folderRepositoryManager.mergeQueueMethodForBranch(this._baseBranch, this._baseRemote.owner, this._baseRemote.repositoryName)]); + let autoMergeDefault = false; + if (mergeConfiguration.viewerCanAutoMerge) { + const defaultCreateOption = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'lastUsed' | 'create' | 'createDraft' | 'createAutoMerge'>(DEFAULT_CREATE_OPTION, 'lastUsed'); + const lastCreateMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } | undefined = this._folderRepositoryManager.context.workspaceState.get<{ autoMerge: boolean, mergeMethod: MergeMethod, isDraft } | undefined>(PREVIOUS_CREATE_METHOD, undefined); + autoMergeDefault = (defaultCreateOption === 'lastUsed' && lastCreateMethod?.autoMerge) || (defaultCreateOption === 'createAutoMerge'); + } + + chooseResult = { + baseRemote: result.remote, + baseBranch: result.branch, + defaultBaseBranch: defaultBranch, + defaultMergeMethod: getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability), + allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, + baseHasMergeQueue: !!mergeQueueMethodForBranch, + mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, + autoMergeDefault, + defaultTitle: titleAndDescription.title, + defaultDescription: titleAndDescription.description + }; + if (baseRemoteChanged) { + /* __GDPR__ + "pr.create.changedBaseRemote" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseRemote'); + this._onDidChangeBaseRemote.fire(this._baseRemote); + } + if (baseBranchChanged) { + /* __GDPR__ + "pr.create.changedBaseBranch" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseBranch'); + this._onDidChangeBaseBranch.fire(this._baseBranch); + } + } else { + this._compareBranch = result.branch; + chooseResult = { + compareRemote: result.remote, + compareBranch: result.branch, + defaultCompareBranch: defaultBranch + }; + /* __GDPR__ + "pr.create.changedCompare" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedCompare'); + this._onDidChangeCompareRemote.fire(result.remote); + this._onDidChangeCompareBranch.fire(this._compareBranch); + } + return chooseResult; + } + + private async changeRemoteAndBranch(message: IRequestMessage, isBase: boolean): Promise { + this.cancelGenerateTitleAndDescription(); + const quickPick = vscode.window.createQuickPick<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })>(); + let githubRepository = this._folderRepositoryManager.findRepo( + repo => message.args.currentRemote?.owner === repo.remote.owner && message.args.currentRemote.repositoryName === repo.remote.repositoryName, + ); + + const chooseDifferentRemote = vscode.l10n.t('Change Repository...'); + const remotePlaceholder = vscode.l10n.t('Choose a remote'); + const branchPlaceholder = isBase ? vscode.l10n.t('Choose a base branch') : vscode.l10n.t('Choose a branch to merge'); + const repositoryPlaceholder = isBase ? vscode.l10n.t('Choose a base repository') : vscode.l10n.t('Choose a repository to merge from'); + + 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; + const remoteAndBranch: Promise<{ remote: RemoteInfo, branch: string } | undefined> = new Promise((resolve) => { + quickPick.onDidAccept(async () => { + if (quickPick.selectedItems.length === 0) { + return; + } + const selectedPick = quickPick.selectedItems[0]; + if (selectedPick.label === chooseDifferentRemote) { + quickPick.busy = true; + quickPick.items = await this.remotePicks(isBase); + quickPick.busy = false; + quickPick.placeholder = githubRepository ? repositoryPlaceholder : remotePlaceholder; + } else if ((selectedPick.branch === undefined) && selectedPick.remote) { + const selectedRemote = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo }; + quickPick.busy = true; + githubRepository = this._folderRepositoryManager.findRepo(repo => repo.remote.owner === selectedRemote.remote.owner && repo.remote.repositoryName === selectedRemote.remote.repositoryName)!; + quickPick.items = await this.branchPicks(githubRepository, chooseDifferentRemote, isBase); + quickPick.placeholder = branchPlaceholder; + quickPick.busy = false; + } else if (selectedPick.branch && selectedPick.remote) { + const selectedBranch = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo, branch: string }; + resolve({ remote: selectedBranch.remote, branch: selectedBranch.branch }); + } + }); + }); + const hidePromise = new Promise((resolve) => quickPick.onDidHide(() => resolve())); + const result = await Promise.race([remoteAndBranch, hidePromise]); + if (!result || !githubRepository) { + quickPick.hide(); + quickPick.dispose(); + return; + } + + quickPick.busy = true; + const chooseResult = await this.processRemoteAndBranchResult(githubRepository, result, isBase); + + quickPick.hide(); + quickPick.dispose(); + return this._replyMessage(message, chooseResult); + } + + private async autoAssign(pr: PullRequestModel): Promise { + const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(ASSIGN_TO); + if (!configuration) { + return; + } + const resolved = await variableSubstitution(configuration, pr, undefined, (await this._folderRepositoryManager.getCurrentUser(pr.githubRepository))?.login); + if (!resolved) { + return; + } + try { + await pr.addAssignees([resolved]); + } catch (e) { + Logger.error(`Unable to assign pull request to user ${resolved}.`); + } + } + + private async enableAutoMerge(pr: PullRequestModel, autoMerge: boolean, automergeMethod: MergeMethod | undefined): Promise { + if (autoMerge && automergeMethod) { + return pr.enableAutoMerge(automergeMethod); + } + } + + private async setLabels(pr: PullRequestModel, labels: ILabel[]): Promise { + if (labels.length > 0) { + await pr.setLabels(labels.map(label => label.name)); + } + } + + private async setAssignees(pr: PullRequestModel, assignees: IAccount[]): Promise { + if (assignees.length) { + await pr.addAssignees(assignees.map(assignee => assignee.login)); + } else { + await this.autoAssign(pr); + } + } + + private async setReviewers(pr: PullRequestModel, reviewers: (IAccount | ITeam)[]): Promise { + if (reviewers.length) { + const users: string[] = []; + const teams: string[] = []; + for (const reviewer of reviewers) { + if (isTeam(reviewer)) { + teams.push(reviewer.id); + } else { + users.push(reviewer.id); + } + } + await pr.requestReview(users, teams); + } + } + + private setMilestone(pr: PullRequestModel, milestone: IMilestone | undefined): void { + if (milestone) { + pr.updateMilestone(milestone.id); + } + } + + private async getRemote(): Promise { + return (await this._folderRepositoryManager.getGitHubRemotes()).find(remote => compareIgnoreCase(remote.owner, this._baseRemote.owner) === 0 && compareIgnoreCase(remote.repositoryName, this._baseRemote.repositoryName) === 0)!; + } + + private milestone: IMilestone | undefined; + public async addMilestone(): Promise { + const remote = await this.getRemote(); + const repo = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.remoteName === remote.remoteName)!; + + return getMilestoneFromQuickPick(this._folderRepositoryManager, repo, this.milestone, (milestone) => { + this.milestone = milestone; + return this._postMessage({ + command: 'set-milestone', + params: { milestone: this.milestone } + }); + }); + } + + private reviewers: (IAccount | ITeam)[] = []; + public async addReviewers(): Promise { + let quickPick: vscode.QuickPick | undefined; + const remote = await this.getRemote(); + try { + const repo = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.remoteName === remote.remoteName)!; + const [metadata, author, teamsCount] = await Promise.all([repo?.getMetadata(), this._folderRepositoryManager.getCurrentUser(), this._folderRepositoryManager.getOrgTeamsCount(repo)]); + quickPick = await reviewersQuickPick(this._folderRepositoryManager, remote.remoteName, !!metadata?.organization, teamsCount, author, this.reviewers.map(reviewer => { return { reviewer, state: 'REQUESTED' }; }), []); + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick!.selectedItems.filter(item => item.user) as (vscode.QuickPickItem & { user: IAccount | ITeam })[] | undefined; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const allReviewers = await Promise.race<(vscode.QuickPickItem & { user: IAccount | ITeam })[] | void>([acceptPromise, hidePromise]); + quickPick.busy = true; + + if (allReviewers) { + this.reviewers = allReviewers.map(item => item.user); + this._postMessage({ + command: 'set-reviewers', + params: { reviewers: this.reviewers } + }); + } + } catch (e) { + Logger.error(formatError(e)); + vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick?.hide(); + quickPick?.dispose(); + } + } + + private assignees: IAccount[] = []; + public async addAssignees(): Promise { + const remote = await this.getRemote(); + const assigneesToAdd = await vscode.window.showQuickPick(getAssigneesQuickPickItems(this._folderRepositoryManager, remote.remoteName, this.assignees), + { canPickMany: true, placeHolder: vscode.l10n.t('Add assignees') }); + if (assigneesToAdd) { + const addedAssignees = assigneesToAdd.map(assignee => assignee.user).filter((assignee): assignee is IAccount => !!assignee); + this.assignees = addedAssignees; + this._postMessage({ + command: 'set-assignees', + params: { assignees: this.assignees } + }); + } + } + + private labels: ILabel[] = []; + public async addLabels(): Promise { + let newLabels: ILabel[] = []; + + const labelsToAdd = await vscode.window.showQuickPick( + getLabelOptions(this._folderRepositoryManager, this.labels, this._baseRemote).then(options => { + newLabels = options.newLabels; + return options.labelPicks; + }) as Promise, + { canPickMany: true, placeHolder: vscode.l10n.t('Apply labels') }, + ); + + if (labelsToAdd) { + const addedLabels: ILabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.label)!); + this.labels = addedLabels; + this._postMessage({ + command: 'set-labels', + params: { labels: this.labels } + }); + } + } + + private async removeLabel(message: IRequestMessage<{ label: ILabel }>,): Promise { + const { label } = message.args; + if (!label) + return; + + const previousLabelsLength = this.labels.length; + this.labels = this.labels.filter(l => l.name !== label.name); + if (previousLabelsLength === this.labels.length) + return; + + this._postMessage({ + command: 'set-labels', + params: { labels: this.labels } + }); + } + + 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._baseRemote.owner; + const name = tryParse.name ?? this._baseRemote.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); + } + }); + + })); + } + } + if (issues.length) { + return (await Promise.all(issues)).filter(issue => !!issue) as { content: string, reference: string }[]; + } + return undefined; + } + + private lastGeneratedTitleAndDescription: { title?: string, description?: string, providerTitle: string } | undefined; + private async getTitleAndDescriptionFromProvider(token: vscode.CancellationToken, searchTerm?: string) { + return CreatePullRequestViewProviderNew.withProgress(async () => { + try { + let commitMessages: string[]; + let patches: string[]; + 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 => file.patch ?? ''))]); + } else { + [commitMessages, patches] = await Promise.all([ + this.model.gitCommits().then(rawCommits => rawCommits.map(commit => commit.message)), + Promise.all((await this.model.gitFiles()).map(async (file) => { + return this._folderRepositoryManager.repository.diffBetween(this.model.baseBranch, this.model.getCompareBranch(), file.uri.fsPath); + }))]); + } + + const issues = await this.findIssueContext(commitMessages); + + const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider(searchTerm); + const result = await provider?.provider.provideTitleAndDescription({ commitMessages, patches, issues }, token); + + if (provider) { + this.lastGeneratedTitleAndDescription = { ...result, providerTitle: provider.title }; + /* __GDPR__ + "pr.generatedTitleAndDescription" : { + "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.generatedTitleAndDescription', { providerTitle: provider?.title }); + } + return result; + } catch (e) { + Logger.error(`Error while generating title and description: ${e}`, CreatePullRequestViewProviderNew.ID); + return undefined; + } + }); + } + + private generatingCancellationToken: vscode.CancellationTokenSource | undefined; + private async generateTitleAndDescription(message: IRequestMessage): Promise { + if (this.generatingCancellationToken) { + this.generatingCancellationToken.cancel(); + } + this.generatingCancellationToken = new vscode.CancellationTokenSource(); + + + const result = await Promise.race([this.getTitleAndDescriptionFromProvider(this.generatingCancellationToken.token, message.args.useCopilot ? 'Copilot' : undefined), + new Promise(resolve => this.generatingCancellationToken?.token.onCancellationRequested(() => resolve(true)))]); + + this.generatingCancellationToken = undefined; + + const generated: { title: string | undefined, description: string | undefined } = { title: undefined, description: undefined }; + if (result !== true) { + generated.title = result?.title; + generated.description = result?.description; + } + return this._replyMessage(message, { title: generated?.title, description: generated?.description }); + } + + private async cancelGenerateTitleAndDescription(): Promise { + if (this.generatingCancellationToken) { + this.generatingCancellationToken.cancel(); + } + } + + private async pushUpstream(compareOwner: string, compareRepositoryName: string, compareBranchName: string): Promise<{ compareUpstream: GitHubRemote, repo: GitHubRepository | undefined } | undefined> { + let createdPushRemote: GitHubRemote | undefined; + const pushRemote = this._folderRepositoryManager.repository.state.remotes.find(localRemote => { + if (!localRemote.pushUrl) { + return false; + } + const testRemote = new GitHubRemote(localRemote.name, localRemote.pushUrl, new Protocol(localRemote.pushUrl), GitHubServerType.GitHubDotCom); + if ((testRemote.owner.toLowerCase() === compareOwner.toLowerCase()) && (testRemote.repositoryName.toLowerCase() === compareRepositoryName.toLowerCase())) { + createdPushRemote = testRemote; + return true; + } + return false; + }); + + if (pushRemote && createdPushRemote) { + Logger.appendLine(`Found push remote ${pushRemote.name} for ${compareOwner}/${compareRepositoryName} and branch ${compareBranchName}`, CreatePullRequestViewProviderNew.ID); + await this._folderRepositoryManager.repository.push(pushRemote.name, compareBranchName, true); + await this._folderRepositoryManager.repository.status(); + return { compareUpstream: createdPushRemote, repo: this._folderRepositoryManager.findRepo(byRemoteName(createdPushRemote.remoteName)) }; + } + } + + public async createFromCommand(isDraft: boolean, autoMerge: boolean, autoMergeMethod: MergeMethod | undefined, mergeWhenReady?: boolean) { + const params: Partial = { + isDraft, + autoMerge, + autoMergeMethod: mergeWhenReady ? 'merge' : autoMergeMethod, + creating: true + }; + return this._postMessage({ + command: 'create', + params + }); + } + + private checkGeneratedTitleAndDescription(title: string, description: string) { + if (!this.lastGeneratedTitleAndDescription) { + return; + } + const usedGeneratedTitle: boolean = !!this.lastGeneratedTitleAndDescription.title && ((this.lastGeneratedTitleAndDescription.title === title) || this.lastGeneratedTitleAndDescription.title?.includes(title) || title?.includes(this.lastGeneratedTitleAndDescription.title)); + const usedGeneratedDescription: boolean = !!this.lastGeneratedTitleAndDescription.description && ((this.lastGeneratedTitleAndDescription.description === description) || this.lastGeneratedTitleAndDescription.description?.includes(description) || description?.includes(this.lastGeneratedTitleAndDescription.description)); + /* __GDPR__ + "pr.usedGeneratedTitleAndDescription" : { + "providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "usedGeneratedTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "usedGeneratedDescription" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('pr.usedGeneratedTitleAndDescription', { providerTitle: this.lastGeneratedTitleAndDescription.providerTitle, usedGeneratedTitle: usedGeneratedTitle.toString(), usedGeneratedDescription: usedGeneratedDescription.toString() }); + } + + private async create(message: IRequestMessage): Promise { + Logger.debug(`Creating pull request with args ${JSON.stringify(message.args)}`, CreatePullRequestViewProviderNew.ID); + + // Save create method + const createMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } = { autoMerge: message.args.autoMerge, mergeMethod: message.args.autoMergeMethod, isDraft: message.args.draft }; + this._folderRepositoryManager.context.workspaceState.update(PREVIOUS_CREATE_METHOD, createMethod); + + const postCreate = (createdPR: PullRequestModel) => { + return Promise.all([ + this.setLabels(createdPR, message.args.labels), + this.enableAutoMerge(createdPR, message.args.autoMerge, message.args.autoMergeMethod), + this.setAssignees(createdPR, message.args.assignees), + this.setReviewers(createdPR, message.args.reviewers), + this.setMilestone(createdPR, message.args.milestone)]); + }; + + CreatePullRequestViewProviderNew.withProgress(() => { + return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async progress => { + let totalIncrement = 0; + progress.report({ message: vscode.l10n.t('Checking for upstream branch'), increment: totalIncrement }); + let createdPR: PullRequestModel | undefined = undefined; + try { + const compareOwner = message.args.compareOwner; + const compareRepositoryName = message.args.compareRepo; + const compareBranchName = message.args.compareBranch; + const compareGithubRemoteName = `${compareOwner}/${compareRepositoryName}`; + const compareBranch = await this._folderRepositoryManager.repository.getBranch(compareBranchName); + let headRepo = compareBranch.upstream ? this._folderRepositoryManager.findRepo((githubRepo) => { + return (githubRepo.remote.owner === compareOwner) && (githubRepo.remote.repositoryName === compareRepositoryName); + }) : undefined; + let existingCompareUpstream = headRepo?.remote; + + if (!existingCompareUpstream + || (existingCompareUpstream.owner !== compareOwner) + || (existingCompareUpstream.repositoryName !== compareRepositoryName)) { + + // We assume this happens only when the compare branch is based on the current branch. + const alwaysPublish = vscode.l10n.t('Always Publish Branch'); + const publish = vscode.l10n.t('Publish Branch'); + const pushBranchSetting = + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(PUSH_BRANCH) === 'always'; + const messageResult = !pushBranchSetting ? await vscode.window.showInformationMessage( + vscode.l10n.t('There is no remote branch on {0}/{1} for \'{2}\'.\n\nDo you want to publish it and then create the pull request?', compareOwner, compareRepositoryName, compareBranchName), + { modal: true }, + publish, + alwaysPublish) + : publish; + if (messageResult === alwaysPublish) { + await vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .update(PUSH_BRANCH, 'always', vscode.ConfigurationTarget.Global); + } + if ((messageResult === alwaysPublish) || (messageResult === publish)) { + progress.report({ message: vscode.l10n.t('Pushing branch'), increment: 10 }); + totalIncrement += 10; + + const pushResult = await this.pushUpstream(compareOwner, compareRepositoryName, compareBranchName); + if (pushResult) { + existingCompareUpstream = pushResult.compareUpstream; + headRepo = pushResult.repo; + } else { + this._throwError(message, vscode.l10n.t('The current repository does not have a push remote for {0}', compareGithubRemoteName)); + } + } + } + if (!existingCompareUpstream) { + this._throwError(message, vscode.l10n.t('No remote branch on {0}/{1} for the merge branch.', compareOwner, compareRepositoryName)); + progress.report({ message: vscode.l10n.t('Pull request cancelled'), increment: 100 - totalIncrement }); + return; + } + + if (!headRepo) { + throw new Error(vscode.l10n.t('Unable to find GitHub repository matching \'{0}\'. You can add \'{0}\' to the setting "githubPullRequests.remotes" to ensure \'{0}\' is found.', existingCompareUpstream.remoteName)); + } + + progress.report({ message: vscode.l10n.t('Creating pull request'), increment: 70 - totalIncrement }); + totalIncrement += 70 - totalIncrement; + const head = `${headRepo.remote.owner}:${compareBranchName}`; + this.checkGeneratedTitleAndDescription(message.args.title, message.args.body); + createdPR = await this._folderRepositoryManager.createPullRequest({ ...message.args, head }); + + // Create was cancelled + if (!createdPR) { + this._throwError(message, vscode.l10n.t('There must be a difference in commits to create a pull request.')); + } else { + await postCreate(createdPR); + } + } catch (e) { + if (!createdPR) { + let errorMessage: string = e.message; + if (errorMessage.startsWith('GraphQL error: ')) { + errorMessage = errorMessage.substring('GraphQL error: '.length); + } + this._throwError(message, errorMessage); + } else { + if ((e as Error).message === 'GraphQL error: ["Pull request Pull request is in unstable status"]') { + // This error can happen if the PR isn't fully created by the time we try to set properties on it. Try again. + await postCreate(createdPR); + } + // All of these errors occur after the PR is created, so the error is not critical. + vscode.window.showErrorMessage(vscode.l10n.t('There was an error creating the pull request: {0}', (e as Error).message)); + } + } finally { + let completeMessage: string; + if (createdPR) { + this._onDone.fire(createdPR); + completeMessage = vscode.l10n.t('Pull request created'); + } else { + await this._replyMessage(message, {}); + completeMessage = vscode.l10n.t('Unable to create pull request'); + } + progress.report({ message: completeMessage, increment: 100 - totalIncrement }); + } + }); + }); + } + + private async changeBranch(newBranch: string, isBase: boolean): Promise<{ title: string, description: string }> { + let compareBranch: Branch | undefined; + if (isBase) { + this._baseBranch = newBranch; + this._onDidChangeBaseBranch.fire(newBranch); + } else { + try { + compareBranch = await this._folderRepositoryManager.repository.getBranch(newBranch); + this._compareBranch = newBranch; + this._onDidChangeCompareBranch.fire(compareBranch.name!); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Branch does not exist locally.')); + } + } + + compareBranch = compareBranch ?? await this._folderRepositoryManager.repository.getBranch(this._compareBranch); + return this.getTitleAndDescription(compareBranch, this._baseBranch); + } + + private async cancel(message: IRequestMessage) { + vscode.commands.executeCommand('setContext', 'github:createPullRequest', false); + this._onDone.fire(undefined); + // Re-fetch the automerge info so that it's updated for next time. + await this.getMergeConfiguration(message.args.owner, message.args.repo, true); + return this._replyMessage(message, undefined); + } + + protected async _onDidReceiveMessage(message: IRequestMessage) { + const result = await super._onDidReceiveMessage(message); + if (result !== this.MESSAGE_UNHANDLED) { + return; + } + + switch (message.command) { + case 'pr.requestInitialize': + return this.initializeParamsPromise(); + + case 'pr.cancelCreate': + return this.cancel(message); + + case 'pr.create': + return this.create(message); + + case 'pr.changeBaseRemoteAndBranch': + return this.changeRemoteAndBranch(message, true); + + case 'pr.changeCompareRemoteAndBranch': + return this.changeRemoteAndBranch(message, false); + + case 'pr.changeLabels': + return this.addLabels(); + + case 'pr.changeReviewers': + return this.addReviewers(); + + case 'pr.changeAssignees': + return this.addAssignees(); + + case 'pr.changeMilestone': + return this.addMilestone(); + + case 'pr.removeLabel': + return this.removeLabel(message); + + case 'pr.generateTitleAndDescription': + return this.generateTitleAndDescription(message); + + case 'pr.cancelGenerateTitleAndDescription': + return this.cancelGenerateTitleAndDescription(); + + default: + // Log error + vscode.window.showErrorMessage('Unsupported webview message'); + } + } + + dispose() { + super.dispose(); + this._postMessage({ command: 'reset' }); + } + + private _getHtmlForWebview() { + const nonce = getNonce(); + + const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-create-pr-view-new.js'); + + return ` + + + + + + + Create Pull Request + + +
+ + +`; + } +} diff --git a/src/github/credentials.ts b/src/github/credentials.ts index cf88dd4420..77c403a893 100644 --- a/src/github/credentials.ts +++ b/src/github/credentials.ts @@ -4,39 +4,44 @@ *--------------------------------------------------------------------------------------------*/ import { Octokit } from '@octokit/rest'; -import { ApolloClient, InMemoryCache, NormalizedCacheObject } from 'apollo-boost'; +import { ApolloClient, InMemoryCache } from 'apollo-boost'; import { setContext } from 'apollo-link-context'; import { createHttpLink } from 'apollo-link-http'; import fetch from 'cross-fetch'; import * as vscode from 'vscode'; +import { AuthProvider } from '../common/authentication'; import Logger from '../common/logger'; import * as PersistentState from '../common/persistentState'; import { ITelemetry } from '../common/telemetry'; import { agent } from '../env/node/net'; -import { OctokitCommon } from './common'; -import { getEnterpriseUri, hasEnterpriseUri } from './utils'; +import { IAccount } from './interface'; +import { LoggingApolloClient, LoggingOctokit, RateLogger } from './loggingOctokit'; +import { convertRESTUserToAccount, getEnterpriseUri, hasEnterpriseUri, isEnterprise } from './utils'; -const TRY_AGAIN = 'Try again?'; -const CANCEL = 'Cancel'; -const SIGNIN_COMMAND = 'Sign in'; -const IGNORE_COMMAND = "Don't show again"; +const TRY_AGAIN = vscode.l10n.t('Try again?'); +const CANCEL = vscode.l10n.t('Cancel'); +const SIGNIN_COMMAND = vscode.l10n.t('Sign In'); +const IGNORE_COMMAND = vscode.l10n.t('Don\'t Show Again'); -const PROMPT_FOR_SIGN_IN_SCOPE = 'prompt for sign in'; +const PROMPT_FOR_SIGN_IN_SCOPE = vscode.l10n.t('prompt for sign in'); const PROMPT_FOR_SIGN_IN_STORAGE_KEY = 'login'; // If the scopes are changed, make sure to notify all interested parties to make sure this won't cause problems. -const SCOPES_OLD = ['read:user', 'user:email', 'repo']; -const SCOPES = ['read:user', 'user:email', 'repo', 'workflow']; +const SCOPES_OLDEST = ['read:user', 'user:email', 'repo']; +const SCOPES_OLD = ['read:user', 'user:email', 'repo', 'workflow']; +const SCOPES_WITH_ADDITIONAL = ['read:user', 'user:email', 'repo', 'workflow', 'project', 'read:org']; -export enum AuthProvider { - github = 'github', - 'github-enterprise' = 'github-enterprise' -} +const LAST_USED_SCOPES_GITHUB_KEY = 'githubPullRequest.lastUsedScopes'; +const LAST_USED_SCOPES_ENTERPRISE_KEY = 'githubPullRequest.lastUsedScopesEnterprise'; export interface GitHub { - octokit: Octokit; - graphql: ApolloClient | null; - currentUser?: OctokitCommon.PullsGetResponseUser; + octokit: LoggingOctokit; + graphql: LoggingApolloClient; + currentUser?: Promise; +} + +interface AuthResult { + canceled: boolean; } export class CredentialStore implements vscode.Disposable { @@ -45,13 +50,21 @@ export class CredentialStore implements vscode.Disposable { private _githubEnterpriseAPI: GitHub | undefined; private _enterpriseSessionId: string | undefined; private _disposables: vscode.Disposable[]; + private _isInitialized: boolean = false; private _onDidInitialize: vscode.EventEmitter = new vscode.EventEmitter(); public readonly onDidInitialize: vscode.Event = this._onDidInitialize.event; + private _scopes: string[] = SCOPES_OLD; + private _scopesEnterprise: string[] = SCOPES_OLD; private _onDidGetSession: vscode.EventEmitter = new vscode.EventEmitter(); public readonly onDidGetSession = this._onDidGetSession.event; - constructor(private readonly _telemetry: ITelemetry) { + private _onDidUpgradeSession: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidUpgradeSession = this._onDidUpgradeSession.event; + + constructor(private readonly _telemetry: ITelemetry, private readonly context: vscode.ExtensionContext) { + this.setScopesFromState(); + this._disposables = []; this._disposables.push( vscode.authentication.onDidChangeSessions(async () => { @@ -60,8 +73,8 @@ export class CredentialStore implements vscode.Disposable { promises.push(this.initialize(AuthProvider.github)); } - if (!this.isAuthenticated(AuthProvider['github-enterprise']) && hasEnterpriseUri()) { - promises.push(this.initialize(AuthProvider['github-enterprise'])); + if (!this.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + promises.push(this.initialize(AuthProvider.githubEnterprise)); } await Promise.all(promises); @@ -72,19 +85,57 @@ export class CredentialStore implements vscode.Disposable { ); } - private async initialize(authProviderId: AuthProvider, getAuthSessionOptions?: vscode.AuthenticationGetSessionOptions): Promise { - if (authProviderId === AuthProvider['github-enterprise']) { + private allScopesIncluded(actualScopes: string[], requiredScopes: string[]) { + return requiredScopes.every(scope => actualScopes.includes(scope)); + } + + private setScopesFromState() { + this._scopes = this.context.globalState.get(LAST_USED_SCOPES_GITHUB_KEY, SCOPES_OLD); + this._scopesEnterprise = this.context.globalState.get(LAST_USED_SCOPES_ENTERPRISE_KEY, SCOPES_OLD); + } + + private async saveScopesInState() { + await this.context.globalState.update(LAST_USED_SCOPES_GITHUB_KEY, this._scopes); + await this.context.globalState.update(LAST_USED_SCOPES_ENTERPRISE_KEY, this._scopesEnterprise); + } + private async initialize(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions = {}, scopes: string[] = (!isEnterprise(authProviderId) ? this._scopes : this._scopesEnterprise), requireScopes?: boolean): Promise { + Logger.debug(`Initializing GitHub${getGitHubSuffix(authProviderId)} authentication provider.`, 'Authentication'); + if (isEnterprise(authProviderId)) { if (!hasEnterpriseUri()) { Logger.debug(`GitHub Enterprise provider selected without URI.`, 'Authentication'); - return; + return { canceled: false }; } } - getAuthSessionOptions = { ...getAuthSessionOptions, ...{ createIfNone: false } }; - let session; + + if (getAuthSessionOptions.createIfNone === undefined && getAuthSessionOptions.forceNewSession === undefined) { + getAuthSessionOptions.createIfNone = false; + } + + let session: vscode.AuthenticationSession | undefined = undefined; + let isNew: boolean = false; + let usedScopes: string[] | undefined = SCOPES_OLD; + const oldScopes = this._scopes; + const oldEnterpriseScopes = this._scopesEnterprise; + const authResult: AuthResult = { canceled: false }; try { - session = await this.getSession(authProviderId, getAuthSessionOptions); + // Set scopes before getting the session to prevent new session events from using the old scopes. + if (!isEnterprise(authProviderId)) { + this._scopes = scopes; + } else { + this._scopesEnterprise = scopes; + } + const result = await this.getSession(authProviderId, getAuthSessionOptions, scopes, !!requireScopes); + usedScopes = result.scopes; + session = result.session; + isNew = result.isNew; } catch (e) { - if (getAuthSessionOptions.forceNewSession && (e.message === 'User did not consent to login.')) { + this._scopes = oldScopes; + this._scopesEnterprise = oldEnterpriseScopes; + const userCanceld = (e.message === 'User did not consent to login.'); + if (userCanceld) { + authResult.canceled = true; + } + if (getAuthSessionOptions.forceNewSession && userCanceld) { // There are cases where a forced login may not be 100% needed, so just continue as usual if // the user didn't consent to the login prompt. } else { @@ -93,7 +144,7 @@ export class CredentialStore implements vscode.Disposable { } if (session) { - if (authProviderId === AuthProvider.github) { + if (!isEnterprise(authProviderId)) { this._sessionId = session.id; } else { this._enterpriseSessionId = session.id; @@ -105,35 +156,51 @@ export class CredentialStore implements vscode.Disposable { if ((e.message === 'Bad credentials') && !getAuthSessionOptions.forceNewSession) { getAuthSessionOptions.forceNewSession = true; getAuthSessionOptions.silent = false; - return this.initialize(authProviderId, getAuthSessionOptions); + return this.initialize(authProviderId, getAuthSessionOptions, scopes, requireScopes); } } - if (authProviderId === AuthProvider.github) { + if (!isEnterprise(authProviderId)) { this._githubAPI = github; + this._scopes = usedScopes; } else { this._githubEnterpriseAPI = github; + this._scopesEnterprise = usedScopes; + } + await this.saveScopesInState(); + + if (!this._isInitialized || isNew) { + this._isInitialized = true; + this._onDidInitialize.fire(); } - if (github) { - await this.setCurrentUser(github); + if (isNew) { + /* __GDPR__ + "auth.session" : {} + */ + this._telemetry.sendTelemetryEvent('auth.session'); } - this._onDidInitialize.fire(); + return authResult; } else { Logger.debug(`No GitHub${getGitHubSuffix(authProviderId)} token found.`, 'Authentication'); + return authResult; } } - private async doCreate(options: vscode.AuthenticationGetSessionOptions) { - await this.initialize(AuthProvider.github, options); + private async doCreate(options: vscode.AuthenticationGetSessionOptions, additionalScopes: boolean = false): Promise { + const github = await this.initialize(AuthProvider.github, options, additionalScopes ? SCOPES_WITH_ADDITIONAL : undefined, additionalScopes); + let enterprise: AuthResult | undefined; if (hasEnterpriseUri()) { - await this.initialize(AuthProvider['github-enterprise'], options); + enterprise = await this.initialize(AuthProvider.githubEnterprise, options, additionalScopes ? SCOPES_WITH_ADDITIONAL : undefined, additionalScopes); } + return { + canceled: github.canceled || !!(enterprise && enterprise.canceled) + }; } - public async create(options: vscode.AuthenticationGetSessionOptions = {}) { - return this.doCreate(options); + public async create(options: vscode.AuthenticationGetSessionOptions = {}, additionalScopes: boolean = false) { + return this.doCreate(options, additionalScopes); } - public async recreate(reason?: string) { + public async recreate(reason?: string): Promise { return this.doCreate({ forceNewSession: reason ? { detail: reason } : true }); } @@ -144,25 +211,55 @@ export class CredentialStore implements vscode.Disposable { } public isAnyAuthenticated() { - return this.isAuthenticated(AuthProvider.github) || this.isAuthenticated(AuthProvider['github-enterprise']); + return this.isAuthenticated(AuthProvider.github) || this.isAuthenticated(AuthProvider.githubEnterprise); } public isAuthenticated(authProviderId: AuthProvider): boolean { - if (authProviderId === AuthProvider.github) { + if (!isEnterprise(authProviderId)) { return !!this._githubAPI; } return !!this._githubEnterpriseAPI; } + public isAuthenticatedWithAdditionalScopes(authProviderId: AuthProvider): boolean { + if (!isEnterprise(authProviderId)) { + return !!this._githubAPI && this.allScopesIncluded(this._scopes, SCOPES_WITH_ADDITIONAL); + } + return !!this._githubEnterpriseAPI && this.allScopesIncluded(this._scopesEnterprise, SCOPES_WITH_ADDITIONAL); + } + public getHub(authProviderId: AuthProvider): GitHub | undefined { - if (authProviderId === AuthProvider.github) { + if (!isEnterprise(authProviderId)) { return this._githubAPI; } return this._githubEnterpriseAPI; } + public areScopesOld(authProviderId: AuthProvider): boolean { + if (!isEnterprise(authProviderId)) { + return !this.allScopesIncluded(this._scopes, SCOPES_OLD); + } + return !this.allScopesIncluded(this._scopesEnterprise, SCOPES_OLD); + } + + public areScopesExtra(authProviderId: AuthProvider): boolean { + if (!isEnterprise(authProviderId)) { + return this.allScopesIncluded(this._scopes, SCOPES_WITH_ADDITIONAL); + } + return this.allScopesIncluded(this._scopesEnterprise, SCOPES_WITH_ADDITIONAL); + } + + public async getHubEnsureAdditionalScopes(authProviderId: AuthProvider): Promise { + const hasScopesAlready = this.isAuthenticatedWithAdditionalScopes(authProviderId); + await this.initialize(authProviderId, { createIfNone: !hasScopesAlready }, SCOPES_WITH_ADDITIONAL, true); + if (!hasScopesAlready) { + this._onDidUpgradeSession.fire(); + } + return this.getHub(authProviderId); + } + public async getHubOrLogin(authProviderId: AuthProvider): Promise { - if (authProviderId === AuthProvider.github) { + if (!isEnterprise(authProviderId)) { return this._githubAPI ?? (await this.login(authProviderId)); } return this._githubEnterpriseAPI ?? (await this.login(authProviderId)); @@ -174,7 +271,7 @@ export class CredentialStore implements vscode.Disposable { } const result = await vscode.window.showInformationMessage( - `In order to use the Pull Requests functionality, you must sign in to GitHub${getGitHubSuffix(authProviderId)}`, + vscode.l10n.t('In order to use the Pull Requests functionality, you must sign in to GitHub{0}', getGitHubSuffix(authProviderId)), SIGNIN_COMMAND, IGNORE_COMMAND, ); @@ -198,33 +295,36 @@ export class CredentialStore implements vscode.Disposable { */ this._telemetry.sendTelemetryEvent('auth.start'); - const errorPrefix = `Error signing in to GitHub${getGitHubSuffix(authProviderId)}`; + const errorPrefix = vscode.l10n.t('Error signing in to GitHub{0}', getGitHubSuffix(authProviderId)); let retry: boolean = true; let octokit: GitHub | undefined = undefined; - - + const sessionOptions: vscode.AuthenticationGetSessionOptions = { createIfNone: true }; + let isCanceled: boolean = false; while (retry) { try { - const token = await this.getSessionOrLogin(authProviderId); - octokit = await this.createHub(token, authProviderId); + await this.initialize(authProviderId, sessionOptions); } catch (e) { - Logger.appendLine(`${errorPrefix}: ${e}`); + Logger.error(`${errorPrefix}: ${e}`); if (e instanceof Error && e.stack) { - Logger.appendLine(e.stack); + Logger.error(e.stack); + } + if (e.message === 'Cancelled') { + isCanceled = true; } } - - if (octokit) { + octokit = this.getHub(authProviderId); + if (octokit || isCanceled) { retry = false; } else { retry = (await vscode.window.showErrorMessage(errorPrefix, TRY_AGAIN, CANCEL)) === TRY_AGAIN; + if (retry) { + sessionOptions.forceNewSession = true; + sessionOptions.createIfNone = undefined; + } } } if (octokit) { - this._githubAPI = octokit; - await this.setCurrentUser(octokit); - /* __GDPR__ "auth.success" : {} */ @@ -239,66 +339,58 @@ export class CredentialStore implements vscode.Disposable { return octokit; } - public async showSamlMessageAndAuth() { - return this.recreate('GitHub Pull Requests and Issues requires that you provide SAML access to your organization when you sign in.'); + public async showSamlMessageAndAuth(organizations: string[]): Promise { + return this.recreate(vscode.l10n.t('GitHub Pull Requests and Issues requires that you provide SAML access to your organization ({0}) when you sign in.', organizations.join(', '))); } - public isCurrentUser(username: string): boolean { - return this._githubAPI?.currentUser?.login === username || this._githubEnterpriseAPI?.currentUser?.login == username; + public async isCurrentUser(username: string): Promise { + return (await this._githubAPI?.currentUser)?.login === username || (await this._githubEnterpriseAPI?.currentUser)?.login == username; } - public getCurrentUser(authProviderId: AuthProvider): OctokitCommon.PullsGetResponseUser { + public getCurrentUser(authProviderId: AuthProvider): Promise { const github = this.getHub(authProviderId); const octokit = github?.octokit; return (octokit && github?.currentUser)!; } - private async setCurrentUser(github: GitHub): Promise { - const user = await github.octokit.users.getAuthenticated({}); - github.currentUser = user.data; + private setCurrentUser(github: GitHub): void { + github.currentUser = new Promise(resolve => { + github.octokit.call(github.octokit.api.users.getAuthenticated, {}).then(result => { + resolve(convertRESTUserToAccount(result.data)); + }); + }); } - private async getSession(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions) { - let session: vscode.AuthenticationSession | undefined = await vscode.authentication.getSession(authProviderId, SCOPES, { silent: true }); - if (session) { - return session; - } - - if (getAuthSessionOptions.createIfNone) { - const silent = getAuthSessionOptions.silent; - getAuthSessionOptions.createIfNone = false; - getAuthSessionOptions.silent = true; - session = await vscode.authentication.getSession(authProviderId, SCOPES_OLD, getAuthSessionOptions); - if (!session) { - getAuthSessionOptions.createIfNone = true; - getAuthSessionOptions.silent = silent; - session = await vscode.authentication.getSession(authProviderId, SCOPES, getAuthSessionOptions); - } - } else { - session = await vscode.authentication.getSession(authProviderId, SCOPES_OLD, getAuthSessionOptions); + private async getSession(authProviderId: AuthProvider, getAuthSessionOptions: vscode.AuthenticationGetSessionOptions, scopes: string[], requireScopes: boolean): Promise<{ session: vscode.AuthenticationSession | undefined, isNew: boolean, scopes: string[] }> { + const existingSession = (getAuthSessionOptions.forceNewSession || requireScopes) ? undefined : await this.findExistingScopes(authProviderId); + if (existingSession?.session) { + return { session: existingSession.session, isNew: false, scopes: existingSession.scopes }; } - return session; + const session = await vscode.authentication.getSession(authProviderId, requireScopes ? scopes : SCOPES_OLD, getAuthSessionOptions); + return { session, isNew: !!session, scopes: requireScopes ? scopes : SCOPES_OLD }; } - private async getSessionOrLogin(authProviderId: AuthProvider): Promise { - const session = (await this.getSession(authProviderId, { createIfNone: true }))!; - if (authProviderId === AuthProvider.github) { - this._sessionId = session.id; - } else { - this._enterpriseSessionId = session.id; + private async findExistingScopes(authProviderId: AuthProvider): Promise<{ session: vscode.AuthenticationSession, scopes: string[] } | undefined> { + const scopesInPreferenceOrder = [SCOPES_WITH_ADDITIONAL, SCOPES_OLD, SCOPES_OLDEST]; + for (const scopes of scopesInPreferenceOrder) { + const session = await vscode.authentication.getSession(authProviderId, scopes, { silent: true }); + if (session) { + return { session, scopes }; + } } - return session.accessToken; } private async createHub(token: string, authProviderId: AuthProvider): Promise { let baseUrl = 'https://api.github.com'; let enterpriseServerUri: vscode.Uri | undefined; - if (authProviderId === AuthProvider['github-enterprise']) { + if (isEnterprise(authProviderId)) { enterpriseServerUri = getEnterpriseUri(); } - if (enterpriseServerUri) { + if (enterpriseServerUri && enterpriseServerUri.authority.endsWith('ghe.com')) { + baseUrl = `${enterpriseServerUri.scheme}://api.${enterpriseServerUri.authority}`; + } else if (enterpriseServerUri) { baseUrl = `${enterpriseServerUri.scheme}://${enterpriseServerUri.authority}/api/v3`; } @@ -338,11 +430,12 @@ export class CredentialStore implements vscode.Disposable { }, }); + const rateLogger = new RateLogger(this._telemetry, isEnterprise(authProviderId)); const github: GitHub = { - octokit, - graphql, + octokit: new LoggingOctokit(octokit, rateLogger), + graphql: new LoggingApolloClient(graphql, rateLogger), }; - await this.setCurrentUser(github); + this.setCurrentUser(github); return github; } @@ -367,5 +460,5 @@ const link = (url: string, token: string) => ); function getGitHubSuffix(authProviderId: AuthProvider) { - return authProviderId === AuthProvider.github ? '' : ' Enterprise'; + return !isEnterprise(authProviderId) ? '' : ' Enterprise'; } diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index 3ab0a1bb70..04ff68402e 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -3,38 +3,51 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as path from 'path'; +import { bulkhead } from 'cockatiel'; import * as vscode from 'vscode'; -import type { Branch, Repository, UpstreamRef } from '../api/api'; -import { GitApiImpl, GitErrorCodes, RefType } from '../api/api1'; +import type { Branch, Commit, Repository, UpstreamRef } from '../api/api'; +import { GitApiImpl, GitErrorCodes } from '../api/api1'; import { GitHubManager } from '../authentication/githubServer'; +import { AuthProvider, GitHubServerType } from '../common/authentication'; +import { commands, contexts } from '../common/executeCommands'; import Logger from '../common/logger'; import { Protocol, ProtocolType } from '../common/protocol'; -import { parseRepositoryRemotes, Remote } from '../common/remote'; -import { ISessionState } from '../common/sessionState'; +import { GitHubRemote, parseRemote, parseRepositoryRemotes, Remote } from '../common/remote'; +import { + ALLOW_FETCH, + AUTO_STASH, + DEFAULT_MERGE_METHOD, + GIT, + PR_SETTINGS_NAMESPACE, + PULL_BEFORE_CHECKOUT, + PULL_BRANCH, + REMOTES, + UPSTREAM_REMOTE, +} from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; -import { EventType, TimelineEvent } from '../common/timelineEvent'; -import { fromPRUri, Schemes } from '../common/uri'; -import { compareIgnoreCase, formatError, Predicate } from '../common/utils'; -import { EXTENSION_ID } from '../constants'; -import { REPO_KEYS, ReposState } from '../extensionState'; -import { UserCompletion, userMarkdown } from '../issues/util'; +import { EventType } from '../common/timelineEvent'; +import { Schemes } from '../common/uri'; +import { formatError, Predicate } from '../common/utils'; +import { PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; +import { NEVER_SHOW_PULL_NOTIFICATION, REPO_KEYS, ReposState } from '../extensionState'; +import { git } from '../gitProviders/gitCommands'; import { OctokitCommon } from './common'; -import { AuthProvider, CredentialStore } from './credentials'; -import { GitHubRepository, ItemsData, PullRequestData, ViewerPermission } from './githubRepository'; +import { CredentialStore } from './credentials'; +import { GitHubRepository, ItemsData, PullRequestData, TeamReviewerRefreshKind, ViewerPermission } from './githubRepository'; import { PullRequestState, UserResponse } from './graphql'; -import { IAccount, ILabel, IPullRequestsPagingOptions, PRType, RepoAccessAndMergeMethods, User } from './interface'; +import { IAccount, ILabel, IMilestone, IProject, IPullRequestsPagingOptions, Issue, ITeam, MergeMethod, PRType, RepoAccessAndMergeMethods, User } from './interface'; import { IssueModel } from './issueModel'; import { MilestoneModel } from './milestoneModel'; import { PullRequestGitHelper, PullRequestMetadata } from './pullRequestGitHelper'; -import { PullRequestModel } from './pullRequestModel'; +import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; import { convertRESTIssueToRawPullRequest, convertRESTPullRequestToRawPullRequest, - convertRESTUserToAccount, - getRelatedUsersFromTimelineEvents, + getOverrideBranch, loginComparator, parseGraphQLUser, + teamComparator, + variableSubstitution, } from './utils'; interface PageInformation { @@ -54,7 +67,7 @@ export class NoGitHubReposError extends Error { } get message() { - return `${this.repository.rootUri.toString()} has no GitHub remotes`; + return vscode.l10n.t('{0} has no GitHub remotes', this.repository.rootUri.toString()); } } @@ -64,7 +77,7 @@ export class DetachedHeadError extends Error { } get message() { - return `${this.repository.rootUri.toString()} has a detached HEAD (create a branch first)`; + return vscode.l10n.t('{0} has a detached HEAD (create a branch first', this.repository.rootUri.toString()); } } @@ -79,13 +92,10 @@ export class BadUpstreamError extends Error { branchName, problem, } = this; - return `The upstream ref ${remote}/${name} for branch ${branchName} ${problem}.`; + return vscode.l10n.t('The upstream ref {0} for branch {1} {2}.', `${remote}/${name}`, branchName, problem); } } -export const SETTINGS_NAMESPACE = 'githubPullRequests'; -export const REMOTES_SETTING = 'remotes'; - export const ReposManagerStateContext: string = 'ReposManagerStateContext'; export enum ReposManagerState { @@ -109,6 +119,8 @@ enum PagedDataType { IssueSearch, } +const CACHED_TEMPLATE_URI = 'templateUri'; + export class FolderRepositoryManager implements vscode.Disposable { static ID = 'FolderRepositoryManager'; @@ -116,20 +128,23 @@ export class FolderRepositoryManager implements vscode.Disposable { private _activePullRequest?: PullRequestModel; private _activeIssue?: IssueModel; private _githubRepositories: GitHubRepository[]; - private _allGitHubRemotes: Remote[] = []; + private _allGitHubRemotes: GitHubRemote[] = []; private _mentionableUsers?: { [key: string]: IAccount[] }; private _fetchMentionableUsersPromise?: Promise<{ [key: string]: IAccount[] }>; private _assignableUsers?: { [key: string]: IAccount[] }; + private _teamReviewers?: { [key: string]: ITeam[] }; private _fetchAssignableUsersPromise?: Promise<{ [key: string]: IAccount[] }>; + private _fetchTeamReviewersPromise?: Promise<{ [key: string]: ITeam[] }>; private _gitBlameCache: { [key: string]: string } = {}; private _githubManager: GitHubManager; private _repositoryPageInformation: Map = new Map(); + private _addedUpstreamCount: number = 0; private _onDidMergePullRequest = new vscode.EventEmitter(); readonly onDidMergePullRequest = this._onDidMergePullRequest.event; - private _onDidChangeActivePullRequest = new vscode.EventEmitter(); - readonly onDidChangeActivePullRequest: vscode.Event = this._onDidChangeActivePullRequest.event; + private _onDidChangeActivePullRequest = new vscode.EventEmitter<{ new: number | undefined, old: number | undefined }>(); + readonly onDidChangeActivePullRequest: vscode.Event<{ new: number | undefined, old: number | undefined }> = this._onDidChangeActivePullRequest.event; private _onDidChangeActiveIssue = new vscode.EventEmitter(); readonly onDidChangeActiveIssue: vscode.Event = this._onDidChangeActiveIssue.event; @@ -142,13 +157,19 @@ export class FolderRepositoryManager implements vscode.Disposable { private _onDidChangeAssignableUsers = new vscode.EventEmitter(); readonly onDidChangeAssignableUsers: vscode.Event = this._onDidChangeAssignableUsers.event; + private _onDidChangeGithubRepositories = new vscode.EventEmitter(); + readonly onDidChangeGithubRepositories: vscode.Event = this._onDidChangeGithubRepositories.event; + + private _onDidDispose = new vscode.EventEmitter(); + readonly onDidDispose: vscode.Event = this._onDidDispose.event; + constructor( + private _id: number, public context: vscode.ExtensionContext, private _repository: Repository, public readonly telemetry: ITelemetry, private _git: GitApiImpl, private _credentialStore: CredentialStore, - private readonly _sessionState: ISessionState ) { this._subs = []; this._githubRepositories = []; @@ -156,7 +177,7 @@ export class FolderRepositoryManager implements vscode.Disposable { this._subs.push( vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.${REMOTES_SETTING}`)) { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${REMOTES}`)) { await this.updateRepositories(); } }), @@ -164,8 +185,6 @@ export class FolderRepositoryManager implements vscode.Disposable { this._subs.push(_credentialStore.onDidInitialize(() => this.updateRepositories())); - this.setUpCompletionItemProvider(); - this.cleanStoredRepoState(); } @@ -187,29 +206,61 @@ export class FolderRepositoryManager implements vscode.Disposable { } } + private get id(): string { + return `${FolderRepositoryManager.ID}+${this._id}`; + } + get gitHubRepositories(): GitHubRepository[] { return this._githubRepositories; } - private computeAllGitHubRemotes(): Promise { + public async computeAllUnknownRemotes(): Promise { const remotes = parseRepositoryRemotes(this.repository); const potentialRemotes = remotes.filter(remote => remote.host); - return Promise.all( + const serverTypes = await Promise.all( potentialRemotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), - ) - .then(results => potentialRemotes.filter((_, index, __) => results[index])) - .catch(e => { - Logger.appendLine(`Resolving GitHub remotes failed: ${e}`); - vscode.window.showErrorMessage(`Resolving GitHub remotes failed: ${formatError(e)}`); - return []; - }); + ).catch(e => { + Logger.error(`Resolving GitHub remotes failed: ${e}`); + vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); + return []; + }); + const unknownRemotes: Remote[] = []; + let i = 0; + for (const potentialRemote of potentialRemotes) { + if (serverTypes[i] === GitHubServerType.None) { + unknownRemotes.push(potentialRemote); + } + i++; + } + return unknownRemotes; + } + + public async computeAllGitHubRemotes(): Promise { + const remotes = parseRepositoryRemotes(this.repository); + const potentialRemotes = remotes.filter(remote => remote.host); + const serverTypes = await Promise.all( + potentialRemotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), + ).catch(e => { + Logger.error(`Resolving GitHub remotes failed: ${e}`); + vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); + return []; + }); + const githubRemotes: GitHubRemote[] = []; + let i = 0; + for (const potentialRemote of potentialRemotes) { + if (serverTypes[i] !== GitHubServerType.None) { + githubRemotes.push(GitHubRemote.remoteAsGitHub(potentialRemote, serverTypes[i])); + } + i++; + } + return githubRemotes; } - public async getActiveGitHubRemotes(allGitHubRemotes: Remote[]): Promise { - const remotesSetting = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(REMOTES_SETTING); + public async getActiveGitHubRemotes(allGitHubRemotes: GitHubRemote[]): Promise { + const remotesSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); if (!remotesSetting) { - Logger.appendLine(`Unable to read remotes setting`); + Logger.error(`Unable to read remotes setting`); return Promise.resolve([]); } @@ -218,222 +269,16 @@ export class FolderRepositoryManager implements vscode.Disposable { }); if (missingRemotes.length === remotesSetting.length) { - Logger.appendLine(`No remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`); + Logger.warn(`No remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`); } else { - Logger.debug(`Not all remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`, FolderRepositoryManager.ID); + Logger.debug(`Not all remotes found. The following remotes are missing: ${missingRemotes.join(', ')}`, this.id); } - Logger.debug(`Displaying configured remotes: ${remotesSetting.join(', ')}`, FolderRepositoryManager.ID); + Logger.debug(`Displaying configured remotes: ${remotesSetting.join(', ')}`, this.id); return remotesSetting .map(remote => allGitHubRemotes.find(repo => repo.remoteName === remote)) - .filter((repo: Remote | undefined): repo is Remote => !!repo); - } - - public setUpCompletionItemProvider() { - let lastPullRequest: PullRequestModel | undefined = undefined; - let lastPullRequestTimelineEvents: TimelineEvent[] = []; - let cachedUsers: UserCompletion[] = []; - - vscode.languages.registerCompletionItemProvider( - { scheme: 'comment' }, - { - provideCompletionItems: async (document, position, _token) => { - try { - const query = JSON.parse(document.uri.query); - if (compareIgnoreCase(query.extensionId, EXTENSION_ID) !== 0) { - return; - } - - const wordRange = document.getWordRangeAtPosition( - position, - /@([a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})?/i, - ); - if (!wordRange || wordRange.isEmpty) { - return; - } - - let prRelatedusers: { login: string; name?: string }[] = []; - const fileRelatedUsersNames: { [key: string]: boolean } = {}; - let mentionableUsers: { [key: string]: { login: string; name?: string }[] } = {}; - let prNumber: number | undefined; - let remoteName: string | undefined; - - const activeTextEditors = vscode.window.visibleTextEditors; - if (activeTextEditors.length) { - const visiblePREditor = activeTextEditors.find( - editor => editor.document.uri.scheme === Schemes.Pr, - ); - - if (visiblePREditor) { - const params = fromPRUri(visiblePREditor.document.uri); - prNumber = params!.prNumber; - remoteName = params!.remoteName; - } else if (this._activePullRequest) { - prNumber = this._activePullRequest.number; - remoteName = this._activePullRequest.remote.remoteName; - } - - if (lastPullRequest && prNumber && prNumber === lastPullRequest.number) { - return cachedUsers; - } - } - - const prRelatedUsersPromise = new Promise(async resolve => { - if (prNumber && remoteName) { - Logger.debug('get Timeline Events and parse users', FolderRepositoryManager.ID); - if (lastPullRequest && lastPullRequest.number === prNumber) { - return lastPullRequestTimelineEvents; - } - - const githubRepo = this._githubRepositories.find( - repo => repo.remote.remoteName === remoteName, - ); - - if (githubRepo) { - lastPullRequest = await githubRepo.getPullRequest(prNumber); - lastPullRequestTimelineEvents = await lastPullRequest!.getTimelineEvents(); - } - - prRelatedusers = getRelatedUsersFromTimelineEvents(lastPullRequestTimelineEvents); - resolve(); - } - - resolve(); - }); - - const fileRelatedUsersNamesPromise = new Promise(async resolve => { - if (activeTextEditors.length) { - try { - Logger.debug('git blame and parse users', FolderRepositoryManager.ID); - const fsPath = path.resolve(activeTextEditors[0].document.uri.fsPath); - let blames: string | undefined; - if (this._gitBlameCache[fsPath]) { - blames = this._gitBlameCache[fsPath]; - } else { - blames = await this.repository.blame(fsPath); - this._gitBlameCache[fsPath] = blames; - } - - const blameLines = blames.split('\n'); - - for (const line of blameLines) { - const matches = /^\w{11} \S*\s*\((.*)\s*\d{4}\-/.exec(line); - - if (matches && matches.length === 2) { - const name = matches[1].trim(); - fileRelatedUsersNames[name] = true; - } - } - } catch (err) { - Logger.debug(err, FolderRepositoryManager.ID); - } - } - - resolve(); - }); - - const getMentionableUsersPromise = new Promise(async resolve => { - Logger.debug('get mentionable users', FolderRepositoryManager.ID); - mentionableUsers = await this.getMentionableUsers(); - resolve(); - }); - - await Promise.all([ - prRelatedUsersPromise, - fileRelatedUsersNamesPromise, - getMentionableUsersPromise, - ]); - - cachedUsers = []; - const prRelatedUsersMap: { [key: string]: { login: string; name?: string } } = {}; - Logger.debug('prepare user suggestions', FolderRepositoryManager.ID); - - prRelatedusers.forEach(user => { - if (!prRelatedUsersMap[user.login]) { - prRelatedUsersMap[user.login] = user; - } - }); - - const secondMap: { [key: string]: boolean } = {}; - - for (const mentionableUserGroup in mentionableUsers) { - for (const user of mentionableUsers[mentionableUserGroup]) { - if (!prRelatedUsersMap[user.login] && !secondMap[user.login]) { - secondMap[user.login] = true; - - let priority = 2; - if ( - fileRelatedUsersNames[user.login] || - (user.name && fileRelatedUsersNames[user.name]) - ) { - priority = 1; - } - - if (prRelatedUsersMap[user.login]) { - priority = 0; - } - - cachedUsers.push({ - label: user.login, - insertText: user.login, - filterText: - `${user.login}` + - (user.name && user.name !== user.login - ? `_${user.name.toLowerCase().replace(' ', '_')}` - : ''), - sortText: `${priority}_${user.login}`, - detail: user.name, - kind: vscode.CompletionItemKind.User, - login: user.login, - uri: this.repository.rootUri, - }); - } - } - } - - for (const user in prRelatedUsersMap) { - if (!secondMap[user]) { - // if the mentionable api call fails partially, we should still populate related users from timeline events into the completion list - cachedUsers.push({ - label: prRelatedUsersMap[user].login, - insertText: `${prRelatedUsersMap[user].login}`, - filterText: - `${prRelatedUsersMap[user].login}` + - (prRelatedUsersMap[user].name && - prRelatedUsersMap[user].name !== prRelatedUsersMap[user].login - ? `_${prRelatedUsersMap[user].name!.toLowerCase().replace(' ', '_')}` - : ''), - sortText: `0_${prRelatedUsersMap[user].login}`, - detail: prRelatedUsersMap[user].name, - kind: vscode.CompletionItemKind.User, - login: prRelatedUsersMap[user].login, - uri: this.repository.rootUri, - }); - } - } - - Logger.debug('done', FolderRepositoryManager.ID); - return cachedUsers; - } catch (e) { - return []; - } - }, - resolveCompletionItem: async (item: vscode.CompletionItem, _token: vscode.CancellationToken) => { - try { - const repo = await this.getPullRequestDefaults(); - const user: User | undefined = await this.resolveUser(repo.owner, repo.repo, (typeof item.label === 'string') ? item.label : item.label.label); - if (user) { - item.documentation = userMarkdown(repo, user); - } - } catch (e) { - // The user might not be resolvable in the repo, since users from outside the repo are included in the list. - } - return item; - }, - }, - '@', - ); + .filter((repo: GitHubRemote | undefined): repo is GitHubRemote => !!repo); } get activeIssue(): IssueModel | undefined { @@ -450,16 +295,22 @@ export class FolderRepositoryManager implements vscode.Disposable { } set activePullRequest(pullRequest: PullRequestModel | undefined) { + if (pullRequest === this._activePullRequest) { + return; + } + const oldNumber = this._activePullRequest?.number; if (this._activePullRequest) { this._activePullRequest.isActive = false; } if (pullRequest) { pullRequest.isActive = true; + pullRequest.githubRepository.commentsHandler?.unregisterCommentController(pullRequest.number); } + const newNumber = pullRequest?.number; this._activePullRequest = pullRequest; - this._onDidChangeActivePullRequest.fire(); + this._onDidChangeActivePullRequest.fire({ old: oldNumber, new: newNumber }); } get repository(): Repository { @@ -474,6 +325,25 @@ export class FolderRepositoryManager implements vscode.Disposable { return this._credentialStore; } + /** + * Using these contexts is fragile in a multi-root workspace where multiple PRs are checked out. + * If you have two active PRs that have the same file path relative to their rootdir, then these context can get confused. + */ + public setFileViewedContext() { + const states = this.activePullRequest?.getViewedFileStates(); + if (states) { + commands.setContext(contexts.VIEWED_FILES, Array.from(states.viewed)); + commands.setContext(contexts.UNVIEWED_FILES, Array.from(states.unviewed)); + } else { + this.clearFileViewedContext(); + } + } + + private clearFileViewedContext() { + commands.setContext(contexts.VIEWED_FILES, []); + commands.setContext(contexts.UNVIEWED_FILES, []); + } + public async loginAndUpdate() { if (!this._credentialStore.isAnyAuthenticated()) { const waitForRepos = new Promise(c => { @@ -487,16 +357,15 @@ export class FolderRepositoryManager implements vscode.Disposable { } } - private async getActiveRemotes(): Promise { + private async getActiveRemotes(): Promise { this._allGitHubRemotes = await this.computeAllGitHubRemotes(); const activeRemotes = await this.getActiveGitHubRemotes(this._allGitHubRemotes); if (activeRemotes.length) { await vscode.commands.executeCommand('setContext', 'github:hasGitHubRemotes', true); - Logger.appendLine('Found GitHub remote'); + Logger.appendLine(`Found GitHub remote for folder ${this.repository.rootUri.fsPath}`); } else { - await vscode.commands.executeCommand('setContext', 'github:hasGitHubRemotes', false); - Logger.appendLine('No GitHub remotes found'); + Logger.appendLine(`No GitHub remotes found for folder ${this.repository.rootUri.fsPath}`); } return activeRemotes; @@ -511,6 +380,31 @@ export class FolderRepositoryManager implements vscode.Disposable { return this._updatingRepositories; } + private checkForAuthMatch(activeRemotes: GitHubRemote[]): boolean { + // Check that our auth matches the remote. + let dotComCount = 0; + let enterpriseCount = 0; + for (const remote of activeRemotes) { + if (remote.githubServerType === GitHubServerType.GitHubDotCom) { + dotComCount++; + } else if (remote.githubServerType === GitHubServerType.Enterprise) { + enterpriseCount++; + } + } + + let isAuthenticated = this._credentialStore.isAuthenticated(AuthProvider.github) || this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise); + if ((dotComCount > 0) && this._credentialStore.isAuthenticated(AuthProvider.github)) { + // good + } else if ((enterpriseCount > 0) && this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { + // also good + } else if (isAuthenticated) { + // Not good. We have a mismatch between auth type and server type. + isAuthenticated = false; + } + vscode.commands.executeCommand('setContext', 'github:authenticated', isAuthenticated); + return isAuthenticated; + } + private async doUpdateRepositories(silent: boolean): Promise { if (this._git.state === 'uninitialized') { Logger.appendLine('Cannot updates repositories as git is uninitialized'); @@ -519,24 +413,39 @@ export class FolderRepositoryManager implements vscode.Disposable { } const activeRemotes = await this.getActiveRemotes(); - const isAuthenticated = this._credentialStore.isAuthenticated(AuthProvider.github) || this._credentialStore.isAuthenticated(AuthProvider['github-enterprise']); - vscode.commands.executeCommand('setContext', 'github:authenticated', isAuthenticated); - + const isAuthenticated = this.checkForAuthMatch(activeRemotes); + if (this.credentialStore.isAnyAuthenticated() && (activeRemotes.length === 0)) { + const areAllNeverGitHub = (await this.computeAllUnknownRemotes()).every(remote => GitHubManager.isNeverGitHub(vscode.Uri.parse(remote.normalizedHost).authority)); + if (areAllNeverGitHub) { + this._onDidLoadRepositories.fire(ReposManagerState.RepositoriesLoaded); + return; + } + } const repositories: GitHubRepository[] = []; const resolveRemotePromises: Promise[] = []; const oldRepositories: GitHubRepository[] = []; this._githubRepositories.forEach(repo => oldRepositories.push(repo)); const authenticatedRemotes = activeRemotes.filter(remote => this._credentialStore.isAuthenticated(remote.authProviderId)); - authenticatedRemotes.forEach(remote => { - const repository = this.createGitHubRepository(remote, this._credentialStore); + for (const remote of authenticatedRemotes) { + const repository = await this.createGitHubRepository(remote, this._credentialStore); resolveRemotePromises.push(repository.resolveRemote()); repositories.push(repository); - }); + } return Promise.all(resolveRemotePromises).then(async (remoteResults: boolean[]) => { - if (remoteResults.some(value => !value)) { - return this._credentialStore.showSamlMessageAndAuth(); + const missingSaml: string[] = []; + for (let i = 0; i < remoteResults.length; i++) { + if (!remoteResults[i]) { + missingSaml.push(repositories[i].remote.owner); + } + } + if (missingSaml.length > 0) { + const result = await this._credentialStore.showSamlMessageAndAuth(missingSaml); + if (result.canceled) { + this.dispose(); + return; + } } this._githubRepositories = repositories; @@ -548,6 +457,10 @@ export class FolderRepositoryManager implements vscode.Disposable { this._githubRepositories.some(newRepo => newRepo.remote.equals(oldRepo.remote)), ); + if (repositoriesChanged) { + this._onDidChangeGithubRepositories.fire(this._githubRepositories); + } + if (this._githubRepositories.length && repositoriesChanged) { if (await this.checkIfMissingUpstream()) { this.updateRepositories(silent); @@ -556,7 +469,7 @@ export class FolderRepositoryManager implements vscode.Disposable { } if (this.activePullRequest) { - this.getMentionableUsers(repositoriesChanged, true); + this.getMentionableUsers(repositoriesChanged); } this.getAssignableUsers(repositoriesChanged); @@ -576,7 +489,8 @@ export class FolderRepositoryManager implements vscode.Disposable { try { const origin = await this.getOrigin(); const metadata = await origin.getMetadata(); - if (metadata.fork && metadata.parent) { + const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + if (metadata.fork && metadata.parent && (configuration.get<'add' | 'never'>(UPSTREAM_REMOTE, 'add') === 'add')) { const parentUrl = new Protocol(metadata.parent.git_url); const missingParentRemote = !this._githubRepositories.some( repo => @@ -591,10 +505,21 @@ export class FolderRepositoryManager implements vscode.Disposable { // check the remotes to see what protocol is being used const isSSH = this.gitHubRepositories[0].remote.gitProtocol.type === ProtocolType.SSH; if (isSSH) { - await this.repository.addRemote(remoteName, metadata.parent.git_url); + await this.repository.addRemote(remoteName, metadata.parent.ssh_url); } else { await this.repository.addRemote(remoteName, metadata.parent.clone_url); } + this._addedUpstreamCount++; + if (this._addedUpstreamCount > 1) { + // We've already added this remote, which means the user likely removed it. Let the user know they can disable this feature. + const neverOption = vscode.l10n.t('Set to `never`'); + vscode.window.showInformationMessage(vscode.l10n.t('An `upstream` remote has been added for this repository. You can disable this feature by setting `githubPullRequests.upstreamRemote` to `never`.'), neverOption) + .then(choice => { + if (choice === neverOption) { + configuration.update(UPSTREAM_REMOTE, 'never', vscode.ConfigurationTarget.Global); + } + }); + } return true; } } @@ -619,28 +544,59 @@ export class FolderRepositoryManager implements vscode.Disposable { return undefined; } - private getMentionableUsersFromGlobalState(): { [key: string]: IAccount[] } | undefined { - Logger.appendLine('Trying to use globalState for mentionable users.'); - const reposState = this.context.globalState.get(REPO_KEYS); - if (reposState) { - const cache: { [key: string]: IAccount[] } = {}; - const hasAllRepos = this._githubRepositories.every(repo => { - const key = `${repo.remote.owner}/${repo.remote.repositoryName}`; - if (!reposState.repos[key]) { - return false; + private async getCachedFromGlobalState(userKind: 'assignableUsers' | 'teamReviewers' | 'mentionableUsers' | 'orgProjects'): Promise<{ [key: string]: T[] } | undefined> { + Logger.appendLine(`Trying to use globalState for ${userKind}.`); + + const usersCacheLocation = vscode.Uri.joinPath(this.context.globalStorageUri, userKind); + let usersCacheExists; + try { + usersCacheExists = await vscode.workspace.fs.stat(usersCacheLocation); + } catch (e) { + // file doesn't exit + } + if (!usersCacheExists) { + Logger.appendLine(`GlobalState does not exist for ${userKind}.`); + return undefined; + } + + const cache: { [key: string]: T[] } = {}; + const hasAllRepos = (await Promise.all(this._githubRepositories.map(async (repo) => { + const key = `${repo.remote.owner}/${repo.remote.repositoryName}.json`; + const repoSpecificFile = vscode.Uri.joinPath(usersCacheLocation, key); + let repoSpecificCache; + let cacheAsJson; + try { + repoSpecificCache = await vscode.workspace.fs.readFile(repoSpecificFile); + cacheAsJson = JSON.parse(repoSpecificCache.toString()); + } catch (e) { + if (e instanceof Error && e.message.includes('Unexpected non-whitespace character after JSON')) { + Logger.error(`Error parsing ${userKind} cache for ${repo.remote.remoteName}.`); } - cache[repo.remote.repositoryName] = reposState.repos[key].mentionableUsers ?? []; + // file doesn't exist + } + if (repoSpecificCache && repoSpecificCache.toString()) { + cache[repo.remote.remoteName] = cacheAsJson ?? []; return true; - }); - if (hasAllRepos) { - Logger.appendLine(`Using globalState mentionable users for ${Object.keys(cache).length}.`); - return cache; } + }))).every(value => value); + if (hasAllRepos) { + Logger.appendLine(`Using globalState ${userKind} for ${Object.keys(cache).length}.`); + return cache; } - Logger.appendLine(`No globalState for mentionable users.`); + + Logger.appendLine(`No globalState for ${userKind}.`); return undefined; } + private async saveInGlobalState(userKind: 'assignableUsers' | 'teamReviewers' | 'mentionableUsers' | 'orgProjects', cache: { [key: string]: T[] }): Promise { + const cacheLocation = vscode.Uri.joinPath(this.context.globalStorageUri, userKind); + await Promise.all(this._githubRepositories.map(async (repo) => { + const key = `${repo.remote.owner}/${repo.remote.repositoryName}.json`; + const repoSpecificFile = vscode.Uri.joinPath(cacheLocation, key); + await vscode.workspace.fs.writeFile(repoSpecificFile, new TextEncoder().encode(JSON.stringify(cache[repo.remote.remoteName]))); + })); + } + private createFetchMentionableUsersPromise(): Promise<{ [key: string]: IAccount[] }> { const cache: { [key: string]: IAccount[] } = {}; return new Promise<{ [key: string]: IAccount[] }>(resolve => { @@ -653,34 +609,23 @@ export class FolderRepositoryManager implements vscode.Disposable { Promise.all(promises).then(() => { this._mentionableUsers = cache; this._fetchMentionableUsersPromise = undefined; - const globalReposState = this.context.globalState.get(REPO_KEYS, { repos: {} }); - this._githubRepositories.forEach(repo => { - const key = `${repo.remote.owner}/${repo.remote.repositoryName}`; - if (!globalReposState.repos[key]) { - globalReposState.repos[key] = {}; - } - globalReposState.repos[key].mentionableUsers = cache[repo.remote.remoteName]; - globalReposState.repos[key].stateModifiedTime = new Date().valueOf(); - }); - this.context.globalState.update(REPO_KEYS, globalReposState); - resolve(cache); + this.saveInGlobalState('mentionableUsers', cache) + .then(() => resolve(cache)); }); }); } - async getMentionableUsers(clearCache?: boolean, useGlobalState?: boolean): Promise<{ [key: string]: IAccount[] }> { + async getMentionableUsers(clearCache?: boolean): Promise<{ [key: string]: IAccount[] }> { if (clearCache) { delete this._mentionableUsers; } if (this._mentionableUsers) { + Logger.appendLine('Using in-memory cached mentionable users.'); return this._mentionableUsers; } - let globalStateMentionableUsers = this.getMentionableUsersFromGlobalState(); - if (useGlobalState && !this._fetchMentionableUsersPromise && globalStateMentionableUsers) { - return globalStateMentionableUsers; - } + const globalStateMentionableUsers = await this.getCachedFromGlobalState('mentionableUsers'); if (!this._fetchMentionableUsersPromise) { this._fetchMentionableUsersPromise = this.createFetchMentionableUsersPromise(); @@ -696,13 +641,16 @@ export class FolderRepositoryManager implements vscode.Disposable { } if (this._assignableUsers) { + Logger.appendLine('Using in-memory cached assignable users.'); return this._assignableUsers; } + const globalStateAssignableUsers = await this.getCachedFromGlobalState('assignableUsers'); + if (!this._fetchAssignableUsersPromise) { const cache: { [key: string]: IAccount[] } = {}; const allAssignableUsers: IAccount[] = []; - return (this._fetchAssignableUsersPromise = new Promise(resolve => { + this._fetchAssignableUsersPromise = new Promise(resolve => { const promises = this._githubRepositories.map(async githubRepository => { const data = await githubRepository.getAssignableUsers(); cache[githubRepository.remote.remoteName] = data.sort(loginComparator); @@ -713,33 +661,140 @@ export class FolderRepositoryManager implements vscode.Disposable { Promise.all(promises).then(() => { this._assignableUsers = cache; this._fetchAssignableUsersPromise = undefined; + this.saveInGlobalState('assignableUsers', cache); resolve(cache); this._onDidChangeAssignableUsers.fire(allAssignableUsers); }); - })); + }); + return globalStateAssignableUsers ?? this._fetchAssignableUsersPromise; } return this._fetchAssignableUsersPromise; } + async getTeamReviewers(refreshKind: TeamReviewerRefreshKind): Promise<{ [key: string]: ITeam[] }> { + if (refreshKind === TeamReviewerRefreshKind.Force) { + delete this._teamReviewers; + } + + if (this._teamReviewers) { + Logger.appendLine('Using in-memory cached team reviewers.'); + return this._teamReviewers; + } + + const globalStateTeamReviewers = (refreshKind === TeamReviewerRefreshKind.Force) ? undefined : await this.getCachedFromGlobalState('teamReviewers'); + if (globalStateTeamReviewers) { + this._teamReviewers = globalStateTeamReviewers; + return globalStateTeamReviewers || {}; + } + + if (!this._fetchTeamReviewersPromise) { + const cache: { [key: string]: ITeam[] } = {}; + return (this._fetchTeamReviewersPromise = new Promise(async (resolve) => { + // Keep track of the org teams we have already gotten so we don't make duplicate calls + const orgTeams: Map = new Map(); + // Go through one github repo at a time so that we don't make overlapping auth calls + for (const githubRepository of this._githubRepositories) { + if (!orgTeams.has(githubRepository.remote.owner)) { + try { + const data = await githubRepository.getOrgTeams(refreshKind); + orgTeams.set(githubRepository.remote.owner, data); + } catch (e) { + break; + } + } + const allTeamsForOrg = orgTeams.get(githubRepository.remote.owner) ?? []; + cache[githubRepository.remote.remoteName] = allTeamsForOrg.filter(team => team.repositoryNames.includes(githubRepository.remote.repositoryName)).sort(teamComparator); + } + + this._teamReviewers = cache; + this._fetchTeamReviewersPromise = undefined; + this.saveInGlobalState('teamReviewers', cache); + resolve(cache); + })); + } + + return this._fetchTeamReviewersPromise; + } + + private createFetchOrgProjectsPromise(): Promise<{ [key: string]: IProject[] }> { + const cache: { [key: string]: IProject[] } = {}; + return new Promise<{ [key: string]: IProject[] }>(async resolve => { + // Keep track of the org teams we have already gotten so we don't make duplicate calls + const orgProjects: Map = new Map(); + // Go through one github repo at a time so that we don't make overlapping auth calls + for (const githubRepository of this._githubRepositories) { + if (!orgProjects.has(githubRepository.remote.owner)) { + try { + const data = await githubRepository.getOrgProjects(); + orgProjects.set(githubRepository.remote.owner, data); + } catch (e) { + break; + } + } + cache[githubRepository.remote.remoteName] = orgProjects.get(githubRepository.remote.owner) ?? []; + } + + await this.saveInGlobalState('orgProjects', cache); + resolve(cache); + }); + } + + async getOrgProjects(clearCache?: boolean): Promise<{ [key: string]: IProject[] }> { + if (clearCache) { + return this.createFetchOrgProjectsPromise(); + } + + const globalStateProjects = await this.getCachedFromGlobalState('orgProjects'); + return globalStateProjects ?? this.createFetchOrgProjectsPromise(); + } + + async getOrgTeamsCount(repository: GitHubRepository): Promise { + if ((await repository.getMetadata()).organization) { + return repository.getOrgTeamsCount(); + } + return 0; + } + + async getPullRequestParticipants(githubRepository: GitHubRepository, pullRequestNumber: number): Promise<{ participants: IAccount[], viewer: IAccount }> { + return { + participants: await githubRepository.getPullRequestParticipants(pullRequestNumber), + viewer: await this.getCurrentUser(githubRepository) + }; + } + /** * Returns the remotes that are currently active, which is those that are important by convention (origin, upstream), * or the remotes configured by the setting githubPullRequests.remotes */ - getGitHubRemotes(): Remote[] { + async getGitHubRemotes(): Promise { const githubRepositories = this._githubRepositories; if (!githubRepositories || !githubRepositories.length) { return []; } - return githubRepositories.map(repository => repository.remote); + const remotes = githubRepositories.map(repo => repo.remote).flat(); + + const serverTypes = await Promise.all( + remotes.map(remote => this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), + ).catch(e => { + Logger.error(`Resolving GitHub remotes failed: ${e}`); + vscode.window.showErrorMessage(vscode.l10n.t('Resolving GitHub remotes failed: {0}', formatError(e))); + return []; + }); + + const githubRemotes = remotes.map((remote, index) => GitHubRemote.remoteAsGitHub(remote, serverTypes[index])); + if (this.checkForAuthMatch(githubRemotes)) { + return githubRemotes; + } + return []; } /** * Returns all remotes from the repository. */ - async getAllGitHubRemotes(): Promise { + async getAllGitHubRemotes(): Promise { return await this.computeAllGitHubRemotes(); } @@ -750,38 +805,45 @@ export class FolderRepositoryManager implements vscode.Disposable { return []; } - const localBranches = this.repository.state.refs - .filter(r => r.type === RefType.Head && r.name !== undefined) + const localBranches = (await this.repository.getRefs({ pattern: 'refs/heads/' })) + .filter(r => r.name !== undefined) .map(r => r.name!); - const promises = localBranches.map(async localBranchName => { - const matchingPRMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch( - this.repository, - localBranchName, - ); + // Chunk localBranches into chunks of 100 to avoid hitting the GitHub API rate limit + const chunkedLocalBranches: string[][] = []; + const chunkSize = 100; + for (let i = 0; i < localBranches.length; i += chunkSize) { + const chunk = localBranches.slice(i, i + chunkSize); + chunkedLocalBranches.push(chunk); + } - if (matchingPRMetadata) { - const { owner, prNumber } = matchingPRMetadata; - const githubRepo = githubRepositories.find( - repo => repo.remote.owner.toLocaleLowerCase() === owner.toLocaleLowerCase(), + const models: (PullRequestModel | undefined)[] = []; + for (const chunk of chunkedLocalBranches) { + models.push(...await Promise.all(chunk.map(async localBranchName => { + const matchingPRMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch( + this.repository, + localBranchName, ); - if (githubRepo) { - const pullRequest: PullRequestModel | undefined = await githubRepo.getPullRequest(prNumber); + if (matchingPRMetadata) { + const { owner, prNumber } = matchingPRMetadata; + const githubRepo = githubRepositories.find( + repo => repo.remote.owner.toLocaleLowerCase() === owner.toLocaleLowerCase(), + ); + + if (githubRepo) { + const pullRequest: PullRequestModel | undefined = await githubRepo.getPullRequest(prNumber); - if (pullRequest) { - pullRequest.localBranchName = localBranchName; - return pullRequest; + if (pullRequest) { + pullRequest.localBranchName = localBranchName; + return pullRequest; + } } } - } - - return Promise.resolve(null); - }); + }))); + } - return Promise.all(promises).then(values => { - return values.filter(value => value !== null) as PullRequestModel[]; - }); + return models.filter(value => value !== undefined) as PullRequestModel[]; } async getLabels(issue?: IssueModel, repoInfo?: { owner: string; repo: string }): Promise { @@ -800,7 +862,7 @@ export class FolderRepositoryManager implements vscode.Disposable { let results: ILabel[] = []; do { - const result = await octokit.issues.listLabelsForRepo({ + const result = await octokit.call(octokit.api.issues.listLabelsForRepo, { owner: remote.owner, repo: remote.repositoryName, per_page: 100, @@ -812,6 +874,7 @@ export class FolderRepositoryManager implements vscode.Disposable { return { name: label.name, color: label.color, + description: label.description ?? undefined }; }), ); @@ -880,7 +943,8 @@ export class FolderRepositoryManager implements vscode.Disposable { type: PRType = PRType.All, query?: string, ): Promise> { - if (!this._githubRepositories || !this._githubRepositories.length) { + const githubRepositoriesWithGitRemotes = pagedDataType === PagedDataType.PullRequest ? this._githubRepositories.filter(repo => this.repository.state.remotes.find(r => r.name === repo.remote.remoteName)) : this._githubRepositories; + if (!githubRepositoriesWithGitRemotes.length) { return { items: [], hasMorePages: false, @@ -891,7 +955,7 @@ export class FolderRepositoryManager implements vscode.Disposable { const getTotalFetchedPages = () => this.totalFetchedPages.get(queryId) || 0; const setTotalFetchedPages = (numPages: number) => this.totalFetchedPages.set(queryId, numPages); - for (const repository of this._githubRepositories) { + for (const repository of githubRepositoriesWithGitRemotes) { const remoteId = repository.remote.url.toString() + queryId; if (!this._repositoryPageInformation.get(remoteId)) { this._repositoryPageInformation.set(remoteId, { @@ -920,17 +984,26 @@ export class FolderRepositoryManager implements vscode.Disposable { for (let i = 0; i < githubRepositories.length; i++) { const githubRepository = githubRepositories[i]; const remoteId = githubRepository.remote.url.toString() + queryId; - const pageInformation = this._repositoryPageInformation.get(remoteId)!; + let storedPageInfo = this._repositoryPageInformation.get(remoteId); + if (!storedPageInfo) { + Logger.warn(`No page information for ${remoteId}`); + storedPageInfo = { pullRequestPage: 0, hasMorePages: null }; + this._repositoryPageInformation.set(remoteId, storedPageInfo); + } + const pageInformation = storedPageInfo; const fetchPage = async ( pageNumber: number, ): Promise<{ items: any[]; hasMorePages: boolean } | undefined> => { + // Resolve variables in the query with each repo + const resolvedQuery = query ? await variableSubstitution(query, undefined, + { base: await githubRepository.getDefaultBranch(), owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName }) : undefined; switch (pagedDataType) { case PagedDataType.PullRequest: { if (type === PRType.All) { return githubRepository.getAllPullRequests(pageNumber); } else { - return githubRepository.getPullRequestsForCategory(query || '', pageNumber); + return githubRepository.getPullRequestsForCategory(resolvedQuery || '', pageNumber); } } case PagedDataType.Milestones: { @@ -940,7 +1013,7 @@ export class FolderRepositoryManager implements vscode.Disposable { return githubRepository.getIssuesWithoutMilestone(pageInformation.pullRequestPage); } case PagedDataType.IssueSearch: { - return githubRepository.getIssues(pageInformation.pullRequestPage, query); + return githubRepository.getIssues(pageInformation.pullRequestPage, resolvedQuery); } } }; @@ -972,8 +1045,8 @@ export class FolderRepositoryManager implements vscode.Disposable { // OR we're fetching all (cases 1&3), and we've fetched as far as we had previously (or further, in case 1). if ( itemData.items.length && - (options.fetchNextPage === true || - (options.fetchNextPage === false && pagesFetched >= getTotalFetchedPages())) + (options.fetchNextPage || + ((options.fetchNextPage === false) && !options.fetchOnePagePerRepo && (pagesFetched >= getTotalFetchedPages()))) ) { if (getTotalFetchedPages() === 0) { // We're in case 1, manually set number of pages we looked through until we found first results. @@ -989,7 +1062,7 @@ export class FolderRepositoryManager implements vscode.Disposable { } return { - items: [], + items: itemData.items, hasMorePages: false, hasUnsearchedRepositories: false, }; @@ -1004,43 +1077,97 @@ export class FolderRepositoryManager implements vscode.Disposable { return this.fetchPagedData(options, queryId, PagedDataType.PullRequest, type, query); } - async getMilestones( + async getMilestoneIssues( options: IPullRequestsPagingOptions = { fetchNextPage: false }, includeIssuesWithoutMilestone: boolean = false, - query?: string, ): Promise> { - const milestones: ItemsResponseResult = await this.fetchPagedData( - options, - 'issuesKey', - PagedDataType.Milestones, - PRType.All, - query, - ); - if (includeIssuesWithoutMilestone) { - const additionalIssues: ItemsResponseResult = await this.fetchPagedData( + try { + const milestones: ItemsResponseResult = await this.fetchPagedData( options, - 'issuesKey', - PagedDataType.IssuesWithoutMilestone, - PRType.All, - query, + 'milestoneIssuesKey', + PagedDataType.Milestones, + PRType.All ); - milestones.items.push({ - milestone: { - createdAt: new Date(0).toDateString(), - id: '', - title: NO_MILESTONE, - }, - issues: additionalIssues.items, + if (includeIssuesWithoutMilestone) { + const additionalIssues: ItemsResponseResult = await this.fetchPagedData( + options, + 'noMilestoneIssuesKey', + PagedDataType.IssuesWithoutMilestone, + PRType.All + ); + milestones.items.push({ + milestone: { + createdAt: new Date(0).toDateString(), + id: '', + title: NO_MILESTONE, + number: -1 + }, + issues: await Promise.all(additionalIssues.items.map(async (issue) => { + const githubRepository = await this.getRepoForIssue(issue); + return new IssueModel(githubRepository, githubRepository.remote, issue); + })), + }); + } + return milestones; + } catch (e) { + Logger.error(`Error fetching milestone issues: ${e instanceof Error ? e.message : e}`, this.id); + return { hasMorePages: false, hasUnsearchedRepositories: false, items: [] }; + } + } + + async createMilestone(repository: GitHubRepository, milestoneTitle: string): Promise { + try { + const { data } = await repository.octokit.call(repository.octokit.api.issues.createMilestone, { + owner: repository.remote.owner, + repo: repository.remote.repositoryName, + title: milestoneTitle }); + return { + title: data.title, + dueOn: data.due_on, + createdAt: data.created_at, + id: data.node_id, + number: data.number + }; } - return milestones; + catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create a milestone\n{0}', formatError(e))); + return undefined; + } + } + + private async getRepoForIssue(parsedIssue: Issue): Promise { + const remote = new Remote( + parsedIssue.repositoryName!, + parsedIssue.repositoryUrl!, + new Protocol(parsedIssue.repositoryUrl!), + ); + return this.createGitHubRepository(remote, this.credentialStore, true, true); + } + /** + * Pull request defaults in the query, like owner and repository variables, will be resolved. + */ async getIssues( - options: IPullRequestsPagingOptions = { fetchNextPage: false }, query?: string, ): Promise> { - return this.fetchPagedData(options, 'issuesKey', PagedDataType.IssueSearch, PRType.All, query); + try { + const data = await this.fetchPagedData({ fetchNextPage: false, fetchOnePagePerRepo: false }, `issuesKey${query}`, PagedDataType.IssueSearch, PRType.All, query); + const mappedData: ItemsResponseResult = { + items: [], + hasMorePages: data.hasMorePages, + hasUnsearchedRepositories: data.hasUnsearchedRepositories + }; + for (const issue of data.items) { + const githubRepository = await this.getRepoForIssue(issue); + mappedData.items.push(new IssueModel(githubRepository, githubRepository.remote, issue)); + } + return mappedData; + } catch (e) { + Logger.error(`Error fetching issues with query ${query}: ${e instanceof Error ? e.message : e}`, this.id); + return { hasMorePages: false, hasUnsearchedRepositories: false, items: [] }; + } } async getMaxIssue(): Promise { @@ -1058,35 +1185,77 @@ export class FolderRepositoryManager implements vscode.Disposable { return max; } - async getPullRequestTemplates(): Promise { + async getIssueTemplates(): Promise { + const pattern = '{docs,.github}/ISSUE_TEMPLATE/*.md'; + return vscode.workspace.findFiles( + new vscode.RelativePattern(this._repository.rootUri, pattern), null + ); + } + + async getPullRequestTemplatesWithCache(): Promise { + const cacheLocation = `${CACHED_TEMPLATE_URI}+${this.repository.rootUri.toString()}`; + + const findTemplate = this.getPullRequestTemplates().then((templates) => { + //update cache + if (templates.length > 0) { + this.context.workspaceState.update(cacheLocation, templates[0].toString()); + } else { + this.context.workspaceState.update(cacheLocation, null); + } + return templates; + }); + const hasCachedTemplate = this.context.workspaceState.keys().includes(cacheLocation); + const cachedTemplateLocation = this.context.workspaceState.get(cacheLocation); + if (hasCachedTemplate) { + if (cachedTemplateLocation === null) { + return []; + } else if (cachedTemplateLocation) { + return [vscode.Uri.parse(cachedTemplateLocation)]; + } + } + return findTemplate; + } + + private async getPullRequestTemplates(): Promise { /** * Places a PR template can be: * - At the root, the docs folder, or the.github folder, named pull_request_template.md or PULL_REQUEST_TEMPLATE.md * - At the same folder locations under a PULL_REQUEST_TEMPLATE folder with any name */ - const pattern1 = '{pull_request_template,PULL_REQUEST_TEMPLATE}.md'; + const pattern1 = '{pull_request_template,PULL_REQUEST_TEMPLATE}.{md,txt}'; const templatesPattern1 = vscode.workspace.findFiles( new vscode.RelativePattern(this._repository.rootUri, pattern1) ); - const pattern2 = '{docs,.github}/{pull_request_template,PULL_REQUEST_TEMPLATE}.md'; + const pattern2 = '{docs,.github}/{pull_request_template,PULL_REQUEST_TEMPLATE}.{md,txt}'; const templatesPattern2 = vscode.workspace.findFiles( new vscode.RelativePattern(this._repository.rootUri, pattern2), null ); - const pattern3 = 'PULL_REQUEST_TEMPLATE/*.md'; + const pattern3 = '{pull_request_template,PULL_REQUEST_TEMPLATE}'; const templatesPattern3 = vscode.workspace.findFiles( new vscode.RelativePattern(this._repository.rootUri, pattern3) ); - const pattern4 = '{docs,.github}/PULL_REQUEST_TEMPLATE/*.md'; + const pattern4 = '{docs,.github}/{pull_request_template,PULL_REQUEST_TEMPLATE}'; const templatesPattern4 = vscode.workspace.findFiles( new vscode.RelativePattern(this._repository.rootUri, pattern4), null ); - const allResults = await Promise.all([templatesPattern1, templatesPattern2, templatesPattern3, templatesPattern4]); + const pattern5 = 'PULL_REQUEST_TEMPLATE/*.md'; + const templatesPattern5 = vscode.workspace.findFiles( + new vscode.RelativePattern(this._repository.rootUri, pattern5) + ); + + const pattern6 = '{docs,.github}/PULL_REQUEST_TEMPLATE/*.md'; + const templatesPattern6 = vscode.workspace.findFiles( + new vscode.RelativePattern(this._repository.rootUri, pattern6), null + ); + + const allResults = await Promise.all([templatesPattern1, templatesPattern2, templatesPattern3, templatesPattern4, templatesPattern5, templatesPattern6]); - return [...allResults[0], ...allResults[1], ...allResults[2], ...allResults[3]]; + const result = [...allResults[0], ...allResults[1], ...allResults[2], ...allResults[3], ...allResults[4], ...allResults[5]]; + return result; } async getPullRequestDefaults(branch?: Branch): Promise { @@ -1096,8 +1265,8 @@ export class FolderRepositoryManager implements vscode.Disposable { const origin = await this.getOrigin(branch); const meta = await origin.getMetadata(); - const remotesSettingDefault = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).inspect(REMOTES_SETTING)?.defaultValue; - const remotesSettingSetValue = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(REMOTES_SETTING); + const remotesSettingDefault = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).inspect(REMOTES)?.defaultValue; + const remotesSettingSetValue = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); const settingsEqual = (!remotesSettingSetValue || remotesSettingDefault?.every((value, index) => remotesSettingSetValue[index] === value)); const parent = (meta.fork && meta.parent && settingsEqual) ? meta.parent @@ -1106,10 +1275,15 @@ export class FolderRepositoryManager implements vscode.Disposable { return { owner: parent.owner!.login, repo: parent.name, - base: parent.default_branch, + base: getOverrideBranch() ?? parent.default_branch, }; } + async getPullRequestDefaultRepo(): Promise { + const defaults = await this.getPullRequestDefaults(); + return this.findRepo(repo => repo.remote.owner === defaults.owner && repo.remote.repositoryName === defaults.repo) || this._githubRepositories[0]; + } + async getMetadata(remote: string): Promise { const repo = this.findRepo(byRemoteName(remote)); return repo && repo.getMetadata(); @@ -1125,15 +1299,28 @@ export class FolderRepositoryManager implements vscode.Disposable { return ''; } - async getTipCommitMessage(branch: string): Promise { + async getTipCommitMessage(branch: string): Promise { + Logger.debug(`Git tip message for branch ${branch} - enter`, this.id); const { repository } = this; - const { commit } = await repository.getBranch(branch); - if (commit) { - const { message } = await repository.getCommit(commit); - return message; - } + let { commit } = await repository.getBranch(branch); + let message: string = ''; + let count = 0; + do { + if (commit) { + let fullCommit: Commit = await repository.getCommit(commit); + if (fullCommit.parents.length <= 1) { + message = fullCommit.message; + break; + } else { + commit = fullCommit.parents[0]; + } + } + count++; + } while (message === '' && commit && count < 5); - return ''; + + Logger.debug(`Git tip message for branch ${branch} - done`, this.id); + return message; } async getOrigin(branch?: Branch): Promise { @@ -1154,7 +1341,7 @@ export class FolderRepositoryManager implements vscode.Disposable { return this.createAndAddGitHubRepository(remote, this._credentialStore); } - Logger.appendLine(`The remote '${upstreamRef.remote}' is not a GitHub repository.`); + Logger.error(`The remote '${upstreamRef.remote}' is not a GitHub repository.`); // No GitHubRepository? We currently won't try pushing elsewhere, // so fail. @@ -1192,8 +1379,9 @@ export class FolderRepositoryManager implements vscode.Disposable { throw new Error(`No matching repository ${params.repo} found for ${params.owner}`); } + let pullRequestModel: PullRequestModel | undefined; try { - const pullRequestModel = await repo.createPullRequest(params); + pullRequestModel = await repo.createPullRequest(params); const branchNameSeparatorIndex = params.head.indexOf(':'); const branchName = params.head.slice(branchNameSeparatorIndex + 1); @@ -1211,12 +1399,13 @@ export class FolderRepositoryManager implements vscode.Disposable { // There are unpushed commits if (this._repository.state.HEAD?.ahead) { // Offer to push changes + const pushCommits = vscode.l10n.t({ message: 'Push Commits', comment: 'Pushes the local commits to the remote.' }); const shouldPush = await vscode.window.showInformationMessage( - `There are no commits between '${params.base}' and '${params.head}'.\n\nDo you want to push your local commits and create the pull request?`, + vscode.l10n.t('There are no commits between \'{0}\' and \'{1}\'.\n\nDo you want to push your local commits and create the pull request?', params.base, params.head), { modal: true }, - 'Push commits', + pushCommits, ); - if (shouldPush === 'Push commits') { + if (shouldPush === pushCommits) { await this._repository.push(); return this.createPullRequest(params); } else { @@ -1226,13 +1415,15 @@ export class FolderRepositoryManager implements vscode.Disposable { // There are uncommitted changes if (this._repository.state.workingTreeChanges.length || this._repository.state.indexChanges.length) { + const commitChanges = vscode.l10n.t('Commit Changes'); const shouldCommit = await vscode.window.showInformationMessage( - `There are no commits between '${params.base}' and '${params.head}'.\n\nDo you want to commit your changes and create the pull request?`, + vscode.l10n.t('There are no commits between \'{0}\' and \'{1}\'.\n\nDo you want to commit your changes and create the pull request?', params.base, params.head), { modal: true }, - 'Commit changes', + commitChanges, ); - if (shouldCommit === 'Commit changes') { - await vscode.commands.executeCommand('git.commit'); + if (shouldCommit === commitChanges) { + await this._repository.add(this._repository.state.indexChanges.map(change => change.uri.fsPath)); + await this.repository.commit(`${params.title}${params.body ? `\n${params.body}` : ''}`); await this._repository.push(); return this.createPullRequest(params); } else { @@ -1241,21 +1432,7 @@ export class FolderRepositoryManager implements vscode.Disposable { } } - if (!this._repository.state.HEAD?.upstream) { - const shouldPushUpstream = await vscode.window.showInformationMessage( - `There is no upstream branch for '${params.base}'.\n\nDo you want to publish it and create the pull request?`, - { modal: true }, - 'Publish branch', - ); - if (shouldPushUpstream === 'Publish branch') { - await this._repository.push(repo.remote.remoteName, params.base, true); - return this.createPullRequest(params); - } else { - return; - } - } - - Logger.appendLine(`GitHubRepository> Creating pull requests failed: ${e}`); + Logger.error(`Creating pull requests failed: ${e}`, this.id); /* __GDPR__ "pr.create.failure" : { @@ -1266,6 +1443,11 @@ export class FolderRepositoryManager implements vscode.Disposable { isDraft: (params.draft || '').toString(), }); + if (pullRequestModel) { + // We have created the pull request but something else failed (ex., modifying the git config) + // We shouldn't show an error as the pull request was successfully created + return pullRequestModel; + } throw new Error(formatError(e)); } } @@ -1282,7 +1464,7 @@ export class FolderRepositoryManager implements vscode.Disposable { await repo.ensure(); // Create PR - const { data } = await repo.octokit.issues.create(params); + const { data } = await repo.octokit.call(repo.octokit.api.issues.create, params); const item = convertRESTIssueToRawPullRequest(data, repo); const issueModel = new IssueModel(repo, repo.remote, item); @@ -1293,13 +1475,13 @@ export class FolderRepositoryManager implements vscode.Disposable { this.telemetry.sendTelemetryEvent('issue.create.success'); return issueModel; } catch (e) { - Logger.appendLine(`GitHubRepository> Creating issue failed: ${e}`); + Logger.error(` Creating issue failed: ${e}`, this.id); /* __GDPR__ "issue.create.failure" : {} */ this.telemetry.sendTelemetryErrorEvent('issue.create.failure'); - vscode.window.showWarningMessage(`Creating issue failed: ${formatError(e)}`); + vscode.window.showWarningMessage(vscode.l10n.t('Creating issue failed: {0}', formatError(e))); } return undefined; @@ -1324,7 +1506,7 @@ export class FolderRepositoryManager implements vscode.Disposable { repo: issue.remote.repositoryName, issue_number: issue.number, }; - await repo.octokit.issues.addAssignees(param); + await repo.octokit.call(repo.octokit.api.issues.addAssignees, param); /* __GDPR__ "issue.assign.success" : { @@ -1332,19 +1514,22 @@ export class FolderRepositoryManager implements vscode.Disposable { */ this.telemetry.sendTelemetryEvent('issue.assign.success'); } catch (e) { - Logger.appendLine(`GitHubRepository> Assigning issue failed: ${e}`); + Logger.error(`Assigning issue failed: ${e}`, this.id); /* __GDPR__ "issue.assign.failure" : { } */ this.telemetry.sendTelemetryErrorEvent('issue.assign.failure'); - vscode.window.showWarningMessage(`Assigning issue failed: ${formatError(e)}`); + vscode.window.showWarningMessage(vscode.l10n.t('Assigning issue failed: {0}', formatError(e))); } } - getCurrentUser(issueModel: IssueModel): IAccount { - return convertRESTUserToAccount(this._credentialStore.getCurrentUser(issueModel.githubRepository.remote.authProviderId), issueModel.githubRepository); + getCurrentUser(githubRepository?: GitHubRepository): Promise { + if (!githubRepository) { + githubRepository = this.gitHubRepositories[0]; + } + return this._credentialStore.getCurrentUser(githubRepository.remote.authProviderId); } async mergePullRequest( @@ -1352,7 +1537,7 @@ export class FolderRepositoryManager implements vscode.Disposable { title?: string, description?: string, method?: 'merge' | 'squash' | 'rebase', - ): Promise { + ): Promise<{ merged: boolean, message: string }> { const { octokit, remote } = await pullRequest.githubRepository.ensure(); const activePRSHA = this.activePullRequest && this.activePullRequest.head && this.activePullRequest.head.sha; @@ -1366,15 +1551,15 @@ export class FolderRepositoryManager implements vscode.Disposable { if (workingDirectorySHA !== mergingPRSHA) { // We are looking at different commit than what will be merged const { ahead } = this.repository.state.HEAD!; - if ( - ahead && + const pluralMessage = vscode.l10n.t('You have {0} unpushed commits on this PR branch.\n\nWould you like to proceed anyway?', ahead ?? 'unknown'); + const singularMessage = vscode.l10n.t('You have 1 unpushed commit on this PR branch.\n\nWould you like to proceed anyway?'); + if (ahead && (await vscode.window.showWarningMessage( - `You have ${ahead} unpushed ${ahead > 1 ? 'commits' : 'commit' - } on this PR branch.\n\nWould you like to proceed anyway?`, + ahead > 1 ? pluralMessage : singularMessage, { modal: true }, - 'Yes', - )) === undefined - ) { + vscode.l10n.t('Yes'), + )) === undefined) { + return { merged: false, message: 'unpushed changes', @@ -1386,9 +1571,9 @@ export class FolderRepositoryManager implements vscode.Disposable { // We have made changes to the PR that are not committed if ( (await vscode.window.showWarningMessage( - 'You have uncommitted changes on this PR branch.\n\n Would you like to proceed anyway?', + vscode.l10n.t('You have uncommitted changes on this PR branch.\n\n Would you like to proceed anyway?'), { modal: true }, - 'Yes', + vscode.l10n.t('Yes'), )) === undefined ) { return { @@ -1399,19 +1584,16 @@ export class FolderRepositoryManager implements vscode.Disposable { } } - return await octokit.pulls - .merge({ - commit_message: description, - commit_title: title, - merge_method: - method || - vscode.workspace - .getConfiguration('githubPullRequests') - .get<'merge' | 'squash' | 'rebase'>('defaultMergeMethod'), - owner: remote.owner, - repo: remote.repositoryName, - pull_number: pullRequest.number, - }) + return octokit.call(octokit.api.pulls.merge, { + commit_message: description, + commit_title: title, + merge_method: + method || + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'merge' | 'squash' | 'rebase'>(DEFAULT_MERGE_METHOD), + owner: remote.owner, + repo: remote.repositoryName, + pull_number: pullRequest.number, + }) .then(x => { /* __GDPR__ "pr.merge.success" : {} @@ -1509,7 +1691,7 @@ export class FolderRepositoryManager implements vscode.Disposable { }, }); - action.legacy = data.repository.pullRequest.state !== 'OPEN'; + action.legacy = data.repository?.pullRequest.state !== 'OPEN'; } catch { } return action; @@ -1520,15 +1702,43 @@ export class FolderRepositoryManager implements vscode.Disposable { if (result.legacy) { result.picked = true; } else { - result.description = `${result.description} is still Open`; + result.description = vscode.l10n.t('{0} is still Open', result.description!); } }); return results; } - private async getRemoteDeletionItems() { - // check if there are remotes that should be cleaned + public async cleanupAfterPullRequest(branchName: string, pullRequest: PullRequestModel) { + const defaults = await this.getPullRequestDefaults(); + if (branchName === defaults.base) { + Logger.debug('Not cleaning up default branch.', this.id); + return; + } + if (pullRequest.author.login === (await this.getCurrentUser()).login) { + Logger.debug('Not cleaning up user\'s branch.', this.id); + return; + } + const branch = await this.repository.getBranch(branchName); + const remote = branch.upstream?.remote; + try { + Logger.debug(`Cleaning up branch ${branchName}`, this.id); + await this.repository.deleteBranch(branchName); + } catch (e) { + // The branch probably had unpushed changes and cannot be deleted. + return; + } + if (!remote) { + return; + } + const remotes = await this.getDeleatableRemotes(undefined); + if (remotes.has(remote) && remotes.get(remote)!.createdForPullRequest) { + Logger.debug(`Cleaning up remote ${remote}`, this.id); + this.repository.removeRemote(remote); + } + } + + private async getDeleatableRemotes(nonExistantBranches?: Set) { const newConfigs = await this.repository.getConfigs(); const remoteInfos: Map< string, @@ -1549,8 +1759,10 @@ export class FolderRepositoryManager implements vscode.Disposable { remoteInfos.set(remoteName, { branches: new Set() }); } - const value = remoteInfos.get(remoteName); - value!.branches.add(branchName); + if (!nonExistantBranches?.has(branchName)) { + const value = remoteInfos.get(remoteName); + value!.branches.add(branchName); + } } } @@ -1574,14 +1786,19 @@ export class FolderRepositoryManager implements vscode.Disposable { } } }); + return remoteInfos; + } + private async getRemoteDeletionItems(nonExistantBranches: Set) { + // check if there are remotes that should be cleaned + const remoteInfos = await this.getDeleatableRemotes(nonExistantBranches); const remoteItems: (vscode.QuickPickItem & { remote: string })[] = []; remoteInfos.forEach((value, key) => { if (value.branches.size === 0) { - let description = value.createdForPullRequest ? '' : 'Not created by GitHub Pull Request extension'; + let description = value.createdForPullRequest ? '' : vscode.l10n.t('Not created by GitHub Pull Request extension'); if (value.url) { - description = description ? description + ' ' + value.url : value.url; + description = description ? `${description} ${value.url}` : value.url; } remoteItems.push({ @@ -1601,43 +1818,54 @@ export class FolderRepositoryManager implements vscode.Disposable { const quickPick = vscode.window.createQuickPick(); quickPick.canSelectMany = true; quickPick.ignoreFocusOut = true; - quickPick.placeholder = 'Choose local branches you want to delete permanently'; + quickPick.placeholder = vscode.l10n.t('Choose local branches you want to delete permanently'); quickPick.show(); quickPick.busy = true; // Check local branches const results = await this.getBranchDeletionItems(); + const defaults = await this.getPullRequestDefaults(); quickPick.items = results; - quickPick.selectedItems = results.filter(result => result.picked); + quickPick.selectedItems = results.filter(result => { + // Do not pick the default branch for the repo. + return result.picked && !((result.label === defaults.base) && (result.metadata.owner === defaults.owner) && (result.metadata.repositoryName === defaults.repo)); + }); quickPick.busy = false; let firstStep = true; quickPick.onDidAccept(async () => { + quickPick.busy = true; + if (firstStep) { const picks = quickPick.selectedItems; + const nonExistantBranches = new Set(); if (picks.length) { - quickPick.busy = true; try { await Promise.all( picks.map(async pick => { - await this.repository.deleteBranch(pick.label, true); - }), - ); - quickPick.busy = false; + try { + await this.repository.deleteBranch(pick.label, true); + } catch (e) { + if ((typeof e.stderr === 'string') && (e.stderr as string).includes('not found')) { + // TODO: The git extension API doesn't support removing configs + // If that support is added we should remove the config as it is no longer useful. + nonExistantBranches.add(pick.label); + } else { + throw e; + } + } + })); } catch (e) { quickPick.hide(); - vscode.window.showErrorMessage(`Deleting branches failed: ${e}`); + vscode.window.showErrorMessage(vscode.l10n.t('Deleting branches failed: {0} {1}', e.message, e.stderr)); } } firstStep = false; - quickPick.busy = true; + const remoteItems = await this.getRemoteDeletionItems(nonExistantBranches); - const remoteItems = await this.getRemoteDeletionItems(); - - if (remoteItems) { - quickPick.placeholder = 'Choose remotes you want to delete permanently'; - quickPick.busy = false; + if (remoteItems && remoteItems.length) { + quickPick.placeholder = vscode.l10n.t('Choose remotes you want to delete permanently'); quickPick.items = remoteItems; quickPick.selectedItems = remoteItems.filter(item => item.picked); } else { @@ -1647,16 +1875,15 @@ export class FolderRepositoryManager implements vscode.Disposable { // delete remotes const picks = quickPick.selectedItems; if (picks.length) { - quickPick.busy = true; await Promise.all( picks.map(async pick => { await this.repository.removeRemote(pick.label); }), ); - quickPick.busy = false; } quickPick.hide(); } + quickPick.busy = false; }); quickPick.onDidHide(() => { @@ -1677,18 +1904,22 @@ export class FolderRepositoryManager implements vscode.Disposable { return mergeOptions; } + async mergeQueueMethodForBranch(branch: string, owner: string, repoName: string): Promise { + return (await this.gitHubRepositories.find(repository => repository.remote.owner === owner && repository.remote.repositoryName === repoName)?.mergeQueueMethodForBranch(branch)); + } + async fulfillPullRequestMissingInfo(pullRequest: PullRequestModel): Promise { try { if (!pullRequest.isResolved()) { return; } - Logger.debug(`Fulfill pull request missing info - start`, FolderRepositoryManager.ID); + Logger.debug(`Fulfill pull request missing info - start`, this.id); const githubRepository = pullRequest.githubRepository; const { octokit, remote } = await githubRepository.ensure(); if (!pullRequest.base) { - const { data } = await octokit.pulls.get({ + const { data } = await octokit.call(octokit.api.pulls.get, { owner: remote.owner, repo: remote.repositoryName, pull_number: pullRequest.number, @@ -1697,7 +1928,7 @@ export class FolderRepositoryManager implements vscode.Disposable { } if (!pullRequest.mergeBase) { - const { data } = await octokit.repos.compareCommits({ + const { data } = await octokit.call(octokit.api.repos.compareCommits, { repo: remote.repositoryName, owner: remote.owner, base: `${pullRequest.base.repositoryCloneUrl.owner}:${pullRequest.base.ref}`, @@ -1707,9 +1938,9 @@ export class FolderRepositoryManager implements vscode.Disposable { pullRequest.mergeBase = data.merge_base_commit.sha; } } catch (e) { - vscode.window.showErrorMessage(`Fetching Pull Request merge base failed: ${formatError(e)}`); + vscode.window.showErrorMessage(vscode.l10n.t('Fetching Pull Request merge base failed: {0}', formatError(e))); } - Logger.debug(`Fulfill pull request missing info - done`, FolderRepositoryManager.ID); + Logger.debug(`Fulfill pull request missing info - done`, this.id); } //#region Git related APIs @@ -1723,8 +1954,9 @@ export class FolderRepositoryManager implements vscode.Disposable { }); if (!githubRepo) { + Logger.appendLine(`GitHubRepository not found: ${owner}/${repositoryName}`, this.id); // try to create the repository - githubRepo = this.createGitHubRepositoryFromOwnerName(owner, repositoryName); + githubRepo = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); } return githubRepo; } @@ -1735,8 +1967,15 @@ export class FolderRepositoryManager implements vscode.Disposable { pullRequestNumber: number, ): Promise { const githubRepo = await this.resolveItem(owner, repositoryName); + Logger.appendLine(`Found GitHub repo for pr #${pullRequestNumber}: ${githubRepo ? 'yes' : 'no'}`, this.id); if (githubRepo) { - return githubRepo.getPullRequest(pullRequestNumber); + const pr = await githubRepo.getPullRequest(pullRequestNumber); + Logger.appendLine(`Found GitHub pr repo for pr #${pullRequestNumber}: ${pr ? 'yes' : 'no'}`, this.id); + if (pr) { + if (await githubRepo.hasBranch(pr.base.name)) { + return pr; + } + } } return undefined; } @@ -1755,8 +1994,8 @@ export class FolderRepositoryManager implements vscode.Disposable { } async resolveUser(owner: string, repositoryName: string, login: string): Promise { - Logger.debug(`Fetch user ${login}`, FolderRepositoryManager.ID); - const githubRepository = this.createGitHubRepositoryFromOwnerName(owner, repositoryName); + Logger.debug(`Fetch user ${login}`, this.id); + const githubRepository = await this.createGitHubRepositoryFromOwnerName(owner, repositoryName); const { query, schema } = await githubRepository.ensure(); try { @@ -1770,7 +2009,7 @@ export class FolderRepositoryManager implements vscode.Disposable { } catch (e) { // Ignore cases where the user doesn't exist if (!(e.message as (string | undefined))?.startsWith('GraphQL error: Could not resolve to a User with the login of')) { - Logger.appendLine(e.message); + Logger.warn(e.message); } } return undefined; @@ -1788,52 +2027,105 @@ export class FolderRepositoryManager implements vscode.Disposable { return matchingPullRequestMetadata; } - async getMatchingPullRequestMetadataFromGitHub(): Promise< + async getMatchingPullRequestMetadataFromGitHub(branch: Branch, remoteName?: string, remoteUrl?: string, upstreamBranchName?: string): Promise< + (PullRequestMetadata & { model: PullRequestModel }) | null + > { + try { + if (remoteName) { + return this.getMatchingPullRequestMetadataFromGitHubWithRemoteName(remoteName, upstreamBranchName); + } + return this.getMatchingPullRequestMetadataFromGitHubWithUrl(branch, remoteUrl, upstreamBranchName); + } catch (e) { + Logger.error(`Unable to get matching pull request metadata from GitHub: ${e}`, this.id); + return null; + } + } + + async getMatchingPullRequestMetadataFromGitHubWithUrl(branch: Branch, remoteUrl?: string, upstreamBranchName?: string): Promise< + (PullRequestMetadata & { model: PullRequestModel }) | null + > { + if (!remoteUrl) { + return null; + } + let headGitHubRepo = this.gitHubRepositories.find(repo => repo.remote.url.toLowerCase() === remoteUrl.toLowerCase()); + let protocol: Protocol | undefined; + if (!headGitHubRepo && this.gitHubRepositories.length > 0) { + protocol = new Protocol(remoteUrl); + const remote = parseRemote(protocol.repositoryName, remoteUrl, protocol); + if (remote) { + headGitHubRepo = await this.createGitHubRepository(remote, this.credentialStore, true, true); + } + } + const matchingPR = await this.doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo, upstreamBranchName); + if (matchingPR && (branch.upstream === undefined) && protocol && headGitHubRepo && branch.name) { + const newRemote = await PullRequestGitHelper.createRemote(this.repository, headGitHubRepo?.remote, protocol); + const trackedBranchName = `refs/remotes/${newRemote}/${matchingPR.model.head?.name}`; + await this.repository.fetch({ remote: newRemote, ref: matchingPR.model.head?.name }); + await this.repository.setBranchUpstream(branch.name, trackedBranchName); + } + + return matchingPR; + } + + async getMatchingPullRequestMetadataFromGitHubWithRemoteName(remoteName?: string, upstreamBranchName?: string): Promise< (PullRequestMetadata & { model: PullRequestModel }) | null > { - if ( - !this.repository || - !this.repository.state.HEAD || - !this.repository.state.HEAD.name || - !this.repository.state.HEAD.upstream - ) { + if (!remoteName) { return null; } const headGitHubRepo = this.gitHubRepositories.find( - repo => repo.remote.remoteName === this.repository.state.HEAD?.upstream?.remote, + repo => repo.remote.remoteName === remoteName, ); - // Find the github repo that matches the upstream - for (const repo of this.gitHubRepositories) { - if (repo.remote.remoteName === this.repository.state.HEAD.upstream.remote) { - const matchingPullRequest = await repo.getPullRequestForBranch( - `${headGitHubRepo?.remote.owner}:${this.repository.state.HEAD.upstream.name}`, - ); - if (matchingPullRequest && matchingPullRequest.length > 0) { - return { - owner: repo.remote.owner, - repositoryName: repo.remote.repositoryName, - prNumber: matchingPullRequest[0].number, - model: matchingPullRequest[0], - }; - } - break; + return this.doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo, upstreamBranchName); + } + + private async doGetMatchingPullRequestMetadataFromGitHub(headGitHubRepo?: GitHubRepository, upstreamBranchName?: string): Promise< + (PullRequestMetadata & { model: PullRequestModel }) | null + > { + if (!headGitHubRepo || !upstreamBranchName) { + return null; + } + + const headRepoMetadata = await headGitHubRepo?.getMetadata(); + if (!headRepoMetadata?.owner) { + return null; + } + + const parentRepos = this.gitHubRepositories.filter(repo => { + if (headRepoMetadata.fork) { + return repo.remote.owner === headRepoMetadata.parent?.owner?.login && repo.remote.repositoryName === headRepoMetadata.parent.name; + } else { + return repo.remote.owner === headRepoMetadata.owner?.login && repo.remote.repositoryName === headRepoMetadata.name; + } + }); + + // Search through each github repo to see if it has a PR with this head branch. + for (const repo of parentRepos) { + const matchingPullRequest = await repo.getPullRequestForBranch(upstreamBranchName, headRepoMetadata.owner.login); + if (matchingPullRequest) { + return { + owner: repo.remote.owner, + repositoryName: repo.remote.repositoryName, + prNumber: matchingPullRequest.number, + model: matchingPullRequest, + }; } } return null; } - async checkoutExistingPullRequestBranch(pullRequest: PullRequestModel): Promise { - return await PullRequestGitHelper.checkoutExistingPullRequestBranch(this.repository, pullRequest); + async checkoutExistingPullRequestBranch(pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { + return await PullRequestGitHelper.checkoutExistingPullRequestBranch(this.repository, pullRequest, progress); } async getBranchNameForPullRequest(pullRequest: PullRequestModel) { return await PullRequestGitHelper.getBranchNRemoteForPullRequest(this.repository, pullRequest); } - async fetchAndCheckout(pullRequest: PullRequestModel): Promise { - await PullRequestGitHelper.fetchAndCheckout(this.repository, this._allGitHubRemotes, pullRequest); + async fetchAndCheckout(pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { + await PullRequestGitHelper.fetchAndCheckout(this.repository, this._allGitHubRemotes, pullRequest, progress); } async checkout(branchName: string): Promise { @@ -1845,72 +2137,192 @@ export class FolderRepositoryManager implements vscode.Disposable { if (pullRequest) { return pullRequest; } else { - vscode.window.showErrorMessage(`Pull request number ${id} does not exist in ${githubRepo.remote.owner}/${githubRepo.remote.repositoryName}`, { modal: true }); + vscode.window.showErrorMessage(vscode.l10n.t('Pull request number {0} does not exist in {1}', id, `${githubRepo.remote.owner}/${githubRepo.remote.repositoryName}`), { modal: true }); } } public async checkoutDefaultBranch(branch: string): Promise { + let branchObj: Branch | undefined; try { - const branchObj = await this.repository.getBranch(branch); + branchObj = await this.repository.getBranch(branch); const currentBranch = this.repository.state.HEAD?.name; if (currentBranch === branchObj.name) { - const chooseABranch = 'Choose a branch'; - vscode.window.showInformationMessage('The default branch is already checked out.', 'Choose a branch').then(choice => { + const chooseABranch = vscode.l10n.t('Choose a Branch'); + vscode.window.showInformationMessage(vscode.l10n.t('The default branch is already checked out.'), chooseABranch).then(choice => { if (choice === chooseABranch) { - return vscode.commands.executeCommand('git.checkout'); + return git.checkout(); } }); return; } + // respect the git setting to fetch before checkout + if (vscode.workspace.getConfiguration(GIT).get(PULL_BEFORE_CHECKOUT, false) && branchObj.upstream) { + await this.repository.fetch({ remote: branchObj.upstream.remote, ref: `${branchObj.upstream.name}:${branchObj.name}` }); + } + if (branchObj.upstream && branch === branchObj.upstream.name) { await this.repository.checkout(branch); } else { - await vscode.commands.executeCommand('git.checkout'); + await git.checkout(); + } + + const fileClose: Thenable[] = []; + // Close the PR description and any open review scheme files. + for (const tabGroup of vscode.window.tabGroups.all) { + for (const tab of tabGroup.tabs) { + let uri: vscode.Uri | string | undefined; + if (tab.input instanceof vscode.TabInputText) { + uri = tab.input.uri; + } else if (tab.input instanceof vscode.TabInputTextDiff) { + uri = tab.input.original; + } else if (tab.input instanceof vscode.TabInputWebview) { + uri = tab.input.viewType; + } + if ((uri instanceof vscode.Uri && uri.scheme === Schemes.Review) || (typeof uri === 'string' && uri.endsWith(PULL_REQUEST_OVERVIEW_VIEW_TYPE))) { + fileClose.push(vscode.window.tabGroups.close(tab)); + } + } } + await Promise.all(fileClose); } catch (e) { if (e.gitErrorCode) { // for known git errors, we should provide actions for users to continue. if (e.gitErrorCode === GitErrorCodes.DirtyWorkTree) { vscode.window.showErrorMessage( - 'Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches', + vscode.l10n.t('Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches'), ); return; } } - + Logger.error(`Exiting failed: ${e}. Target branch ${branch} used to find branch ${branchObj?.name ?? 'unknown'} with upstream ${branchObj?.upstream?.name ?? 'unknown'}.`); vscode.window.showErrorMessage(`Exiting failed: ${e}`); } } + private async pullBranchConfiguration(): Promise<'never' | 'prompt' | 'always'> { + const neverShowPullNotification = this.context.globalState.get(NEVER_SHOW_PULL_NOTIFICATION, false); + if (neverShowPullNotification) { + this.context.globalState.update(NEVER_SHOW_PULL_NOTIFICATION, false); + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'never', vscode.ConfigurationTarget.Global); + } + return vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'never' | 'prompt' | 'always'>(PULL_BRANCH, 'prompt'); + } + + private async pullBranch(branch: Branch) { + if (this._repository.state.HEAD?.name === branch.name) { + await this._repository.pull(); + } + } + + private async promptPullBrach(pr: PullRequestModel, branch: Branch, autoStashSetting?: boolean) { + if (!this._updateMessageShown || autoStashSetting) { + this._updateMessageShown = true; + const pull = vscode.l10n.t('Pull'); + const always = vscode.l10n.t('Always Pull'); + const never = vscode.l10n.t('Never Show Again'); + const options = [pull]; + if (!autoStashSetting) { + options.push(always, never); + } + const result = await vscode.window.showInformationMessage( + vscode.l10n.t('There are updates available for pull request {0}.', `${pr.number}: ${pr.title}`), + {}, + ...options + ); + + if (result === pull) { + await this.pullBranch(branch); + this._updateMessageShown = false; + } else if (never) { + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'never', vscode.ConfigurationTarget.Global); + } else if (always) { + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(PULL_BRANCH, 'always', vscode.ConfigurationTarget.Global); + await this.pullBranch(branch); + } + } + } + + private _updateMessageShown: boolean = false; + public async checkBranchUpToDate(pr: PullRequestModel & IResolvedPullRequestModel, shouldFetch: boolean): Promise { + if (this.activePullRequest?.id !== pr.id) { + return; + } + const branch = this._repository.state.HEAD; + if (branch) { + const remote = branch.upstream ? branch.upstream.remote : null; + const remoteBranch = branch.upstream ? branch.upstream.name : branch.name; + if (remote) { + try { + if (shouldFetch && vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(ALLOW_FETCH, true)) { + await this._repository.fetch(remote, remoteBranch); + } + } catch (e) { + if (e.stderr) { + if ((e.stderr as string).startsWith('fatal: couldn\'t find remote ref')) { + // We've managed to check out the PR, but the remote has been deleted. This is fine, but we can't fetch now. + } else { + vscode.window.showErrorMessage(vscode.l10n.t('An error occurred when fetching the repository: {0}', e.stderr)); + } + } + Logger.error(`Error when fetching: ${e.stderr ?? e}`, this.id); + } + const pullBranchConfiguration = await this.pullBranchConfiguration(); + if (branch.behind !== undefined && branch.behind > 0) { + switch (pullBranchConfiguration) { + case 'always': { + const autoStash = vscode.workspace.getConfiguration(GIT).get(AUTO_STASH, false); + if (autoStash) { + return this.promptPullBrach(pr, branch, autoStash); + } else { + return this.pullBranch(branch); + } + } + case 'prompt': { + return this.promptPullBrach(pr, branch); + } + case 'never': return; + } + } + + } + } + } + private findExistingGitHubRepository(remote: { owner: string, repositoryName: string, remoteName?: string }): GitHubRepository | undefined { return this._githubRepositories.find( r => - (r.remote.owner === remote.owner) - && (r.remote.repositoryName === remote.repositoryName) + (r.remote.owner.toLowerCase() === remote.owner.toLowerCase()) + && (r.remote.repositoryName.toLowerCase() === remote.repositoryName.toLowerCase()) && (!remote.remoteName || (r.remote.remoteName === remote.remoteName)), ); } - private createAndAddGitHubRepository(remote: Remote, credentialStore: CredentialStore) { - const repo = new GitHubRepository(remote, this.repository.rootUri, credentialStore, this.telemetry, this._sessionState); + private async createAndAddGitHubRepository(remote: Remote, credentialStore: CredentialStore, silent?: boolean) { + const repo = new GitHubRepository(GitHubRemote.remoteAsGitHub(remote, await this._githubManager.isGitHub(remote.gitProtocol.normalizeUri()!)), this.repository.rootUri, credentialStore, this.telemetry, silent); this._githubRepositories.push(repo); return repo; } - createGitHubRepository(remote: Remote, credentialStore: CredentialStore): GitHubRepository { - return this.findExistingGitHubRepository(remote) ?? - this.createAndAddGitHubRepository(remote, credentialStore); + private _createGitHubRepositoryBulkhead = bulkhead(1, 300); + async createGitHubRepository(remote: Remote, credentialStore: CredentialStore, silent?: boolean, ignoreRemoteName: boolean = false): Promise { + // Use a bulkhead/semaphore to ensure that we don't create multiple GitHubRepositories for the same remote at the same time. + return this._createGitHubRepositoryBulkhead.execute(() => { + return this.findExistingGitHubRepository({ owner: remote.owner, repositoryName: remote.repositoryName, remoteName: ignoreRemoteName ? undefined : remote.remoteName }) ?? + this.createAndAddGitHubRepository(remote, credentialStore, silent); + }); } - createGitHubRepositoryFromOwnerName(owner: string, repositoryName: string): GitHubRepository { - const existing = this.findExistingGitHubRepository({owner, repositoryName}); + async createGitHubRepositoryFromOwnerName(owner: string, repositoryName: string): Promise { + const existing = this.findExistingGitHubRepository({ owner, repositoryName }); if (existing) { return existing; } - const uri = `https://github.com/${owner}/${repositoryName}`; - return this.createAndAddGitHubRepository(new Remote(repositoryName, uri, new Protocol(uri)), this._credentialStore); + const gitRemotes = parseRepositoryRemotes(this.repository); + const gitRemote = gitRemotes.find(r => r.owner === owner && r.repositoryName === repositoryName); + const uri = gitRemote?.url ?? `https://github.com/${owner}/${repositoryName}`; + return this.createAndAddGitHubRepository(new Remote(gitRemote?.remoteName ?? repositoryName, uri, new Protocol(uri)), this._credentialStore); } async findUpstreamForItem(item: { @@ -1966,19 +2378,19 @@ export class FolderRepositoryManager implements vscode.Disposable { repoString: string, matchingRepo: Repository, ): Promise { - progress.report({ message: `Forking ${repoString}...` }); + progress.report({ message: vscode.l10n.t('Forking {0}...', repoString) }); const result = await githubRepository.fork(); progress.report({ increment: 50 }); if (!result) { vscode.window.showErrorMessage( - `Unable to create a fork of ${repoString}. Check that your GitHub credentials are correct.`, + vscode.l10n.t('Unable to create a fork of {0}. Check that your GitHub credentials are correct.', repoString), ); return; } const workingRemoteName: string = matchingRepo.state.remotes.length > 1 ? 'origin' : matchingRepo.state.remotes[0].name; - progress.report({ message: 'Adding remotes. This may take a few moments.' }); + progress.report({ message: vscode.l10n.t('Adding remotes. This may take a few moments.') }); await matchingRepo.renameRemote(workingRemoteName, 'upstream'); await matchingRepo.addRemote(workingRemoteName, result); // Now the extension is responding to all the git changes. @@ -2004,7 +2416,7 @@ export class FolderRepositoryManager implements vscode.Disposable { matchingRepo: Repository, ): Promise { return vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Creating Fork' }, + { location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Creating Fork') }, async progress => { try { return this.forkWithProgress(progress, githubRepository, repoString, matchingRepo); @@ -2019,10 +2431,10 @@ export class FolderRepositoryManager implements vscode.Disposable { async tryOfferToFork(githubRepository: GitHubRepository): Promise { const repoString = `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`; - const fork = 'Fork'; - const dontFork = "Don't Fork"; + const fork = vscode.l10n.t('Fork'); + const dontFork = vscode.l10n.t('Don\'t Fork'); const response = await vscode.window.showInformationMessage( - `You don't have permission to push to ${repoString}. Do you want to fork ${repoString}? This will modify your git remotes to set \`origin\` to the fork, and \`upstream\` to ${repoString}.`, + vscode.l10n.t('You don\'t have permission to push to {0}. Do you want to fork {0}? This will modify your git remotes to set \`origin\` to the fork, and \`upstream\` to {0}.', repoString), { modal: true }, fork, dontFork, @@ -2038,8 +2450,13 @@ export class FolderRepositoryManager implements vscode.Disposable { } } + public getTitleAndDescriptionProvider(searchTerm?: string) { + return this._git.getTitleAndDescriptionProvider(searchTerm); + } + dispose() { this._subs.forEach(sub => sub.dispose()); + this._onDidDispose.fire(); } } @@ -2068,7 +2485,11 @@ const ownedByMe: Predicate = repo => { export const byRemoteName = (name: string): Predicate => ({ remote: { remoteName } }) => remoteName === name; -export const titleAndBodyFrom = (message: string): { title: string; body: string } => { +export const titleAndBodyFrom = async (promise: Promise): Promise<{ title: string; body: string } | undefined> => { + const message = await promise; + if (!message) { + return; + } const idxLineBreak = message.indexOf('\n'); return { title: idxLineBreak === -1 ? message : message.substr(0, idxLineBreak), diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index 76f80eef54..a5d56dedcd 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -3,40 +3,70 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Octokit } from '@octokit/rest'; -import * as OctokitTypes from '@octokit/types'; -import { ApolloQueryResult, FetchResult, MutationOptions, NetworkStatus, QueryOptions } from 'apollo-boost'; +import { ApolloQueryResult, DocumentNode, FetchResult, MutationOptions, NetworkStatus, QueryOptions } from 'apollo-boost'; import * as vscode from 'vscode'; -import { AuthenticationError, isSamlError } from '../common/authentication'; +import { AuthenticationError, AuthProvider, GitHubServerType, isSamlError } from '../common/authentication'; import Logger from '../common/logger'; import { Protocol } from '../common/protocol'; -import { parseRemote, Remote } from '../common/remote'; -import { ISessionState } from '../common/sessionState'; +import { GitHubRemote, parseRemote } from '../common/remote'; import { ITelemetry } from '../common/telemetry'; import { PRCommentControllerRegistry } from '../view/pullRequestCommentControllerRegistry'; -import { OctokitCommon } from './common'; +import { mergeQuerySchemaWithShared, OctokitCommon, Schema } from './common'; import { CredentialStore, GitHub } from './credentials'; import { AssignableUsersResponse, + CreatePullRequestResponse, + FileContentResponse, ForkDetailsResponse, + GetBranchResponse, + GetChecksResponse, + isCheckRun, IssuesResponse, IssuesSearchResponse, + ListBranchesResponse, MaxIssueResponse, MentionableUsersResponse, + MergeQueueForBranchResponse, MilestoneIssuesResponse, + OrganizationTeamsCountResponse, + OrganizationTeamsResponse, + OrgProjectsResponse, + PullRequestParticipantsResponse, PullRequestResponse, + PullRequestsResponse, + RepoProjectsResponse, ViewerPermissionResponse, } from './graphql'; -import { IAccount, IMilestone, Issue, PullRequest, RepoAccessAndMergeMethods } from './interface'; +import { + CheckState, + IAccount, + IMilestone, + IProject, + Issue, + ITeam, + MergeMethod, + PullRequest, + PullRequestChecks, + PullRequestReviewRequirement, + RepoAccessAndMergeMethods, +} from './interface'; import { IssueModel } from './issueModel'; +import { LoggingOctokit } from './loggingOctokit'; import { PullRequestModel } from './pullRequestModel'; import defaultSchema from './queries.gql'; +import * as extraSchema from './queriesExtra.gql'; +import * as limitedSchema from './queriesLimited.gql'; +import * as sharedSchema from './queriesShared.gql'; import { convertRESTPullRequestToRawPullRequest, + getAvatarWithEnterpriseFallback, + getOverrideBranch, getPRFetchQuery, + isInCodespaces, parseGraphQLIssue, parseGraphQLPullRequest, parseGraphQLViewerPermission, + parseMergeMethod, parseMilestone, } from './utils'; @@ -50,11 +80,11 @@ export interface ItemsData { } export interface IssueData extends ItemsData { - items: IssueModel[]; + items: Issue[]; hasMorePages: boolean; } -export interface PullRequestData extends IssueData { +export interface PullRequestData extends ItemsData { items: PullRequestModel[]; } @@ -72,6 +102,12 @@ export enum ViewerPermission { Write = 'WRITE', } +export enum TeamReviewerRefreshKind { + None, + Try, + Force +} + export interface ForkDetails { isFork: boolean; parent: { @@ -86,6 +122,12 @@ export interface IMetadata extends OctokitCommon.ReposGetResponseData { currentUser: any; } +interface GraphQLError { + extensions: { + code: string; + }; +} + export class GitHubRepository implements vscode.Disposable { static ID = 'GitHubRepository'; protected _initialized: boolean = false; @@ -95,7 +137,8 @@ export class GitHubRepository implements vscode.Disposable { public commentsController?: vscode.CommentController; public commentsHandler?: PRCommentControllerRegistry; private _pullRequestModels = new Map(); - public readonly isGitHubDotCom: boolean; + private _queriesSchema: any; + private _areQueriesLimited: boolean = false; private _onDidAddPullRequest: vscode.EventEmitter = new vscode.EventEmitter(); public readonly onDidAddPullRequest: vscode.Event = this._onDidAddPullRequest.event; @@ -127,10 +170,10 @@ export class GitHubRepository implements vscode.Disposable { await this.ensure(); this.commentsController = vscode.comments.createCommentController( - `github-browse-${this.remote.normalizedHost}`, - `GitHub Pull Request for ${this.remote.normalizedHost}`, + `github-browse-${this.remote.normalizedHost}-${this.remote.owner}-${this.remote.repositoryName}`, + `Pull Request (${this.remote.owner}/${this.remote.repositoryName})`, ); - this.commentsHandler = new PRCommentControllerRegistry(this.commentsController, this._sessionState); + this.commentsHandler = new PRCommentControllerRegistry(this.commentsController); this._toDispose.push(this.commentsHandler); this._toDispose.push(this.commentsController); } catch (e) { @@ -140,26 +183,61 @@ export class GitHubRepository implements vscode.Disposable { dispose() { this._toDispose.forEach(d => d.dispose()); + this._toDispose = []; + this.commentsController = undefined; + this.commentsHandler = undefined; } - public get octokit(): Octokit { + public get octokit(): LoggingOctokit { return this.hub && this.hub.octokit; } constructor( - public remote: Remote, + public remote: GitHubRemote, public readonly rootUri: vscode.Uri, private readonly _credentialStore: CredentialStore, private readonly _telemetry: ITelemetry, - private readonly _sessionState: ISessionState + silent: boolean = false ) { - this.isGitHubDotCom = remote.host.toLowerCase() === 'github.com'; + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as unknown as Schema, defaultSchema as unknown as Schema); + // kick off the comments controller early so that the Comments view is visible and doesn't pop up later in an way that's jarring + if (!silent) { + this.ensureCommentsController(); + } + } + + get authMatchesServer(): boolean { + if ((this.remote.githubServerType === GitHubServerType.GitHubDotCom) && this._credentialStore.isAuthenticated(AuthProvider.github)) { + return true; + } else if ((this.remote.githubServerType === GitHubServerType.Enterprise) && this._credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { + return true; + } else { + // Not good. We have a mismatch between auth type and server type. + return false; + } } - query = async (query: QueryOptions): Promise> => { - const gql = this.hub && this.hub.graphql; + private codespacesTokenError(action: QueryOptions | MutationOptions) { + if (isInCodespaces() && this._metadata?.fork) { + // :( https://github.com/microsoft/vscode-pull-request-github/issues/5325#issuecomment-1798243852 + /* __GDPR__ + "pr.codespacesTokenError" : { + "action": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + } + */ + this._telemetry.sendTelemetryErrorEvent('pr.codespacesTokenError', { + action: action.context + }); + + throw new Error(vscode.l10n.t('This action cannot be completed in a GitHub Codespace on a fork.')); + } + } + + query = async (query: QueryOptions, ignoreSamlErrors: boolean = false, legacyFallback?: { query: DocumentNode }): Promise> => { + const gql = this.authMatchesServer && this.hub && this.hub.graphql; if (!gql) { - Logger.debug(`Not available for query: ${query}`, GRAPHQL_COMPONENT_ID); + const logValue = (query.query.definitions[0] as { name: { value: string } | undefined }).name?.value; + Logger.debug(`Not available for query: ${logValue ?? 'unknown'}`, GRAPHQL_COMPONENT_ID); return { data: null, loading: false, @@ -168,27 +246,43 @@ export class GitHubRepository implements vscode.Disposable { } as any; } - Logger.debug(`Request: ${JSON.stringify(query, null, 2)}`, GRAPHQL_COMPONENT_ID); let rsp; try { rsp = await gql.query(query); } catch (e) { - // There's an issue with the GetChecks that can result in this error. - if ((query.query !== this.schema.GetChecks) && e.message?.startsWith('GraphQL error: Resource protected by organization SAML enforcement.')) { + if (legacyFallback) { + query.query = legacyFallback.query; + return this.query(query, ignoreSamlErrors); + } + + if (e.graphQLErrors && e.graphQLErrors.length && ((e.graphQLErrors as GraphQLError[]).some(error => error.extensions.code === 'undefinedField')) && !this._areQueriesLimited) { + // We're running against a GitHub server that doesn't support the query we're trying to run. + // Switch to the limited schema and try again. + this._areQueriesLimited = true; + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, limitedSchema.default as any); + query.query = this.schema[(query.query.definitions[0] as { name: { value: string } }).name.value]; + rsp = await gql.query(query); + } else if (!ignoreSamlErrors && (e.message as string | undefined)?.startsWith('GraphQL error: Resource protected by organization SAML enforcement.')) { + // Some queries just result in SAML errors, and some queries we may not want to retry because it will be too disruptive. await this._credentialStore.recreate(); rsp = await gql.query(query); + } else if ((e.message as string | undefined)?.includes('401 Unauthorized')) { + await this._credentialStore.recreate(vscode.l10n.t('Your authentication session has lost authorization. You need to sign in again to regain authorization.')); + rsp = await gql.query(query); } else { + if (e.graphQLErrors && e.graphQLErrors.length && e.graphQLErrors[0].message === 'Resource not accessible by integration') { + this.codespacesTokenError(query); + } throw e; } } - Logger.debug(`Response: ${JSON.stringify(rsp, null, 2)}`, GRAPHQL_COMPONENT_ID); return rsp; }; - mutate = async (mutation: MutationOptions): Promise> => { - const gql = this.hub && this.hub.graphql; + mutate = async (mutation: MutationOptions, legacyFallback?: { mutation: DocumentNode, deleteProps: string[] }): Promise> => { + const gql = this.authMatchesServer && this.hub && this.hub.graphql; if (!gql) { - Logger.debug(`Not available for query: ${mutation}`, GRAPHQL_COMPONENT_ID); + Logger.debug(`Not available for query: ${mutation.context as string}`, GRAPHQL_COMPONENT_ID); return { data: null, loading: false, @@ -197,14 +291,28 @@ export class GitHubRepository implements vscode.Disposable { } as any; } - Logger.debug(`Request: ${JSON.stringify(mutation, null, 2)}`, GRAPHQL_COMPONENT_ID); - const rsp = await gql.mutate(mutation); - Logger.debug(`Response: ${JSON.stringify(rsp, null, 2)}`, GRAPHQL_COMPONENT_ID); + let rsp; + try { + rsp = await gql.mutate(mutation); + } catch (e) { + if (legacyFallback) { + mutation.mutation = legacyFallback.mutation; + if (mutation.variables?.input) { + for (const prop of legacyFallback.deleteProps) { + delete mutation.variables.input[prop]; + } + } + return this.mutate(mutation); + } else if (e.graphQLErrors && e.graphQLErrors.length && e.graphQLErrors[0].message === 'Resource not accessible by integration') { + this.codespacesTokenError(mutation); + } + throw e; + } return rsp; }; get schema() { - return defaultSchema as any; + return this._queriesSchema; } async getMetadata(): Promise { @@ -217,7 +325,7 @@ export class GitHubRepository implements vscode.Disposable { return this._metadata; } const { octokit, remote } = await this.ensure(); - const result = await octokit.repos.get({ + const result = await octokit.call(octokit.api.repos.get, { owner: remote.owner, repo: remote.repositoryName, }); @@ -226,12 +334,16 @@ export class GitHubRepository implements vscode.Disposable { return this._metadata; } + /** + * Resolves remotes with redirects. + * @returns + */ async resolveRemote(): Promise { try { const { clone_url } = await this.getMetadata(); - this.remote = parseRemote(this.remote.remoteName, clone_url, this.remote.gitProtocol)!; + this.remote = GitHubRemote.remoteAsGitHub(parseRemote(this.remote.remoteName, clone_url, this.remote.gitProtocol)!, this.remote.githubServerType); } catch (e) { - Logger.appendLine(`Unable to resolve remote: ${e}`); + Logger.warn(`Unable to resolve remote: ${e}`); if (isSamlError(e)) { return false; } @@ -239,63 +351,83 @@ export class GitHubRepository implements vscode.Disposable { return true; } - async ensure(): Promise { + async ensure(additionalScopes: boolean = false): Promise { this._initialized = true; - + const oldHub = this._hub; if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) { // We need auth now. (ex., a PR is already checked out) // We can no longer wait until later for login to be done - await this._credentialStore.create(); + await this._credentialStore.create(undefined, additionalScopes); if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) { this._hub = await this._credentialStore.showSignInNotification(this.remote.authProviderId); } } else { - this._hub = this._credentialStore.getHub(this.remote.authProviderId); + if (additionalScopes) { + this._hub = await this._credentialStore.getHubEnsureAdditionalScopes(this.remote.authProviderId); + } else { + this._hub = this._credentialStore.getHub(this.remote.authProviderId); + } } + if (oldHub !== this._hub) { + if (this._credentialStore.areScopesExtra(this.remote.authProviderId)) { + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, extraSchema.default as any); + } else if (this._credentialStore.areScopesOld(this.remote.authProviderId)) { + this._areQueriesLimited = true; + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, limitedSchema.default as any); + } else { + this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, defaultSchema as any); + } + } return this; } + async ensureAdditionalScopes(): Promise { + return this.ensure(true); + } + async getDefaultBranch(): Promise { + const overrideSetting = getOverrideBranch(); + if (overrideSetting) { + return overrideSetting; + } try { Logger.debug(`Fetch default branch - enter`, GitHubRepository.ID); - const { octokit, remote } = await this.ensure(); - const { data } = await octokit.repos.get({ - owner: remote.owner, - repo: remote.repositoryName, - }); + const data = await this.getMetadata(); Logger.debug(`Fetch default branch - done`, GitHubRepository.ID); return data.default_branch; } catch (e) { - Logger.appendLine(`GitHubRepository> Fetching default branch failed: ${e}`); + Logger.warn(`Fetching default branch failed: ${e}`, GitHubRepository.ID); } return 'master'; } - async getRepoAccessAndMergeMethods(): Promise { + private _repoAccessAndMergeMethods: RepoAccessAndMergeMethods | undefined; + async getRepoAccessAndMergeMethods(refetch: boolean = false): Promise { try { - Logger.debug(`Fetch repo permissions and available merge methods - enter`, GitHubRepository.ID); - const { octokit, remote } = await this.ensure(); - const { data } = await octokit.repos.get({ - owner: remote.owner, - repo: remote.repositoryName, - }); - Logger.debug(`Fetch repo permissions and available merge methods - done`, GitHubRepository.ID); - - return { - // Users with push access to repo have rights to merge/close PRs, - // edit title/description, assign reviewers/labels etc. - hasWritePermission: data.permissions?.push ?? false, - mergeMethodsAvailability: { - merge: data.allow_merge_commit ?? false, - squash: data.allow_squash_merge ?? false, - rebase: data.allow_rebase_merge ?? false, - }, - }; + if (!this._repoAccessAndMergeMethods || refetch) { + Logger.debug(`Fetch repo permissions and available merge methods - enter`, GitHubRepository.ID); + const data = await this.getMetadata(); + + Logger.debug(`Fetch repo permissions and available merge methods - done`, GitHubRepository.ID); + const hasWritePermission = data.permissions?.push ?? false; + this._repoAccessAndMergeMethods = { + // Users with push access to repo have rights to merge/close PRs, + // edit title/description, assign reviewers/labels etc. + hasWritePermission, + mergeMethodsAvailability: { + merge: data.allow_merge_commit ?? false, + squash: data.allow_squash_merge ?? false, + rebase: data.allow_rebase_merge ?? false, + }, + viewerCanAutoMerge: ((data as any).allow_auto_merge && hasWritePermission) ?? false + }; + } + return this._repoAccessAndMergeMethods; } catch (e) { - Logger.appendLine(`GitHubRepository> Fetching repo permissions and available merge methods failed: ${e}`); + Logger.warn(`GitHubRepository> Fetching repo permissions and available merge methods failed: ${e}`); } return { @@ -305,14 +437,46 @@ export class GitHubRepository implements vscode.Disposable { squash: true, rebase: true, }, + viewerCanAutoMerge: false }; } + private _branchHasMergeQueue: Map = new Map(); + async mergeQueueMethodForBranch(branch: string): Promise { + if (this._branchHasMergeQueue.has(branch)) { + return this._branchHasMergeQueue.get(branch)!; + } + try { + Logger.debug('Fetch branch has merge queue - enter', GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + if (!schema.MergeQueueForBranch) { + return undefined; + } + const result = await query({ + query: schema.MergeQueueForBranch, + variables: { + owner: remote.owner, + name: remote.repositoryName, + branch + } + }); + + Logger.debug('Fetch branch has merge queue - done', GitHubRepository.ID); + const mergeMethod = parseMergeMethod(result.data.repository.mergeQueue?.configuration?.mergeMethod); + if (mergeMethod) { + this._branchHasMergeQueue.set(branch, mergeMethod); + } + return mergeMethod; + } catch (e) { + Logger.error(`Fetching branch has merge queue failed: ${e}`, GitHubRepository.ID); + } + } + async getAllPullRequests(page?: number): Promise { try { Logger.debug(`Fetch all pull requests - enter`, GitHubRepository.ID); const { octokit, remote } = await this.ensure(); - const result = await octokit.pulls.list({ + const result = await octokit.call(octokit.api.pulls.list, { owner: remote.owner, repo: remote.repositoryName, per_page: PULL_REQUEST_PAGE_SIZE, @@ -323,8 +487,8 @@ export class GitHubRepository implements vscode.Disposable { if (!result.data) { // We really don't expect this to happen, but it seems to (see #574). // Log a warning and return an empty set. - Logger.appendLine( - `Warning: no result data for ${remote.owner}/${remote.repositoryName} Status: ${result.status}`, + Logger.warn( + `No result data for ${remote.owner}/${remote.repositoryName} Status: ${result.status}`, ); return { items: [], @@ -335,7 +499,7 @@ export class GitHubRepository implements vscode.Disposable { const pullRequests = result.data .map(pullRequest => { if (!pullRequest.head.repo) { - Logger.appendLine('GitHubRepository> The remote branch for this PR was already deleted.'); + Logger.appendLine('The remote branch for this PR was already deleted.', GitHubRepository.ID); return null; } @@ -351,7 +515,7 @@ export class GitHubRepository implements vscode.Disposable { hasMorePages, }; } catch (e) { - Logger.appendLine(`Fetching all pull requests failed: ${e}`, GitHubRepository.ID); + Logger.error(`Fetching all pull requests failed: ${e}`, GitHubRepository.ID); if (e.code === 404) { // not found vscode.window.showWarningMessage( @@ -364,56 +528,152 @@ export class GitHubRepository implements vscode.Disposable { return undefined; } - async getPullRequestForBranch(remoteAndBranch: string): Promise { + async getPullRequestForBranch(branch: string, headOwner: string): Promise { try { Logger.debug(`Fetch pull requests for branch - enter`, GitHubRepository.ID); - const { octokit, remote } = await this.ensure(); - const result = await octokit.pulls.list({ - owner: remote.owner, - repo: remote.repositoryName, - head: remoteAndBranch + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.PullRequestForHead, + variables: { + owner: remote.owner, + name: remote.repositoryName, + headRefName: branch, + }, }); - - const pullRequests = result.data - .map(pullRequest => { - return this.createOrUpdatePullRequestModel( - convertRESTPullRequestToRawPullRequest(pullRequest, this), - ); - }) - .filter(item => item !== null) as PullRequestModel[]; - Logger.debug(`Fetch pull requests for branch - done`, GitHubRepository.ID); - return pullRequests; + + if (data?.repository && data.repository.pullRequests.nodes.length > 0) { + const prs = data.repository.pullRequests.nodes.map(node => parseGraphQLPullRequest(node, this)).filter(pr => pr.head?.repo.owner === headOwner); + if (prs.length === 0) { + return undefined; + } + const mostRecentOrOpenPr = prs.find(pr => pr.state.toLowerCase() === 'open') ?? prs[0]; + return this.createOrUpdatePullRequestModel(mostRecentOrOpenPr); + } } catch (e) { - Logger.appendLine(`Fetching pull requests for branch failed: ${e}`, GitHubRepository.ID); + Logger.error(`Fetching pull requests for branch failed: ${e}`, GitHubRepository.ID); if (e.code === 404) { // not found vscode.window.showWarningMessage( `Fetching pull requests for remote '${this.remote.remoteName}' failed, please check if the url ${this.remote.url} is valid.`, ); - } else { - throw e; } } - return undefined; } - private getRepoForIssue(githubRepository: GitHubRepository, parsedIssue: Issue): GitHubRepository { - if ( - parsedIssue.repositoryName && - parsedIssue.repositoryUrl && - (githubRepository.remote.owner !== parsedIssue.repositoryOwner || - githubRepository.remote.repositoryName !== parsedIssue.repositoryName) - ) { - const remote = new Remote( - parsedIssue.repositoryName, - parsedIssue.repositoryUrl, - new Protocol(parsedIssue.repositoryUrl), - ); - githubRepository = new GitHubRepository(remote, this.rootUri, this._credentialStore, this._telemetry, this._sessionState); + async getOrgProjects(): Promise { + Logger.debug(`Fetch org projects - enter`, GitHubRepository.ID); + let { query, remote, schema } = await this.ensure(); + const projects: IProject[] = []; + + try { + const { data } = await query({ + query: schema.GetOrgProjects, + variables: { + owner: remote.owner, + after: null, + } + }); + + if (data && data.organization.projectsV2 && data.organization.projectsV2.nodes) { + data.organization.projectsV2.nodes.forEach(raw => { + projects.push(raw); + }); + } + + } catch (e) { + Logger.error(`Unable to fetch org projects: ${e}`, GitHubRepository.ID); + return projects; + } + Logger.debug(`Fetch org projects - done`, GitHubRepository.ID); + + return projects; + } + + async getProjects(): Promise { + try { + Logger.debug(`Fetch projects - enter`, GitHubRepository.ID); + let { query, remote, schema } = await this.ensure(); + if (!schema.GetRepoProjects) { + const additional = await this.ensureAdditionalScopes(); + query = additional.query; + remote = additional.remote; + schema = additional.schema; + } + const { data } = await query({ + query: schema.GetRepoProjects, + variables: { + owner: remote.owner, + name: remote.repositoryName, + }, + }); + Logger.debug(`Fetch projects - done`, GitHubRepository.ID); + + const projects: IProject[] = []; + if (data && data.repository?.projectsV2 && data.repository.projectsV2.nodes) { + data.repository.projectsV2.nodes.forEach(raw => { + projects.push(raw); + }); + } + return projects; + } catch (e) { + Logger.error(`Unable to fetch projects: ${e}`, GitHubRepository.ID); + return; + } + } + + async getMilestones(includeClosed: boolean = false): Promise { + try { + Logger.debug(`Fetch milestones - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + const states = ['OPEN']; + if (includeClosed) { + states.push('CLOSED'); + } + const { data } = await query({ + query: schema.GetMilestones, + variables: { + owner: remote.owner, + name: remote.repositoryName, + states: states, + }, + }); + Logger.debug(`Fetch milestones - done`, GitHubRepository.ID); + + const milestones: IMilestone[] = []; + if (data && data.repository?.milestones && data.repository.milestones.nodes) { + data.repository.milestones.nodes.forEach(raw => { + const milestone = parseMilestone(raw); + if (milestone) { + milestones.push(milestone); + } + }); + } + return milestones; + } catch (e) { + Logger.error(`Unable to fetch milestones: ${e}`, GitHubRepository.ID); + return; + } + } + + async getLines(sha: string, file: string, lineStart: number, lineEnd: number): Promise { + Logger.debug(`Fetch milestones - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + const { data } = await query({ + query: schema.GetFileContent, + variables: { + owner: remote.owner, + name: remote.repositoryName, + expression: `${sha}:${file}` + } + }); + + if (!data.repository?.object.text) { + return undefined; } - return githubRepository; + + return data.repository.object.text.split('\n').slice(lineStart - 1, lineEnd).join('\n'); } async getIssuesForUserByMilestone(_page?: number): Promise { @@ -421,26 +681,23 @@ export class GitHubRepository implements vscode.Disposable { Logger.debug(`Fetch all issues - enter`, GitHubRepository.ID); const { query, remote, schema } = await this.ensure(); const { data } = await query({ - query: schema.GetMilestones, + query: schema.GetMilestonesWithIssues, variables: { owner: remote.owner, name: remote.repositoryName, - assignee: this._credentialStore.getCurrentUser(remote.authProviderId)?.login, + assignee: (await this._credentialStore.getCurrentUser(remote.authProviderId))?.login, }, }); Logger.debug(`Fetch all issues - done`, GitHubRepository.ID); const milestones: { milestone: IMilestone; issues: IssueModel[] }[] = []; - let githubRepository: GitHubRepository = this; - if (data && data.repository.milestones && data.repository.milestones.nodes) { + if (data && data.repository?.milestones && data.repository.milestones.nodes) { data.repository.milestones.nodes.forEach(raw => { const milestone = parseMilestone(raw); if (milestone) { const issues: IssueModel[] = []; raw.issues.edges.forEach(issue => { - const parsedIssue = parseGraphQLIssue(issue.node, this); - githubRepository = this.getRepoForIssue(githubRepository, parsedIssue); - issues.push(new IssueModel(githubRepository, githubRepository.remote, parsedIssue)); + issues.push(new IssueModel(this, this.remote, parseGraphQLIssue(issue.node, this))); }); milestones.push({ milestone, issues }); } @@ -448,10 +705,10 @@ export class GitHubRepository implements vscode.Disposable { } return { items: milestones, - hasMorePages: data.repository.milestones.pageInfo.hasNextPage, + hasMorePages: !!data.repository?.milestones.pageInfo.hasNextPage, }; } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch issues: ${e}`); + Logger.error(`Unable to fetch issues: ${e}`, GitHubRepository.ID); return; } } @@ -465,28 +722,25 @@ export class GitHubRepository implements vscode.Disposable { variables: { owner: remote.owner, name: remote.repositoryName, - assignee: this._credentialStore.getCurrentUser(remote.authProviderId)?.login, + assignee: (await this._credentialStore.getCurrentUser(remote.authProviderId))?.login, }, }); Logger.debug(`Fetch issues without milestone - done`, GitHubRepository.ID); - const issues: IssueModel[] = []; - let githubRepository: GitHubRepository = this; - if (data && data.repository.issues.edges) { + const issues: Issue[] = []; + if (data && data.repository?.issues.edges) { data.repository.issues.edges.forEach(raw => { if (raw.node.id) { - const parsedIssue = parseGraphQLIssue(raw.node, this); - githubRepository = this.getRepoForIssue(githubRepository, parsedIssue); - issues.push(new IssueModel(githubRepository, githubRepository.remote, parsedIssue)); + issues.push(parseGraphQLIssue(raw.node, this)); } }); } return { items: issues, - hasMorePages: data.repository.issues.pageInfo.hasNextPage, + hasMorePages: !!data.repository?.issues.pageInfo.hasNextPage, }; } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch issues without milestone: ${e}`); + Logger.error(`Unable to fetch issues without milestone: ${e}`, GitHubRepository.ID); return; } } @@ -503,14 +757,11 @@ export class GitHubRepository implements vscode.Disposable { }); Logger.debug(`Fetch issues with query - done`, GitHubRepository.ID); - const issues: IssueModel[] = []; - let githubRepository: GitHubRepository = this; + const issues: Issue[] = []; if (data && data.search.edges) { data.search.edges.forEach(raw => { if (raw.node.id) { - const parsedIssue = parseGraphQLIssue(raw.node, this); - githubRepository = this.getRepoForIssue(githubRepository, parsedIssue); - issues.push(new IssueModel(githubRepository, githubRepository.remote, parsedIssue)); + issues.push(parseGraphQLIssue(raw.node, this)); } }); } @@ -519,7 +770,7 @@ export class GitHubRepository implements vscode.Disposable { hasMorePages: data.search.pageInfo.hasNextPage, }; } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch issues with query: ${e}`); + Logger.error(`Unable to fetch issues with query: ${e}`, GitHubRepository.ID); return; } } @@ -537,12 +788,12 @@ export class GitHubRepository implements vscode.Disposable { }); Logger.debug(`Fetch max issue - done`, GitHubRepository.ID); - if (data && data.repository.issues.edges.length === 1) { + if (data?.repository && data.repository.issues.edges.length === 1) { return data.repository.issues.edges[0].node.number; } return; } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch issues with query: ${e}`); + Logger.error(`Unable to fetch issues with query: ${e}`, GitHubRepository.ID); return; } } @@ -561,7 +812,7 @@ export class GitHubRepository implements vscode.Disposable { Logger.debug(`Fetch viewer permission - done`, GitHubRepository.ID); return parseGraphQLViewerPermission(data); } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch viewer permission: ${e}`); + Logger.error(`Unable to fetch viewer permission: ${e}`, GitHubRepository.ID); return ViewerPermission.Unknown; } } @@ -570,13 +821,13 @@ export class GitHubRepository implements vscode.Disposable { try { Logger.debug(`Fork repository`, GitHubRepository.ID); const { octokit, remote } = await this.ensure(); - const result = (await octokit.repos.createFork({ + const result = await octokit.call(octokit.api.repos.createFork, { owner: remote.owner, repo: remote.repositoryName, - })) as any; + }); return result.data.clone_url; } catch (e) { - Logger.appendLine(`GitHubRepository> Forking repository failed: ${e}`); + Logger.error(`GitHubRepository> Forking repository failed: ${e}`, GitHubRepository.ID); return undefined; } } @@ -595,41 +846,40 @@ export class GitHubRepository implements vscode.Disposable { Logger.debug(`Fetch repository fork details - done`, GitHubRepository.ID); return data.repository; } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch repository fork details: ${e}`); + Logger.error(`Unable to fetch repository fork details: ${e}`, GitHubRepository.ID); return; } } async getAuthenticatedUser(): Promise { - const { octokit } = await this.ensure(); - const user = await octokit.users.getAuthenticated({}); - return user.data.login; + return (await this._credentialStore.getCurrentUser(this.remote.authProviderId)).login; } async getPullRequestsForCategory(categoryQuery: string, page?: number): Promise { try { Logger.debug(`Fetch pull request category ${categoryQuery} - enter`, GitHubRepository.ID); - const { octokit, remote } = await this.ensure(); - const user = await octokit.users.getAuthenticated({}); + const { octokit, query, schema } = await this.ensure(); + + const user = await this.getAuthenticatedUser(); // Search api will not try to resolve repo that redirects, so get full name first - const repo = await octokit.repos.get({ owner: this.remote.owner, repo: this.remote.repositoryName }); - const { data, headers } = await octokit.search.issuesAndPullRequests({ - q: getPRFetchQuery(repo.data.full_name, user.data.login, categoryQuery), + const repo = await this.getMetadata(); + const { data, headers } = await octokit.call(octokit.api.search.issuesAndPullRequests, { + q: getPRFetchQuery(repo.full_name, user, categoryQuery), per_page: PULL_REQUEST_PAGE_SIZE, page: page || 1, }); - const promises: Promise>[] = []; - data.items.forEach((item: any /** unluckily Octokit.AnyResponse */) => { - promises.push( - new Promise(async (resolve, _reject) => { - const prData = await octokit.pulls.get({ - owner: remote.owner, - repo: remote.repositoryName, - pull_number: item.number, - }); - resolve(prData); - }), - ); + + const promises: Promise[] = data.items.map(async (item) => { + const prRepo = new Protocol(item.repository_url); + const { data } = await query({ + query: schema.PullRequest, + variables: { + owner: prRepo.owner, + name: prRepo.repositoryName, + number: item.number + } + }); + return data; }); const hasMorePages = !!headers.link && headers.link.indexOf('rel="next"') > -1; @@ -637,13 +887,13 @@ export class GitHubRepository implements vscode.Disposable { const pullRequests = pullRequestResponses .map(response => { - if (!response.data.head.repo) { - Logger.appendLine('GitHubRepository> The remote branch for this PR was already deleted.'); + if (!response.repository?.pullRequest.headRef) { + Logger.appendLine('The remote branch for this PR was already deleted.', GitHubRepository.ID); return null; } return this.createOrUpdatePullRequestModel( - convertRESTPullRequestToRawPullRequest(response.data, this), + parseGraphQLPullRequest(response.repository.pullRequest, this), ); }) .filter(item => item !== null) as PullRequestModel[]; @@ -655,7 +905,7 @@ export class GitHubRepository implements vscode.Disposable { hasMorePages, }; } catch (e) { - Logger.appendLine(`GitHubRepository> Fetching all pull requests failed: ${e}`); + Logger.error(`Fetching all pull requests failed: ${e}`, GitHubRepository.ID); if (e.code === 404) { // not found vscode.window.showWarningMessage( @@ -673,7 +923,7 @@ export class GitHubRepository implements vscode.Disposable { if (model) { model.update(pullRequest); } else { - model = new PullRequestModel(this._telemetry, this, this.remote, pullRequest); + model = new PullRequestModel(this._credentialStore, this._telemetry, this, this.remote, pullRequest); model.onDidInvalidate(() => this.getPullRequest(pullRequest.number)); this._pullRequestModels.set(pullRequest.number, model); this._onDidAddPullRequest.fire(model); @@ -683,9 +933,33 @@ export class GitHubRepository implements vscode.Disposable { } async createPullRequest(params: OctokitCommon.PullsCreateParams): Promise { - const { octokit } = await this.ensure(); - const { data } = await octokit.pulls.create(params); - return this.createOrUpdatePullRequestModel(convertRESTPullRequestToRawPullRequest(data, this)); + try { + Logger.debug(`Create pull request - enter`, GitHubRepository.ID); + const metadata = await this.getMetadata(); + const { mutate, schema } = await this.ensure(); + + const { data } = await mutate({ + mutation: schema.CreatePullRequest, + variables: { + input: { + repositoryId: metadata.node_id, + baseRefName: params.base, + headRefName: params.head, + title: params.title, + body: params.body, + draft: params.draft + } + } + }); + Logger.debug(`Create pull request - done`, GitHubRepository.ID); + if (!data) { + throw new Error('Failed to create pull request.'); + } + return this.createOrUpdatePullRequestModel(parseGraphQLPullRequest(data.createPullRequest.pullRequest, this)); + } catch (e) { + Logger.error(`Unable to create PR: ${e}`, GitHubRepository.ID); + throw e; + } } async getPullRequest(id: number): Promise { @@ -700,11 +974,16 @@ export class GitHubRepository implements vscode.Disposable { name: remote.repositoryName, number: id, }, - }); + }, true); + if (data.repository === null) { + Logger.error('Unexpected null repository when getting PR', GitHubRepository.ID); + return; + } + Logger.debug(`Fetch pull request ${id} - done`, GitHubRepository.ID); - return this.createOrUpdatePullRequestModel(parseGraphQLPullRequest(data, this)); + return this.createOrUpdatePullRequestModel(parseGraphQLPullRequest(data.repository.pullRequest, this)); } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch PR: ${e}`); + Logger.error(`Unable to fetch PR: ${e}`, GitHubRepository.ID); return; } } @@ -721,43 +1000,73 @@ export class GitHubRepository implements vscode.Disposable { name: remote.repositoryName, number: id, }, - }); + }, true); // Don't retry on SAML errors as it's too disruptive for this query. + + if (data.repository === null) { + Logger.error('Unexpected null repository when getting issue', GitHubRepository.ID); + return undefined; + } Logger.debug(`Fetch issue ${id} - done`, GitHubRepository.ID); return new IssueModel(this, remote, parseGraphQLIssue(data.repository.pullRequest, this)); } catch (e) { - Logger.appendLine(`GithubRepository> Unable to fetch PR: ${e}`); + Logger.error(`Unable to fetch PR: ${e}`, GitHubRepository.ID); return; } } + async hasBranch(branchName: string): Promise { + Logger.appendLine(`Fetch branch ${branchName} - enter`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + + const { data } = await query({ + query: schema.GetBranch, + variables: { + owner: remote.owner, + name: remote.repositoryName, + qualifiedName: `refs/heads/${branchName}`, + } + }); + Logger.appendLine(`Fetch branch ${branchName} - done: ${data.repository?.ref !== null}`, GitHubRepository.ID); + return data.repository?.ref !== null; + } + async listBranches(owner: string, repositoryName: string): Promise { - const { octokit } = await this.ensure(); + const { query, remote, schema } = await this.ensure(); Logger.debug(`List branches for ${owner}/${repositoryName} - enter`, GitHubRepository.ID); - try { - let branches: string[] = []; - const startingTime = new Date().getTime(); - for await (const response of octokit.paginate.iterator( - 'GET /repos/:owner/:repo/branches', - { - owner: owner, - repo: repositoryName, - per_page: 100 - }, - ) as any) { - branches.push(...response.data.map(branch => branch.name)); + let after: string | null = null; + let hasNextPage = false; + const branches: string[] = []; + const startingTime = new Date().getTime(); + + do { + try { + const { data } = await query({ + query: schema.ListBranches, + variables: { + owner: remote.owner, + name: remote.repositoryName, + first: 100, + after: after, + }, + }); + + branches.push(...data.repository.refs.nodes.map(node => node.name)); if (new Date().getTime() - startingTime > 5000) { + Logger.warn('List branches timeout hit.', GitHubRepository.ID); break; } + hasNextPage = data.repository.refs.pageInfo.hasNextPage; + after = data.repository.refs.pageInfo.endCursor; + } catch (e) { + Logger.debug(`List branches for ${owner}/${repositoryName} failed`, GitHubRepository.ID); + throw e; } + } while (hasNextPage); - Logger.debug(`List branches for ${owner}/${repositoryName} - done`, GitHubRepository.ID); - return branches; - } catch (e) { - Logger.debug(`List branches for ${owner}/${repositoryName} failed`, GitHubRepository.ID); - throw e; - } + Logger.debug(`List branches for ${owner}/${repositoryName} - done`, GitHubRepository.ID); + return branches; } async deleteBranch(pullRequestModel: PullRequestModel): Promise { @@ -768,13 +1077,13 @@ export class GitHubRepository implements vscode.Disposable { } try { - await octokit.git.deleteRef({ + await octokit.call(octokit.api.git.deleteRef, { owner: pullRequestModel.head.repositoryCloneUrl.owner, repo: pullRequestModel.head.repositoryCloneUrl.repositoryName, ref: `heads/${pullRequestModel.head.ref}`, }); } catch (e) { - Logger.appendLine(`GithubRepository> Unable to delete branch: ${e}`); + Logger.error(`Unable to delete branch: ${e}`, GitHubRepository.ID); return; } } @@ -799,14 +1108,20 @@ export class GitHubRepository implements vscode.Disposable { }, }); + if (result.data.repository === null) { + Logger.error('Unexpected null repository when getting mentionable users', GitHubRepository.ID); + return []; + } + ret.push( ...result.data.repository.mentionableUsers.nodes.map(node => { return { login: node.login, - avatarUrl: node.avatarUrl, + avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), name: node.name, url: node.url, email: node.email, + id: node.id }; }), ); @@ -840,16 +1155,22 @@ export class GitHubRepository implements vscode.Disposable { first: 100, after: after, }, - }); + }, true); // we ignore SAML errors here because this query can happen at startup + + if (result.data.repository === null) { + Logger.error('Unexpected null repository when getting assignable users', GitHubRepository.ID); + return []; + } ret.push( ...result.data.repository.assignableUsers.nodes.map(node => { return { login: node.login, - avatarUrl: node.avatarUrl, + avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), name: node.name, url: node.url, email: node.email, + id: node.id }; }), ); @@ -874,24 +1195,318 @@ export class GitHubRepository implements vscode.Disposable { return ret; } + async getOrgTeamsCount(): Promise { + Logger.debug(`Fetch Teams Count - enter`, GitHubRepository.ID); + if (!this._credentialStore.isAuthenticatedWithAdditionalScopes(this.remote.authProviderId)) { + return 0; + } + + const { query, remote, schema } = await this.ensureAdditionalScopes(); + + try { + const result: { data: OrganizationTeamsCountResponse } = await query({ + query: schema.GetOrganizationTeamsCount, + variables: { + login: remote.owner + }, + }); + return result.data.organization.teams.totalCount; + } catch (e) { + Logger.debug(`Unable to fetch teams Count: ${e}`, GitHubRepository.ID); + if ( + e.graphQLErrors && + e.graphQLErrors.length > 0 && + e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' + ) { + vscode.window.showWarningMessage( + `GitHub teams features will not work. ${e.graphQLErrors[0].message}`, + ); + } + return 0; + } + } + + async getOrgTeams(refreshKind: TeamReviewerRefreshKind): Promise<(ITeam & { repositoryNames: string[] })[]> { + Logger.debug(`Fetch Teams - enter`, GitHubRepository.ID); + if ((refreshKind === TeamReviewerRefreshKind.None) || (refreshKind === TeamReviewerRefreshKind.Try && !this._credentialStore.isAuthenticatedWithAdditionalScopes(this.remote.authProviderId))) { + Logger.debug(`Fetch Teams - exit without fetching teams`, GitHubRepository.ID); + return []; + } + + const { query, remote, schema } = await this.ensureAdditionalScopes(); + + let after: string | null = null; + let hasNextPage = false; + const orgTeams: (ITeam & { repositoryNames: string[] })[] = []; + + do { + try { + const result: { data: OrganizationTeamsResponse } = await query({ + query: schema.GetOrganizationTeams, + variables: { + login: remote.owner, + after: after, + repoName: remote.repositoryName, + }, + }); + + result.data.organization.teams.nodes.forEach(node => { + const team: ITeam = { + avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), + name: node.name, + url: node.url, + slug: node.slug, + id: node.id, + org: remote.owner + }; + orgTeams.push({ ...team, repositoryNames: node.repositories.nodes.map(repo => repo.name) }); + }); + + hasNextPage = result.data.organization.teams.pageInfo.hasNextPage; + after = result.data.organization.teams.pageInfo.endCursor; + } catch (e) { + Logger.debug(`Unable to fetch teams: ${e}`, GitHubRepository.ID); + if ( + e.graphQLErrors && + e.graphQLErrors.length > 0 && + e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' + ) { + vscode.window.showWarningMessage( + `GitHub teams features will not work. ${e.graphQLErrors[0].message}`, + ); + } + return orgTeams; + } + } while (hasNextPage); + + Logger.debug(`Fetch Teams - exit`, GitHubRepository.ID); + return orgTeams; + } + + async getPullRequestParticipants(pullRequestNumber: number): Promise { + Logger.debug(`Fetch participants from a Pull Request`, GitHubRepository.ID); + const { query, remote, schema } = await this.ensure(); + + const ret: IAccount[] = []; + + try { + const result: { data: PullRequestParticipantsResponse } = await query({ + query: schema.GetParticipants, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: pullRequestNumber, + first: 18 + }, + }); + if (result.data.repository === null) { + Logger.error('Unexpected null repository when fetching participants', GitHubRepository.ID); + return []; + } + + ret.push( + ...result.data.repository.pullRequest.participants.nodes.map(node => { + return { + login: node.login, + avatarUrl: getAvatarWithEnterpriseFallback(node.avatarUrl, undefined, this.remote.isEnterprise), + name: node.name, + url: node.url, + email: node.email, + id: node.id + }; + }), + ); + } catch (e) { + Logger.debug(`Unable to fetch participants from a PullRequest: ${e}`, GitHubRepository.ID); + if ( + e.graphQLErrors && + e.graphQLErrors.length > 0 && + e.graphQLErrors[0].type === 'INSUFFICIENT_SCOPES' + ) { + vscode.window.showWarningMessage( + `GitHub user features will not work. ${e.graphQLErrors[0].message}`, + ); + } + } + + return ret; + } + /** * Compare across commits. * @param base The base branch. Must be a branch name. If comparing across repositories, use the format :branch. * @param head The head branch. Must be a branch name. If comparing across repositories, use the format :branch. */ - public async compareCommits(base: string, head: string): Promise { - const { remote, octokit } = await this.ensure(); - const { data } = await octokit.repos.compareCommits({ - repo: remote.repositoryName, - owner: remote.owner, - base, - head, - }); - - return data; + public async compareCommits(base: string, head: string): Promise { + Logger.debug('Compare commits - enter', GitHubRepository.ID); + try { + const { remote, octokit } = await this.ensure(); + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base, + head, + }); + Logger.debug('Compare commits - done', GitHubRepository.ID); + return data; + } catch (e) { + Logger.error(`Unable to compare commits between ${base} and ${head}: ${e}`, GitHubRepository.ID); + } } - isCurrentUser(login: string): boolean { + isCurrentUser(login: string): Promise { return this._credentialStore.isCurrentUser(login); } + + /** + * Get the status checks of the pull request, those for the last commit. + * + * This method should go in PullRequestModel, but because of the status checks bug we want to track `_useFallbackChecks` at a repo level. + */ + private _useFallbackChecks: boolean = false; + async getStatusChecks(number: number): Promise<[PullRequestChecks | null, PullRequestReviewRequirement | null]> { + const { query, remote, schema } = await this.ensure(); + const captureUseFallbackChecks = this._useFallbackChecks; + let result: ApolloQueryResult; + try { + result = await query({ + query: captureUseFallbackChecks ? schema.GetChecksWithoutSuite : schema.GetChecks, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: number, + }, + }, true); // There's an issue with the GetChecks that can result in SAML errors. + } catch (e) { + if (e.message?.startsWith('GraphQL error: Resource protected by organization SAML enforcement.')) { + // There seems to be an issue with fetching status checks if you haven't SAML'd with every org you have + // The issue is specifically with the CheckSuite property. Make the query again, but without that property. + if (!captureUseFallbackChecks) { + this._useFallbackChecks = true; + return this.getStatusChecks(number); + } + } + throw e; + } + + if ((result.data.repository === null) || (result.data.repository.pullRequest.commits.nodes === undefined) || (result.data.repository.pullRequest.commits.nodes.length === 0)) { + Logger.error(`Unable to fetch PR checks: ${result.errors?.map(error => error.message).join(', ')}`, GitHubRepository.ID); + return [null, null]; + } + + // We always fetch the status checks for only the last commit, so there should only be one node present + const statusCheckRollup = result.data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup; + + const checks: PullRequestChecks = !statusCheckRollup + ? { + 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, + 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, + targetUrl: context.targetUrl, + isRequired: context.isRequired, + }; + } + }), + }; + + let reviewRequirement: PullRequestReviewRequirement | null = null; + const rule = result.data.repository.pullRequest.baseRef.refUpdateRule; + if (rule) { + const prUrl = result.data.repository.pullRequest.url; + + for (const context of rule.requiredStatusCheckContexts || []) { + if (!checks.statuses.some(status => status.context === context)) { + checks.state = CheckState.Pending; + checks.statuses.push({ + id: '', + url: undefined, + avatarUrl: undefined, + state: CheckState.Pending, + description: vscode.l10n.t('Waiting for status to be reported'), + context: context, + targetUrl: prUrl, + isRequired: true + }); + } + } + + const requiredApprovingReviews = rule.requiredApprovingReviewCount ?? 0; + const approvingReviews = result.data.repository.pullRequest.latestReviews.nodes.filter( + review => review.authorCanPushToRepository && review.state === 'APPROVED', + ); + const requestedChanges = result.data.repository.pullRequest.reviewsRequestingChanges.nodes.filter( + review => review.authorCanPushToRepository + ); + let state: CheckState = CheckState.Success; + if (approvingReviews.length < requiredApprovingReviews) { + state = CheckState.Failure; + + if (requestedChanges.length) { + state = CheckState.Pending; + } + } + if (requiredApprovingReviews > 0) { + reviewRequirement = { + count: requiredApprovingReviews, + approvals: approvingReviews.map(review => review.author.login), + requestedChanges: requestedChanges.map(review => review.author.login), + state: state + }; + } + } + + return [checks.statuses.length ? checks : null, reviewRequirement]; + } + + mapStateAsCheckState(state: string | null | undefined): CheckState { + switch (state) { + case 'EXPECTED': + case 'PENDING': + case 'ACTION_REQUIRED': + case 'STALE': + return CheckState.Pending; + case 'ERROR': + case 'FAILURE': + case 'TIMED_OUT': + case 'STARTUP_FAILURE': + return CheckState.Failure; + case 'SUCCESS': + return CheckState.Success; + case 'NEUTRAL': + case 'SKIPPED': + return CheckState.Neutral; + } + + return CheckState.Unknown; + } } diff --git a/src/github/graphql.ts b/src/github/graphql.ts index cb669f7c75..1bf331f743 100644 --- a/src/github/graphql.ts +++ b/src/github/graphql.ts @@ -3,9 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DiffSide, ViewedState } from '../common/comment'; +import { DiffSide, SubjectType, ViewedState } from '../common/comment'; import { ForkDetails } from './githubRepository'; +interface PageInfo { + hasNextPage: boolean; + endCursor: string; +} + export interface MergedEvent { __typename: string; id: string; @@ -42,7 +47,8 @@ export interface AbbreviatedIssueComment { login: string; avatarUrl: string; url: string; - email?: string + email?: string; + id: string; }; body: string; databaseId: number; @@ -64,11 +70,36 @@ export interface IssueComment extends AbbreviatedIssueComment { export interface ReactionGroup { content: string; viewerHasReacted: boolean; - users: { + reactors: { + nodes: { + login: string; + }[] totalCount: number; }; } +export interface Account { + login: string; + avatarUrl: string; + name: string; + url: string; + email: string; + id: string; +} + +interface Team { + avatarUrl: string; + name: string; + url: string; + repositories: { + nodes: { + name: string + }[]; + }; + slug: string; + id: string; +} + export interface ReviewComment { __typename: string; id: string; @@ -78,6 +109,7 @@ export interface ReviewComment { login: string; avatarUrl: string; url: string; + id: string; }; path: string; originalPosition: number; @@ -113,6 +145,7 @@ export interface Commit { login: string; avatarUrl: string; url: string; + id: string; }; }; committer: { @@ -129,7 +162,7 @@ export interface Commit { export interface AssignedEvent { __typename: string; - databaseId: number; + id: number; actor: { login: string; avatarUrl: string; @@ -139,9 +172,18 @@ export interface AssignedEvent { login: string; avatarUrl: string; url: string; + id: string; }; } +export interface MergeQueueEntry { + position: number, + state: MergeQueueState; + mergeQueue: { + url: string; + } +} + export interface Review { __typename: string; id: string; @@ -152,6 +194,7 @@ export interface Review { login: string; avatarUrl: string; url: string; + id: string; }; state: 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'PENDING'; body: string; @@ -173,8 +216,16 @@ export interface ReviewThread { originalStartLine: number | null; originalLine: number; isOutdated: boolean; + subjectType?: SubjectType; comments: { nodes: ReviewComment[]; + edges: [{ + node: { + pullRequestReview: { + databaseId: number + } + } + }] }; } @@ -185,10 +236,22 @@ export interface TimelineEventsResponse { nodes: (MergedEvent | Review | IssueComment | Commit | AssignedEvent | HeadRefDeletedEvent)[]; }; }; - }; + } | null; rateLimit: RateLimit; } +export interface LatestReviewCommitResponse { + repository: { + pullRequest: { + viewerLatestReview: { + commit: { + oid: string; + } + }; + }; + } | null; +} + export interface PendingReviewIdResponse { node: { reviews: { @@ -198,6 +261,29 @@ export interface PendingReviewIdResponse { rateLimit: RateLimit; } +export interface GetReviewRequestsResponse { + repository: { + pullRequest: { + reviewRequests: { + nodes: { + requestedReviewer: { + // Shared properties between accounts and teams + avatarUrl: string; + url: string; + name: string; + // Account properties + login?: string; + email?: string; + // Team properties + slug?: string; + id: string; + } | null; + }[]; + }; + }; + } | null; +} + export interface PullRequestState { repository: { pullRequest: { @@ -205,7 +291,7 @@ export interface PullRequestState { number: number; state: 'OPEN' | 'CLOSED' | 'MERGED'; }; - }; + } | null; } export interface PullRequestCommentsResponse { @@ -213,49 +299,67 @@ export interface PullRequestCommentsResponse { pullRequest: { reviewThreads: { nodes: ReviewThread[]; + pageInfo: PageInfo; }; }; - }; + } | null; } export interface MentionableUsersResponse { repository: { mentionableUsers: { - nodes: { - login: string; - avatarUrl: string; - name: string; - url: string; - email: string; - }[]; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; + nodes: Account[]; + pageInfo: PageInfo; }; - }; + } | null; rateLimit: RateLimit; } export interface AssignableUsersResponse { repository: { assignableUsers: { - nodes: { - login: string; - avatarUrl: string; - name: string; - url: string; - email: string; - }[]; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; + nodes: Account[]; + pageInfo: PageInfo; + }; + } | null; + rateLimit: RateLimit; +} + +export interface OrganizationTeamsCountResponse { + organization: { + teams: { + totalCount: number; + }; + }; +} + +export interface OrganizationTeamsResponse { + organization: { + teams: { + nodes: Team[]; + totalCount: number; + pageInfo: PageInfo; }; }; rateLimit: RateLimit; } +export interface PullRequestParticipantsResponse { + repository: { + pullRequest: { + participants: { + nodes: Account[]; + }; + }; + } | null; +} + +export interface CreatePullRequestResponse { + createPullRequest: { + pullRequest: PullRequest + } +} + export interface AddReviewThreadResponse { addPullRequestReviewThread: { thread: ReviewThread; @@ -296,6 +400,26 @@ export interface MarkPullRequestReadyForReviewResponse { }; } +export interface MergeQueueForBranchResponse { + repository: { + mergeQueue?: { + configuration?: { + mergeMethod: MergeMethod; + } + } + } +} + +export interface DequeuePullRequestResponse { + mergeQueueEntry: MergeQueueEntry; +} + +export interface EnqueuePullRequestResponse { + enqueuePullRequest: { + mergeQueueEntry: MergeQueueEntry; + } +} + export interface SubmittedReview extends Review { comments: { nodes: ReviewComment[]; @@ -347,16 +471,55 @@ export interface UpdatePullRequestResponse { body: string; bodyHTML: string; title: string; + titleHTML: string; }; }; } +export interface AddPullRequestToProjectResponse { + addProjectV2ItemById: { + item: { + id: string; + }; + }; +} + +export interface GetBranchResponse { + repository: { + ref: { + target: { + oid: string; + } + } + } | null; +} + +export interface ListBranchesResponse { + repository: { + refs: { + nodes: { + name: string; + }[]; + pageInfo: PageInfo; + }; + } | null; +} + export interface RefRepository { + isInOrganization: boolean; owner: { login: string; }; url: string; } + +export interface BaseRefRepository extends RefRepository { + squashMergeCommitTitle?: DefaultCommitTitle; + squashMergeCommitMessage?: DefaultCommitMessage; + mergeCommitMessage?: DefaultCommitMessage; + mergeCommitTitle?: DefaultCommitTitle; +} + export interface Ref { name: string; repository: RefRepository; @@ -373,9 +536,13 @@ export interface SuggestedReviewerResponse { avatarUrl: string; name: string; url: string; + id: string; }; } +export type MergeMethod = 'MERGE' | 'REBASE' | 'SQUASH'; +export type MergeQueueState = 'AWAITING_CHECKS' | 'LOCKED' | 'MERGEABLE' | 'QUEUED' | 'UNMERGEABLE'; + export interface PullRequest { id: string; databaseId: number; @@ -385,18 +552,28 @@ export interface PullRequest { body: string; bodyHTML: string; title: string; + titleHTML: string; assignees?: { nodes: { login: string; url: string; email: string; avatarUrl: string; + id: string; }[]; }; author: { login: string; url: string; avatarUrl: string; + id: string; + }; + commits: { + nodes: { + commit: { + message: string; + }; + }[]; }; comments?: { nodes: AbbreviatedIssueComment[]; @@ -410,7 +587,7 @@ export interface PullRequest { baseRef?: Ref; baseRefName: string; baseRefOid: string; - baseRepository: RefRepository; + baseRepository: BaseRefRepository; labels: { nodes: { name: string; @@ -419,14 +596,30 @@ export interface PullRequest { }; merged: boolean; mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; + mergeQueueEntry?: MergeQueueEntry | null; mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; + autoMergeRequest?: { + mergeMethod: MergeMethod; + }; + viewerCanEnableAutoMerge: boolean; + viewerCanDisableAutoMerge: boolean; isDraft?: boolean; suggestedReviewers: SuggestedReviewerResponse[]; + projectItems?: { + nodes: { + project: { + id: string; + title: string; + }, + id: string + }[]; + }; milestone?: { title: string; dueOn?: string; id: string; createdAt: string; + number: number; }; repository?: { name: string; @@ -437,10 +630,23 @@ export interface PullRequest { }; } +export enum DefaultCommitTitle { + prTitle = 'PR_TITLE', + commitOrPrTitle = 'COMMIT_OR_PR_TITLE', + mergeMessage = 'MERGE_MESSAGE' +} + +export enum DefaultCommitMessage { + prBody = 'PR_BODY', + commitMessages = 'COMMIT_MESSAGES', + blank = 'BLANK', + prTitle = 'PR_TITLE' +} + export interface PullRequestResponse { repository: { pullRequest: PullRequest; - }; + } | null; rateLimit: RateLimit; } @@ -450,17 +656,14 @@ export interface PullRequestMergabilityResponse { mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; }; - }; + } | null; rateLimit: RateLimit; } export interface IssuesSearchResponse { search: { issueCount: number; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; + pageInfo: PageInfo; edges: { node: PullRequest; }[]; @@ -468,6 +671,29 @@ export interface IssuesSearchResponse { rateLimit: RateLimit; } +export interface RepoProjectsResponse { + repository: { + projectsV2: { + nodes: { + title: string; + id: string; + }[]; + } + } | null; +} + +export interface OrgProjectsResponse { + organization: { + projectsV2: { + nodes: { + title: string; + id: string; + }[]; + pageInfo: PageInfo; + } + } +} + export interface MilestoneIssuesResponse { repository: { milestones: { @@ -476,18 +702,16 @@ export interface MilestoneIssuesResponse { createdAt: string; title: string; id: string; + number: number issues: { edges: { node: PullRequest; }[]; }; }[]; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; + pageInfo: PageInfo; }; - }; + } | null; } export interface IssuesResponse { @@ -496,12 +720,17 @@ export interface IssuesResponse { edges: { node: PullRequest; }[]; - pageInfo: { - hasNextPage: boolean; - endCursor: string; - }; + pageInfo: PageInfo; }; - }; + } | null; +} + +export interface PullRequestsResponse { + repository: { + pullRequests: { + nodes: PullRequest[] + } + } | null; } export interface MaxIssueResponse { @@ -513,13 +742,13 @@ export interface MaxIssueResponse { }; }[]; }; - }; + } | null; } export interface ViewerPermissionResponse { repository: { viewerPermission: string; - }; + } | null; } export interface ForkDetailsResponse { @@ -559,9 +788,18 @@ export interface UserResponse { name: string; contributionsCollection: ContributionsCollection; url: string; + id: string; }; } +export interface FileContentResponse { + repository: { + object: { + text: string | undefined; + } + } | null; +} + export interface StartReviewResponse { addPullRequestReview: { pullRequestReview: { @@ -571,57 +809,86 @@ export interface StartReviewResponse { } export interface StatusContext { + __typename: string; id: string; - state?: 'ERROR' | 'EXPECTED' | 'FAILURE' | 'PENDING' | 'SUCCESS'; - description?: string; + state: 'ERROR' | 'EXPECTED' | 'FAILURE' | 'PENDING' | 'SUCCESS'; + description: string | null; context: string; - targetUrl?: string; - avatarUrl?: string; + targetUrl: string | null; + avatarUrl: string | null; + isRequired: boolean; } export interface CheckRun { + __typename: string; id: string; - conclusion?: - | 'ACTION_REQUIRED' - | 'CANCELLED' - | 'FAILURE' - | 'NEUTRAL' - | 'SKIPPED' - | 'STALE' - | 'SUCCESS' - | 'TIMED_OUT'; + conclusion: + | 'ACTION_REQUIRED' + | 'CANCELLED' + | 'FAILURE' + | 'NEUTRAL' + | 'SKIPPED' + | 'STALE' + | 'SUCCESS' + | 'TIMED_OUT' + | null; name: string; - title?: string; - detailsUrl?: string; - checkSuite: { - app?: { + title: string | null; + detailsUrl: string | null; + checkSuite?: { + app: { logoUrl: string; url: string; - }; + } | null; }; + isRequired: boolean; } export function isCheckRun(x: CheckRun | StatusContext): x is CheckRun { - return (x as any).__typename === 'CheckRun'; + return x.__typename === 'CheckRun'; +} + +export interface ChecksReviewNode { + authorAssociation: 'MEMBER' | 'OWNER' | 'MANNEQUIN' | 'COLLABORATOR' | 'CONTRIBUTOR' | 'FIRST_TIME_CONTRIBUTOR' | 'FIRST_TIMER' | 'NONE'; + authorCanPushToRepository: boolean + state: 'PENDING' | 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'DISMISSED'; + author: { + login: string; + } } export interface GetChecksResponse { repository: { pullRequest: { + url: string; + latestReviews: { + nodes: ChecksReviewNode[]; + }; + reviewsRequestingChanges: { + nodes: ChecksReviewNode[]; + }; + baseRef: { + refUpdateRule: { + requiredApprovingReviewCount: number | null; + requiredStatusCheckContexts: string[] | null; + requiresCodeOwnerReviews: boolean; + viewerCanPush: boolean; + } | null; + }; commits: { nodes: { commit: { statusCheckRollup?: { - state: string; + state: 'EXPECTED' | 'ERROR' | 'FAILURE' | 'PENDING' | 'SUCCESS'; contexts: { nodes: (StatusContext | CheckRun)[]; }; }; }; - }[]; + }[] | undefined; }; }; - }; + } | null; } export interface ResolveReviewThreadResponse { @@ -650,5 +917,5 @@ export interface PullRequestFilesResponse { }; } } - } -} \ No newline at end of file + } | null; +} diff --git a/src/github/interface.ts b/src/github/interface.ts index c2babc13a4..f4acc433b2 100644 --- a/src/github/interface.ts +++ b/src/github/interface.ts @@ -26,31 +26,91 @@ export enum PullRequestMergeability { NotMergeable, Conflict, Unknown, + Behind, +} + +export enum MergeQueueState { + AwaitingChecks, + Locked, + Mergeable, + Queued, + Unmergeable } export interface ReviewState { - reviewer: IAccount; + reviewer: IAccount | ITeam; state: string; } -export interface IAccount { +export interface IActor { + login: string; + avatarUrl?: string; + url: string; +} + +export interface IAccount extends IActor { login: string; + id: string; name?: string; avatarUrl?: string; url: string; email?: string; } +export interface ITeam { + name?: string; + avatarUrl?: string; + url: string; + slug: string; + org: string; + id: string; +} + +export interface MergeQueueEntry { + position: number; + state: MergeQueueState; + url: string; +} + +export function reviewerId(reviewer: ITeam | IAccount): string { + return isTeam(reviewer) ? reviewer.id : reviewer.login; +} + +export function reviewerLabel(reviewer: ITeam | IAccount | IActor): string { + return isTeam(reviewer) ? (reviewer.name ?? reviewer.slug) : reviewer.login; +} + +export function isTeam(reviewer: ITeam | IAccount | IActor): reviewer is ITeam { + return 'org' in reviewer; +} + export interface ISuggestedReviewer extends IAccount { isAuthor: boolean; isCommenter: boolean; } +export function isSuggestedReviewer( + reviewer: IAccount | ISuggestedReviewer | ITeam +): reviewer is ISuggestedReviewer { + return 'isAuthor' in reviewer && 'isCommenter' in reviewer; +} + +export interface IProject { + title: string; + id: string; +} + +export interface IProjectItem { + id: string; + project: IProject; +} + export interface IMilestone { title: string; dueOn?: string | null; createdAt: string; id: string; + number: number; } export interface MergePullRequest { @@ -62,6 +122,9 @@ export interface MergePullRequest { export interface IRepository { cloneUrl: string; + isInOrganization: boolean; + owner: string; + name: string; } export interface IGitHubRef { @@ -74,6 +137,7 @@ export interface IGitHubRef { export interface ILabel { name: string; color: string; + description?: string; } export interface Issue { @@ -85,11 +149,13 @@ export interface Issue { body: string; bodyHTML?: string; title: string; + titleHTML: string; assignees?: IAccount[]; createdAt: string; updatedAt: string; user: IAccount; labels: ILabel[]; + projectItems?: IProjectItem[]; milestone?: IMilestone; repositoryOwner?: string; repositoryName?: string; @@ -107,8 +173,17 @@ export interface PullRequest extends Issue { head?: IGitHubRef; isRemoteBaseDeleted?: boolean; base?: IGitHubRef; + commits: { + message: string; + }[]; merged?: boolean; mergeable?: PullRequestMergeability; + mergeQueueEntry?: MergeQueueEntry | null; + autoMerge?: boolean; + autoMergeMethod?: MergeMethod; + allowAutoMerge?: boolean; + mergeCommitMeta?: { title: string, description: string }; + squashCommitMeta?: { title: string, description: string }; suggestedReviewers?: ISuggestedReviewer[]; } @@ -126,6 +201,7 @@ export interface IRawFileChange { export interface IPullRequestsPagingOptions { fetchNextPage: boolean; + fetchOnePagePerRepo?: boolean; } export interface IPullRequestEditData { @@ -142,6 +218,7 @@ export type MergeMethodsAvailability = { export type RepoAccessAndMergeMethods = { hasWritePermission: boolean; mergeMethodsAvailability: MergeMethodsAvailability; + viewerCanAutoMerge: boolean; }; export interface User extends IAccount { @@ -154,15 +231,33 @@ export interface User extends IAccount { }[]; } +export enum CheckState { + Success = 'success', + Failure = 'failure', + Neutral = 'neutral', + Pending = 'pending', + Unknown = 'unknown' +} + +export interface PullRequestCheckStatus { + id: string; + url: string | undefined; + avatarUrl: string | undefined; + state: CheckState; + description: string | null; + targetUrl: string | null; + context: string; + isRequired: boolean; +} + export interface PullRequestChecks { - state: string; - statuses: { - id: string; - url?: string; - avatar_url?: string; - state?: string; - description?: string; - target_url?: string; - context: string; - }[]; + state: CheckState; + statuses: PullRequestCheckStatus[]; +} + +export interface PullRequestReviewRequirement { + count: number; + state: CheckState; + approvals: string[]; + requestedChanges: string[]; } diff --git a/src/github/issueModel.ts b/src/github/issueModel.ts index 914b5b9a96..11a9c19bdc 100644 --- a/src/github/issueModel.ts +++ b/src/github/issueModel.ts @@ -13,11 +13,12 @@ import { OctokitCommon } from './common'; import { GitHubRepository } from './githubRepository'; import { AddIssueCommentResponse, + AddPullRequestToProjectResponse, EditIssueCommentResponse, TimelineEventsResponse, UpdatePullRequestResponse, } from './graphql'; -import { GithubItemStateEnum, IAccount, IMilestone, IPullRequestEditData, Issue } from './interface'; +import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, IPullRequestEditData, Issue } from './interface'; import { parseGraphQlIssueComment, parseGraphQLTimelineEvents } from './utils'; export class IssueModel { @@ -26,6 +27,7 @@ export class IssueModel { public graphNodeId: string; public number: number; public title: string; + public titleHTML: string; public html_url: string; public state: GithubItemStateEnum = GithubItemStateEnum.Open; public author: IAccount; @@ -115,7 +117,12 @@ export class IssueModel { this.graphNodeId = issue.graphNodeId; this.number = issue.number; this.title = issue.title; - this.bodyHTML = issue.bodyHTML; + if (issue.titleHTML) { + this.titleHTML = issue.titleHTML; + } + if (!this.bodyHTML || (issue.body !== this.body)) { + this.bodyHTML = issue.bodyHTML; + } this.html_url = issue.url; this.author = issue.user; this.milestone = issue.milestone; @@ -147,7 +154,7 @@ export class IssueModel { return true; } - async edit(toEdit: IPullRequestEditData): Promise<{ body: string; bodyHTML: string; title: string }> { + async edit(toEdit: IPullRequestEditData): Promise<{ body: string; bodyHTML: string; title: string; titleHTML: string }> { try { const { mutate, schema } = await this.githubRepository.ensure(); @@ -165,6 +172,7 @@ export class IssueModel { this.item.body = data.updatePullRequest.pullRequest.body; this.bodyHTML = data.updatePullRequest.pullRequest.bodyHTML; this.title = data.updatePullRequest.pullRequest.title; + this.titleHTML = data.updatePullRequest.pullRequest.titleHTML; this.invalidate(); } return data!.updatePullRequest.pullRequest; @@ -173,7 +181,7 @@ export class IssueModel { } } - canEdit(): boolean { + canEdit(): Promise { const username = this.author && this.author.login; return this.githubRepository.isCurrentUser(username); } @@ -182,7 +190,7 @@ export class IssueModel { Logger.debug(`Fetch issue comments of PR #${this.number} - enter`, IssueModel.ID); const { octokit, remote } = await this.githubRepository.ensure(); - const promise = await octokit.issues.listComments({ + const promise = await octokit.call(octokit.api.issues.listComments, { owner: remote.owner, repo: remote.repositoryName, issue_number: this.number, @@ -205,7 +213,7 @@ export class IssueModel { }, }); - return parseGraphQlIssueComment(data!.addComment.commentEdge.node); + return parseGraphQlIssueComment(data!.addComment.commentEdge.node, this.githubRepository); } async editIssueComment(comment: IComment, text: string): Promise { @@ -222,7 +230,7 @@ export class IssueModel { }, }); - return parseGraphQlIssueComment(data!.updateIssueComment.issueComment); + return parseGraphQlIssueComment(data!.updateIssueComment.issueComment, this.githubRepository); } catch (e) { throw new Error(formatError(e)); } @@ -232,7 +240,7 @@ export class IssueModel { try { const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.issues.deleteComment({ + await octokit.call(octokit.api.issues.deleteComment, { owner: remote.owner, repo: remote.repositoryName, comment_id: Number(commentId), @@ -242,19 +250,26 @@ export class IssueModel { } } - async addLabels(labels: string[]): Promise { + async setLabels(labels: string[]): Promise { const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.issues.addLabels({ - owner: remote.owner, - repo: remote.repositoryName, - issue_number: this.number, - labels, - }); + try { + await octokit.call(octokit.api.issues.setLabels, { + owner: remote.owner, + repo: remote.repositoryName, + issue_number: this.number, + labels, + }); + } catch (e) { + // We don't get a nice error message from the API when setting labels fails. + // Since adding labels isn't a critical part of the PR creation path it's safe to catch all errors that come from setting labels. + Logger.error(`Failed to add labels to PR #${this.number}`, IssueModel.ID); + vscode.window.showWarningMessage(vscode.l10n.t('Some, or all, labels could not be added to the pull request.')); + } } async removeLabel(label: string): Promise { const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.issues.removeLabel({ + await octokit.call(octokit.api.issues.removeLabel, { owner: remote.owner, repo: remote.repositoryName, issue_number: this.number, @@ -262,6 +277,57 @@ export class IssueModel { }); } + public async removeProjects(projectItems: IProjectItem[]): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + + try { + await Promise.all(projectItems.map(project => + mutate({ + mutation: schema.RemovePullRequestFromProject, + variables: { + input: { + itemId: project.id, + projectId: project.project.id + }, + }, + }))); + this.item.projectItems = this.item.projectItems?.filter(project => !projectItems.find(p => p.project.id === project.project.id)); + } catch (err) { + Logger.error(err, IssueModel.ID); + } + } + + private async addProjects(projects: IProject[]): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + + try { + const itemIds = await Promise.all(projects.map(project => + mutate({ + mutation: schema.AddPullRequestToProject, + variables: { + input: { + contentId: this.item.graphNodeId, + projectId: project.id + }, + }, + }))); + if (!this.item.projectItems) { + this.item.projectItems = []; + } + this.item.projectItems.push(...projects.map((project, index) => { return { project, id: itemIds[index].data!.addProjectV2ItemById.item.id }; })); + } catch (err) { + Logger.error(err, IssueModel.ID); + } + } + + async updateProjects(projects: IProject[]): Promise { + const projectsToAdd: IProject[] = projects.filter(project => !this.item.projectItems?.find(p => p.project.id === project.id)); + const projectsToRemove: IProjectItem[] = this.item.projectItems?.filter(project => !projects.find(p => p.id === project.project.id)) ?? []; + await this.removeProjects(projectsToRemove); + await this.addProjects(projectsToAdd); + return this.item.projectItems; + } + async getIssueTimelineEvents(): Promise { Logger.debug(`Fetch timeline events of issue #${this.number} - enter`, IssueModel.ID); const githubRepository = this.githubRepository; @@ -276,6 +342,11 @@ export class IssueModel { number: this.number, }, }); + + if (data.repository === null) { + Logger.error('Unexpected null repository when getting issue timeline events', IssueModel.ID); + return []; + } const ret = data.repository.pullRequest.timelineItems.nodes; const events = parseGraphQLTimelineEvents(ret, githubRepository); diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index db01cc5075..ab978acb19 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -7,13 +7,14 @@ import * as vscode from 'vscode'; import { IComment } from '../common/comment'; import Logger from '../common/logger'; -import { formatError } from '../common/utils'; +import { asPromise, formatError } from '../common/utils'; import { getNonce, IRequestMessage, WebviewBase } from '../common/webview'; import { DescriptionNode } from '../view/treeNodes/descriptionNode'; import { OctokitCommon } from './common'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { ILabel } from './interface'; import { IssueModel } from './issueModel'; +import { getLabelOptions } from './quickPicks'; export class IssueOverviewPanel extends WebviewBase { public static ID: string = 'PullRequestOverviewPanel'; @@ -22,7 +23,7 @@ export class IssueOverviewPanel extends W */ public static currentPanel?: IssueOverviewPanel; - protected static readonly _viewType: string = 'IssueOverview'; + private static readonly _viewType: string = 'IssueOverview'; protected readonly _panel: vscode.WebviewPanel; protected _disposables: vscode.Disposable[] = []; @@ -66,6 +67,15 @@ export class IssueOverviewPanel extends W } } + protected setPanelTitle(title: string): void { + try { + this._panel.title = title; + } catch (e) { + // The webview can be disposed at the time that we try to set the title if the user has closed + // it while it's still loading. + } + } + protected constructor( private readonly _extensionUri: vscode.Uri, column: vscode.ViewColumn, @@ -133,7 +143,7 @@ export class IssueOverviewPanel extends W } this._item = issue as TItem; - this._panel.title = `Pull Request #${issueModel.number.toString()}`; + this.setPanelTitle(`Pull Request #${issueModel.number.toString()}`); Logger.debug('pr.initialize', IssueOverviewPanel.ID); this._postMessage({ @@ -159,6 +169,7 @@ export class IssueOverviewPanel extends W // TODO@eamodio What is status? status: /*status ? status :*/ { statuses: [] }, isIssue: true, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark }, }); }) @@ -193,7 +204,7 @@ export class IssueOverviewPanel extends W case 'pr.comment': return this.createComment(message); case 'scroll': - this._scrollPosition = message.args; + this._scrollPosition = message.args.scrollPosition; return; case 'pr.edit-comment': return this.editComment(message); @@ -218,39 +229,42 @@ export class IssueOverviewPanel extends W } private async addLabels(message: IRequestMessage): Promise { + const quickPick = vscode.window.createQuickPick(); try { let newLabels: ILabel[] = []; - async function getLabelOptions( - folderRepoManager: FolderRepositoryManager, - issue: IssueModel, - ): Promise { - const allLabels = await folderRepoManager.getLabels(issue); - newLabels = allLabels.filter(l => !issue.item.labels.some(label => label.name === l.name)); - - return newLabels.map(label => { - return { - label: label.name, - }; - }); - } - const labelsToAdd = await vscode.window.showQuickPick( - getLabelOptions(this._folderRepositoryManager, this._item), - { canPickMany: true }, - ); + quickPick.busy = true; + quickPick.canSelectMany = true; + quickPick.show(); + quickPick.items = await (getLabelOptions(this._folderRepositoryManager, this._item.item.labels, this._item.remote).then(options => { + newLabels = options.newLabels; + return options.labelPicks; + })); + quickPick.selectedItems = quickPick.items.filter(item => item.picked); + + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick.selectedItems; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const labelsToAdd = await Promise.race([acceptPromise, hidePromise]); + quickPick.busy = true; - if (labelsToAdd && labelsToAdd.length) { - await this._item.addLabels(labelsToAdd.map(r => r.label)); + if (labelsToAdd) { + await this._item.setLabels(labelsToAdd.map(r => r.label)); const addedLabels: ILabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.label)!); - this._item.item.labels = this._item.item.labels.concat(...addedLabels); + this._item.item.labels = addedLabels; - this._replyMessage(message, { + await this._replyMessage(message, { added: addedLabels, }); } } catch (e) { vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick.hide(); + quickPick.dispose(); } } @@ -283,10 +297,10 @@ export class IssueOverviewPanel extends W }); } private editTitle(message: IRequestMessage<{ text: string }>) { - this._item + return this._item .edit({ title: message.args.text }) .then(result => { - this._replyMessage(message, { text: result.title }); + return this._replyMessage(message, { titleHTML: result.titleHTML }); }) .catch(e => { this._throwError(message, e); @@ -318,7 +332,7 @@ export class IssueOverviewPanel extends W private deleteComment(message: IRequestMessage) { vscode.window - .showWarningMessage('Are you sure you want to delete this comment?', { modal: true }, 'Delete') + .showWarningMessage(vscode.l10n.t('Are you sure you want to delete this comment?'), { modal: true }, 'Delete') .then(value => { if (value === 'Delete') { this.deleteCommentPromise(message.args) diff --git a/src/github/loggingOctokit.ts b/src/github/loggingOctokit.ts new file mode 100644 index 0000000000..56a98e33d5 --- /dev/null +++ b/src/github/loggingOctokit.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Octokit } from '@octokit/rest'; +import { ApolloClient, ApolloQueryResult, FetchResult, MutationOptions, NormalizedCacheObject, OperationVariables, QueryOptions } from 'apollo-boost'; +import { bulkhead, BulkheadPolicy } from 'cockatiel'; +import * as vscode from 'vscode'; +import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; +import { RateLimit } from './graphql'; + +interface RestResponse { + headers: { + 'x-ratelimit-limit': string; + 'x-ratelimit-remaining': string; + } +} + +export class RateLogger { + private bulkhead: BulkheadPolicy = bulkhead(140); + private static ID = 'RateLimit'; + private hasLoggedLowRateLimit: boolean = false; + + constructor(private readonly telemetry: ITelemetry, private readonly errorOnFlood: boolean) { } + + public logAndLimit(info: string | undefined, apiRequest: () => Promise): Promise | undefined { + if (this.bulkhead.executionSlots === 0) { + Logger.error('API call count has exceeded 140 concurrent calls.', RateLogger.ID); + // We have hit more than 140 concurrent API requests. + /* __GDPR__ + "pr.highApiCallRate" : {} + */ + this.telemetry.sendTelemetryErrorEvent('pr.highApiCallRate'); + + if (!this.errorOnFlood) { + // We don't want to error on flood, so try to execute the API request anyway. + return apiRequest(); + } else { + vscode.window.showErrorMessage(vscode.l10n.t('The GitHub Pull Requests extension is making too many requests to GitHub. This indicates a bug in the extension. Please file an issue on GitHub and include the output from "GitHub Pull Request".')); + return undefined; + } + } + const log = `Extension rate limit remaining: ${this.bulkhead.executionSlots}, ${info}`; + if (this.bulkhead.executionSlots < 5) { + Logger.appendLine(log, RateLogger.ID); + } else { + Logger.debug(log, RateLogger.ID); + } + + return this.bulkhead.execute(() => apiRequest()); + } + + public async logRateLimit(info: string | undefined, result: Promise<{ data: { rateLimit: RateLimit | undefined } | undefined } | undefined>, isRest: boolean = false) { + let rateLimitInfo; + try { + const resolvedResult = await result; + rateLimitInfo = resolvedResult?.data?.rateLimit; + } catch (e) { + // Ignore errors here since we're just trying to log the rate limit. + return; + } + const isSearch = info?.startsWith('/search/'); + if ((rateLimitInfo?.limit ?? 5000) < 5000) { + if (!isSearch) { + Logger.appendLine(`Unexpectedly low rate limit: ${rateLimitInfo?.limit}`, RateLogger.ID); + } else if (rateLimitInfo.limit < 30) { + Logger.appendLine(`Unexpectedly low SEARCH rate limit: ${rateLimitInfo?.limit}`, RateLogger.ID); + } + } + const remaining = `${isRest ? 'REST' : 'GraphQL'} Rate limit remaining: ${rateLimitInfo?.remaining}, ${info}`; + if (((rateLimitInfo?.remaining ?? 1000) < 1000) && !isSearch) { + if (!this.hasLoggedLowRateLimit) { + /* __GDPR__ + "pr.lowRateLimitRemaining" : {} + */ + this.telemetry.sendTelemetryErrorEvent('pr.lowRateLimitRemaining'); + this.hasLoggedLowRateLimit = true; + } + Logger.warn(remaining, RateLogger.ID); + } else { + Logger.debug(remaining, RateLogger.ID); + } + } + + public async logRestRateLimit(info: string | undefined, restResponse: Promise) { + let result; + try { + result = await restResponse; + } catch (e) { + // Ignore errors here since we're just trying to log the rate limit. + return; + } + const rateLimit: RateLimit = { + cost: -1, + limit: Number(result.headers['x-ratelimit-limit']), + remaining: Number(result.headers['x-ratelimit-remaining']), + resetAt: '' + }; + this.logRateLimit(info, Promise.resolve({ data: { rateLimit } }), true); + } +} + +export class LoggingApolloClient { + constructor(private readonly _graphql: ApolloClient, private _rateLogger: RateLogger) { } + + query(options: QueryOptions): Promise> { + const logInfo = (options.query.definitions[0] as { name: { value: string } | undefined }).name?.value; + const result = this._rateLogger.logAndLimit(logInfo, () => this._graphql.query(options)); + if (result === undefined) { + throw new Error('API call count has exceeded a rate limit.'); + } + this._rateLogger.logRateLimit(logInfo, result as any); + return result; + } + + mutate(options: MutationOptions): Promise> { + const logInfo = options.context; + const result = this._rateLogger.logAndLimit(logInfo, () => this._graphql.mutate(options)); + if (result === undefined) { + throw new Error('API call count has exceeded a rate limit.'); + } + this._rateLogger.logRateLimit(logInfo, result as any); + return result; + } +} + +export class LoggingOctokit { + constructor(public readonly api: Octokit, private _rateLogger: RateLogger) { } + + async call(api: (T) => Promise, args: T): Promise { + const logInfo = (api as unknown as { endpoint: { DEFAULTS: { url: string } | undefined } | undefined }).endpoint?.DEFAULTS?.url; + const result = this._rateLogger.logAndLimit(logInfo, () => api(args)); + if (result === undefined) { + throw new Error('API call count has exceeded a rate limit.'); + } + this._rateLogger.logRestRateLimit(logInfo, result as Promise as Promise); + return result; + } +} diff --git a/src/github/notifications.ts b/src/github/notifications.ts new file mode 100644 index 0000000000..6d4122c0c4 --- /dev/null +++ b/src/github/notifications.ts @@ -0,0 +1,399 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { OctokitResponse } from '@octokit/types'; +import * as vscode from 'vscode'; +import { AuthProvider } from '../common/authentication'; +import Logger from '../common/logger'; +import { NOTIFICATION_SETTING, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { createPRNodeUri } from '../common/uri'; +import { PullRequestsTreeDataProvider } from '../view/prsTreeDataProvider'; +import { CategoryTreeNode } from '../view/treeNodes/categoryNode'; +import { PRNode } from '../view/treeNodes/pullRequestNode'; +import { TreeNode } from '../view/treeNodes/treeNode'; +import { CredentialStore, GitHub } from './credentials'; +import { GitHubRepository } from './githubRepository'; +import { PullRequestState } from './graphql'; +import { PullRequestModel } from './pullRequestModel'; +import { RepositoriesManager } from './repositoriesManager'; +import { hasEnterpriseUri } from './utils'; + +const DEFAULT_POLLING_DURATION = 60; + +export class Notification { + public readonly identifier; + public readonly threadId: number; + public readonly repositoryName: string; + public readonly pullRequestNumber: number; + public pullRequestModel?: PullRequestModel; + + constructor(identifier: string, threadId: number, repositoryName: string, + pullRequestNumber: number, pullRequestModel?: PullRequestModel) { + + this.identifier = identifier; + this.threadId = threadId; + this.repositoryName = repositoryName; + this.pullRequestNumber = pullRequestNumber; + this.pullRequestModel = pullRequestModel; + } +} + +export class NotificationProvider implements vscode.Disposable { + private static ID = 'NotificationProvider'; + private readonly _gitHubPrsTree: PullRequestsTreeDataProvider; + private readonly _credentialStore: CredentialStore; + private _authProvider: AuthProvider | undefined; + // The key uniquely identifies a PR from a Repository. The key is created with `getPrIdentifier` + private _notifications: Map; + private readonly _reposManager: RepositoriesManager; + + private _pollingDuration: number; + private _lastModified: string; + private _pollingHandler: NodeJS.Timeout | null; + + private disposables: vscode.Disposable[] = []; + + private _onDidChangeNotifications: vscode.EventEmitter = new vscode.EventEmitter(); + public onDidChangeNotifications = this._onDidChangeNotifications.event; + + constructor( + gitHubPrsTree: PullRequestsTreeDataProvider, + credentialStore: CredentialStore, + reposManager: RepositoriesManager + ) { + this._gitHubPrsTree = gitHubPrsTree; + this._credentialStore = credentialStore; + this._reposManager = reposManager; + this._notifications = new Map(); + + this._lastModified = ''; + this._pollingDuration = DEFAULT_POLLING_DURATION; + this._pollingHandler = null; + + this.registerAuthProvider(credentialStore); + + for (const manager of this._reposManager.folderManagers) { + this.disposables.push( + manager.onDidChangeGithubRepositories(() => { + this.refreshOrLaunchPolling(); + }) + ); + } + + this.disposables.push( + gitHubPrsTree.onDidChangeTreeData((node) => { + if (NotificationProvider.isPRNotificationsOn()) { + this.adaptPRNotifications(node); + } + }) + ); + this.disposables.push( + gitHubPrsTree.onDidChange(() => { + if (NotificationProvider.isPRNotificationsOn()) { + this.adaptPRNotifications(); + } + }) + ); + + this.disposables.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${NOTIFICATION_SETTING}`)) { + this.checkNotificationSetting(); + } + }) + ); + } + + private static isPRNotificationsOn() { + return ( + vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(NOTIFICATION_SETTING) === + 'pullRequests' + ); + } + + private registerAuthProvider(credentialStore: CredentialStore) { + if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + this._authProvider = AuthProvider.githubEnterprise; + } else if (credentialStore.isAuthenticated(AuthProvider.github)) { + this._authProvider = AuthProvider.github; + } + + this.disposables.push( + vscode.authentication.onDidChangeSessions(_ => { + if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + this._authProvider = AuthProvider.githubEnterprise; + } + + if (credentialStore.isAuthenticated(AuthProvider.github)) { + this._authProvider = AuthProvider.github; + } + }) + ); + } + + private getPrIdentifier(pullRequest: PullRequestModel | OctokitResponse['data']): string { + if (pullRequest instanceof PullRequestModel) { + return `${pullRequest.remote.url}:${pullRequest.number}`; + } + const splitPrUrl = pullRequest.subject.url.split('/'); + const prNumber = splitPrUrl[splitPrUrl.length - 1]; + return `${pullRequest.repository.html_url}.git:${prNumber}`; + } + + /* Takes a PullRequestModel or a PRIdentifier and + returns true if there is a Notification for the corresponding PR */ + public hasNotification(pullRequest: PullRequestModel | string): boolean { + const identifier = pullRequest instanceof PullRequestModel ? + this.getPrIdentifier(pullRequest) : + pullRequest; + const prNotifications = this._notifications.get(identifier); + return prNotifications !== undefined && prNotifications.length > 0; + } + + private updateViewBadge() { + const treeView = this._gitHubPrsTree.view; + const singularMessage = vscode.l10n.t('1 notification'); + const pluralMessage = vscode.l10n.t('{0} notifications', this._notifications.size); + treeView.badge = this._notifications.size !== 0 ? { + tooltip: this._notifications.size === 1 ? singularMessage : pluralMessage, + value: this._notifications.size + } : undefined; + } + + private adaptPRNotifications(node: TreeNode | void) { + if (this._pollingHandler === undefined) { + this.startPolling(); + } + + if (node instanceof PRNode) { + const prNotifications = this._notifications.get(this.getPrIdentifier(node.pullRequestModel)); + if (prNotifications) { + for (const prNotification of prNotifications) { + if (prNotification) { + prNotification.pullRequestModel = node.pullRequestModel; + return; + } + } + } + } + + this._gitHubPrsTree.cachedChildren().then(async (catNodes: CategoryTreeNode[]) => { + let allPrs: PullRequestModel[] = []; + + for (const catNode of catNodes) { + if (catNode.id === 'All Open') { + if (catNode.prs.length === 0) { + for (const prNode of await catNode.cachedChildren()) { + if (prNode instanceof PRNode) { + allPrs.push(prNode.pullRequestModel); + } + } + } + else { + allPrs = catNode.prs; + } + + } + } + + allPrs.forEach((pr) => { + const prNotifications = this._notifications.get(this.getPrIdentifier(pr)); + if (prNotifications) { + for (const prNotification of prNotifications) { + prNotification.pullRequestModel = pr; + } + } + }); + }); + } + + public refreshOrLaunchPolling() { + this._lastModified = ''; + this.checkNotificationSetting(); + } + + private checkNotificationSetting() { + const notificationsTurnedOn = NotificationProvider.isPRNotificationsOn(); + if (notificationsTurnedOn && this._pollingHandler === null) { + this.startPolling(); + } + else if (!notificationsTurnedOn && this._pollingHandler !== null) { + clearInterval(this._pollingHandler); + this._lastModified = ''; + this._pollingHandler = null; + this._pollingDuration = DEFAULT_POLLING_DURATION; + + this._onDidChangeNotifications.fire(this.uriFromNotifications()); + this._notifications.clear(); + this.updateViewBadge(); + } + } + + private uriFromNotifications(): vscode.Uri[] { + const notificationUris: vscode.Uri[] = []; + for (const [identifier, prNotifications] of this._notifications.entries()) { + if (prNotifications.length) { + notificationUris.push(createPRNodeUri(identifier)); + } + } + return notificationUris; + } + + private getGitHub(): GitHub | undefined { + return (this._authProvider !== undefined) ? + this._credentialStore.getHub(this._authProvider) : + undefined; + } + + private async getNotifications() { + const gitHub = this.getGitHub(); + if (gitHub === undefined) + return undefined; + const { data, headers } = await gitHub.octokit.call(gitHub.octokit.api.activity.listNotificationsForAuthenticatedUser, {}); + return { data: data, headers: headers }; + } + + private async markNotificationThreadAsRead(thredId) { + const github = this.getGitHub(); + if (!github) { + return; + } + await github.octokit.call(github.octokit.api.activity.markThreadAsRead, { + thread_id: thredId + }); + } + + public async markPrNotificationsAsRead(pullRequestModel: PullRequestModel) { + const identifier = this.getPrIdentifier(pullRequestModel); + const prNotifications = this._notifications.get(identifier); + if (prNotifications && prNotifications.length) { + for (const notification of prNotifications) { + await this.markNotificationThreadAsRead(notification.threadId); + } + + const uris = this.uriFromNotifications(); + this._onDidChangeNotifications.fire(uris); + this._notifications.delete(identifier); + this.updateViewBadge(); + } + } + + private async pollForNewNotifications() { + const response = await this.getNotifications(); + if (response === undefined) { + return; + } + const { data, headers } = response; + const pollTimeSuggested = Number(headers['x-poll-interval']); + + // Adapt polling interval if it has changed. + if (pollTimeSuggested !== this._pollingDuration) { + this._pollingDuration = pollTimeSuggested; + if (this._pollingHandler && NotificationProvider.isPRNotificationsOn()) { + Logger.appendLine('Notifications: Clearing interval'); + clearInterval(this._pollingHandler); + Logger.appendLine(`Notifications: Starting new polling interval with ${this._pollingDuration}`); + this.startPolling(); + } + } + + // Only update if the user has new notifications + if (this._lastModified === headers['last-modified']) { + return; + } + this._lastModified = headers['last-modified'] ?? ''; + + const prNodesToUpdate = this.uriFromNotifications(); + this._notifications.clear(); + + const currentRepos = new Map(); + + this._reposManager.folderManagers.forEach(manager => { + manager.gitHubRepositories.forEach(repo => { + currentRepos.set(repo.remote.url, repo); + }); + }); + + await Promise.all(data.map(async (notification) => { + + const repoUrl = `${notification.repository.html_url}.git`; + const githubRepo = currentRepos.get(repoUrl); + + if (githubRepo && notification.subject.type === 'PullRequest') { + const splitPrUrl = notification.subject.url.split('/'); + const prNumber = Number(splitPrUrl[splitPrUrl.length - 1]); + const identifier = this.getPrIdentifier(notification); + + const { remote, query, schema } = await githubRepo.ensure(); + + const { data } = await query({ + query: schema.PullRequestState, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: prNumber, + }, + }); + + if (data.repository === null) { + Logger.error('Unexpected null repository when getting notifications', NotificationProvider.ID); + } + + // We only consider open PullRequests as these are displayed in the AllOpen PR category. + // Other categories could have queries with closed PRs, but its hard to figure out if a PR + // belongs to a query without loading each PR of that query. + if (data.repository?.pullRequest.state === 'OPEN') { + + const newNotification = new Notification( + identifier, + Number(notification.id), + notification.repository.name, + Number(prNumber) + ); + + const currentPrNotifications = this._notifications.get(identifier); + if (currentPrNotifications === undefined) { + this._notifications.set( + identifier, [newNotification] + ); + } + else { + currentPrNotifications.push(newNotification); + } + } + + } + })); + + this.adaptPRNotifications(); + + this.updateViewBadge(); + for (const uri of this.uriFromNotifications()) { + if (prNodesToUpdate.find(u => u.fsPath === uri.fsPath) === undefined) { + prNodesToUpdate.push(uri); + } + } + + this._onDidChangeNotifications.fire(prNodesToUpdate); + } + + private startPolling() { + this.pollForNewNotifications(); + this._pollingHandler = setInterval( + function (notificationProvider: NotificationProvider) { + notificationProvider.pollForNewNotifications(); + }, + this._pollingDuration * 1000, + this + ); + } + + public dispose() { + if (this._pollingHandler) { + clearInterval(this._pollingHandler); + } + this.disposables.forEach(displosable => displosable.dispose()); + } +} \ No newline at end of file diff --git a/src/github/prComment.ts b/src/github/prComment.ts index 772e985d67..a57a5b742f 100644 --- a/src/github/prComment.ts +++ b/src/github/prComment.ts @@ -3,12 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as path from 'path'; import * as vscode from 'vscode'; import { IComment } from '../common/comment'; +import { DataUri } from '../common/uri'; +import { JSDOC_NON_USERS, PHPDOC_NON_USERS } from '../common/user'; +import { stringReplaceAsync } from '../common/utils'; +import { GitHubRepository } from './githubRepository'; import { IAccount } from './interface'; import { updateCommentReactions } from './utils'; -export interface GHPRCommentThread extends vscode.CommentThread { +export interface GHPRCommentThread extends vscode.CommentThread2 { gitHubThreadId: string; /** @@ -20,7 +25,7 @@ export interface GHPRCommentThread extends vscode.CommentThread { * The range the comment thread is located within the document. The thread icon will be shown * at the first line of the range. */ - range: vscode.Range; + range: vscode.Range | undefined; /** * The ordered comments of the thread. @@ -46,21 +51,14 @@ export interface GHPRCommentThread extends vscode.CommentThread { dispose: () => void; } -/** - * Used to optimistically render updates to comment threads. Temporary comments are immediately - * set when a command is run, and then replaced with real data when the operation finishes. - */ -export class TemporaryComment implements vscode.Comment { - static is(comment: GHPRComment | TemporaryComment): comment is TemporaryComment { - return comment.commentId === undefined; +export namespace GHPRCommentThread { + export function is(value: any): value is GHPRCommentThread { + return (value && (typeof (value as GHPRCommentThread).gitHubThreadId) === 'string'); } +} - public commentId: undefined; - - /** - * The id of the comment - */ - public id: number; +abstract class CommentBase implements vscode.Comment { + public abstract commentId: undefined | string; /** * The comment thread the comment is from @@ -68,14 +66,10 @@ export class TemporaryComment implements vscode.Comment { public parent: GHPRCommentThread; /** - * The text of the comment - */ - public body: string | vscode.MarkdownString; - - /** - * If the temporary comment is in place for an edit, the original text value of the comment + * The text of the comment as from GitHub */ - public originalBody?: string; + public abstract get body(): string | vscode.MarkdownString; + public abstract set body(body: string | vscode.MarkdownString); /** * Whether the comment is in edit mode or not @@ -95,39 +89,24 @@ export class TemporaryComment implements vscode.Comment { /** * The list of reactions to the comment */ - public commentReactions?: vscode.CommentReaction[] | undefined; + public reactions?: vscode.CommentReaction[] | undefined; /** * The context value, used to determine whether the command should be visible/enabled based on clauses in package.json */ public contextValue: string; - static idPool = 0; - constructor( parent: GHPRCommentThread, - input: string, - isDraft: boolean, - currentUser: IAccount, - originalComment?: GHPRComment, ) { this.parent = parent; - this.body = new vscode.MarkdownString(input); - this.mode = vscode.CommentMode.Preview; - this.author = { - name: currentUser.login, - iconPath: currentUser.avatarUrl ? vscode.Uri.parse(`${currentUser.avatarUrl}&s=64`) : undefined, - }; - this.label = isDraft ? 'Pending' : undefined; - this.contextValue = 'canEdit,canDelete'; - this.originalBody = originalComment ? originalComment._rawComment.body : undefined; - this.commentReactions = originalComment ? originalComment.reactions : undefined; - this.id = TemporaryComment.idPool++; } + public abstract commentEditId(): number | string; + startEdit() { this.parent.comments = this.parent.comments.map(cmt => { - if (cmt instanceof TemporaryComment && cmt.id === this.id) { + if (cmt instanceof CommentBase && cmt.commentEditId() === this.commentEditId()) { cmt.mode = vscode.CommentMode.Editing; } @@ -135,11 +114,13 @@ export class TemporaryComment implements vscode.Comment { }); } + protected abstract getCancelEditBody(): string | vscode.MarkdownString; + cancelEdit() { this.parent.comments = this.parent.comments.map(cmt => { - if (cmt instanceof TemporaryComment && cmt.id === this.id) { + if (cmt instanceof CommentBase && cmt.commentEditId() === this.commentEditId()) { cmt.mode = vscode.CommentMode.Preview; - cmt.body = cmt.originalBody || cmt.body; + cmt.body = this.getCancelEditBody(); } return cmt; @@ -147,69 +128,97 @@ export class TemporaryComment implements vscode.Comment { } } -export class GHPRComment implements vscode.Comment { - static is(comment: GHPRComment | TemporaryComment): comment is GHPRComment { - return comment.commentId !== undefined; - } +/** + * Used to optimistically render updates to comment threads. Temporary comments are immediately + * set when a command is run, and then replaced with real data when the operation finishes. + */ +export class TemporaryComment extends CommentBase { + public commentId: undefined; /** - * The database id of the comment + * The id of the comment */ - public commentId: string; + public id: number; /** - * The comment thread the comment is from + * If the temporary comment is in place for an edit, the original text value of the comment */ - public parent: GHPRCommentThread; + public originalBody?: string; - /** - * The text of the comment - */ - public body: string | vscode.MarkdownString; + static idPool = 0; - /** - * Whether the comment is in edit mode or not - */ - public mode: vscode.CommentMode; + constructor( + parent: GHPRCommentThread, + private input: string, + isDraft: boolean, + currentUser: IAccount, + originalComment?: GHPRComment, + ) { + super(parent); + this.mode = vscode.CommentMode.Preview; + this.author = { + name: currentUser.login, + iconPath: currentUser.avatarUrl ? vscode.Uri.parse(`${currentUser.avatarUrl}&s=64`) : undefined, + }; + this.label = isDraft ? vscode.l10n.t('Pending') : undefined; + this.contextValue = 'temporary,canEdit,canDelete'; + this.originalBody = originalComment ? originalComment.rawComment.body : undefined; + this.reactions = originalComment ? originalComment.reactions : undefined; + this.id = TemporaryComment.idPool++; + } - /** - * The author of the comment - */ - public author: vscode.CommentAuthorInformation; + set body(input: string | vscode.MarkdownString) { + if (typeof input === 'string') { + this.input = input; + } + } - /** - * The label to display on the comment, 'Pending' or nothing - */ - public label: string | undefined; + get body(): string | vscode.MarkdownString { + return new vscode.MarkdownString(this.input); + } - /** - * The list of reactions to the comment - */ - public reactions?: vscode.CommentReaction[] | undefined; + commentEditId() { + return this.id; + } - /** - * The complete comment data returned from GitHub - */ - public _rawComment: IComment; + protected getCancelEditBody() { + return this.originalBody || this.body; + } +} + +const SUGGESTION_EXPRESSION = /```suggestion(\r\n|\n)((?[\s\S]*?)(\r\n|\n))?```/; + +export class GHPRComment extends CommentBase { + public commentId: string; + public timestamp: Date; /** - * The context value, used to determine whether the command should be visible/enabled based on clauses in package.json + * The complete comment data returned from GitHub */ - public contextValue: string; + public rawComment: IComment; - public timestamp: Date; + private _rawBody: string | vscode.MarkdownString; + private replacedBody: string; - constructor(comment: IComment, parent: GHPRCommentThread) { - this._rawComment = comment; + constructor(context: vscode.ExtensionContext, comment: IComment, parent: GHPRCommentThread, private readonly githubRepository?: GitHubRepository) { + super(parent); + this.rawComment = comment; + this.body = comment.body; this.commentId = comment.id.toString(); - this.body = new vscode.MarkdownString(comment.body); this.author = { name: comment.user!.login, iconPath: comment.user && comment.user.avatarUrl ? vscode.Uri.parse(comment.user.avatarUrl) : undefined, }; + if (comment.user) { + DataUri.avatarCirclesAsImageDataUris(context, [comment.user], 28, 28).then(avatarUris => { + this.author.iconPath = avatarUris[0]; + this.refresh(); + }); + } + updateCommentReactions(this, comment.reactions); - this.label = comment.isDraft ? 'Pending' : undefined; + this.label = comment.isDraft ? vscode.l10n.t('Pending') : undefined; const contextValues: string[] = []; if (comment.canEdit) { @@ -220,29 +229,174 @@ export class GHPRComment implements vscode.Comment { contextValues.push('canDelete'); } + if (this.suggestion !== undefined) { + contextValues.push('hasSuggestion'); + } + this.contextValue = contextValues.join(','); - this.parent = parent; this.timestamp = new Date(comment.createdAt); } - startEdit() { - this.parent.comments = this.parent.comments.map(cmt => { - if (cmt instanceof GHPRComment && cmt.commentId === this.commentId) { - cmt.mode = vscode.CommentMode.Editing; - } + update(comment: IComment) { + const oldRawComment = this.rawComment; + this.rawComment = comment; + let refresh: boolean = false; - return cmt; + if (updateCommentReactions(this, comment.reactions)) { + refresh = true; + } + + const oldLabel = this.label; + this.label = comment.isDraft ? vscode.l10n.t('Pending') : undefined; + if (this.label !== oldLabel) { + refresh = true; + } + + const contextValues: string[] = []; + if (comment.canEdit) { + contextValues.push('canEdit'); + } + + if (comment.canDelete) { + contextValues.push('canDelete'); + } + + if (this.suggestion !== undefined) { + contextValues.push('hasSuggestion'); + } + + const oldContextValue = this.contextValue; + this.contextValue = contextValues.join(','); + if (oldContextValue !== this.contextValue) { + refresh = true; + } + + // Set the comment body last as it will trigger an update if set. + if (oldRawComment.body !== comment.body) { + this.body = comment.body; + refresh = false; + } + + if (refresh) { + this.refresh(); + } + } + + private refresh() { + // Self assign the comments to trigger an update of the comments in VS Code now that we have replaced the body. + // eslint-disable-next-line no-self-assign + this.parent.comments = this.parent.comments; + } + + get suggestion(): string | undefined { + const match = this.rawComment.body.match(SUGGESTION_EXPRESSION); + const suggestionBody = match?.groups?.suggestion; + if (match?.length === 5) { + return suggestionBody ? `${suggestionBody}\n` : ''; + } + } + + public commentEditId() { + return this.commentId; + } + + private replaceSuggestion(body: string) { + return body.replace(new RegExp(SUGGESTION_EXPRESSION, 'g'), (_substring: string, ...args: any[]) => { + return `*** +Suggested change: +\`\`\` +${args[2] ?? ''} +\`\`\` +***`; }); } - cancelEdit() { - this.parent.comments = this.parent.comments.map(cmt => { - if (cmt instanceof GHPRComment && cmt.commentId === this.commentId) { - cmt.mode = vscode.CommentMode.Preview; - cmt.body = cmt._rawComment.body; + private async createLocalFilePath(rootUri: vscode.Uri, fileSubPath: string, startLine: number, endLine: number): Promise { + const localFile = vscode.Uri.joinPath(rootUri, fileSubPath); + const stat = await vscode.workspace.fs.stat(localFile); + if (stat.type === vscode.FileType.File) { + return `${localFile.with({ fragment: `${startLine}-${endLine}` }).toString()}`; + } + } + + private async replacePermalink(body: string): Promise { + const githubRepository = this.githubRepository; + if (!githubRepository) { + return body; + } + + const expression = new RegExp(`https://github.com/${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}/blob/([0-9a-f]{40})/(.*)#L([0-9]+)(-L([0-9]+))?`, 'g'); + return stringReplaceAsync(body, expression, async (match: string, sha: string, file: string, start: string, _endGroup?: string, end?: string, index?: number) => { + if (index && (index > 0) && (body.charAt(index - 1) === '(')) { + return match; } + const startLine = parseInt(start); + const endLine = end ? parseInt(end) : startLine + 1; + const lineContents = await githubRepository.getLines(sha, file, startLine, endLine); + if (!lineContents) { + return match; + } + const localFile = await this.createLocalFilePath(githubRepository.rootUri, file, startLine, endLine); + const lineMessage = end ? `Lines ${startLine} to ${endLine} in \`${sha.substring(0, 7)}\`` : `Line ${startLine} in \`${sha.substring(0, 7)}\``; + return ` +*** +[${file}](${localFile ?? match})${localFile ? ` ([view on GitHub](${match}))` : ''} + +${lineMessage} +\`\`\` +${lineContents} +\`\`\` +***`; + }); + } - return cmt; + private replaceNewlines(body: string) { + return body.replace(/(? { + if (body instanceof vscode.MarkdownString) { + const permalinkReplaced = await this.replacePermalink(body.value); + return this.replaceSuggestion(permalinkReplaced); + } + const newLinesReplaced = this.replaceNewlines(body); + const documentLanguage = (await vscode.workspace.openTextDocument(this.parent.uri)).languageId; + // Replace user + const linkified = newLinesReplaced.replace(/([^\[`]|^)\@([^\s`]+)/g, (substring, _1, _2, offset) => { + // Do not try to replace user if there's a code block. + if ((newLinesReplaced.substring(0, offset).match(/```/g)?.length ?? 0) % 2 === 1) { + return substring; + } + const username = substring.substring(substring.startsWith('@') ? 1 : 2); + if ((((documentLanguage === 'javascript') || (documentLanguage === 'typescript')) && JSDOC_NON_USERS.includes(username)) + || ((documentLanguage === 'php') && PHPDOC_NON_USERS.includes(username))) { + return substring; + } + return `${substring.startsWith('@') ? '' : substring.charAt(0)}[@${username}](${path.dirname(this.rawComment.user!.url)}/${username})`; }); + + const permalinkReplaced = await this.replacePermalink(linkified); + return this.replaceSuggestion(permalinkReplaced); + } + + set body(body: string | vscode.MarkdownString) { + this._rawBody = body; + this.replaceBody(body).then(replacedBody => { + if (replacedBody !== this.replacedBody) { + this.replacedBody = replacedBody; + this.refresh(); + } + }); + } + + get body(): string | vscode.MarkdownString { + if (this.mode === vscode.CommentMode.Editing) { + return this._rawBody; + } + return new vscode.MarkdownString(this.replacedBody); + } + + protected getCancelEditBody() { + return new vscode.MarkdownString(this.rawComment.body); } } diff --git a/src/github/pullRequestGitHelper.ts b/src/github/pullRequestGitHelper.ts index 446c2836b7..b9e3a28546 100644 --- a/src/github/pullRequestGitHelper.ts +++ b/src/github/pullRequestGitHelper.ts @@ -6,15 +6,18 @@ /* * Inspired by and includes code from GitHub/VisualStudio project, obtained from https://github.com/github/VisualStudio/blob/165a97bdcab7559e0c4393a571b9ff2aed4ba8a7/src/GitHub.App/Services/PullRequestService.cs */ - +import * as vscode from 'vscode'; import { Branch, Repository } from '../api/api'; +import { GitErrorCodes } from '../api/api1'; import Logger from '../common/logger'; import { Protocol } from '../common/protocol'; import { parseRepositoryRemotes, Remote } from '../common/remote'; +import { PR_SETTINGS_NAMESPACE, PULL_PR_BRANCH_BEFORE_CHECKOUT } from '../common/settingKeys'; import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; const PullRequestRemoteMetadataKey = 'github-pr-remote'; export const PullRequestMetadataKey = 'github-pr-owner-number'; +const BaseBranchMetadataKey = 'github-pr-base-branch'; const PullRequestBranchRegex = /branch\.(.+)\.github-pr-owner-number/; const PullRequestRemoteRegex = /branch\.(.+)\.remote/; @@ -24,12 +27,19 @@ export interface PullRequestMetadata { prNumber: number; } +export interface BaseBranchMetadata { + owner: string; + repositoryName: string; + branch: string; +} + export class PullRequestGitHelper { static ID = 'PullRequestGitHelper'; static async checkoutFromFork( repository: Repository, pullRequest: PullRequestModel & IResolvedPullRequestModel, remoteName: string | undefined, + progress: vscode.Progress<{ message?: string; increment?: number }> ) { // the branch is from a fork const localBranchName = await PullRequestGitHelper.calculateUniqueBranchNameForPR(repository, pullRequest); @@ -40,6 +50,7 @@ export class PullRequestGitHelper { `Branch ${localBranchName} is from a fork. Create a remote first.`, PullRequestGitHelper.ID, ); + progress.report({ message: vscode.l10n.t('Creating git remote for {0}', `${pullRequest.remote.owner}/${pullRequest.remote.repositoryName}`) }); remoteName = await PullRequestGitHelper.createRemote( repository, pullRequest.remote, @@ -50,12 +61,15 @@ export class PullRequestGitHelper { // fetch the branch const ref = `${pullRequest.head.ref}:${localBranchName}`; Logger.debug(`Fetch ${remoteName}/${pullRequest.head.ref}:${localBranchName} - start`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Fetching branch {0}', ref) }); await repository.fetch(remoteName, ref, 1); Logger.debug(`Fetch ${remoteName}/${pullRequest.head.ref}:${localBranchName} - done`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Checking out {0}', ref) }); await repository.checkout(localBranchName); // set remote tracking branch for the local branch await repository.setBranchUpstream(localBranchName, `refs/remotes/${remoteName}/${pullRequest.head.ref}`); - await this.unshallow(repository); + // Don't await unshallow as the whole point of unshallowing and only fetching to depth 1 above is so that we can unshallow without slowwing down checkout later. + this.unshallow(repository); await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, localBranchName); } @@ -63,6 +77,7 @@ export class PullRequestGitHelper { repository: Repository, remotes: Remote[], pullRequest: PullRequestModel, + progress: vscode.Progress<{ message?: string; increment?: number }> ): Promise { if (!pullRequest.validatePullRequestModel('Checkout pull request failed')) { return; @@ -71,7 +86,7 @@ export class PullRequestGitHelper { const remote = PullRequestGitHelper.getHeadRemoteForPullRequest(remotes, pullRequest); const isFork = pullRequest.head.repositoryCloneUrl.owner !== pullRequest.base.repositoryCloneUrl.owner; if (!remote || isFork) { - return PullRequestGitHelper.checkoutFromFork(repository, pullRequest, remote && remote.remoteName); + return PullRequestGitHelper.checkoutFromFork(repository, pullRequest, remote && remote.remoteName, progress); } const branchName = pullRequest.head.ref; @@ -86,6 +101,7 @@ export class PullRequestGitHelper { return; } Logger.debug(`Checkout ${branchName}`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Checking out {0}', branchName) }); await repository.checkout(branchName); if (!branch.upstream) { @@ -96,6 +112,7 @@ export class PullRequestGitHelper { if (branch.behind !== undefined && branch.behind > 0 && branch.ahead === 0) { Logger.debug(`Pull from upstream`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Pulling {0}', branchName) }); await repository.pull(); } } catch (err) { @@ -106,12 +123,16 @@ export class PullRequestGitHelper { ); const trackedBranchName = `refs/remotes/${remoteName}/${branchName}`; Logger.appendLine(`Fetch tracked branch ${trackedBranchName}`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Fetching branch {0}', branchName) }); await repository.fetch(remoteName, branchName, 1); const trackedBranch = await repository.getBranch(trackedBranchName); // create branch + progress.report({ message: vscode.l10n.t('Creating and checking out branch {0}', branchName) }); await repository.createBranch(branchName, true, trackedBranch.commit); await repository.setBranchUpstream(branchName, trackedBranchName); - await this.unshallow(repository); + + // Don't await unshallow as the whole point of unshallowing and only fetching to depth 1 above is so that we can unshallow without slowwing down checkout later. + this.unshallow(repository); } await PullRequestGitHelper.associateBranchWithPullRequest(repository, pullRequest, branchName); @@ -122,20 +143,38 @@ export class PullRequestGitHelper { * will fail, so fall back to a normal pull. */ static async unshallow(repository: Repository): Promise { + let error: Error & { gitErrorCode?: GitErrorCodes }; try { await repository.pull(true); + return; } catch (e) { - Logger.appendLine(`Unshallowing failed: ${e}. Falling back to git pull`); - try { - await repository.pull(false); - } catch (e) { - Logger.appendLine(`Pull after failed unshallow still failed: ${e}`); - throw e; + Logger.appendLine(`Unshallowing failed: ${e}.`); + if (e.stderr && (e.stderr as string).includes('would clobber existing tag')) { + // ignore this error + return; } + error = e; + } + try { + if (error.gitErrorCode === GitErrorCodes.DirtyWorkTree) { + Logger.appendLine(`Getting status and trying unshallow again.`); + await repository.status(); + await repository.pull(true); + return; + } + } catch (e) { + Logger.appendLine(`Unshallowing still failed: ${e}.`); + } + try { + Logger.appendLine(`Falling back to git pull.`); + await repository.pull(false); + } catch (e) { + Logger.error(`Pull after failed unshallow still failed: ${e}`); + throw e; } } - static async checkoutExistingPullRequestBranch(repository: Repository, pullRequest: PullRequestModel) { + static async checkoutExistingPullRequestBranch(repository: Repository, pullRequest: PullRequestModel, progress: vscode.Progress<{ message?: string; increment?: number }>) { const key = PullRequestGitHelper.buildPullRequestMetadata(pullRequest); const configs = await repository.getConfigs(); @@ -155,13 +194,25 @@ export class PullRequestGitHelper { if (branchInfos && branchInfos.length) { // let's immediately checkout to branchInfos[0].branch const branchName = branchInfos[0].branch!; + progress.report({ message: vscode.l10n.t('Checking out branch {0}', branchName) }); await repository.checkout(branchName); - const remote = readConfig(`branch.${branchName}.remote`); - const ref = readConfig(`branch.${branchName}.merge`); - await repository.fetch(remote, ref); + + // respect the git setting to fetch before checkout + if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(PULL_PR_BRANCH_BEFORE_CHECKOUT, true)) { + const remote = readConfig(`branch.${branchName}.remote`); + const ref = readConfig(`branch.${branchName}.merge`); + progress.report({ message: vscode.l10n.t('Fetching branch {0}', branchName) }); + await repository.fetch(remote, ref); + } + const branchStatus = await repository.getBranch(branchInfos[0].branch!); + if (branchStatus.upstream === undefined) { + return false; + } + if (branchStatus.behind !== undefined && branchStatus.behind > 0 && branchStatus.ahead === 0) { Logger.debug(`Pull from upstream`, PullRequestGitHelper.ID); + progress.report({ message: vscode.l10n.t('Pulling branch {0}', branchName) }); await repository.pull(); } @@ -248,10 +299,14 @@ export class PullRequestGitHelper { return null; } - static buildPullRequestMetadata(pullRequest: PullRequestModel) { + private static buildPullRequestMetadata(pullRequest: PullRequestModel) { return `${pullRequest.base.repositoryCloneUrl.owner}#${pullRequest.base.repositoryCloneUrl.repositoryName}#${pullRequest.number}`; } + private static buildBaseBranchMetadata(owner: string, repository: string, baseBranch: string) { + return `${owner}#${repository}#${baseBranch}`; + } + static parsePullRequestMetadata(value: string): PullRequestMetadata | undefined { if (value) { const matches = /(.*)#(.*)#(.*)/g.exec(value); @@ -267,10 +322,29 @@ export class PullRequestGitHelper { return undefined; } - static getMetadataKeyForBranch(branchName: string): string { + private static parseBaseBranchMetadata(value: string): BaseBranchMetadata | undefined { + if (value) { + const matches = /(.*)#(.*)#(.*)/g.exec(value); + if (matches && matches.length === 4) { + const [, owner, repo, branch] = matches; + return { + owner, + repositoryName: repo, + branch, + }; + } + } + return undefined; + } + + private static getMetadataKeyForBranch(branchName: string): string { return `branch.${branchName}.${PullRequestMetadataKey}`; } + private static getBaseBranchMetadataKeyForBranch(branchName: string): string { + return `branch.${branchName}.${BaseBranchMetadataKey}`; + } + static async getMatchingPullRequestMetadataForBranch( repository: Repository, branchName: string, @@ -284,6 +358,19 @@ export class PullRequestGitHelper { } } + static async getMatchingBaseBranchMetadataForBranch( + repository: Repository, + branchName: string, + ): Promise { + try { + const configKey = this.getBaseBranchMetadataKeyForBranch(branchName); + const configValue = await repository.getConfig(configKey); + return PullRequestGitHelper.parseBaseBranchMetadata(configValue); + } catch (_) { + return; + } + } + static async createRemote(repository: Repository, baseRemote: Remote, cloneUrl: Protocol) { Logger.appendLine(`create remote for ${cloneUrl}.`, PullRequestGitHelper.ID); @@ -355,7 +442,7 @@ export class PullRequestGitHelper { pullRequest: PullRequestModel & IResolvedPullRequestModel, ): Remote | undefined { return remotes.find( - remote => remote.gitProtocol && remote.gitProtocol.equals(pullRequest.head.repositoryCloneUrl), + remote => remote.gitProtocol && (remote.gitProtocol.owner.toLowerCase() === pullRequest.head.repositoryCloneUrl.owner.toLowerCase()) && (remote.gitProtocol.repositoryName.toLowerCase() === pullRequest.head.repositoryCloneUrl.repositoryName.toLowerCase()) ); } @@ -364,8 +451,28 @@ export class PullRequestGitHelper { pullRequest: PullRequestModel, branchName: string, ) { - Logger.appendLine(`associate ${branchName} with Pull Request #${pullRequest.number}`, PullRequestGitHelper.ID); - const prConfigKey = `branch.${branchName}.${PullRequestMetadataKey}`; - await repository.setConfig(prConfigKey, PullRequestGitHelper.buildPullRequestMetadata(pullRequest)); + try { + Logger.appendLine(`associate ${branchName} with Pull Request #${pullRequest.number}`, PullRequestGitHelper.ID); + const prConfigKey = `branch.${branchName}.${PullRequestMetadataKey}`; + await repository.setConfig(prConfigKey, PullRequestGitHelper.buildPullRequestMetadata(pullRequest)); + } catch (e) { + Logger.error(`associate ${branchName} with Pull Request #${pullRequest.number} failed`, PullRequestGitHelper.ID); + } + } + + static async associateBaseBranchWithBranch( + repository: Repository, + branch: string, + owner: string, + repo: string, + baseBranch: string + ) { + try { + Logger.appendLine(`associate ${branch} with base branch ${owner}/${repo}#${baseBranch}`, PullRequestGitHelper.ID); + const prConfigKey = `branch.${branch}.${BaseBranchMetadataKey}`; + await repository.setConfig(prConfigKey, PullRequestGitHelper.buildBaseBranchMetadata(owner, repo, baseBranch)); + } catch (e) { + Logger.error(`associate ${branch} with base branch ${owner}/${repo}#${baseBranch} failed`, PullRequestGitHelper.ID); + } } } diff --git a/src/github/pullRequestModel.ts b/src/github/pullRequestModel.ts index dfbe4ab259..ddbb69ba1c 100644 --- a/src/github/pullRequestModel.ts +++ b/src/github/pullRequestModel.ts @@ -6,20 +6,22 @@ import * as buffer from 'buffer'; import * as path from 'path'; import equals from 'fast-deep-equal'; +import gql from 'graphql-tag'; import * as vscode from 'vscode'; import { Repository } from '../api/api'; -import { DiffSide, IComment, IReviewThread, ViewedState } from '../common/comment'; +import { DiffSide, IComment, IReviewThread, SubjectType, ViewedState } from '../common/comment'; import { parseDiff } from '../common/diffHunk'; -import { commands, contexts } from '../common/executeCommands'; import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; import { GitHubRef } from '../common/githubRef'; import Logger from '../common/logger'; import { Remote } from '../common/remote'; import { ITelemetry } from '../common/telemetry'; -import { ReviewEvent as CommonReviewEvent, isReviewEvent, TimelineEvent } from '../common/timelineEvent'; -import { resolvePath, toPRUri, toReviewUri } from '../common/uri'; +import { ReviewEvent as CommonReviewEvent, EventType, TimelineEvent } from '../common/timelineEvent'; +import { resolvePath, Schemes, toPRUri, toReviewUri } from '../common/uri'; import { formatError } from '../common/utils'; +import { InMemFileChangeModel, RemoteFileChangeModel } from '../view/fileChangeModel'; import { OctokitCommon } from './common'; +import { CredentialStore } from './credentials'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GitHubRepository } from './githubRepository'; import { @@ -28,9 +30,11 @@ import { AddReviewThreadResponse, DeleteReactionResponse, DeleteReviewResponse, + DequeuePullRequestResponse, EditCommentResponse, - GetChecksResponse, - isCheckRun, + EnqueuePullRequestResponse, + GetReviewRequestsResponse, + LatestReviewCommitResponse, MarkPullRequestReadyForReviewResponse, PendingReviewIdResponse, PullRequestCommentsResponse, @@ -49,23 +53,30 @@ import { IAccount, IRawFileChange, ISuggestedReviewer, + ITeam, + MergeMethod, + MergeQueueEntry, PullRequest, PullRequestChecks, PullRequestMergeability, + PullRequestReviewRequirement, ReviewEvent, } from './interface'; import { IssueModel } from './issueModel'; import { convertRESTPullRequestToRawPullRequest, convertRESTReviewEvent, - convertRESTUserToAccount, + getAvatarWithEnterpriseFallback, getReactionGroup, + insertNewCommitsSinceReview, parseGraphQLComment, parseGraphQLReaction, parseGraphQLReviewEvent, parseGraphQLReviewThread, parseGraphQLTimelineEvents, parseMergeability, + parseMergeQueueEntry, + restPaginate, } from './utils'; interface IPullRequestModel { @@ -91,13 +102,18 @@ export interface FileViewedStateChangeEvent { export type FileViewedState = { [key: string]: ViewedState }; +const BATCH_SIZE = 100; + export class PullRequestModel extends IssueModel implements IPullRequestModel { static ID = 'PullRequestModel'; public isDraft?: boolean; public localBranchName?: string; public mergeBase?: string; + public mergeQueueEntry?: MergeQueueEntry; public suggestedReviewers?: ISuggestedReviewer[]; + public hasChangesSinceLastReview?: boolean; + private _showChangesSinceReview: boolean; private _hasPendingReview: boolean = false; private _onDidChangePendingReviewState: vscode.EventEmitter = new vscode.EventEmitter(); public onDidChangePendingReviewState = this._onDidChangePendingReviewState.event; @@ -113,15 +129,26 @@ export class PullRequestModel extends IssueModel implements IPullRe private _onDidChangeFileViewedState = new vscode.EventEmitter(); public onDidChangeFileViewedState = this._onDidChangeFileViewedState.event; - private _comments: IComment[] | undefined; + private _onDidChangeChangesSinceReview = new vscode.EventEmitter(); + public onDidChangeChangesSinceReview = this._onDidChangeChangesSinceReview.event; + + private _comments: readonly IComment[] | undefined; private _onDidChangeComments: vscode.EventEmitter = new vscode.EventEmitter(); public readonly onDidChangeComments: vscode.Event = this._onDidChangeComments.event; // Whether the pull request is currently checked out locally - public isActive: boolean; + private _isActive: boolean; + public get isActive(): boolean { + return this._isActive; + } + public set isActive(isActive: boolean) { + this._isActive = isActive; + } + _telemetry: ITelemetry; constructor( + private readonly credentialStore: CredentialStore, telemetry: ITelemetry, githubRepository: GitHubRepository, remote: Remote, @@ -133,6 +160,8 @@ export class PullRequestModel extends IssueModel implements IPullRe this._telemetry = telemetry; this.isActive = !!isActive; + this._showChangesSinceReview = false; + this.update(item); } @@ -170,11 +199,23 @@ export class PullRequestModel extends IssueModel implements IPullRe } } - get comments(): IComment[] { + public get showChangesSinceReview() { + return this._showChangesSinceReview; + } + + public set showChangesSinceReview(isChangesSinceReview: boolean) { + if (this._showChangesSinceReview !== isChangesSinceReview) { + this._showChangesSinceReview = isChangesSinceReview; + this._fileChanges.clear(); + this._onDidChangeChangesSinceReview.fire(); + } + } + + get comments(): readonly IComment[] { return this._comments ?? []; } - set comments(comments: IComment[]) { + set comments(comments: readonly IComment[]) { this._comments = comments; this._onDidChangeComments.fire(); } @@ -191,8 +232,10 @@ export class PullRequestModel extends IssueModel implements IPullRe protected updateState(state: string) { if (state.toLowerCase() === 'open') { this.state = GithubItemStateEnum.Open; + } else if (state.toLowerCase() === 'merged' || this.item.merged) { + this.state = GithubItemStateEnum.Merged; } else { - this.state = this.item.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Closed; + this.state = GithubItemStateEnum.Closed; } } @@ -205,14 +248,17 @@ export class PullRequestModel extends IssueModel implements IPullRe this.isRemoteHeadDeleted = item.isRemoteHeadDeleted; } if (item.head) { - this.head = new GitHubRef(item.head.ref, item.head.label, item.head.sha, item.head.repo.cloneUrl); + this.head = new GitHubRef(item.head.ref, item.head.label, item.head.sha, item.head.repo.cloneUrl, item.head.repo.owner, item.head.repo.name, item.head.repo.isInOrganization); } if (item.isRemoteBaseDeleted != null) { this.isRemoteBaseDeleted = item.isRemoteBaseDeleted; } if (item.base) { - this.base = new GitHubRef(item.base.ref, item.base!.label, item.base!.sha, item.base!.repo.cloneUrl); + this.base = new GitHubRef(item.base.ref, item.base!.label, item.base!.sha, item.base!.repo.cloneUrl, item.base.repo.owner, item.base.repo.name, item.base.repo.isInOrganization); + } + if (item.mergeQueueEntry !== undefined) { + this.mergeQueueEntry = item.mergeQueueEntry ?? undefined; } } @@ -233,7 +279,7 @@ export class PullRequestModel extends IssueModel implements IPullRe return true; } - const reason = `There is no upstream branch for Pull Request #${this.number}. View it on GitHub for more details`; + const reason = vscode.l10n.t('There is no upstream branch for Pull Request #{0}. View it on GitHub for more details', this.number); if (message) { message += `: ${reason}`; @@ -241,8 +287,9 @@ export class PullRequestModel extends IssueModel implements IPullRe message = reason; } - vscode.window.showWarningMessage(message, 'Open on GitHub').then(action => { - if (action && action === 'Open on GitHub') { + const openString = vscode.l10n.t('Open on GitHub'); + vscode.window.showWarningMessage(message, openString).then(action => { + if (action && action === openString) { vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(this.html_url)); } }); @@ -254,7 +301,25 @@ export class PullRequestModel extends IssueModel implements IPullRe * Approve the pull request. * @param message Optional approval comment text. */ - async approve(message?: string): Promise { + async approve(repository: Repository, message?: string): Promise { + // Check that the remote head of the PR branch matches the local head of the PR branch + let remoteHead: string | undefined; + let localHead: string | undefined; + let rejectMessage: string | undefined; + if (this.isActive) { + localHead = repository.state.HEAD?.commit; + remoteHead = (await this.githubRepository.getPullRequest(this.number))?.head?.sha; + rejectMessage = vscode.l10n.t('The remote head of the PR branch has changed. Please pull the latest changes from the remote branch before approving.'); + } else { + localHead = this.head?.sha; + remoteHead = (await this.githubRepository.getPullRequest(this.number))?.head?.sha; + rejectMessage = vscode.l10n.t('The remote head of the PR branch has changed. Please refresh the pull request before approving.'); + } + + if (!remoteHead || remoteHead !== localHead) { + return Promise.reject(rejectMessage); + } + const action: Promise = (await this.getPendingReviewId()) ? this.submitReview(ReviewEvent.Approve, message) : this.createReview(ReviewEvent.Approve, message); @@ -264,6 +329,7 @@ export class PullRequestModel extends IssueModel implements IPullRe "pr.approve" : {} */ this._telemetry.sendTelemetryEvent('pr.approve'); + this._onDidChangeComments.fire(); return x; }); } @@ -282,6 +348,7 @@ export class PullRequestModel extends IssueModel implements IPullRe "pr.requestChanges" : {} */ this._telemetry.sendTelemetryEvent('pr.requestChanges'); + this._onDidChangeComments.fire(); return x; }); } @@ -291,7 +358,7 @@ export class PullRequestModel extends IssueModel implements IPullRe */ async close(): Promise { const { octokit, remote } = await this.githubRepository.ensure(); - const ret = await octokit.pulls.update({ + const ret = await octokit.call(octokit.api.pulls.update, { owner: remote.owner, repo: remote.repositoryName, pull_number: this.number, @@ -314,7 +381,7 @@ export class PullRequestModel extends IssueModel implements IPullRe private async createReview(event: ReviewEvent, message?: string): Promise { const { octokit, remote } = await this.githubRepository.ensure(); - const { data } = await octokit.pulls.createReview({ + const { data } = await octokit.call(octokit.api.pulls.createReview, { owner: remote.owner, repo: remote.repositoryName, pull_number: this.number, @@ -382,13 +449,13 @@ export class PullRequestModel extends IssueModel implements IPullRe }, }); } catch (err) { - Logger.appendLine(err); + Logger.error(err, PullRequestModel.ID); } } - async updateAssignees(assignees: string[]): Promise { + async addAssignees(assignees: string[]): Promise { const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.issues.addAssignees({ + await octokit.call(octokit.api.issues.addAssignees, { owner: remote.owner, repo: remote.repositoryName, issue_number: this.number, @@ -416,6 +483,33 @@ 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(); + + try { + const { data } = await query({ + query: schema.LatestReviewCommit, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + }, + }); + + if (data.repository === null) { + Logger.error('Unexpected null repository while getting last review commit', PullRequestModel.ID); + } + + return data.repository?.pullRequest.viewerLatestReview ? { + sha: data.repository?.pullRequest.viewerLatestReview.commit.oid, + } : undefined; + } + catch (e) { + return undefined; + } + } + /** * Delete an existing in progress review. */ @@ -438,7 +532,7 @@ export class PullRequestModel extends IssueModel implements IPullRe return { deletedReviewId: databaseId, - deletedReviewComments: comments.nodes.map(comment => parseGraphQLComment(comment, false)), + deletedReviewComments: comments.nodes.map(comment => parseGraphQLComment(comment, false, this.githubRepository)), }; } @@ -463,7 +557,8 @@ export class PullRequestModel extends IssueModel implements IPullRe if (!data) { throw new Error('Failed to start review'); } - + this.hasPendingReview = true; + this._onDidChangeComments.fire(); return data.addPullRequestReview.pullRequestReview.id; } @@ -483,8 +578,8 @@ export class PullRequestModel extends IssueModel implements IPullRe async createReviewThread( body: string, commentPath: string, - startLine: number, - endLine: number, + startLine: number | undefined, + endLine: number | undefined, side: DiffSide, suppressDraftModeUpdate?: boolean, ): Promise { @@ -503,23 +598,28 @@ export class PullRequestModel extends IssueModel implements IPullRe pullRequestId: this.graphNodeId, pullRequestReviewId: pendingReviewId, startLine: startLine === endLine ? undefined : startLine, - line: endLine, + line: (endLine === undefined) ? 0 : endLine, side, - }, - }, - }); + subjectType: (startLine === undefined || endLine === undefined) ? SubjectType.FILE : SubjectType.LINE + } + } + }, { mutation: schema.LegacyAddReviewThread, deleteProps: ['subjectType'] }); if (!data) { throw new Error('Creating review thread failed.'); } + if (!data.addPullRequestReviewThread.thread) { + throw new Error('File has been deleted.'); + } + if (!suppressDraftModeUpdate) { this.hasPendingReview = true; await this.updateDraftModeContext(); } const thread = data.addPullRequestReviewThread.thread; - const newThread = parseGraphQLReviewThread(thread); + const newThread = parseGraphQLReviewThread(thread, this.githubRepository); this._reviewThreadsCache.push(newThread); this._onDidChangeReviewThreads.fire({ added: [newThread], changed: [], removed: [] }); return newThread; @@ -567,7 +667,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } const { comment } = data.addPullRequestReviewComment; - const newComment = parseGraphQLComment(comment, false); + const newComment = parseGraphQLComment(comment, false, this.githubRepository); if (isSingleComment) { newComment.isDraft = false; @@ -636,6 +736,7 @@ export class PullRequestModel extends IssueModel implements IPullRe const newComment = parseGraphQLComment( data.updatePullRequestReviewComment.pullRequestReviewComment, !!comment.isResolved, + this.githubRepository ); if (threadWithComment) { const index = threadWithComment.comments.findIndex(c => c.graphNodeId === comment.graphNodeId); @@ -659,7 +760,7 @@ export class PullRequestModel extends IssueModel implements IPullRe if (threadIndex === -1) { this.deleteIssueComment(commentId); } else { - await octokit.pulls.deleteReviewComment({ + await octokit.call(octokit.api.pulls.deleteReviewComment, { owner: remote.owner, repo: remote.repositoryName, comment_id: id, @@ -685,29 +786,66 @@ export class PullRequestModel extends IssueModel implements IPullRe /** * Get existing requests to review. */ - async getReviewRequests(): Promise { + async getReviewRequests(): Promise<(IAccount | ITeam)[]> { const githubRepository = this.githubRepository; - const { remote, octokit } = await githubRepository.ensure(); - const result = await octokit.pulls.listRequestedReviewers({ - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, + const { remote, query, schema } = await githubRepository.ensure(); + + const { data } = await query({ + query: this.credentialStore.isAuthenticatedWithAdditionalScopes(githubRepository.remote.authProviderId) ? schema.GetReviewRequestsAdditionalScopes : schema.GetReviewRequests, + variables: { + number: this.number, + owner: remote.owner, + name: remote.repositoryName + }, }); - return result.data.users.map((user: any) => convertRESTUserToAccount(user, githubRepository)); + if (data.repository === null) { + Logger.error('Unexpected null repository while getting review requests', PullRequestModel.ID); + return []; + } + + const reviewers: (IAccount | ITeam)[] = []; + for (const reviewer of data.repository.pullRequest.reviewRequests.nodes) { + if (reviewer.requestedReviewer?.login) { + const account: IAccount = { + login: reviewer.requestedReviewer.login, + url: reviewer.requestedReviewer.url, + avatarUrl: getAvatarWithEnterpriseFallback(reviewer.requestedReviewer.avatarUrl, undefined, remote.isEnterprise), + email: reviewer.requestedReviewer.email, + name: reviewer.requestedReviewer.name, + id: reviewer.requestedReviewer.id + }; + reviewers.push(account); + } else if (reviewer.requestedReviewer) { + const team: ITeam = { + name: reviewer.requestedReviewer.name, + url: reviewer.requestedReviewer.url, + avatarUrl: getAvatarWithEnterpriseFallback(reviewer.requestedReviewer.avatarUrl, undefined, remote.isEnterprise), + id: reviewer.requestedReviewer.id!, + org: remote.owner, + slug: reviewer.requestedReviewer.slug! + }; + reviewers.push(team); + } + } + return reviewers; } /** * Add reviewers to a pull request * @param reviewers A list of GitHub logins */ - async requestReview(reviewers: string[]): Promise { - const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.pulls.requestReviewers({ - owner: remote.owner, - repo: remote.repositoryName, - pull_number: this.number, - reviewers, + async requestReview(reviewers: string[], teamReviewers: string[]): Promise { + const { mutate, schema } = await this.githubRepository.ensure(); + await mutate({ + mutation: schema.AddReviewers, + variables: { + input: { + pullRequestId: this.graphNodeId, + teamIds: teamReviewers, + userIds: reviewers + }, + }, }); } @@ -715,33 +853,34 @@ export class PullRequestModel extends IssueModel implements IPullRe * Remove a review request that has not yet been completed * @param reviewer A GitHub Login */ - async deleteReviewRequest(reviewer: string): Promise { + async deleteReviewRequest(reviewers: string[], teamReviewers: string[]): Promise { const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.pulls.removeRequestedReviewers({ + await octokit.call(octokit.api.pulls.removeRequestedReviewers, { owner: remote.owner, repo: remote.repositoryName, pull_number: this.number, - reviewers: [reviewer], + reviewers, + team_reviewers: teamReviewers }); } - async deleteAssignees(assignee: string): Promise { + async deleteAssignees(assignees: string[]): Promise { const { octokit, remote } = await this.githubRepository.ensure(); - await octokit.issues.removeAssignees({ + await octokit.call(octokit.api.issues.removeAssignees, { owner: remote.owner, repo: remote.repositoryName, issue_number: this.number, - assignees: [assignee], + assignees, }); } - private diffThreads(newReviewThreads: IReviewThread[]): void { + private diffThreads(oldReviewThreads: IReviewThread[], newReviewThreads: IReviewThread[]): void { const added: IReviewThread[] = []; const changed: IReviewThread[] = []; const removed: IReviewThread[] = []; newReviewThreads.forEach(thread => { - const existingThread = this._reviewThreadsCache.find(t => t.id === thread.id); + const existingThread = oldReviewThreads.find(t => t.id === thread.id); if (existingThread) { if (!equals(thread, existingThread)) { changed.push(thread); @@ -751,7 +890,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } }); - this._reviewThreadsCache.forEach(thread => { + oldReviewThreads.forEach(thread => { if (!newReviewThreads.find(t => t.id === thread.id)) { removed.push(thread); } @@ -766,26 +905,35 @@ export class PullRequestModel extends IssueModel implements IPullRe async getReviewThreads(): Promise { const { remote, query, schema } = await this.githubRepository.ensure(); + let after: string | null = null; + let hasNextPage = false; + const reviewThreads: IReviewThread[] = []; try { - const { data } = await query({ - query: schema.PullRequestComments, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - }, - }); + do { + const { data } = await query({ + query: schema.PullRequestComments, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + after + }, + }, false, { query: schema.LegacyPullRequestComments }); - const reviewThreads = data.repository.pullRequest.reviewThreads.nodes.map(node => { - return parseGraphQLReviewThread(node); - }); + reviewThreads.push(...data.repository.pullRequest.reviewThreads.nodes.map(node => { + return parseGraphQLReviewThread(node, this.githubRepository); + })); - this.diffThreads(reviewThreads); - this._reviewThreadsCache = reviewThreads; + hasNextPage = data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage; + after = data.repository.pullRequest.reviewThreads.pageInfo.endCursor; + } while (hasNextPage && reviewThreads.length < 1000); + const oldReviewThreads = this._reviewThreadsCache; + this._reviewThreadsCache = reviewThreads; + this.diffThreads(oldReviewThreads, reviewThreads); return reviewThreads; } catch (e) { - Logger.appendLine(`Failed to get pull request review comments: ${e}`); + Logger.error(`Failed to get pull request review comments: ${e}`, PullRequestModel.ID); return []; } } @@ -795,26 +943,34 @@ export class PullRequestModel extends IssueModel implements IPullRe */ async initializeReviewComments(): Promise { const { remote, query, schema } = await this.githubRepository.ensure(); + let after: string | null = null; + let hasNextPage = false; + const comments: IComment[] = []; try { - const { data } = await query({ - query: schema.PullRequestComments, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - }, - }); - - const comments = data.repository.pullRequest.reviewThreads.nodes - .map(node => node.comments.nodes.map(comment => parseGraphQLComment(comment, node.isResolved), remote)) - .reduce((prev, curr) => prev.concat(curr), []) - .sort((a: IComment, b: IComment) => { - return a.createdAt > b.createdAt ? 1 : -1; - }); - + do { + const { data } = await query({ + query: schema.PullRequestComments, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + after, + }, + }, false, { query: schema.LegacyPullRequestComments }); + + comments.push(...data.repository.pullRequest.reviewThreads.nodes + .map(node => node.comments.nodes.map(comment => parseGraphQLComment(comment, node.isResolved, this.githubRepository), remote)) + .reduce((prev, curr) => prev.concat(curr), []) + .sort((a: IComment, b: IComment) => { + return a.createdAt > b.createdAt ? 1 : -1; + })); + + hasNextPage = data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage; + after = data.repository.pullRequest.reviewThreads.pageInfo.endCursor; + } while (hasNextPage && comments.length < 1000); this.comments = comments; } catch (e) { - Logger.appendLine(`Failed to get pull request review comments: ${e}`); + Logger.error(`Failed to get pull request review comments: ${e}`, PullRequestModel.ID); } } @@ -825,14 +981,14 @@ export class PullRequestModel extends IssueModel implements IPullRe try { Logger.debug(`Fetch commits of PR #${this.number} - enter`, PullRequestModel.ID); const { remote, octokit } = await this.githubRepository.ensure(); - const commitData = await octokit.pulls.listCommits({ + const commitData = await restPaginate(octokit.api.pulls.listCommits, { pull_number: this.number, owner: remote.owner, repo: remote.repositoryName, }); Logger.debug(`Fetch commits of PR #${this.number} - done`, PullRequestModel.ID); - return commitData.data; + return commitData; } catch (e) { vscode.window.showErrorMessage(`Fetching commits failed: ${formatError(e)}`); return []; @@ -852,7 +1008,7 @@ export class PullRequestModel extends IssueModel implements IPullRe PullRequestModel.ID, ); const { octokit, remote } = await this.githubRepository.ensure(); - const fullCommit = await octokit.repos.getCommit({ + const fullCommit = await octokit.call(octokit.api.repos.getCommit, { owner: remote.owner, repo: remote.repositoryName, ref: commit.sha, @@ -862,7 +1018,7 @@ export class PullRequestModel extends IssueModel implements IPullRe PullRequestModel.ID, ); - return fullCommit.data.files?.filter(file => !!file.patch) ?? []; + return fullCommit.data.files ?? []; } catch (e) { vscode.window.showErrorMessage(`Fetching commit file changes failed: ${formatError(e)}`); return []; @@ -876,7 +1032,7 @@ export class PullRequestModel extends IssueModel implements IPullRe */ async getFile(filePath: string, commit: string) { const { octokit, remote } = await this.githubRepository.ensure(); - const fileContent = await octokit.repos.getContent({ + const fileContent = await octokit.call(octokit.api.repos.getContent, { owner: remote.owner, repo: remote.repositoryName, path: filePath, @@ -900,17 +1056,29 @@ export class PullRequestModel extends IssueModel implements IPullRe const { query, remote, schema } = await this.githubRepository.ensure(); try { - const { data } = await query({ - query: schema.TimelineEvents, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - }, - }); - const ret = data.repository.pullRequest.timelineItems.nodes; - const events = parseGraphQLTimelineEvents(ret, this.githubRepository); - await this.addReviewTimelineEventComments(events); + const [{ data }, latestReviewCommitInfo, currentUser, reviewThreads] = await Promise.all([ + query({ + query: schema.TimelineEvents, + variables: { + owner: remote.owner, + name: remote.repositoryName, + number: this.number, + }, + }), + this.getViewerLatestReviewCommit(), + this.githubRepository.getAuthenticatedUser(), + this.getReviewThreads() + ]); + + if (data.repository === null) { + Logger.error('Unexpected null repository when fetching timeline', PullRequestModel.ID); + } + + const ret = data.repository?.pullRequest.timelineItems.nodes; + const events = ret ? parseGraphQLTimelineEvents(ret, this.githubRepository) : []; + + this.addReviewTimelineEventComments(events, reviewThreads); + insertNewCommitsSinceReview(events, latestReviewCommitInfo?.sha, currentUser, this.head); return events; } catch (e) { @@ -919,13 +1087,12 @@ export class PullRequestModel extends IssueModel implements IPullRe } } - private async addReviewTimelineEventComments(events: TimelineEvent[]): Promise { + private addReviewTimelineEventComments(events: TimelineEvent[], reviewThreads: IReviewThread[]): void { interface CommentNode extends IComment { childComments?: CommentNode[]; } - const reviewEvents = events.filter(isReviewEvent); - const reviewThreads = await this.getReviewThreads(); + const reviewEvents = events.filter((e): e is CommonReviewEvent => e.event === EventType.Reviewed); const reviewComments = reviewThreads.reduce((previous, current) => (previous as IComment[]).concat(current.comments), []); const reviewEventsById = reviewEvents.reduce((index, evt) => { @@ -959,6 +1126,20 @@ export class PullRequestModel extends IssueModel implements IPullRe } }); + reviewThreads.forEach(thread => { + if (!thread.prReviewDatabaseId || !reviewEventsById[thread.prReviewDatabaseId]) { + return; + } + const prReviewThreadEvent = reviewEventsById[thread.prReviewDatabaseId]; + prReviewThreadEvent.reviewThread = { + threadId: thread.id, + canResolve: thread.viewerCanResolve, + canUnresolve: thread.viewerCanUnresolve, + isResolved: thread.isResolved + }; + + }); + const pendingReview = reviewEvents.filter(r => r.state.toLowerCase() === 'pending')[0]; if (pendingReview) { // Ensures that pending comments made in reply to other reviews are included for the pending review @@ -969,84 +1150,30 @@ export class PullRequestModel extends IssueModel implements IPullRe /** * Get the status checks of the pull request, those for the last commit. */ - async getStatusChecks(): Promise { - const { query, remote, schema, octokit } = await this.githubRepository.ensure(); - let result; - try { - result = await query({ - query: schema.GetChecks, - variables: { - owner: remote.owner, - name: remote.repositoryName, - number: this.number, - }, - }); - } catch (e) { - if (e.message?.startsWith('GraphQL error: Resource protected by organization SAML enforcement.')) { - // There seems to be an issue with fetching status checks if you haven't SAML'd with every org you have - // Ignore SAML errors here. - return { - state: 'pending', - statuses: [], - }; - } - } + async getStatusChecks(): Promise<[PullRequestChecks | null, PullRequestReviewRequirement | null]> { + return this.githubRepository.getStatusChecks(this.number); + } - // 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; + static async openChanges(folderManager: FolderRepositoryManager, pullRequestModel: PullRequestModel) { + const isCurrentPR = folderManager.activePullRequest?.number === pullRequestModel.number; + const changes = pullRequestModel.fileChanges.size > 0 ? pullRequestModel.fileChanges.values() : await pullRequestModel.getFileChangesInfo(); + const args: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][] = []; - if (!statusCheckRollup) { - return { - state: 'pending', - statuses: [], - }; - } - - const checks: PullRequestChecks = { - state: statusCheckRollup.state.toLowerCase(), - statuses: statusCheckRollup.contexts.nodes.map(context => { - if (isCheckRun(context)) { - return { - id: context.id, - url: context.checkSuite.app?.url, - avatar_url: context.checkSuite.app?.logoUrl, - state: context.conclusion?.toLowerCase() || 'pending', - description: context.title, - context: context.name, - target_url: context.detailsUrl, - }; - } else { - return { - id: context.id, - url: context.targetUrl, - avatar_url: context.avatarUrl, - state: context.state?.toLowerCase(), - description: context.description, - context: context.context, - target_url: context.targetUrl, - }; - } - }), - }; - - // Fun info: The checks don't include whether a review is required. - // Also, unless you're an admin on the repo, you can't just do octokit.repos.getBranchProtection - if (this.item.mergeable === PullRequestMergeability.NotMergeable) { - const branch = await octokit.repos.getBranch({ branch: this.base.ref, owner: remote.owner, repo: remote.repositoryName }); - if (branch.data.protected && branch.data.protection.required_status_checks.enforcement_level !== 'off') { - // We need to add the "review required" check manually. - checks.statuses.unshift({ - id: 'unknown', - context: 'Branch Protection', - description: 'Requirements have not been met.', - state: 'failure', - target_url: this.html_url - }); - checks.state = 'failure'; + for (const change of changes) { + let changeModel; + if (change instanceof SlimFileChange) { + changeModel = new RemoteFileChangeModel(folderManager, change, pullRequestModel); + } else { + changeModel = new InMemFileChangeModel(folderManager, pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), change, isCurrentPR, pullRequestModel.mergeBase!); } + args.push([changeModel.filePath, changeModel.parentFilePath, changeModel.filePath]); } - return checks; + /* __GDPR__ + "pr.openChanges" : {} + */ + folderManager.telemetry.sendTelemetryEvent('pr.openChanges'); + return vscode.commands.executeCommand('vscode.changes', vscode.l10n.t('Changes in Pull Request #{0}', pullRequestModel.number), args); } static async openDiffFromComment( @@ -1054,7 +1181,7 @@ export class PullRequestModel extends IssueModel implements IPullRe pullRequestModel: PullRequestModel, comment: IComment, ): Promise { - const contentChanges = await pullRequestModel.getFileChangesInfo(folderManager.repository); + const contentChanges = await pullRequestModel.getFileChangesInfo(); const change = contentChanges.find( fileChange => fileChange.fileName === comment.path || fileChange.previousFileName === comment.path, ); @@ -1062,6 +1189,31 @@ export class PullRequestModel extends IssueModel implements IPullRe throw new Error(`Can't find matching file`); } + const pathSegments = comment.path!.split('/'); + const line = (comment.diffHunks && comment.diffHunks.length > 0) ? comment.diffHunks[0].newLineNumber : undefined; + this.openDiff(folderManager, pullRequestModel, change, pathSegments[pathSegments.length - 1], line); + } + + static async openFirstDiff( + folderManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + ) { + const contentChanges = await pullRequestModel.getFileChangesInfo(); + if (!contentChanges.length) { + return; + } + + const firstChange = contentChanges[0]; + this.openDiff(folderManager, pullRequestModel, firstChange, firstChange.fileName); + } + + static async openDiff( + folderManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + change: SlimFileChange | InMemFileChange, + diffTitle: string, + line?: number + ): Promise { let headUri, baseUri: vscode.Uri; if (!pullRequestModel.equals(folderManager.activePullRequest)) { const headCommit = pullRequestModel.head!.sha; @@ -1074,6 +1226,7 @@ export class PullRequestModel extends IssueModel implements IPullRe change.fileName, false, change.status, + change.previousFileName ); baseUri = toPRUri( vscode.Uri.file(resolvePath(folderManager.repository.rootUri, parentFileName)), @@ -1083,6 +1236,7 @@ export class PullRequestModel extends IssueModel implements IPullRe change.fileName, true, change.status, + change.previousFileName ); } else { const uri = vscode.Uri.file(path.resolve(folderManager.repository.rootUri.fsPath, change.fileName)); @@ -1112,13 +1266,12 @@ export class PullRequestModel extends IssueModel implements IPullRe ); } - const pathSegments = comment.path!.split('/'); vscode.commands.executeCommand( 'vscode.diff', baseUri, headUri, - `${pathSegments[pathSegments.length - 1]} (Pull Request)`, - {}, + `${diffTitle} (Pull Request)`, + line ? { selection: { start: { line, character: 0 }, end: { line, character: 0 } } } : {}, ); } @@ -1127,11 +1280,11 @@ export class PullRequestModel extends IssueModel implements IPullRe return this._fileChanges; } - async getFileChangesInfo(repo: Repository) { + async getFileChangesInfo() { this._fileChanges.clear(); const data = await this.getRawFileChangesInfo(); const mergebase = this.mergeBase || this.base.sha; - const parsed = await parseDiff(data, repo, mergebase); + const parsed = await parseDiff(data, mergebase); parsed.forEach(fileChange => { this._fileChanges.set(fileChange.fileName, fileChange); }); @@ -1150,7 +1303,7 @@ export class PullRequestModel extends IssueModel implements IPullRe const { octokit, remote } = await githubRepository.ensure(); if (!this.base) { - const info = await octokit.pulls.get({ + const info = await octokit.call(octokit.api.pulls.get, { owner: remote.owner, repo: remote.repositoryName, pull_number: this.number, @@ -1158,8 +1311,17 @@ export class PullRequestModel extends IssueModel implements IPullRe this.update(convertRESTPullRequestToRawPullRequest(info.data, githubRepository)); } + let compareWithBaseRef = this.base.sha; + const latestReview = await this.getViewerLatestReviewCommit(); + const oldHasChangesSinceReview = this.hasChangesSinceLastReview; + this.hasChangesSinceLastReview = latestReview !== undefined && this.head?.sha !== latestReview.sha; + + if (this._showChangesSinceReview && this.hasChangesSinceLastReview && latestReview != undefined) { + compareWithBaseRef = latestReview.sha; + } + if (this.item.merged) { - const response = await octokit.pulls.listFiles({ + const response = await restPaginate(octokit.api.pulls.listFiles, { repo: remote.repositoryName, owner: remote.owner, pull_number: this.number, @@ -1168,37 +1330,40 @@ export class PullRequestModel extends IssueModel implements IPullRe // Use the original base to compare against for merged PRs this.mergeBase = this.base.sha; - return response.data as IRawFileChange[]; + return response; } - const { data } = await octokit.repos.compareCommits({ + const { data } = await octokit.call(octokit.api.repos.compareCommits, { repo: remote.repositoryName, owner: remote.owner, - base: `${this.base.repositoryCloneUrl.owner}:${this.base.ref}`, - head: `${this.head!.repositoryCloneUrl.owner}:${this.head!.ref}`, + base: `${this.base.repositoryCloneUrl.owner}:${compareWithBaseRef}`, + head: `${this.head!.repositoryCloneUrl.owner}:${this.head!.sha}`, }); this.mergeBase = data.merge_base_commit.sha; - const MAX_FILE_CHANGES_IN_COMPARE_COMMITS = 300; + const MAX_FILE_CHANGES_IN_COMPARE_COMMITS = 100; let files: IRawFileChange[] = []; - if (data.files.length >= MAX_FILE_CHANGES_IN_COMPARE_COMMITS) { - // compareCommits will return a maximum of 300 changed files + if (data.files && data.files.length >= MAX_FILE_CHANGES_IN_COMPARE_COMMITS) { + // compareCommits will return a maximum of 100 changed files // If we have (maybe) more than that, we'll need to fetch them with listFiles API call Logger.debug( `More than ${MAX_FILE_CHANGES_IN_COMPARE_COMMITS} files changed, fetching all file changes of PR #${this.number}`, PullRequestModel.ID, ); - files = await octokit.paginate(`GET /repos/:owner/:repo/pulls/:pull_number/files`, { + files = await restPaginate(octokit.api.pulls.listFiles, { owner: this.base.repositoryCloneUrl.owner, pull_number: this.number, repo: remote.repositoryName, - per_page: 100, }); } else { // if we're under the limit, just use the result from compareCommits, don't make additional API calls. - files = data.files as IRawFileChange[]; + files = data.files ? data.files as IRawFileChange[] : []; + } + + if (oldHasChangesSinceReview !== undefined && oldHasChangesSinceReview !== this.hasChangesSinceLastReview && this.hasChangesSinceLastReview && this._showChangesSinceReview) { + this._onDidChangeChangesSinceReview.fire(); } Logger.debug( @@ -1208,6 +1373,26 @@ export class PullRequestModel extends IssueModel implements IPullRe return files; } + get autoMerge(): boolean { + return !!this.item.autoMerge; + } + + get autoMergeMethod(): MergeMethod | undefined { + return this.item.autoMergeMethod; + } + + get allowAutoMerge(): boolean { + return !!this.item.allowAutoMerge; + } + + get mergeCommitMeta(): { title: string; description: string } | undefined { + return this.item.mergeCommitMeta; + } + + get squashCommitMeta(): { title: string; description: string } | undefined { + return this.item.squashCommitMeta; + } + /** * Get the current mergeability of the pull request. */ @@ -1224,10 +1409,16 @@ export class PullRequestModel extends IssueModel implements IPullRe number: this.number, }, }); + if (data.repository === null) { + Logger.error('Unexpected null repository while getting mergeability', PullRequestModel.ID); + } + Logger.debug(`Fetch pull request mergeability ${this.number} - done`, PullRequestModel.ID); - return parseMergeability(data.repository.pullRequest.mergeable, data.repository.pullRequest.mergeStateStatus); + const mergeability = parseMergeability(data.repository?.pullRequest.mergeable, data.repository?.pullRequest.mergeStateStatus); + this.item.mergeable = mergeability; + return mergeability; } catch (e) { - Logger.appendLine(`PullRequestModel> Unable to fetch PR Mergeability: ${e}`); + Logger.error(`Unable to fetch PR Mergeability: ${e}`, PullRequestModel.ID); return PullRequestMergeability.Unknown; } } @@ -1331,49 +1522,199 @@ export class PullRequestModel extends IssueModel implements IPullRe return data; } + private undoOptimisticResolveState(oldThread: IReviewThread | undefined) { + if (oldThread) { + oldThread.isResolved = !oldThread.isResolved; + oldThread.viewerCanResolve = !oldThread.viewerCanResolve; + oldThread.viewerCanUnresolve = !oldThread.viewerCanUnresolve; + this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); + } + } + async resolveReviewThread(threadId: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.ResolveReviewThread, - variables: { - input: { - threadId, + const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId); + + try { + Logger.debug(`Resolve review thread - enter`, PullRequestModel.ID); + + const { mutate, schema } = await this.githubRepository.ensure(); + + // optimistically update + if (oldThread && oldThread.viewerCanResolve) { + oldThread.isResolved = true; + oldThread.viewerCanResolve = false; + oldThread.viewerCanUnresolve = true; + this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); + } + + const { data } = await mutate({ + mutation: schema.ResolveReviewThread, + variables: { + input: { + threadId, + }, }, - }, - }); + }, { mutation: schema.LegacyResolveReviewThread, deleteProps: [] }); - if (!data) { - throw new Error('Resolve review thread failed.'); - } + if (!data) { + this.undoOptimisticResolveState(oldThread); + throw new Error('Resolve review thread failed.'); + } - const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); - if (index > -1) { - const thread = parseGraphQLReviewThread(data.resolveReviewThread.thread); - this._reviewThreadsCache.splice(index, 1, thread); - this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); + const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); + if (index > -1) { + const thread = parseGraphQLReviewThread(data.resolveReviewThread.thread, this.githubRepository); + this._reviewThreadsCache.splice(index, 1, thread); + this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); + } + Logger.debug(`Resolve review thread - done`, PullRequestModel.ID); + } catch (e) { + Logger.error(`Resolve review thread failed: ${e}`, PullRequestModel.ID); + this.undoOptimisticResolveState(oldThread); } } async unresolveReviewThread(threadId: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const { data } = await mutate({ - mutation: schema.UnresolveReviewThread, - variables: { - input: { - threadId, + const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId); + + try { + Logger.debug(`Unresolve review thread - enter`, PullRequestModel.ID); + + const { mutate, schema } = await this.githubRepository.ensure(); + + // optimistically update + if (oldThread && oldThread.viewerCanUnresolve) { + oldThread.isResolved = false; + oldThread.viewerCanUnresolve = false; + oldThread.viewerCanResolve = true; + this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] }); + } + + const { data } = await mutate({ + mutation: schema.UnresolveReviewThread, + variables: { + input: { + threadId, + }, }, - }, - }); + }, { mutation: schema.LegacyUnresolveReviewThread, deleteProps: [] }); - if (!data) { - throw new Error('Unresolve review thread failed.'); + if (!data) { + this.undoOptimisticResolveState(oldThread); + throw new Error('Unresolve review thread failed.'); + } + + const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); + if (index > -1) { + const thread = parseGraphQLReviewThread(data.unresolveReviewThread.thread, this.githubRepository); + this._reviewThreadsCache.splice(index, 1, thread); + this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); + } + Logger.debug(`Unresolve review thread - done`, PullRequestModel.ID); + } catch (e) { + Logger.error(`Unresolve review thread failed: ${e}`, PullRequestModel.ID); + this.undoOptimisticResolveState(oldThread); + } + } + + async enableAutoMerge(mergeMethod: MergeMethod): Promise { + try { + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.EnablePullRequestAutoMerge, + variables: { + input: { + mergeMethod: mergeMethod.toUpperCase(), + pullRequestId: this.graphNodeId + } + } + }); + + if (!data) { + throw new Error('Enable auto-merge failed.'); + } + this.item.autoMerge = true; + this.item.autoMergeMethod = mergeMethod; + } catch (e) { + if (e.message === 'GraphQL error: ["Pull request Pull request is in clean status"]') { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to enable auto-merge. Pull request status checks are already green.')); + } else { + throw e; + } + } + } + + async disableAutoMerge(): Promise { + try { + const { mutate, schema } = await this.githubRepository.ensure(); + const { data } = await mutate({ + mutation: schema.DisablePullRequestAutoMerge, + variables: { + input: { + pullRequestId: this.graphNodeId + } + } + }); + + if (!data) { + throw new Error('Disable auto-merge failed.'); + } + this.item.autoMerge = false; + } catch (e) { + if (e.message === 'GraphQL error: ["Pull request Pull request is in clean status"]') { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to enable auto-merge. Pull request status checks are already green.')); + } else { + throw e; + } } + } + + async dequeuePullRequest(): Promise { + Logger.debug(`Dequeue pull request ${this.number} - enter`, GitHubRepository.ID); + const { mutate, schema } = await this.githubRepository.ensure(); + if (!schema.DequeuePullRequest) { + return false; + } + try { + await mutate({ + mutation: schema.DequeuePullRequest, + variables: { + input: { + id: this.graphNodeId + } + } + }); - const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); - if (index > -1) { - const thread = parseGraphQLReviewThread(data.unresolveReviewThread.thread); - this._reviewThreadsCache.splice(index, 1, thread); - this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); + Logger.debug(`Dequeue pull request ${this.number} - done`, GitHubRepository.ID); + this.mergeQueueEntry = undefined; + return true; + } catch (e) { + Logger.error(`Dequeueing pull request failed: ${e}`, GitHubRepository.ID); + return false; + } + } + + async enqueuePullRequest(): Promise { + Logger.debug(`Enqueue pull request ${this.number} - enter`, GitHubRepository.ID); + const { mutate, schema } = await this.githubRepository.ensure(); + if (!schema.EnqueuePullRequest) { + return; + } + try { + const { data } = await mutate({ + mutation: schema.EnqueuePullRequest, + variables: { + input: { + pullRequestId: this.graphNodeId + } + } + }); + + Logger.debug(`Enqueue pull request ${this.number} - done`, GitHubRepository.ID); + const temp = parseMergeQueueEntry(data?.enqueuePullRequest.mergeQueueEntry) ?? undefined; + return temp; + } catch (e) { + Logger.error(`Enqueuing pull request failed: ${e}`, GitHubRepository.ID); } } @@ -1413,42 +1754,61 @@ export class PullRequestModel extends IssueModel implements IPullRe } } - async markFileAsViewed(filePathOrSubpath: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const fileName = filePathOrSubpath.startsWith(this.githubRepository.rootUri.path) ? - filePathOrSubpath.substring(this.githubRepository.rootUri.path.length + 1) : filePathOrSubpath; - await mutate({ - mutation: schema.MarkFileAsViewed, - variables: { - input: { - path: fileName, - pullRequestId: this.graphNodeId, - }, - }, - }); + async markFiles(filePathOrSubpaths: string[], event: boolean, state: 'viewed' | 'unviewed'): Promise { + const { mutate } = await this.githubRepository.ensure(); + const pullRequestId = this.graphNodeId; - this.setFileViewedState(fileName, ViewedState.VIEWED, true); - } + const allFilenames = filePathOrSubpaths + .map((f) => + f.startsWith(this.githubRepository.rootUri.path) + ? f.substring(this.githubRepository.rootUri.path.length + 1) + : f + ); - async unmarkFileAsViewed(filePathOrSubpath: string): Promise { - const { mutate, schema } = await this.githubRepository.ensure(); - const fileName = filePathOrSubpath.startsWith(this.githubRepository.rootUri.path) ? - filePathOrSubpath.substring(this.githubRepository.rootUri.path.length + 1) : filePathOrSubpath; - await mutate({ - mutation: schema.UnmarkFileAsViewed, - variables: { - input: { - path: fileName, - pullRequestId: this.graphNodeId, - }, - }, - }); + const mutationName = state === 'viewed' + ? 'markFileAsViewed' + : 'unmarkFileAsViewed'; + + // We only ever send 100 mutations at once. Any more than this and + // we risk a timeout from GitHub. + for (let i = 0; i < allFilenames.length; i += BATCH_SIZE) { + const batch = allFilenames.slice(i, i + BATCH_SIZE); + // See below for an example of what a mutation produced by this + // will look like + const mutation = gql`mutation Batch${mutationName}{ + ${batch.map((filename, i) => + `alias${i}: ${mutationName}( + input: {path: "${filename}", pullRequestId: "${pullRequestId}"} + ) { clientMutationId } + ` + )} + }`; + await mutate({ mutation }); + } + + // mutation BatchUnmarkFileAsViewedInline { + // alias0: unmarkFileAsViewed( + // input: { path: "some_folder/subfolder/A.txt", pullRequestId: "PR_someid" } + // ) { + // clientMutationId + // } + // alias1: unmarkFileAsViewed( + // input: { path: "some_folder/subfolder/B.txt", pullRequestId: "PR_someid" } + // ) { + // clientMutationId + // } + // } + + filePathOrSubpaths.forEach(path => this.setFileViewedState(path, state === 'viewed' ? ViewedState.VIEWED : ViewedState.UNVIEWED, event)); + } - this.setFileViewedState(fileName, ViewedState.UNVIEWED, true); + async unmarkAllFilesAsViewed(): Promise { + return this.markFiles(Array.from(this.fileChanges.keys()), true, 'unviewed'); } private setFileViewedState(fileSubpath: string, viewedState: ViewedState, event: boolean) { - const filePath = vscode.Uri.joinPath(this.githubRepository.rootUri, fileSubpath).fsPath; + const uri = vscode.Uri.joinPath(this.githubRepository.rootUri, fileSubpath); + const filePath = (this.githubRepository.rootUri.scheme === Schemes.VscodeVfs) ? uri.path : uri.fsPath; switch (viewedState) { case ViewedState.DISMISSED: { this._viewedFiles.delete(filePath); @@ -1471,12 +1831,10 @@ export class PullRequestModel extends IssueModel implements IPullRe } } - /** - * Using these contexts is fragile in a multi-root workspace where multiple PRs are checked out. - * If you have two active PRs that have the same file path relative to their rootdir, then these context can get confused. - */ - public setFileViewedContext() { - commands.setContext(contexts.VIEWED_FILES, Array.from(this._viewedFiles)); - commands.setContext(contexts.UNVIEWED_FILES, Array.from(this._unviewedFiles)); + public getViewedFileStates() { + return { + viewed: this._viewedFiles, + unviewed: this._unviewedFiles + }; } } diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index 249bd42290..feb981fc4b 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -4,35 +4,35 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import * as path from 'path'; import * as vscode from 'vscode'; import { onDidUpdatePR, openPullRequestOnGitHub } from '../commands'; import { IComment } from '../common/comment'; import Logger from '../common/logger'; +import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent'; -import { formatError } from '../common/utils'; -import { IRequestMessage } from '../common/webview'; +import { asPromise, dispose, formatError } from '../common/utils'; +import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GithubItemStateEnum, IAccount, IMilestone, - ISuggestedReviewer, + IProject, + IProjectItem, + isTeam, + ITeam, MergeMethod, MergeMethodsAvailability, + reviewerId, ReviewEvent, ReviewState, } from './interface'; import { IssueOverviewPanel } from './issueOverview'; import { PullRequestModel } from './pullRequestModel'; import { PullRequestView } from './pullRequestOverviewCommon'; -import { isInCodespaces, parseReviewers } from './utils'; - -type MilestoneQuickPickItem = vscode.QuickPickItem & { id: string; milestone: IMilestone }; - -function isMilestoneQuickPickItem(x: vscode.QuickPickItem | MilestoneQuickPickItem): x is MilestoneQuickPickItem { - return !!(x as MilestoneQuickPickItem).id && !!(x as MilestoneQuickPickItem).milestone; -} +import { getAssigneesQuickPickItems, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks'; +import { isInCodespaces, parseReviewers, vscodeDevPrLink } from './utils'; +import { ProjectItemsReply, PullRequest, ReviewType } from './views'; export class PullRequestOverviewPanel extends IssueOverviewPanel { public static ID: string = 'PullRequestOverviewPanel'; @@ -41,29 +41,30 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { if (pr) { @@ -126,10 +126,25 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + this._disposables.push(folderRepositoryManager.credentialStore.onDidUpgradeSession(() => { + this.updatePullRequest(this._item); + })); + + this._disposables.push(vscode.commands.registerCommand('review.approveDescription', (e) => this.approvePullRequestCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.commentDescription', (e) => this.submitReviewCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.requestChangesDescription', (e) => this.requestChangesCommand(e))); + this._disposables.push(vscode.commands.registerCommand('review.approveOnDotComDescription', () => { + return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + })); + this._disposables.push(vscode.commands.registerCommand('review.requestChangesOnDotComDescription', () => { + return openPullRequestOnGitHub(this._item, (this._item as any)._telemetry); + })); + } + + registerPrListeners() { + dispose(this._prListeners); + this._prListeners = []; + this._prListeners.push(this._folderRepositoryManager.onDidChangeActivePullRequest(_ => { if (this._folderRepositoryManager && this._item) { const isCurrentlyCheckedOut = this._item.equals(this._folderRepositoryManager.activePullRequest); this._postMessage({ @@ -137,7 +152,15 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + if (!this._isUpdating) { + this.refreshPanel(); + } + })); + } } /** @@ -146,12 +169,12 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel r.reviewer.login === currentUser.login); + 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; } - public async updatePullRequest(pullRequestModel: PullRequestModel): Promise { + private async updatePullRequest(pullRequestModel: PullRequestModel): Promise { return Promise.all([ this._folderRepositoryManager.resolvePullRequest( pullRequestModel.remote.owner, @@ -164,7 +187,10 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { const [ pullRequest, @@ -174,6 +200,10 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel('defaultMergeMethod'); - const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability, preferredMergeMethod); + const canEdit = hasWritePermission || viewerCanEdit; + + const defaultMergeMethod = getDefaultMergeMethod(mergeMethodsAvailability); this._existingReviewers = parseReviewers(requestedReviewers!, timelineEvents!, pullRequest.author); - const currentUser = this._folderRepositoryManager.getCurrentUser(this._item); const isCrossRepository = pullRequest.base && - pullRequest.head && + !!pullRequest.head && !pullRequest.base.repositoryCloneUrl.equals(pullRequest.head.repositoryCloneUrl); const continueOnGitHub = isCrossRepository && isInCodespaces(); const reviewState = this.getCurrentUserReviewState(this._existingReviewers, currentUser); Logger.debug('pr.initialize', PullRequestOverviewPanel.ID); + const context: Partial = { + number: pullRequest.number, + title: pullRequest.title, + titleHTML: pullRequest.titleHTML, + url: pullRequest.html_url, + createdAt: pullRequest.createdAt, + body: pullRequest.body, + bodyHTML: pullRequest.bodyHTML, + labels: pullRequest.item.labels, + author: { + id: pullRequest.author.id, + login: pullRequest.author.login, + name: pullRequest.author.name, + avatarUrl: pullRequest.userAvatar, + url: pullRequest.author.url, + }, + state: pullRequest.state, + events: timelineEvents, + isCurrentlyCheckedOut: isCurrentlyCheckedOut, + isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, + base: pullRequest.base.label, + isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, + isLocalHeadDeleted: !branchInfo, + head: pullRequest.head?.label ?? '', + repositoryDefaultBranch: defaultBranch, + canEdit: canEdit, + hasWritePermission, + status: status[0], + reviewRequirement: status[1], + mergeable: pullRequest.item.mergeable, + reviewers: this._existingReviewers, + isDraft: pullRequest.isDraft, + mergeMethodsAvailability, + defaultMergeMethod, + autoMerge: pullRequest.autoMerge, + allowAutoMerge: pullRequest.allowAutoMerge, + autoMergeMethod: pullRequest.autoMergeMethod, + mergeQueueMethod: mergeQueueMethod, + mergeQueueEntry: pullRequest.mergeQueueEntry, + mergeCommitMeta: pullRequest.mergeCommitMeta, + squashCommitMeta: pullRequest.squashCommitMeta, + isIssue: false, + projectItems: pullRequest.item.projectItems, + milestone: pullRequest.milestone, + assignees: pullRequest.assignees, + continueOnGitHub, + isAuthor: currentUser.login === pullRequest.author.login, + currentUserReviewState: reviewState, + isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark, + isEnterprise: pullRequest.githubRepository.remote.isEnterprise + }; this._postMessage({ command: 'pr.initialize', - pullrequest: { - number: pullRequest.number, - title: pullRequest.title, - url: pullRequest.html_url, - createdAt: pullRequest.createdAt, - body: pullRequest.body, - bodyHTML: pullRequest.bodyHTML, - labels: pullRequest.item.labels, - author: { - login: pullRequest.author.login, - name: pullRequest.author.name, - avatarUrl: pullRequest.userAvatar, - url: pullRequest.author.url, - }, - state: pullRequest.state, - events: timelineEvents, - isCurrentlyCheckedOut: isCurrentlyCheckedOut, - isRemoteBaseDeleted: pullRequest.isRemoteBaseDeleted, - base: pullRequest.base.label, - isRemoteHeadDeleted: pullRequest.isRemoteHeadDeleted, - isLocalHeadDeleted: !branchInfo, - head: pullRequest.head?.label ?? '', - repositoryDefaultBranch: defaultBranch, - canEdit: canEdit, - hasWritePermission, - status: status ? status : { statuses: [] }, - mergeable: pullRequest.item.mergeable, - reviewers: this._existingReviewers, - isDraft: pullRequest.isDraft, - mergeMethodsAvailability, - defaultMergeMethod, - isIssue: false, - milestone: pullRequest.milestone, - assignees: pullRequest.assignees, - continueOnGitHub, - isAuthor: currentUser.login === pullRequest.author.login, - currentUserReviewState: reviewState - }, + pullrequest: context }); + if (pullRequest.isResolved()) { + this._folderRepositoryManager.checkBranchUpToDate(pullRequest, true); + } }) .catch(e => { vscode.window.showErrorMessage(formatError(e)); @@ -257,11 +303,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { if (this._folderRepositoryManager !== folderRepositoryManager) { this._folderRepositoryManager = folderRepositoryManager; - if (this._changeActivePullRequestListener) { - this._changeActivePullRequestListener.dispose(); - this._changeActivePullRequestListener = undefined; - this.registerFolderRepositoryListener(); - } + this.registerPrListeners(); } this._postMessage({ @@ -269,7 +311,9 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - if (!suggestedReviewers) { - return []; - } - - const allAssignableUsers = await this._folderRepositoryManager.getAssignableUsers(); - const assignableUsers = allAssignableUsers[this._item.remote.remoteName] ?? []; - - // used to track logins that shouldn't be added to pick list - // e.g. author, existing and already added reviewers - const skipList: Set = new Set([ - this._item.author.login, - ...this._existingReviewers.map(reviewer => reviewer.reviewer.login), - ]); - - const reviewers: (vscode.QuickPickItem & { reviewer?: IAccount })[] = []; - for (const user of suggestedReviewers) { - const { login, name, isAuthor, isCommenter } = user; - if (skipList.has(login)) { - continue; - } - - const suggestionReason: string = - isAuthor && isCommenter - ? 'Recently edited and reviewed changes to these files' - : isAuthor - ? 'Recently edited these files' - : isCommenter - ? 'Recently reviewed changes to these files' - : 'Suggested reviewer'; - - reviewers.push({ - label: login, - description: name, - detail: suggestionReason, - reviewer: user, - }); - // this user shouldn't be added later from assignable users list - skipList.add(login); - } - - for (const user of assignableUsers) { - if (skipList.has(user.login)) { - continue; - } - - reviewers.push({ - label: user.login, - description: user.name, - reviewer: user, - }); - } - - if (reviewers.length === 0) { - reviewers.push({ - label: 'No reviewers available for this repository' - }); - } - - return reviewers; + private gotoChangesSinceReview() { + this._item.showChangesSinceReview = true; } - private getAssigneesQuickPickItems( - assignableUsers: IAccount[] | undefined, - suggestedReviewers: ISuggestedReviewer[] | undefined, - ): (vscode.QuickPickItem & { assignee?: IAccount })[] { - if (!suggestedReviewers) { - return []; - } - assignableUsers = assignableUsers ?? []; - // used to track logins that shouldn't be added to pick list - // e.g. author, existing and already added reviewers - const skipList: Set = new Set([...(this._item.assignees?.map(assignee => assignee.login) ?? [])]); - - const assignees: (vscode.QuickPickItem & { assignee?: IAccount })[] = []; - for (const suggestedReviewer of suggestedReviewers) { - const { login, name, isAuthor, isCommenter } = suggestedReviewer; - if (skipList.has(login)) { - continue; - } - - const suggestionReason: string = - isAuthor && isCommenter - ? 'Recently edited and reviewed changes to these files' - : isAuthor - ? 'Recently edited these files' - : isCommenter - ? 'Recently reviewed changes to these files' - : 'Suggested reviewer'; - - assignees.push({ - label: login, - description: name, - detail: suggestionReason, - assignee: suggestedReviewer, - }); - // this user shouldn't be added later from assignable users list - skipList.add(login); - } - for (const user of assignableUsers) { - if (skipList.has(user.login)) { - continue; - } - - assignees.push({ - label: user.login, - description: user.name, - assignee: user, - }); - } + private async changeReviewers(message: IRequestMessage): Promise { + let quickPick: vscode.QuickPick | undefined; - if (assignees.length === 0) { - assignees.push({ - label: 'No assignees available for this repository' + try { + quickPick = await reviewersQuickPick(this._folderRepositoryManager, this._item.remote.remoteName, this._item.base.isInOrganization, this._teamsCount, this._item.author, this._existingReviewers, this._item.suggestedReviewers); + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick!.selectedItems.filter(item => item.user) as (vscode.QuickPickItem & { user: IAccount | ITeam })[] | undefined; }); - } + const hidePromise = asPromise(quickPick.onDidHide); + const allReviewers = await Promise.race<(vscode.QuickPickItem & { user: IAccount | ITeam })[] | void>([acceptPromise, hidePromise]); + quickPick.busy = true; + + if (allReviewers) { + const newUserReviewers: string[] = []; + const newTeamReviewers: string[] = []; + allReviewers.forEach(reviewer => { + const newReviewers = isTeam(reviewer.user) ? newTeamReviewers : newUserReviewers; + newReviewers.push(reviewer.user.id); + }); - return assignees; - } + const removedUserReviewers: string[] = []; + const removedTeamReviewers: string[] = []; + this._existingReviewers.forEach(existing => { + let newReviewers: string[] = isTeam(existing.reviewer) ? newTeamReviewers : newUserReviewers; + let removedReviewers: string[] = isTeam(existing.reviewer) ? removedTeamReviewers : removedUserReviewers; + if (!newReviewers.find(newTeamReviewer => newTeamReviewer === existing.reviewer.id)) { + removedReviewers.push(existing.reviewer.id); + } + }); - private async addReviewers(message: IRequestMessage): Promise { - try { - const reviewersToAdd: (vscode.QuickPickItem & { reviewer: IAccount })[] | undefined = (await vscode.window.showQuickPick( - this.getReviewersQuickPickItems(this._item.suggestedReviewers), - { - canPickMany: true, - matchOnDescription: true, - }, - ))?.filter(item => item.reviewer) as (vscode.QuickPickItem & { reviewer: IAccount })[] | undefined; - - if (reviewersToAdd) { - await this._item.requestReview(reviewersToAdd.map(r => r.label)); - const addedReviewers: ReviewState[] = reviewersToAdd.map(selected => { + await this._item.requestReview(newUserReviewers, newTeamReviewers); + await this._item.deleteReviewRequest(removedUserReviewers, removedTeamReviewers); + const addedReviewers: ReviewState[] = allReviewers.map(selected => { return { - reviewer: selected.reviewer, + reviewer: selected.user, state: 'REQUESTED', }; }); - this._existingReviewers = this._existingReviewers.concat(addedReviewers); - this._replyMessage(message, { - added: addedReviewers, + this._existingReviewers = addedReviewers; + await this._replyMessage(message, { + reviewers: addedReviewers, }); } } catch (e) { + Logger.error(formatError(e)); vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick?.hide(); + quickPick?.dispose(); } } private async addMilestone(message: IRequestMessage): Promise { - try { - async function getMilestoneOptions( - folderRepoManager: FolderRepositoryManager, - ): Promise<(MilestoneQuickPickItem | vscode.QuickPickItem)[]> { - const milestones = await folderRepoManager.getMilestones(); - if (!milestones.items.length) { - return [ - { - label: 'No milestones created for this repository.', - }, - ]; - } - - return milestones.items.map(result => { - return { - label: result.milestone.title, - id: result.milestone.id, - milestone: result.milestone, - }; - }); - } - - const milestoneToAdd = await vscode.window.showQuickPick( - getMilestoneOptions(this._folderRepositoryManager), - { - canPickMany: false, - }, - ); + return getMilestoneFromQuickPick(this._folderRepositoryManager, this._item.githubRepository, this._item.milestone, (milestone) => this.updateMilestone(milestone, message)); + } - if (milestoneToAdd && isMilestoneQuickPickItem(milestoneToAdd)) { - await this._item.updateMilestone(milestoneToAdd.id); - this._replyMessage(message, { - added: milestoneToAdd.milestone, - }); - } - } catch (e) { - vscode.window.showErrorMessage(formatError(e)); + private async updateMilestone(milestone: IMilestone | undefined, message: IRequestMessage) { + if (!milestone) { + return this.removeMilestone(message); } + await this._item.updateMilestone(milestone.id); + this._replyMessage(message, { + added: milestone, + }); } private async removeMilestone(message: IRequestMessage): Promise { @@ -522,55 +466,74 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { - try { - const allAssignableUsers = await this._folderRepositoryManager.getAssignableUsers(); - const assignableUsers = allAssignableUsers[this._item.remote.remoteName]; - - const assigneesToAdd = (await vscode.window.showQuickPick( - this.getAssigneesQuickPickItems(assignableUsers, []), - { - canPickMany: true, - matchOnDescription: true, - }, - ))?.filter(item => item.assignee) as (vscode.QuickPickItem & { assignee: IAccount })[] | undefined;; - - if (assigneesToAdd) { - const addedAssignees: IAccount[] = assigneesToAdd.map(item => item.assignee); - this._item.assignees = this._item.assignees?.concat(addedAssignees); - - await this._item.updateAssignees(addedAssignees.map(assignee => assignee.login)); + private async changeProjects(message: IRequestMessage): Promise { + return getProjectFromQuickPick(this._folderRepositoryManager, this._item.githubRepository, this._item.remote.remoteName, this._item.base.isInOrganization, this._item.item.projectItems, (project) => this.updateProjects(project, message)); + } - this._replyMessage(message, { - added: addedAssignees, - }); - } - } catch (e) { - vscode.window.showErrorMessage(formatError(e)); + private async updateProjects(projects: IProject[] | undefined, message: IRequestMessage) { + if (projects) { + const newProjects = await this._item.updateProjects(projects); + const projectItemsReply: ProjectItemsReply = { + projectItems: newProjects, + }; + return this._replyMessage(message, projectItemsReply); } } - private async removeReviewer(message: IRequestMessage): Promise { - try { - await this._item.deleteReviewRequest(message.args); + private async removeProject(message: IRequestMessage): Promise { + await this._item.removeProjects([message.args]); + return this._replyMessage(message, {}); + } - const index = this._existingReviewers.findIndex(reviewer => reviewer.reviewer.login === message.args); - this._existingReviewers.splice(index, 1); + private async changeAssignees(message: IRequestMessage): Promise { + const quickPick = vscode.window.createQuickPick(); - this._replyMessage(message, {}); + try { + quickPick.busy = true; + quickPick.canSelectMany = true; + quickPick.matchOnDescription = true; + quickPick.show(); + quickPick.items = await getAssigneesQuickPickItems(this._folderRepositoryManager, this._item.remote.remoteName, this._item.assignees ?? [], this._item); + quickPick.selectedItems = quickPick.items.filter(item => item.picked); + + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick.selectedItems.filter(item => item.user) as (vscode.QuickPickItem & { user: IAccount })[] | undefined; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const allAssignees = await Promise.race<(vscode.QuickPickItem & { user: IAccount })[] | void>([acceptPromise, hidePromise]); + quickPick.busy = true; + + if (allAssignees) { + const newAssignees: IAccount[] = allAssignees.map(item => item.user); + const removeAssignees: IAccount[] = this._item.assignees?.filter(currentAssignee => !newAssignees.find(newAssignee => newAssignee.login === currentAssignee.login)) ?? []; + this._item.assignees = newAssignees; + + await this._item.addAssignees(newAssignees.map(assignee => assignee.login)); + await this._item.deleteAssignees(removeAssignees.map(assignee => assignee.login)); + await this._replyMessage(message, { + assignees: newAssignees, + }); + } } catch (e) { vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick.hide(); + quickPick.dispose(); } } - private async removeAssignee(message: IRequestMessage): Promise { + private async addAssigneeYourself(message: IRequestMessage): Promise { try { - await this._item.deleteAssignees(message.args); - - const index = this._item.assignees?.findIndex(assignee => assignee.login === message.args) ?? -1; - this._item.assignees?.splice(index, 1); - - this._replyMessage(message, {}); + const currentUser = await this._folderRepositoryManager.getCurrentUser(); + const alreadyAssigned = this._item.assignees?.find(user => user.login === currentUser.login); + if (!alreadyAssigned) { + this._item.assignees = this._item.assignees?.concat(currentUser); + await this._item.addAssignees([currentUser.login]); + } + this._replyMessage(message, { + assignees: this._item.assignees, + }); } catch (e) { vscode.window.showErrorMessage(formatError(e)); } @@ -582,20 +545,16 @@ 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._item.getTimelineEvents(); + this._replyMessage(message, timelineEvents); + } catch (e) { + vscode.window.showErrorMessage(e); + this._replyMessage(message, undefined); } } @@ -623,7 +598,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel, + message: IRequestMessage<{ title: string | undefined; description: string | undefined; method: 'merge' | 'squash' | 'rebase' }>, ): void { const { title, description, method } = message.args; this._folderRepositoryManager @@ -671,7 +646,11 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): 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, {}); @@ -681,7 +660,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel review.user.login === reviewer.reviewer.login, + reviewer => review.user.login === (reviewer.reviewer as IAccount).login, ); if (existingReviewer) { existingReviewer.state = review.state; @@ -694,60 +673,145 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): void { - this._item.approve(message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - //refresh the pr list as this one is approved - vscode.commands.executeCommand('pr.refreshList'); - }, - e => { - vscode.window.showErrorMessage(`Approving pull request failed. ${formatError(e)}`); + private 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 = { + command: 'pr.append-review', + review, + reviewers: this._existingReviewers + }; + 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' }); + } + } - this._throwError(message, `${formatError(e)}`); - }, - ); + private async doReviewMessage(message: IRequestMessage, action: (body) => Promise) { + try { + const review = await action(message.args); + this.updateReviewers(review); + this._replyMessage(message, { + review: review, + reviewers: this._existingReviewers, + }); + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Submitting review failed. {0}', formatError(e))); + this._throwError(message, `${formatError(e)}`); + } } - private requestChanges(message: IRequestMessage): void { - this._item.requestChanges(message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - }, - e => { - vscode.window.showErrorMessage(`Requesting changes failed. ${formatError(e)}`); - this._throwError(message, `${formatError(e)}`); - }, - ); + private approvePullRequest(body: string): Promise { + return this._item.approve(this._folderRepositoryManager.repository, body); } - private submitReview(message: IRequestMessage): void { - this._item.submitReview(ReviewEvent.Comment, message.args).then( - review => { - this.updateReviewers(review); - this._replyMessage(message, { - review: review, - reviewers: this._existingReviewers, - }); - }, - e => { - vscode.window.showErrorMessage(`Submitting review failed. ${formatError(e)}`); - this._throwError(message, `${formatError(e)}`); - }, - ); + private approvePullRequestMessage(message: IRequestMessage): Promise { + return this.doReviewMessage(message, (body) => this.approvePullRequest(body)); + } + + private approvePullRequestCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.Approve, (body) => this.approvePullRequest(body)); + } + + private requestChanges(body: string): Promise { + return this._item.requestChanges(body); + } + + private requestChangesCommand(context: { body: string }): Promise { + return this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body)); + } + + private requestChangesMessage(message: IRequestMessage): Promise { + return this.doReviewMessage(message, (body) => this.requestChanges(body)); + } + + private submitReview(body: string): Promise { + return this._item.submitReview(ReviewEvent.Comment, body); + } + + private submitReviewCommand(context: { body: string }) { + return this.doReviewCommand(context, ReviewType.Comment, (body) => this.submitReview(body)); + } + + private submitReviewMessage(message: IRequestMessage) { + return this.doReviewMessage(message, (body) => this.submitReview(body)); + } + + private reRequestReview(message: IRequestMessage): void { + let targetReviewer: ReviewState | undefined; + const userReviewers: string[] = []; + const teamReviewers: string[] = []; + + for (const reviewer of this._existingReviewers) { + let id: string | undefined; + let reviewerArray: string[] | undefined; + if (reviewer && isTeam(reviewer.reviewer)) { + id = reviewer.reviewer.id; + reviewerArray = teamReviewers; + } else if (reviewer && !isTeam(reviewer.reviewer)) { + id = reviewer.reviewer.id; + reviewerArray = userReviewers; + } + if (reviewerArray && id && ((reviewer.state === 'REQUESTED') || (id === message.args))) { + reviewerArray.push(id); + if (id === message.args) { + targetReviewer = reviewer; + } + } + } + + this._item.requestReview(userReviewers, teamReviewers).then(() => { + if (targetReviewer) { + targetReviewer.state = 'REQUESTED'; + } + this._replyMessage(message, { + reviewers: this._existingReviewers, + }); + }); } private async copyPrLink(): Promise { - await vscode.env.clipboard.writeText(this._item.html_url); - vscode.window.showInformationMessage(`Copied link to PR "${this._item.title}"!`); + return vscode.env.clipboard.writeText(this._item.html_url); + } + + private async copyVscodeDevLink(): Promise { + return vscode.env.clipboard.writeText(vscodeDevPrLink(this._item)); + } + + private async updateAutoMerge(message: IRequestMessage<{ autoMerge?: boolean, autoMergeMethod: MergeMethod }>): Promise { + let replyMessage: { autoMerge: boolean, autoMergeMethod?: MergeMethod }; + if (!message.args.autoMerge && !this._item.autoMerge) { + replyMessage = { autoMerge: false }; + } else if ((message.args.autoMerge === false) && this._item.autoMerge) { + await this._item.disableAutoMerge(); + replyMessage = { autoMerge: this._item.autoMerge }; + } else { + if (this._item.autoMerge && message.args.autoMergeMethod !== this._item.autoMergeMethod) { + await this._item.disableAutoMerge(); + } + await this._item.enableAutoMerge(message.args.autoMergeMethod); + replyMessage = { autoMerge: this._item.autoMerge, autoMergeMethod: this._item.autoMergeMethod }; + } + this._replyMessage(message, replyMessage); + } + + private async dequeue(message: IRequestMessage): Promise { + const result = await this._item.dequeuePullRequest(); + this._replyMessage(message, result); + } + + private async enqueue(message: IRequestMessage): Promise { + const result = await this._item.enqueuePullRequest(); + this._replyMessage(message, { mergeQueueEntry: result }); } protected editCommentPromise(comment: IComment, text: string): Promise { @@ -760,22 +824,19 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel(DEFAULT_MERGE_METHOD); // Use default merge method specified by user if it is available if (userPreferred && methodsAvailability.hasOwnProperty(userPreferred) && methodsAvailability[userPreferred]) { return userPreferred; } const methods: MergeMethod[] = ['merge', 'squash', 'rebase']; - // GitHub requires to have at leas one merge method to be enabled; use first available as default + // GitHub requires to have at least one merge method to be enabled; use first available as default return methods.find(method => methodsAvailability[method])!; } diff --git a/src/github/pullRequestOverviewCommon.ts b/src/github/pullRequestOverviewCommon.ts index 0ae824dcbb..cbc9867a61 100644 --- a/src/github/pullRequestOverviewCommon.ts +++ b/src/github/pullRequestOverviewCommon.ts @@ -5,6 +5,12 @@ 'use strict'; import * as vscode from 'vscode'; +import { + DEFAULT_DELETION_METHOD, + PR_SETTINGS_NAMESPACE, + SELECT_LOCAL_BRANCH, + SELECT_REMOTE, +} from '../common/settingKeys'; import { Schemes } from '../common/uri'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { PullRequestModel } from './pullRequestModel'; @@ -21,7 +27,7 @@ export namespace PullRequestView { const isDefaultBranch = defaultBranch === item.head.ref; if (!isDefaultBranch && !item.isRemoteHeadDeleted) { actions.push({ - label: `Delete remote branch ${item.remote.remoteName}/${branchHeadRef}`, + label: vscode.l10n.t('Delete remote branch {0}', `${item.remote.remoteName}/${branchHeadRef}`), description: `${item.remote.normalizedHost}/${item.remote.owner}/${item.remote.repositoryName}`, type: 'upstream', picked: true, @@ -31,21 +37,21 @@ export namespace PullRequestView { if (branchInfo) { const preferredLocalBranchDeletionMethod = vscode.workspace - .getConfiguration('githubPullRequests') - .get('defaultDeletionMethod.selectLocalBranch'); + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_LOCAL_BRANCH}`); actions.push({ - label: `Delete local branch ${branchInfo.branch}`, + label: vscode.l10n.t('Delete local branch {0}', branchInfo.branch), type: 'local', picked: !!preferredLocalBranchDeletionMethod, }); const preferredRemoteDeletionMethod = vscode.workspace - .getConfiguration('githubPullRequests') - .get('defaultDeletionMethod.selectRemote'); + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(`${DEFAULT_DELETION_METHOD}.${SELECT_REMOTE}`); if (branchInfo.remote && branchInfo.createdForPullRequest && !branchInfo.remoteInUse) { actions.push({ - label: `Delete remote ${branchInfo.remote}, which is no longer used by any other branch`, + label: vscode.l10n.t('Delete remote {0}, which is no longer used by any other branch', branchInfo.remote), type: 'remote', picked: !!preferredRemoteDeletionMethod, }); @@ -54,14 +60,14 @@ export namespace PullRequestView { if (vscode.env.remoteName === 'codespaces') { actions.push({ - label: 'Suspend Codespace', + label: vscode.l10n.t('Suspend Codespace'), type: 'suspend' }); } if (!actions.length) { vscode.window.showWarningMessage( - `There is no longer an upstream or local branch for Pull Request #${item.number}`, + vscode.l10n.t('There is no longer an upstream or local branch for Pull Request #{0}', item.number), ); return { isReply: true, @@ -95,12 +101,13 @@ export namespace PullRequestView { case 'local': if (isBranchActive) { if (folderRepositoryManager.repository.state.workingTreeChanges.length) { + const yes = vscode.l10n.t('Yes'); const response = await vscode.window.showWarningMessage( - `Your local changes will be lost, do you want to continue?`, + vscode.l10n.t('Your local changes will be lost, do you want to continue?'), { modal: true }, - 'Yes', + yes, ); - if (response === 'Yes') { + if (response === yes) { await vscode.commands.executeCommand('git.cleanAll'); } else { return; diff --git a/src/github/queries.gql b/src/github/queries.gql index 0ed41dad4c..812fc908ac 100644 --- a/src/github/queries.gql +++ b/src/github/queries.gql @@ -3,990 +3,165 @@ # * Licensed under the MIT License. See License.txt in the project root for license information. # *--------------------------------------------------------------------------------------------*/ -fragment Merged on MergedEvent { - id - actor { - login - avatarUrl - url - } - createdAt - mergeRef { - name - } - commit { - oid - commitUrl - } - url -} +#import "./queriesShared.gql" -fragment HeadRefDeleted on HeadRefDeletedEvent { - id - actor { - login - avatarUrl - url - } - createdAt - headRefName -} - -fragment Ref on Ref { - name - repository { - owner { - login - } - url - } - target { - oid - } -} +# Queries that are also in the limit file, but are not limited (by scope or API availability) here -fragment Comment on IssueComment { - id - databaseId - authorAssociation - author { - login - avatarUrl - url - ... on User { - email - } - ... on Organization { - email - } - } +fragment PullRequestFragment on PullRequest { + number url + state body bodyHTML - updatedAt - createdAt - viewerCanUpdate - viewerCanReact - viewerCanDelete -} - -fragment Commit on PullRequestCommit { - id - commit { - author { - user { - login - avatarUrl - url - email - } - } - committer { - avatarUrl - name - } - oid - message - authoredDate - } - url -} - -fragment AssignedEvent on AssignedEvent { - actor { - login - avatarUrl - url - } - user { - login - avatarUrl - url - } -} - -fragment Review on PullRequestReview { - id - databaseId - authorAssociation - url + titleHTML + title author { login - avatarUrl url + avatarUrl ... on User { email + id } ... on Organization { email + id } } - state - body - bodyHTML - submittedAt - updatedAt - createdAt -} - -fragment Reactable on Reactable { - reactionGroups { - content - viewerHasReacted - users { - totalCount - } - } -} - -fragment ReviewThread on PullRequestReviewThread { - id - isResolved - viewerCanResolve - viewerCanUnresolve - path - diffSide - line - startLine - originalStartLine - originalLine - isOutdated - comments(first: 100) { + commits(first: 50) { nodes { - ...ReviewComment - } - } -} - -query TimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - timelineItems(last: $last) { - nodes { - __typename - ...Merged - ...Comment - ...Review - ...Commit - ...AssignedEvent - ...HeadRefDeleted - } + commit { + message } } } - rateLimit { - limit - cost - remaining - resetAt + createdAt + updatedAt + headRef { + ...Ref } -} - -query IssueTimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { - repository(owner: $owner, name: $name) { - pullRequest: issue(number: $number) { - timelineItems(last: $last) { - nodes { - __typename - ...Comment - ...AssignedEvent - } - } + headRefName + headRefOid + headRepository { + isInOrganization + owner { + login } - } -} - -fragment ReviewComment on PullRequestReviewComment { - id - databaseId - url - author { - login - avatarUrl url - ... on User { - email - } - ... on Organization { - email - } - } - path - originalPosition - body - bodyHTML - diffHunk - position - state - pullRequestReview { - databaseId - } - commit { - oid - } - replyTo { - databaseId } - createdAt - originalCommit { - oid + baseRef { + ...Ref } - reactionGroups { - content - viewerHasReacted - users { - totalCount + baseRefName + baseRefOid + baseRepository { + isInOrganization + owner { + login } + url + squashMergeCommitTitle + squashMergeCommitMessage + mergeCommitMessage + mergeCommitTitle } - viewerCanUpdate - viewerCanDelete -} - -query GetPendingReviewId($pullRequestId: ID!, $author: String!) { - node(id: $pullRequestId) { - ... on PullRequest { - reviews(first: 1, author: $author, states: [PENDING]) { - nodes { - id - } - } + labels(first: 50) { + nodes { + name + color } } - rateLimit { - limit - cost - remaining - resetAt + merged + mergeable + mergeQueueEntry { + ...MergeQueueEntryFragment } -} - -query PullRequestComments($owner: String!, $name: String!, $number: Int!, $first: Int = 100) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviewThreads(first: $first) { - nodes { - id - isResolved - viewerCanResolve - viewerCanUnresolve - path - diffSide - startLine - line - originalStartLine - originalLine - isOutdated - comments(first: 100) { - nodes { - ...ReviewComment - } - } - } - } - } + mergeStateStatus + autoMergeRequest { + mergeMethod } -} - -query PullRequest($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - number - url - state - body - bodyHTML - title - author { - login - url - avatarUrl - ... on User { - email - } - ... on Organization { - email - } - } - createdAt - updatedAt - headRef { - ...Ref - } - headRefName - headRefOid - headRepository { - owner { - login - } - url - } - baseRef { - ...Ref - } - baseRefName - baseRefOid - baseRepository { - owner { - login - } - url - } - labels(first: 50) { - nodes { - name - } - } - merged - mergeable - mergeStateStatus + viewerCanEnableAutoMerge + viewerCanDisableAutoMerge + id + databaseId + isDraft + milestone { + title + dueOn + createdAt + id + number + } + assignees(first: 10) { + nodes { + login + name + avatarUrl id - databaseId - isDraft - milestone { - title - dueOn - createdAt - id - } - assignees(first: 10) { - nodes { - login - name - avatarUrl - url - email - } - } - suggestedReviewers { - isAuthor - isCommenter - reviewer { - login - avatarUrl - name - url - } - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query PullRequestFiles($owner: String!, $name: String!, $number: Int!, $after: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - files(first: 100, after: $after) { - nodes { - path - viewerViewedState - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -} - -query Issue($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest: issue(number: $number) { - number url - state - body - bodyHTML - title - author { - login - url - avatarUrl - ... on User { - email - } - ... on Organization { - email - } - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId + email } } -} - -query IssueWithComments($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest: issue(number: $number) { - number - url - state - body - bodyHTML - title - author { - login - url - avatarUrl - ... on User { - email - } - ... on Organization { - email - } - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } + suggestedReviewers { + isAuthor + isCommenter + reviewer { + login + avatarUrl id - databaseId - comments(first: 50) { - nodes { - author { - login - url - avatarUrl - ... on User { - email - } - ... on Organization { - email - } - } - body - databaseId - } - } - } - } -} - -query GetUser($login: String!) { - user(login: $login) { - login - avatarUrl(size: 50) - bio - name - company - location - contributionsCollection { - commitContributionsByRepository(maxRepositories: 50) { - contributions(first: 1) { - nodes { - occurredAt - } - } - repository { - nameWithOwner - } - } + name + url } - url } } -query PullRequestMergeability($owner: String!, $name: String!, $number: Int!) { +query PullRequest($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { - mergeable - mergeStateStatus + ...PullRequestFragment } } rateLimit { - limit - cost - remaining - resetAt - } -} - -query PullRequestState($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - title - number - state - } + ...RateLimit } } -mutation AddComment($input: AddPullRequestReviewCommentInput!) { - addPullRequestReviewComment(input: $input) { - comment { - ...ReviewComment - } - } -} - -mutation AddReviewThread($input: AddPullRequestReviewThreadInput!) { - addPullRequestReviewThread(input: $input) { - thread { - ...ReviewThread - } - } -} - -mutation EditComment($input: UpdatePullRequestReviewCommentInput!) { - updatePullRequestReviewComment(input: $input) { - pullRequestReviewComment { - ...ReviewComment - } - } -} - -mutation ReadyForReview($input: MarkPullRequestReadyForReviewInput!) { - markPullRequestReadyForReview(input: $input) { - pullRequest { - isDraft - } - } -} - -mutation StartReview($input: AddPullRequestReviewInput!) { - addPullRequestReview(input: $input) { - pullRequestReview { - id - } - } -} - -mutation SubmitReview($id: ID!, $event: PullRequestReviewEvent!, $body: String) { - submitPullRequestReview(input: { event: $event, pullRequestReviewId: $id, body: $body }) { - pullRequestReview { - comments(first: 100) { - nodes { - ...ReviewComment - } - } - ...Review - } - } -} - -mutation DeleteReview($input: DeletePullRequestReviewInput!) { - deletePullRequestReview(input: $input) { - pullRequestReview { - databaseId - comments(first: 100) { - nodes { - ...ReviewComment - } - } - } - } -} - -mutation AddReaction($input: AddReactionInput!) { - addReaction(input: $input) { - reaction { - content - } - subject { - ...Reactable - } - } -} -mutation DeleteReaction($input: RemoveReactionInput!) { - removeReaction(input: $input) { - reaction { - content - } - subject { - ...Reactable - } - } -} - -mutation UpdatePullRequest($input: UpdatePullRequestInput!) { - updatePullRequest(input: $input) { - pullRequest { - body - bodyHTML - title - } - } -} - -mutation AddIssueComment($input: AddCommentInput!) { - addComment(input: $input) { - commentEdge { - node { - ...Comment - } - } - } -} - -mutation EditIssueComment($input: UpdateIssueCommentInput!) { - updateIssueComment(input: $input) { - issueComment { - ...Comment - } - } -} - -query GetMentionableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { - repository(owner: $owner, name: $name) { - mentionableUsers(first: $first, after: $after) { - nodes { - login - avatarUrl - name - url - email - } - pageInfo { - hasNextPage - endCursor - } - } - } - rateLimit { - limit - cost - remaining - resetAt - } -} - -query GetAssignableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { +query PullRequestForHead($owner: String!, $name: String!, $headRefName: String!) { repository(owner: $owner, name: $name) { - assignableUsers(first: $first, after: $after) { + pullRequests(first: 3, headRefName: $headRefName, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { - login - avatarUrl - name - url - email - } - pageInfo { - hasNextPage - endCursor + ...PullRequestFragment } } } rateLimit { - limit - cost - remaining - resetAt - } -} - -query IssuesWithoutMilestone($owner: String!, $name: String!, $assignee: String!) { - repository(owner: $owner, name: $name) { - issues( - first: 100 - states: OPEN - filterBy: { assignee: $assignee, milestone: null } - orderBy: { direction: DESC, field: UPDATED_AT } - ) { - edges { - node { - ... on Issue { - number - url - state - body - bodyHTML - title - assignees(first: 10) { - nodes { - login - url - email - } - } - author { - login - url - avatarUrl(size: 50) - ... on User { - email - } - ... on Organization { - email - } - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - milestone { - title - dueOn - } - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } -} - -query MaxIssue($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - issues(first: 1, orderBy: { direction: DESC, field: CREATED_AT }) { - edges { - node { - ... on Issue { - number - } - } - } - } + ...RateLimit } } -query GetMilestones($owner: String!, $name: String!, $assignee: String!) { - repository(owner: $owner, name: $name) { - milestones(first: 12, orderBy: { direction: DESC, field: DUE_DATE }, states: OPEN) { - nodes { - dueOn - title - createdAt - id - issues( - first: 100 - filterBy: { assignee: $assignee } - orderBy: { direction: DESC, field: UPDATED_AT } - states: OPEN - ) { - edges { - node { - ... on Issue { - number - url - state - body - bodyHTML - title - assignees(first: 10) { - nodes { - avatarUrl - email - login - url - } - } - author { - login - url - avatarUrl(size: 50) - ... on User { - email - } - ... on Organization { - email - } - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - milestone { - title - dueOn - } - } - } - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } -} - -query Issues($query: String!) { - search(first: 100, type: ISSUE, query: $query) { - issueCount - pageInfo { - hasNextPage - endCursor - } - edges { - node { - ... on Issue { - number - url - state - body - bodyHTML - title - assignees(first: 10) { - nodes { - avatarUrl - email - login - url - } - } - author { - login - url - avatarUrl(size: 50) - ... on User { - email - } - ... on Organization { - email - } - } - createdAt - updatedAt - labels(first: 50) { - nodes { - name - color - } - } - id - databaseId - milestone { - title - dueOn - id - createdAt - } - repository { - name - owner { - login - } - url - } - } - } - } - } -} - -query GetViewerPermission($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - viewerPermission - } -} - -query GetRepositoryForkDetails($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - isFork - parent { - name - owner { - login - } - } - } -} - -query GetChecks($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - commits(last: 1) { - nodes { - commit { - statusCheckRollup { - state - contexts(first: 100) { - nodes { - ... on StatusContext { - id - state - targetUrl - description - context - avatarUrl - } - ... on CheckRun { - id - conclusion - title - detailsUrl - name - resourcePath - checkSuite { - app { - logoUrl - url - } - } - } - } - } - } - } - } - } - } - } -} - -mutation ResolveReviewThread($input: ResolveReviewThreadInput!) { - resolveReviewThread(input: $input) { - thread { - ...ReviewThread +mutation CreatePullRequest($input: CreatePullRequestInput!) { + createPullRequest(input: $input) { + pullRequest { + ...PullRequestFragment } } } -mutation UnresolveReviewThread($input: UnresolveReviewThreadInput!) { - unresolveReviewThread(input: $input) { - thread { - ...ReviewThread - } - } -} +# Queries that only exist in this file and in extra -mutation MarkFileAsViewed($input: MarkFileAsViewedInput!) { - markFileAsViewed(input: $input) { - pullRequest { - id +mutation DequeuePullRequest($input: DequeuePullRequestInput!) { + dequeuePullRequest(input: $input) { + mergeQueueEntry { + ...MergeQueueEntryFragment } } } -mutation UnmarkFileAsViewed($input: UnmarkFileAsViewedInput!) { - unmarkFileAsViewed(input: $input) { - pullRequest { - id +mutation EnqueuePullRequest($input: EnqueuePullRequestInput!) { + enqueuePullRequest(input: $input) { + mergeQueueEntry { + ...MergeQueueEntryFragment } } } \ No newline at end of file diff --git a/src/github/queriesExtra.gql b/src/github/queriesExtra.gql new file mode 100644 index 0000000000..9e8df8d511 --- /dev/null +++ b/src/github/queriesExtra.gql @@ -0,0 +1,216 @@ +# /*--------------------------------------------------------------------------------------------- +# * Copyright (c) Microsoft Corporation. All rights reserved. +# * Licensed under the MIT License. See License.txt in the project root for license information. +# *--------------------------------------------------------------------------------------------*/ + +#import "./queriesShared.gql" + +# Queries that are only available with extra auth scopes + +fragment PullRequestFragment on PullRequest { + number + url + state + body + bodyHTML + titleHTML + title + author { + login + url + avatarUrl + ... on User { + email + id + } + ... on Organization { + email + id + } + } + commits(first: 50) { + nodes { + commit { + message + } + } + } + createdAt + updatedAt + headRef { + ...Ref + } + headRefName + headRefOid + headRepository { + isInOrganization + owner { + login + } + url + } + baseRef { + ...Ref + } + baseRefName + baseRefOid + baseRepository { + isInOrganization + owner { + login + } + url + squashMergeCommitTitle + squashMergeCommitMessage + mergeCommitMessage + mergeCommitTitle + } + labels(first: 50) { + nodes { + name + color + } + } + merged + mergeable + mergeQueueEntry { + ...MergeQueueEntryFragment + } + mergeStateStatus + autoMergeRequest { + mergeMethod + } + viewerCanEnableAutoMerge + viewerCanDisableAutoMerge + id + databaseId + isDraft + projectItems(first: 100) { + nodes { + id + project { + title + id + } + } + } + milestone { + title + dueOn + createdAt + id + number + } + assignees(first: 10) { + nodes { + login + name + avatarUrl + id + url + email + } + } + suggestedReviewers { + isAuthor + isCommenter + reviewer { + login + avatarUrl + id + name + url + } + } +} + +query PullRequest($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + ...PullRequestFragment + } + } + rateLimit { + ...RateLimit + } +} + + +query PullRequestForHead($owner: String!, $name: String!, $headRefName: String!) { + repository(owner: $owner, name: $name) { + pullRequests(first: 3, headRefName: $headRefName, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { + ...PullRequestFragment + } + } + } + rateLimit { + ...RateLimit + } +} + +mutation CreatePullRequest($input: CreatePullRequestInput!) { + createPullRequest(input: $input) { + pullRequest { + ...PullRequestFragment + } + } +} + +# Queries that only exist in this file + +query GetRepoProjects($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + projectsV2(first: 100, query: "state:OPEN") { + nodes { + title + id + } + } + } +} + +query GetOrgProjects($owner: String!, $after: String) { + organization(login: $owner) { + projectsV2(first: 100, after: $after, query: "state:OPEN", orderBy: { field: UPDATED_AT, direction: DESC }) { + nodes { + title + id + } + pageInfo { + hasNextPage + endCursor + } + } + } +} + +mutation AddPullRequestToProject($input: AddProjectV2ItemByIdInput!) { + addProjectV2ItemById(input: $input) { + item { + id + } + } +} + +mutation RemovePullRequestFromProject($input: DeleteProjectV2ItemInput!) { + deleteProjectV2Item(input: $input) { + deletedItemId + } +} + +mutation DequeuePullRequest($input: DequeuePullRequestInput!) { + dequeuePullRequest(input: $input) { + mergeQueueEntry { + ...MergeQueueEntryFragment + } + } +} + +mutation EnqueuePullRequest($input: EnqueuePullRequestInput!) { + enqueuePullRequest(input: $input) { + mergeQueueEntry { + ...MergeQueueEntryFragment + } + } +} \ No newline at end of file diff --git a/src/github/queriesLimited.gql b/src/github/queriesLimited.gql new file mode 100644 index 0000000000..df60096c47 --- /dev/null +++ b/src/github/queriesLimited.gql @@ -0,0 +1,140 @@ +# /*--------------------------------------------------------------------------------------------- +# * Copyright (c) Microsoft Corporation. All rights reserved. +# * Licensed under the MIT License. See License.txt in the project root for license information. +# *--------------------------------------------------------------------------------------------*/ + +#import "./queriesShared.gql" + +fragment PullRequestFragment on PullRequest { + number + url + state + body + bodyHTML + titleHTML + title + author { + login + url + avatarUrl + ... on User { + email + id + } + ... on Organization { + email + id + } + } + commits(first: 50) { + nodes { + commit { + message + } + } + } + createdAt + updatedAt + headRef { + ...Ref + } + headRefName + headRefOid + headRepository { + isInOrganization + owner { + login + } + url + } + baseRef { + ...Ref + } + baseRefName + baseRefOid + baseRepository { + isInOrganization + owner { + login + } + url + } + labels(first: 50) { + nodes { + name + color + } + } + merged + mergeable + mergeStateStatus + autoMergeRequest { + mergeMethod + } + viewerCanEnableAutoMerge + viewerCanDisableAutoMerge + id + databaseId + isDraft + milestone { + title + dueOn + createdAt + id + number + } + assignees(first: 10) { + nodes { + login + name + avatarUrl + id + url + email + } + } + suggestedReviewers { + isAuthor + isCommenter + reviewer { + login + avatarUrl + id + name + url + } + } +} + +query PullRequest($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + ...PullRequestFragment + } + } + rateLimit { + ...RateLimit + } +} + + +query PullRequestForHead($owner: String!, $name: String!, $headRefName: String!) { + repository(owner: $owner, name: $name) { + pullRequests(first: 3, headRefName: $headRefName, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { + ...PullRequestFragment + } + } + } + rateLimit { + ...RateLimit + } +} + +mutation CreatePullRequest($input: CreatePullRequestInput!) { + createPullRequest(input: $input) { + pullRequest { + ...PullRequestFragment + } + } +} \ No newline at end of file diff --git a/src/github/queriesShared.gql b/src/github/queriesShared.gql new file mode 100644 index 0000000000..23118dbe64 --- /dev/null +++ b/src/github/queriesShared.gql @@ -0,0 +1,1420 @@ +# /*--------------------------------------------------------------------------------------------- +# * Copyright (c) Microsoft Corporation. All rights reserved. +# * Licensed under the MIT License. See License.txt in the project root for license information. +# *--------------------------------------------------------------------------------------------*/ + +fragment RateLimit on RateLimit { + limit + cost + remaining + resetAt +} + +fragment Merged on MergedEvent { + id + actor { + login + avatarUrl + url + } + createdAt + mergeRef { + name + } + commit { + oid + commitUrl + } + url +} + +fragment HeadRefDeleted on HeadRefDeletedEvent { + id + actor { + login + avatarUrl + url + } + createdAt + headRefName +} + +fragment Ref on Ref { + name + repository { + owner { + login + } + url + } + target { + oid + } +} + +fragment Comment on IssueComment { + id + databaseId + authorAssociation + author { + login + avatarUrl + url + ... on User { + email + id + } + ... on Organization { + email + id + } + } + url + body + bodyHTML + updatedAt + createdAt + viewerCanUpdate + viewerCanReact + viewerCanDelete +} + +fragment Commit on PullRequestCommit { + id + commit { + author { + user { + login + avatarUrl + id + url + email + } + } + committer { + avatarUrl + name + } + oid + message + authoredDate + } + url +} + +fragment AssignedEvent on AssignedEvent { + id + actor { + login + avatarUrl + url + } + user { + login + avatarUrl + id + url + } +} + +fragment Review on PullRequestReview { + id + databaseId + authorAssociation + url + author { + login + avatarUrl + url + ... on User { + email + id + } + ... on Organization { + email + id + } + } + state + body + bodyHTML + submittedAt + updatedAt + createdAt +} + +fragment Reactable on Reactable { + reactionGroups { + content + viewerHasReacted + reactors(first: 10) { + nodes { + ... on User { + login + } + } + totalCount + } + } +} + + +fragment ReviewThread on PullRequestReviewThread { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + line + startLine + originalStartLine + originalLine + isOutdated + subjectType + comments(first: 100) { + nodes { + ...ReviewComment + } + } +} + +fragment LegacyReviewThread on PullRequestReviewThread { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + line + startLine + originalStartLine + originalLine + isOutdated + comments(first: 100) { + nodes { + ...ReviewComment + } + } +} + +fragment MergeQueueEntryFragment on MergeQueueEntry { + position + state + mergeQueue { + url + } +} + +query TimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + timelineItems(last: $last) { + nodes { + __typename + ...Merged + ...Comment + ...Review + ...Commit + ...AssignedEvent + ...HeadRefDeleted + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query IssueTimelineEvents($owner: String!, $name: String!, $number: Int!, $last: Int = 150) { + repository(owner: $owner, name: $name) { + pullRequest: issue(number: $number) { + timelineItems(last: $last) { + nodes { + __typename + ...Comment + ...AssignedEvent + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LatestReviewCommit($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + viewerLatestReview { + commit { + oid + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LatestReviews($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + latestReviews (first: 10) { + nodes { + state + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetOrganizationTeamsCount($login: String!) { + organization(login: $login) { + teams(first: 0, privacy: VISIBLE) { + totalCount + } + } + rateLimit { + ...RateLimit + } +} + +query GetOrganizationTeams($login: String!, $after: String, $repoName: String!) { + organization(login: $login) { + teams(first: 100, after: $after, privacy: VISIBLE) { + nodes { + name + avatarUrl + url + repositories(first: 5, query: $repoName) { + nodes { + name + } + } + slug + id + } + totalCount + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetReviewRequestsAdditionalScopes($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewRequests(first: 100) { + nodes { + requestedReviewer { + ... on User { + login + avatarUrl + id + url + email + name + } + ... on Team { + name + avatarUrl + url + slug + id + } + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetReviewRequests($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewRequests(first: 100) { + nodes { + requestedReviewer { + ... on User { + login + avatarUrl + id + url + email + name + } + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +fragment ReviewComment on PullRequestReviewComment { + id + databaseId + url + author { + login + avatarUrl + url + ... on User { + email + id + } + ... on Organization { + email + id + } + } + path + originalPosition + body + bodyHTML + diffHunk + position + state + pullRequestReview { + databaseId + } + commit { + oid + } + replyTo { + databaseId + } + createdAt + originalCommit { + oid + } + ...Reactable + viewerCanUpdate + viewerCanDelete +} + +query GetParticipants($owner: String!, $name: String!, $number: Int!, $first: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + participants(first: $first) { + nodes { + login + avatarUrl + id + name + url + email + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetPendingReviewId($pullRequestId: ID!, $author: String!) { + node(id: $pullRequestId) { + ... on PullRequest { + reviews(first: 1, author: $author, states: [PENDING]) { + nodes { + id + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestComments($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $after) { + nodes { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + startLine + line + originalStartLine + originalLine + isOutdated + subjectType + comments(first: 100) { + edges { + node { + pullRequestReview { + databaseId + } + } + } + nodes { + ...ReviewComment + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query LegacyPullRequestComments($owner: String!, $name: String!, $number: Int!, $first: Int = 100) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: $first) { + nodes { + id + isResolved + viewerCanResolve + viewerCanUnresolve + path + diffSide + startLine + line + originalStartLine + originalLine + isOutdated + comments(first: 100) { + edges { + node { + pullRequestReview { + databaseId + } + } + } + nodes { + ...ReviewComment + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query Viewer { + viewer { + login + avatarUrl + id + name + url + email + } + rateLimit { + ...RateLimit + } +} + +query PullRequestFiles($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + files(first: 100, after: $after) { + nodes { + path + viewerViewedState + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query Issue($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest: issue(number: $number) { + number + url + state + body + bodyHTML + title + author { + login + url + avatarUrl + ... on User { + email + id + } + ... on Organization { + email + id + } + } + createdAt + updatedAt + labels(first: 50) { + nodes { + name + color + } + } + id + databaseId + } + } + rateLimit { + ...RateLimit + } +} + +query IssueWithComments($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest: issue(number: $number) { + number + url + state + body + bodyHTML + title + author { + login + url + avatarUrl + ... on User { + email + id + } + ... on Organization { + email + id + } + } + createdAt + updatedAt + labels(first: 50) { + nodes { + name + color + } + } + id + databaseId + comments(first: 50) { + nodes { + author { + login + url + avatarUrl + ... on User { + email + id + } + ... on Organization { + email + id + } + } + body + databaseId + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetUser($login: String!) { + user(login: $login) { + login + avatarUrl(size: 50) + id + bio + name + company + location + contributionsCollection { + commitContributionsByRepository(maxRepositories: 50) { + contributions(first: 1) { + nodes { + occurredAt + } + } + repository { + nameWithOwner + } + } + } + url + } + rateLimit { + ...RateLimit + } +} + +query PullRequestMergeability($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + mergeable + mergeStateStatus + } + } + rateLimit { + ...RateLimit + } +} + +query PullRequestState($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + title + number + state + } + } + rateLimit { + ...RateLimit + } +} + +mutation AddComment($input: AddPullRequestReviewCommentInput!) { + addPullRequestReviewComment(input: $input) { + comment { + ...ReviewComment + } + } +} + +mutation AddReviewThread($input: AddPullRequestReviewThreadInput!) { + addPullRequestReviewThread(input: $input) { + thread { + ...ReviewThread + } + } +} + +mutation LegacyAddReviewThread($input: AddPullRequestReviewThreadInput!) { + addPullRequestReviewThread(input: $input) { + thread { + ...LegacyReviewThread + } + } +} + +mutation AddReviewers($input: RequestReviewsInput!) { + requestReviews(input: $input) { + pullRequest { + id + } + } +} + +mutation EditComment($input: UpdatePullRequestReviewCommentInput!) { + updatePullRequestReviewComment(input: $input) { + pullRequestReviewComment { + ...ReviewComment + } + } +} + +mutation ReadyForReview($input: MarkPullRequestReadyForReviewInput!) { + markPullRequestReadyForReview(input: $input) { + pullRequest { + isDraft + } + } +} + +mutation StartReview($input: AddPullRequestReviewInput!) { + addPullRequestReview(input: $input) { + pullRequestReview { + id + } + } +} + +mutation SubmitReview($id: ID!, $event: PullRequestReviewEvent!, $body: String) { + submitPullRequestReview(input: { event: $event, pullRequestReviewId: $id, body: $body }) { + pullRequestReview { + comments(first: 100) { + nodes { + ...ReviewComment + } + } + ...Review + } + } +} + +mutation DeleteReview($input: DeletePullRequestReviewInput!) { + deletePullRequestReview(input: $input) { + pullRequestReview { + databaseId + comments(first: 100) { + nodes { + ...ReviewComment + } + } + } + } +} + +mutation AddReaction($input: AddReactionInput!) { + addReaction(input: $input) { + reaction { + content + } + subject { + ...Reactable + } + } +} + +mutation DeleteReaction($input: RemoveReactionInput!) { + removeReaction(input: $input) { + reaction { + content + } + subject { + ...Reactable + } + } +} + +mutation UpdatePullRequest($input: UpdatePullRequestInput!) { + updatePullRequest(input: $input) { + pullRequest { + body + bodyHTML + title + titleHTML + } + } +} + +mutation AddIssueComment($input: AddCommentInput!) { + addComment(input: $input) { + commentEdge { + node { + ...Comment + } + } + } +} + +mutation EditIssueComment($input: UpdateIssueCommentInput!) { + updateIssueComment(input: $input) { + issueComment { + ...Comment + } + } +} + +query GetMentionableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + mentionableUsers(first: $first, after: $after) { + nodes { + login + avatarUrl + name + url + email + id + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetAssignableUsers($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + assignableUsers(first: $first, after: $after) { + nodes { + login + avatarUrl + name + url + email + id + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetBranch($owner: String!, $name: String!, $qualifiedName: String!) { + repository(owner: $owner, name: $name) { + ref(qualifiedName: $qualifiedName) { + target { + oid + } + } + } + rateLimit { + ...RateLimit + } +} + +query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { + refs(first: $first, after: $after, refPrefix: "refs/heads/") { + nodes { + name + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query IssuesWithoutMilestone($owner: String!, $name: String!, $assignee: String!) { + repository(owner: $owner, name: $name) { + issues( + first: 100 + states: OPEN + filterBy: { assignee: $assignee, milestone: null } + orderBy: { direction: DESC, field: UPDATED_AT } + ) { + edges { + node { + ... on Issue { + number + url + state + body + bodyHTML + title + assignees(first: 10) { + nodes { + login + url + email + } + } + author { + login + url + avatarUrl(size: 50) + ... on User { + email + } + ... on Organization { + email + } + } + createdAt + updatedAt + labels(first: 50) { + nodes { + name + color + } + } + id + databaseId + milestone { + title + dueOn + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query MaxIssue($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + issues(first: 1, orderBy: { direction: DESC, field: CREATED_AT }) { + edges { + node { + ... on Issue { + number + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetMilestones($owner: String!, $name: String!, $states: [MilestoneState!]!) { + repository(owner: $owner, name: $name) { + milestones(first: 100, orderBy: { direction: DESC, field: DUE_DATE }, states: $states) { + nodes { + dueOn + title + createdAt + id + number + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetMilestonesWithIssues($owner: String!, $name: String!, $assignee: String!) { + repository(owner: $owner, name: $name) { + milestones(first: 12, orderBy: { direction: DESC, field: DUE_DATE }, states: OPEN) { + nodes { + dueOn + title + createdAt + id + issues( + first: 100 + filterBy: { assignee: $assignee } + orderBy: { direction: DESC, field: UPDATED_AT } + states: OPEN + ) { + edges { + node { + ... on Issue { + number + url + state + body + bodyHTML + title + assignees(first: 10) { + nodes { + avatarUrl + email + login + url + id + } + } + author { + login + url + avatarUrl(size: 50) + ... on User { + email + id + } + ... on Organization { + email + id + } + } + createdAt + updatedAt + labels(first: 50) { + nodes { + name + color + } + } + id + databaseId + milestone { + title + dueOn + } + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + rateLimit { + ...RateLimit + } +} + +query Issues($query: String!) { + search(first: 100, type: ISSUE, query: $query) { + issueCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + ... on Issue { + number + url + state + body + bodyHTML + title + assignees(first: 10) { + nodes { + avatarUrl + email + login + url + id + } + } + author { + login + url + avatarUrl(size: 50) + ... on User { + email + id + } + ... on Organization { + email + id + } + } + createdAt + updatedAt + labels(first: 50) { + nodes { + name + color + } + } + id + databaseId + milestone { + title + dueOn + id + createdAt + } + repository { + name + owner { + login + } + url + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetViewerPermission($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + viewerPermission + } + rateLimit { + ...RateLimit + } +} + +query GetRepositoryForkDetails($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + isFork + parent { + name + owner { + login + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetChecks($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + url + latestReviews (first: 10) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + reviewsRequestingChanges: reviews (last: 5, states: [CHANGES_REQUESTED]) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + baseRef { + refUpdateRule { + requiredApprovingReviewCount + requiredStatusCheckContexts + requiresCodeOwnerReviews + viewerCanPush + } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + contexts(first: 100) { + nodes { + __typename + ... on StatusContext { + id + state + targetUrl + description + context + avatarUrl + isRequired(pullRequestNumber: $number) + } + ... on CheckRun { + id + conclusion + title + detailsUrl + name + resourcePath + isRequired(pullRequestNumber: $number) + checkSuite { + app { + logoUrl + url + } + } + } + } + } + } + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query GetChecksWithoutSuite($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + url + latestReviews (first: 10) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + reviewsRequestingChanges: reviews (last: 5, states: [CHANGES_REQUESTED]) { + nodes { + authorAssociation + authorCanPushToRepository + state + author { + login + } + } + } + baseRef { + refUpdateRule { + requiredApprovingReviewCount + requiredStatusCheckContexts + requiresCodeOwnerReviews + viewerCanPush + } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + contexts(first: 100) { + nodes { + __typename + ... on StatusContext { + id + state + targetUrl + description + context + avatarUrl + isRequired(pullRequestNumber: $number) + } + ... on CheckRun { + id + conclusion + title + detailsUrl + name + resourcePath + isRequired(pullRequestNumber: $number) + } + } + } + } + } + } + } + } + } + rateLimit { + ...RateLimit + } +} + +query MergeQueueForBranch($owner: String!, $name: String!, $branch: String!) { + repository(owner: $owner, name: $name) { + mergeQueue(branch: $branch) { + configuration { + mergeMethod + } + } + } +} + +query GetFileContent($owner: String!, $name: String!, $expression: String!) { + repository(owner: $owner, name: $name) { + object(expression: $expression) { + ... on Blob { + text + } + } + } + rateLimit { + ...RateLimit + } +} + +mutation ResolveReviewThread($input: ResolveReviewThreadInput!) { + resolveReviewThread(input: $input) { + thread { + ...ReviewThread + } + } +} + +mutation LegacyResolveReviewThread($input: ResolveReviewThreadInput!) { + resolveReviewThread(input: $input) { + thread { + ...LegacyReviewThread + } + } +} + +mutation UnresolveReviewThread($input: UnresolveReviewThreadInput!) { + unresolveReviewThread(input: $input) { + thread { + ...ReviewThread + } + } +} + +mutation LegacyUnresolveReviewThread($input: UnresolveReviewThreadInput!) { + unresolveReviewThread(input: $input) { + thread { + ...LegacyReviewThread + } + } +} + +mutation EnablePullRequestAutoMerge($input: EnablePullRequestAutoMergeInput!) { + enablePullRequestAutoMerge(input: $input) { + pullRequest { + id + } + } +} + +mutation DisablePullRequestAutoMerge($input: DisablePullRequestAutoMergeInput!) { + disablePullRequestAutoMerge(input: $input) { + pullRequest { + id + } + } +} + +mutation MarkFileAsViewed($input: MarkFileAsViewedInput!) { + markFileAsViewed(input: $input) { + pullRequest { + id + } + } +} + +mutation UnmarkFileAsViewed($input: UnmarkFileAsViewedInput!) { + unmarkFileAsViewed(input: $input) { + pullRequest { + id + } + } +} diff --git a/src/github/quickPicks.ts b/src/github/quickPicks.ts new file mode 100644 index 0000000000..056532b228 --- /dev/null +++ b/src/github/quickPicks.ts @@ -0,0 +1,389 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import { Buffer } from 'buffer'; +import * as vscode from 'vscode'; +import { RemoteInfo } from '../../common/views'; +import Logger from '../common/logger'; +import { DataUri } from '../common/uri'; +import { formatError } from '../common/utils'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GitHubRepository, TeamReviewerRefreshKind } from './githubRepository'; +import { IAccount, ILabel, IMilestone, IProject, IProjectItem, isSuggestedReviewer, isTeam, ISuggestedReviewer, ITeam, reviewerId, ReviewState } from './interface'; +import { PullRequestModel } from './pullRequestModel'; + +async function getItems(context: vscode.ExtensionContext, skipList: Set, users: T[], picked: boolean, tooManyAssignable: boolean = false): Promise<(vscode.QuickPickItem & { user?: T })[]> { + const alreadyAssignedItems: (vscode.QuickPickItem & { user?: T })[] = []; + // Address skip list before first await + const filteredUsers: T[] = []; + for (const user of users) { + const id = reviewerId(user); + if (!skipList.has(id)) { + filteredUsers.push(user); + skipList.add(id); + } + } + + const avatars = await DataUri.avatarCirclesAsImageDataUris(context, filteredUsers, 16, 16, tooManyAssignable); + for (let i = 0; i < filteredUsers.length; i++) { + const user = filteredUsers[i]; + + let detail: string | undefined; + if (isSuggestedReviewer(user)) { + detail = user.isAuthor && user.isCommenter + ? vscode.l10n.t('Recently edited and reviewed changes to these files') + : user.isAuthor + ? vscode.l10n.t('Recently edited these files') + : user.isCommenter + ? vscode.l10n.t('Recently reviewed changes to these files') + : vscode.l10n.t('Suggested reviewer'); + } + + alreadyAssignedItems.push({ + label: isTeam(user) ? `${user.org}/${user.slug}` : (user as IAccount).login, + description: user.name, + user, + picked, + detail, + iconPath: avatars[i] ?? userThemeIcon(user) + }); + } + return alreadyAssignedItems; +} + +export async function getAssigneesQuickPickItems(folderRepositoryManager: FolderRepositoryManager, remoteName: string, alreadyAssigned: IAccount[], item?: PullRequestModel): + Promise<(vscode.QuickPickItem & { user?: IAccount })[]> { + + const [allAssignableUsers, participantsAndViewer] = await Promise.all([ + folderRepositoryManager.getAssignableUsers(), + item ? folderRepositoryManager.getPullRequestParticipants(item.githubRepository, item.number) : undefined + ]); + const viewer = participantsAndViewer?.viewer; + const participants = participantsAndViewer?.participants ?? []; + + let assignableUsers = allAssignableUsers[remoteName]; + + assignableUsers = assignableUsers ?? []; + // used to track logins that shouldn't be added to pick list + // e.g. author, existing and already added reviewers + const skipList: Set = new Set(); + + const assigneePromises: Promise<(vscode.QuickPickItem & { assignee?: IAccount })[]>[] = []; + + // Start with all currently assigned so they show at the top + if (alreadyAssigned.length) { + assigneePromises.push(getItems(folderRepositoryManager.context, skipList, alreadyAssigned ?? [], true)); + } + + // Check if the viewer is allowed to be assigned to the PR + if (viewer && !skipList.has(viewer.login) && (assignableUsers.findIndex((assignableUser: IAccount) => assignableUser.login === viewer.login) !== -1)) { + assigneePromises.push(getItems(folderRepositoryManager.context, skipList, [viewer], false)); + } + + // Suggested reviewers + if (participants.length) { + assigneePromises.push(getItems(folderRepositoryManager.context, skipList, participants, false)); + } + + if (assigneePromises.length !== 0) { + assigneePromises.unshift(Promise.resolve([{ + kind: vscode.QuickPickItemKind.Separator, + label: vscode.l10n.t('Suggestions') + }])); + } + + if (assignableUsers.length) { + const tooManyAssignable = assignableUsers.length > 80; + assigneePromises.push(getItems(folderRepositoryManager.context, skipList, assignableUsers, false, tooManyAssignable)); + } + + const assignees = (await Promise.all(assigneePromises)).flat(); + + if (assignees.length === 0) { + assignees.push({ + label: vscode.l10n.t('No assignees available for this repository') + }); + } + + return assignees; +} + +function userThemeIcon(user: IAccount | ITeam) { + return (isTeam(user) ? new vscode.ThemeIcon('organization') : new vscode.ThemeIcon('account')); +} + +async function getReviewersQuickPickItems(folderRepositoryManager: FolderRepositoryManager, remoteName: string, isInOrganization: boolean, author: IAccount, existingReviewers: ReviewState[], + suggestedReviewers: ISuggestedReviewer[] | undefined, refreshKind: TeamReviewerRefreshKind, +): Promise<(vscode.QuickPickItem & { user?: IAccount | ITeam })[]> { + if (!suggestedReviewers) { + return []; + } + + const allAssignableUsers = await folderRepositoryManager.getAssignableUsers(); + const allTeamReviewers = isInOrganization ? await folderRepositoryManager.getTeamReviewers(refreshKind) : []; + const teamReviewers: ITeam[] = allTeamReviewers[remoteName] ?? []; + const assignableUsers: (IAccount | ITeam)[] = [...teamReviewers]; + if (allAssignableUsers[remoteName]) { + assignableUsers.push(...allAssignableUsers[remoteName]); + } + + // used to track logins that shouldn't be added to pick list + // e.g. author, existing and already added reviewers + const skipList: Set = new Set([ + author.login + ]); + + const reviewersPromises: Promise<(vscode.QuickPickItem & { reviewer?: IAccount | ITeam })[]>[] = []; + + // Start with all existing reviewers so they show at the top + if (existingReviewers.length) { + reviewersPromises.push(getItems(folderRepositoryManager.context, skipList, existingReviewers.map(reviewer => reviewer.reviewer), true)); + } + + // Suggested reviewers + reviewersPromises.push(getItems(folderRepositoryManager.context, skipList, suggestedReviewers, false)); + + const tooManyAssignable = assignableUsers.length > 60; + reviewersPromises.push(getItems(folderRepositoryManager.context, skipList, assignableUsers, false, tooManyAssignable)); + + const reviewers = (await Promise.all(reviewersPromises)).flat(); + + if (reviewers.length === 0) { + reviewers.push({ + label: vscode.l10n.t('No reviewers available for this repository') + }); + } + + return reviewers; +} + +export async function reviewersQuickPick(folderRepositoryManager: FolderRepositoryManager, remoteName: string, isInOrganization: boolean, teamsCount: number, author: IAccount, existingReviewers: ReviewState[], + suggestedReviewers: ISuggestedReviewer[] | undefined): Promise> { + const quickPick = vscode.window.createQuickPick(); + // The quick-max is used to show the "update reviewers" button. If the number of teams is less than the quick-max, then they'll be automatically updated when the quick pick is opened. + const quickMaxTeamReviewers = 100; + const defaultPlaceholder = vscode.l10n.t('Add reviewers'); + quickPick.busy = true; + quickPick.canSelectMany = true; + quickPick.matchOnDescription = true; + quickPick.placeholder = defaultPlaceholder; + if (isInOrganization) { + quickPick.buttons = [{ iconPath: new vscode.ThemeIcon('organization'), tooltip: vscode.l10n.t('Show or refresh team reviewers') }]; + } + quickPick.show(); + const updateItems = async (refreshKind: TeamReviewerRefreshKind) => { + const slowWarning = setTimeout(() => { + quickPick.placeholder = vscode.l10n.t('Getting team reviewers can take several minutes. Results will be cached.'); + }, 3000); + const start = performance.now(); + quickPick.items = await getReviewersQuickPickItems(folderRepositoryManager, remoteName, isInOrganization, author, existingReviewers, suggestedReviewers, refreshKind); + Logger.appendLine(`Setting quick pick reviewers took ${performance.now() - start}ms`, 'QuickPicks'); + clearTimeout(slowWarning); + quickPick.selectedItems = quickPick.items.filter(item => item.picked); + quickPick.placeholder = defaultPlaceholder; + }; + + await updateItems((teamsCount !== 0 && teamsCount <= quickMaxTeamReviewers) ? TeamReviewerRefreshKind.Try : TeamReviewerRefreshKind.None); + quickPick.onDidTriggerButton(() => { + quickPick.busy = true; + quickPick.ignoreFocusOut = true; + updateItems(TeamReviewerRefreshKind.Force).then(() => { + quickPick.ignoreFocusOut = false; + quickPick.busy = false; + }); + }); + return quickPick; +} + +type ProjectQuickPickItem = vscode.QuickPickItem & { id: string; project: IProject }; + +function isProjectQuickPickItem(x: vscode.QuickPickItem | ProjectQuickPickItem): x is ProjectQuickPickItem { + return !!(x as ProjectQuickPickItem).id && !!(x as ProjectQuickPickItem).project; +} + +export async function getProjectFromQuickPick(folderRepoManager: FolderRepositoryManager, githubRepository: GitHubRepository, remoteName: string, isInOrganization: boolean, currentProjects: IProjectItem[] | undefined, callback: (projects: IProject[]) => Promise): Promise { + try { + let selectedItems: vscode.QuickPickItem[] = []; + async function getProjectOptions(): Promise<(ProjectQuickPickItem | vscode.QuickPickItem)[]> { + const [repoProjects, orgProjects] = (await Promise.all([githubRepository.getProjects(), (isInOrganization ? folderRepoManager.getOrgProjects() : undefined)])); + const projects = [...(repoProjects ?? []), ...(orgProjects ? orgProjects[remoteName] : [])]; + if (!projects || !projects.length) { + return [ + { + label: vscode.l10n.t('No projects created for this repository.'), + }, + ]; + } + + const projectItems: (ProjectQuickPickItem | vscode.QuickPickItem)[] = projects.map(result => { + const item = { + iconPath: new vscode.ThemeIcon('project'), + label: result.title, + id: result.id, + project: result + }; + if (currentProjects && currentProjects.find(project => project.project.id === result.id)) { + selectedItems.push(item); + } + return item; + }); + return projectItems; + } + + const quickPick = vscode.window.createQuickPick(); + quickPick.busy = true; + quickPick.canSelectMany = true; + quickPick.title = vscode.l10n.t('Set Project'); + quickPick.show(); + quickPick.items = await getProjectOptions(); + quickPick.selectedItems = selectedItems; + quickPick.busy = false; + + // Kick off a cache refresh + folderRepoManager.getOrgProjects(true); + quickPick.onDidAccept(async () => { + quickPick.hide(); + const projectsToAdd = quickPick.selectedItems.map(item => isProjectQuickPickItem(item) ? item.project : undefined).filter(project => project !== undefined) as IProject[]; + if (projectsToAdd) { + await callback(projectsToAdd); + } + }); + } catch (e) { + vscode.window.showErrorMessage(`Failed to add project: ${formatError(e)}`); + } +} + +type MilestoneQuickPickItem = vscode.QuickPickItem & { id: string; milestone: IMilestone }; + +function isMilestoneQuickPickItem(x: vscode.QuickPickItem | MilestoneQuickPickItem): x is MilestoneQuickPickItem { + return !!(x as MilestoneQuickPickItem).id && !!(x as MilestoneQuickPickItem).milestone; +} + +export async function getMilestoneFromQuickPick(folderRepositoryManager: FolderRepositoryManager, githubRepository: GitHubRepository, currentMilestone: IMilestone | undefined, callback: (milestone: IMilestone | undefined) => Promise): Promise { + try { + const removeMilestoneItem: vscode.QuickPickItem = { + label: vscode.l10n.t('Remove Milestone') + }; + let selectedItem: vscode.QuickPickItem | undefined; + async function getMilestoneOptions(): Promise<(MilestoneQuickPickItem | vscode.QuickPickItem)[]> { + const milestones = await githubRepository.getMilestones(); + if (!milestones || !milestones.length) { + return [ + { + label: vscode.l10n.t('No milestones created for this repository.'), + }, + ]; + } + + const milestonesItems: (MilestoneQuickPickItem | vscode.QuickPickItem)[] = milestones.map(result => { + const item = { + iconPath: new vscode.ThemeIcon('milestone'), + label: result.title, + id: result.id, + milestone: result + }; + if (currentMilestone && currentMilestone.id === result.id) { + selectedItem = item; + } + return item; + }); + if (currentMilestone) { + milestonesItems.unshift({ label: 'Milestones', kind: vscode.QuickPickItemKind.Separator }); + milestonesItems.unshift(removeMilestoneItem); + } + return milestonesItems; + } + + const quickPick = vscode.window.createQuickPick(); + quickPick.busy = true; + quickPick.canSelectMany = false; + quickPick.title = vscode.l10n.t('Set milestone'); + quickPick.buttons = [{ + iconPath: new vscode.ThemeIcon('add'), + tooltip: 'Create', + }]; + quickPick.onDidTriggerButton((_) => { + quickPick.hide(); + + const inputBox = vscode.window.createInputBox(); + inputBox.title = vscode.l10n.t('Create new milestone'); + inputBox.placeholder = vscode.l10n.t('New milestone title'); + if (quickPick.value !== '') { + inputBox.value = quickPick.value; + } + inputBox.show(); + inputBox.onDidAccept(async () => { + inputBox.hide(); + if (inputBox.value === '') { + return; + } + if (inputBox.value.length > 255) { + vscode.window.showErrorMessage(vscode.l10n.t(`Failed to create milestone: The title can contain a maximum of 255 characters`)); + return; + } + // Check if milestone already exists (only check open ones) + for (const existingMilestone of quickPick.items) { + if (existingMilestone.label === inputBox.value) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create milestone: The milestone \'{0}\' already exists', inputBox.value)); + return; + } + } + try { + const milestone = await folderRepositoryManager.createMilestone(githubRepository, inputBox.value); + if (milestone !== undefined) { + await callback(milestone); + } + } catch (e) { + if (e.errors && Array.isArray(e.errors) && e.errors.find(error => error.code === 'already_exists') !== undefined) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create milestone: The milestone already exists and might be closed')); + } + else { + vscode.window.showErrorMessage(`Failed to create milestone: ${formatError(e)}`); + } + } + }); + }); + + quickPick.show(); + quickPick.items = await getMilestoneOptions(); + quickPick.activeItems = selectedItem ? [selectedItem] : (currentMilestone ? [quickPick.items[1]] : [quickPick.items[0]]); + quickPick.busy = false; + + quickPick.onDidAccept(async () => { + quickPick.hide(); + const milestoneToAdd = quickPick.selectedItems[0]; + if (milestoneToAdd && isMilestoneQuickPickItem(milestoneToAdd)) { + await callback(milestoneToAdd.milestone); + } else if (milestoneToAdd && milestoneToAdd === removeMilestoneItem) { + await callback(undefined); + } + }); + } catch (e) { + vscode.window.showErrorMessage(`Failed to add milestone: ${formatError(e)}`); + } +} + +export async function getLabelOptions( + folderRepoManager: FolderRepositoryManager, + labels: ILabel[], + base: RemoteInfo +): Promise<{ newLabels: ILabel[], labelPicks: vscode.QuickPickItem[] }> { + const newLabels = await folderRepoManager.getLabels(undefined, { owner: base.owner, repo: base.repositoryName }); + + const labelPicks = newLabels.map(label => { + return { + label: label.name, + description: label.description, + picked: labels.some(existingLabel => existingLabel.name === label.name), + iconPath: DataUri.asImageDataURI(Buffer.from(` + + `, 'utf8')) + }; + }); + return { newLabels, labelPicks }; +} \ No newline at end of file diff --git a/src/github/repositoriesManager.ts b/src/github/repositoriesManager.ts index b41d3c0a38..6fb5065679 100644 --- a/src/github/repositoriesManager.ts +++ b/src/github/repositoriesManager.ts @@ -3,17 +3,18 @@ * 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 { Repository, UpstreamRef } from '../api/api'; -import { ISessionState } from '../common/sessionState'; +import { Repository } from '../api/api'; +import { AuthProvider } from '../common/authentication'; +import { commands, contexts } from '../common/executeCommands'; +import Logger from '../common/logger'; import { ITelemetry } from '../common/telemetry'; import { EventType } from '../common/timelineEvent'; -import { compareIgnoreCase } from '../common/utils'; -import { AuthProvider, CredentialStore } from './credentials'; +import { compareIgnoreCase, dispose } from '../common/utils'; +import { CredentialStore } from './credentials'; import { FolderRepositoryManager, ReposManagerState, ReposManagerStateContext } from './folderRepositoryManager'; import { IssueModel } from './issueModel'; -import { hasEnterpriseUri } from './utils'; +import { findDotComAndEnterpriseRemotes, getEnterpriseUri, hasEnterpriseUri, setEnterpriseUri } from './utils'; export interface ItemsResponseResult { items: T[]; @@ -21,84 +22,65 @@ export interface ItemsResponseResult { hasUnsearchedRepositories: boolean; } -export class NoGitHubReposError extends Error { - constructor(public repository: Repository) { - super(); - } - - get message() { - return `${this.repository.rootUri.toString()} has no GitHub remotes`; - } -} - -export class DetachedHeadError extends Error { - constructor(public repository: Repository) { - super(); - } - - get message() { - return `${this.repository.rootUri.toString()} has a detached HEAD (create a branch first)`; - } -} - -export class BadUpstreamError extends Error { - constructor(public branchName: string, public upstreamRef: UpstreamRef, public problem: string) { - super(); - } - - get message() { - const { - upstreamRef: { remote, name }, - branchName, - problem, - } = this; - return `The upstream ref ${remote}/${name} for branch ${branchName} ${problem}.`; - } -} - export interface PullRequestDefaults { owner: string; repo: string; base: string; } -export const NO_MILESTONE: string = 'No Milestone'; - export class RepositoriesManager implements vscode.Disposable { static ID = 'RepositoriesManager'; - private _subs: vscode.Disposable[]; + private _folderManagers: FolderRepositoryManager[] = []; + private _subs: Map; private _onDidChangeState = new vscode.EventEmitter(); readonly onDidChangeState: vscode.Event = this._onDidChangeState.event; - private _onDidChangeFolderRepositories = new vscode.EventEmitter(); + private _onDidChangeFolderRepositories = new vscode.EventEmitter<{ added?: FolderRepositoryManager }>(); readonly onDidChangeFolderRepositories = this._onDidChangeFolderRepositories.event; + private _onDidLoadAnyRepositories = new vscode.EventEmitter(); + readonly onDidLoadAnyRepositories = this._onDidLoadAnyRepositories.event; + private _state: ReposManagerState = ReposManagerState.Initializing; constructor( - private _folderManagers: FolderRepositoryManager[], private _credentialStore: CredentialStore, private _telemetry: ITelemetry, - private readonly _sessionState: ISessionState ) { - this._subs = []; + this._subs = new Map(); vscode.commands.executeCommand('setContext', ReposManagerStateContext, this._state); + } - this._subs.push( - ..._folderManagers.map(folderManager => { - return folderManager.onDidLoadRepositories(state => (this.state = state)); - }), - ); + private updateActiveReviewCount() { + let count = 0; + for (const folderManager of this._folderManagers) { + if (folderManager.activePullRequest) { + count++; + } + } + commands.setContext(contexts.ACTIVE_PR_COUNT, count); } get folderManagers(): FolderRepositoryManager[] { return this._folderManagers; } + private registerFolderListeners(folderManager: FolderRepositoryManager) { + const disposables = [ + folderManager.onDidLoadRepositories(state => { + this.state = state; + this._onDidLoadAnyRepositories.fire(); + }), + folderManager.onDidChangeActivePullRequest(() => this.updateActiveReviewCount()), + folderManager.onDidDispose(() => this.removeRepo(folderManager.repository)) + ]; + this._subs.set(folderManager, disposables); + } + insertFolderManager(folderManager: FolderRepositoryManager) { - this._subs.push(folderManager.onDidLoadRepositories(state => (this.state = state))); + this.registerFolderListeners(folderManager); // Try to insert the new repository in workspace folder order const workspaceFolders = vscode.workspace.workspaceFolders; @@ -111,12 +93,14 @@ export class RepositoriesManager implements vscode.Disposable { this._folderManagers = this._folderManagers.slice(0, index); this._folderManagers.push(folderManager); this._folderManagers.push(...arrayEnd); - this._onDidChangeFolderRepositories.fire(); + this.updateActiveReviewCount(); + this._onDidChangeFolderRepositories.fire({ added: folderManager }); return; } } this._folderManagers.push(folderManager); - this._onDidChangeFolderRepositories.fire(); + this.updateActiveReviewCount(); + this._onDidChangeFolderRepositories.fire({ added: folderManager }); } removeRepo(repo: Repository) { @@ -125,9 +109,12 @@ export class RepositoriesManager implements vscode.Disposable { ); if (existingFolderManagerIndex > -1) { const folderManager = this._folderManagers[existingFolderManagerIndex]; + dispose(this._subs.get(folderManager)!); + this._subs.delete(folderManager); this._folderManagers.splice(existingFolderManagerIndex); folderManager.dispose(); - this._onDidChangeFolderRepositories.fire(); + this.updateActiveReviewCount(); + this._onDidChangeFolderRepositories.fire({}); } } @@ -135,15 +122,12 @@ export class RepositoriesManager implements vscode.Disposable { if (issueModel === undefined) { return undefined; } - const issueRemoteUrl = issueModel.remote.url.substring( - 0, - issueModel.remote.url.length - path.extname(issueModel.remote.url).length, - ); + const issueRemoteUrl = `${issueModel.remote.owner.toLowerCase()}/${issueModel.remote.repositoryName.toLowerCase()}`; for (const folderManager of this._folderManagers) { if ( folderManager.gitHubRepositories .map(repo => - repo.remote.url.substring(0, repo.remote.url.length - path.extname(repo.remote.url).length), + `${repo.remote.owner.toLowerCase()}/${repo.remote.repositoryName.toLowerCase()}` ) .includes(issueRemoteUrl) ) { @@ -158,7 +142,12 @@ export class RepositoriesManager implements vscode.Disposable { return this._folderManagers[0]; } - for (const folderManager of this._folderManagers) { + // Prioritize longest path first to handle nested workspaces + const folderManagers = this._folderManagers + .slice() + .sort((a, b) => b.repository.rootUri.path.length - a.repository.rootUri.path.length); + + for (const folderManager of folderManagers) { const managerPath = folderManager.repository.rootUri.path; const testUriRelativePath = uri.path.substring( managerPath.length > 1 ? managerPath.length + 1 : managerPath.length, @@ -192,17 +181,60 @@ export class RepositoriesManager implements vscode.Disposable { this.state = ReposManagerState.Initializing; } - async authenticate(): Promise { - const github = await this._credentialStore.login(AuthProvider.github); + async authenticate(enterprise?: boolean): Promise { + if (enterprise === false) { + return !!this._credentialStore.login(AuthProvider.github); + } + const { dotComRemotes, enterpriseRemotes, unknownRemotes } = await findDotComAndEnterpriseRemotes(this.folderManagers); + const yes = vscode.l10n.t('Yes'); + + if (enterprise) { + const remoteToUse = getEnterpriseUri()?.toString() ?? (enterpriseRemotes.length ? enterpriseRemotes[0].normalizedHost : (unknownRemotes.length ? unknownRemotes[0].normalizedHost : undefined)); + if (enterpriseRemotes.length === 0 && unknownRemotes.length === 0) { + Logger.appendLine(`Enterprise login selected, but no possible enterprise remotes discovered (${dotComRemotes.length} .com)`); + } + if (remoteToUse) { + const promptResult = await vscode.window.showInformationMessage(vscode.l10n.t('Would you like to set up GitHub Pull Requests and Issues to authenticate with the enterprise server {0}?', remoteToUse), + { modal: true }, yes, vscode.l10n.t('No, manually set {0}', 'github-enterprise.uri')); + if (promptResult === yes) { + await setEnterpriseUri(remoteToUse); + } else { + return false; + } + } else { + const setEnterpriseUriPrompt = await vscode.window.showInputBox({ placeHolder: vscode.l10n.t('Set a GitHub Enterprise server URL'), ignoreFocusOut: true }); + if (setEnterpriseUriPrompt) { + await setEnterpriseUri(setEnterpriseUriPrompt); + } else { + return false; + } + } + } + // If we have no github.com remotes, but we do have github remotes, then we likely have github enterprise remotes. + else if (!hasEnterpriseUri() && (dotComRemotes.length === 0) && (enterpriseRemotes.length > 0)) { + const promptResult = await vscode.window.showInformationMessage(vscode.l10n.t('It looks like you might be using GitHub Enterprise. Would you like to set up GitHub Pull Requests and Issues to authenticate with the enterprise server {0}?', enterpriseRemotes[0].normalizedHost), + { modal: true }, yes, vscode.l10n.t('No, use GitHub.com')); + if (promptResult === yes) { + await setEnterpriseUri(enterpriseRemotes[0].normalizedHost); + } else if (promptResult === undefined) { + return false; + } + } + let githubEnterprise; - if (hasEnterpriseUri()) { - githubEnterprise = await this._credentialStore.login(AuthProvider['github-enterprise']); + const hasNonDotComRemote = (enterpriseRemotes.length > 0) || (unknownRemotes.length > 0); + if ((hasEnterpriseUri() || (dotComRemotes.length === 0)) && hasNonDotComRemote) { + githubEnterprise = await this._credentialStore.login(AuthProvider.githubEnterprise); + } + let github; + if (!githubEnterprise && (!hasEnterpriseUri() || enterpriseRemotes.length === 0)) { + github = await this._credentialStore.login(AuthProvider.github); } return !!github || !!githubEnterprise; } dispose() { - this._subs.forEach(sub => sub.dispose()); + this._subs.forEach(sub => dispose(sub)); } } diff --git a/src/github/utils.ts b/src/github/utils.ts index faa6395534..ced796c928 100644 --- a/src/github/utils.ts +++ b/src/github/utils.ts @@ -5,37 +5,83 @@ 'use strict'; import * as crypto from 'crypto'; +import * as OctokitTypes from '@octokit/types'; import * as vscode from 'vscode'; import { Repository } from '../api/api'; import { GitApiImpl } from '../api/api1'; -import { IComment, IReviewThread, Reaction } from '../common/comment'; +import { AuthProvider, GitHubServerType } from '../common/authentication'; +import { IComment, IReviewThread, Reaction, SubjectType } from '../common/comment'; import { DiffHunk, parseDiffHunk } from '../common/diffHunk'; +import { GitHubRef } from '../common/githubRef'; +import Logger from '../common/logger'; +import { Remote } from '../common/remote'; import { Resource } from '../common/resources'; +import { GITHUB_ENTERPRISE, OVERRIDE_DEFAULT_BRANCH, PR_SETTINGS_NAMESPACE, URI } from '../common/settingKeys'; import * as Common from '../common/timelineEvent'; import { uniqBy } from '../common/utils'; import { OctokitCommon } from './common'; -import { AuthProvider } from './credentials'; -import { SETTINGS_NAMESPACE } from './folderRepositoryManager'; +import { FolderRepositoryManager, PullRequestDefaults } from './folderRepositoryManager'; import { GitHubRepository, ViewerPermission } from './githubRepository'; import * as GraphQL from './graphql'; import { IAccount, + IActor, IGitHubRef, ILabel, IMilestone, + IProjectItem, Issue, ISuggestedReviewer, + ITeam, + MergeMethod, + MergeQueueEntry, + MergeQueueState, PullRequest, PullRequestMergeability, + reviewerId, + reviewerLabel, ReviewState, User, } from './interface'; +import { IssueModel } from './issueModel'; import { GHPRComment, GHPRCommentThread } from './prComment'; +import { PullRequestModel } from './pullRequestModel'; + +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)/; export interface CommentReactionHandler { toggleReaction(comment: vscode.Comment, reaction: vscode.CommentReaction): Promise; } +export type ParsedIssue = { + owner: string | undefined; + name: string | undefined; + issueNumber: number; + commentNumber?: number; +}; + +export function parseIssueExpressionOutput(output: RegExpMatchArray | null): ParsedIssue | undefined { + if (!output) { + return undefined; + } + const issue: ParsedIssue = { owner: undefined, name: undefined, issueNumber: 0 }; + if (output.length === 7) { + issue.owner = output[2]; + issue.name = output[3]; + issue.issueNumber = parseInt(output[5]); + return issue; + } else if (output.length === 16) { + issue.owner = output[3] || output[11]; + issue.name = output[4] || output[12]; + issue.issueNumber = parseInt(output[7] || output[14]); + issue.commentNumber = output[9] !== undefined ? parseInt(output[9]) : undefined; + return issue; + } else { + return undefined; + } +} + export function threadRange(startLine: number, endLine: number, endCharacter?: number): vscode.Range { if ((startLine !== endLine) && (endCharacter === undefined)) { endCharacter = 300; // 300 is a "large" number that will select a lot of the line since don't know anything about the line length @@ -46,16 +92,19 @@ export function threadRange(startLine: number, endLine: number, endCharacter?: n } export function createVSCodeCommentThreadForReviewThread( + context: vscode.ExtensionContext, uri: vscode.Uri, - range: vscode.Range, + range: vscode.Range | undefined, thread: IReviewThread, commentController: vscode.CommentController, + currentUser: string, + githubRepository?: GitHubRepository ): GHPRCommentThread { const vscodeThread = commentController.createCommentThread(uri, range, []); (vscodeThread as GHPRCommentThread).gitHubThreadId = thread.id; - vscodeThread.comments = thread.comments.map(comment => new GHPRComment(comment, vscodeThread as GHPRCommentThread)); + vscodeThread.comments = thread.comments.map(comment => new GHPRComment(context, comment, vscodeThread as GHPRCommentThread, githubRepository)); vscodeThread.state = isResolvedToResolvedState(thread.isResolved); if (thread.viewerCanResolve && !thread.isResolved) { @@ -65,7 +114,7 @@ export function createVSCodeCommentThreadForReviewThread( } updateCommentThreadLabel(vscodeThread as GHPRCommentThread); - vscodeThread.collapsibleState = getCommentCollapsibleState(thread.isResolved); + vscodeThread.collapsibleState = getCommentCollapsibleState(thread, undefined, currentUser); return vscodeThread as GHPRCommentThread; } @@ -77,12 +126,13 @@ function isResolvedToResolvedState(isResolved: boolean) { export const COMMENT_EXPAND_STATE_SETTING = 'commentExpandState'; export const COMMENT_EXPAND_STATE_COLLAPSE_VALUE = 'collapseAll'; export const COMMENT_EXPAND_STATE_EXPAND_VALUE = 'expandUnresolved'; -export function getCommentCollapsibleState(isResolved: boolean, expand?: boolean) { - if (isResolved) { +export function getCommentCollapsibleState(thread: IReviewThread, expand?: boolean, currentUser?: string) { + if (thread.isResolved + || (currentUser && (thread.comments[thread.comments.length - 1].user?.login === currentUser))) { return vscode.CommentThreadCollapsibleState.Collapsed; } if (expand === undefined) { - const config = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE)?.get(COMMENT_EXPAND_STATE_SETTING); + const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE)?.get(COMMENT_EXPAND_STATE_SETTING); expand = config === COMMENT_EXPAND_STATE_EXPAND_VALUE; } return expand @@ -90,19 +140,22 @@ export function getCommentCollapsibleState(isResolved: boolean, expand?: boolean } -export function updateThreadWithRange(vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, expand?: boolean) { +export function updateThreadWithRange(context: vscode.ExtensionContext, vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepository: GitHubRepository, expand?: boolean) { + if (!vscodeThread.range) { + return; + } const editors = vscode.window.visibleTextEditors; for (let editor of editors) { if (editor.document.uri.toString() === vscodeThread.uri.toString()) { const endLine = editor.document.lineAt(vscodeThread.range.end.line); const range = new vscode.Range(vscodeThread.range.start.line, 0, vscodeThread.range.end.line, endLine.text.length); - updateThread(vscodeThread, reviewThread, expand, range); + updateThread(context, vscodeThread, reviewThread, githubRepository, expand, range); break; } } } -export function updateThread(vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, expand?: boolean, range?: vscode.Range) { +export function updateThread(context: vscode.ExtensionContext, vscodeThread: GHPRCommentThread, reviewThread: IReviewThread, githubRepository: GitHubRepository, expand?: boolean, range?: vscode.Range) { if (reviewThread.viewerCanResolve && !reviewThread.isResolved) { vscodeThread.contextValue = 'canResolve'; } else if (reviewThread.viewerCanUnresolve && reviewThread.isResolved) { @@ -113,17 +166,28 @@ export function updateThread(vscodeThread: GHPRCommentThread, reviewThread: IRev if (vscodeThread.state !== newResolvedState) { vscodeThread.state = newResolvedState; } - vscodeThread.collapsibleState = getCommentCollapsibleState(reviewThread.isResolved, expand); + vscodeThread.collapsibleState = getCommentCollapsibleState(reviewThread, expand); if (range) { vscodeThread.range = range; } - vscodeThread.comments = reviewThread.comments.map(c => new GHPRComment(c, vscodeThread)); + if ((vscodeThread.comments.length === reviewThread.comments.length) && vscodeThread.comments.every((vscodeComment, index) => vscodeComment.commentId === `${reviewThread.comments[index].id}`)) { + // The comments all still exist. Update them instead of creating new ones. This allows the UI to be more stable. + let index = 0; + for (const comment of vscodeThread.comments) { + if (comment instanceof GHPRComment) { + comment.update(reviewThread.comments[index]); + } + index++; + } + } else { + vscodeThread.comments = reviewThread.comments.map(c => new GHPRComment(context, c, vscodeThread, githubRepository)); + } updateCommentThreadLabel(vscodeThread); } export function updateCommentThreadLabel(thread: GHPRCommentThread) { if (thread.state === vscode.CommentThreadState.Resolved) { - thread.label = 'Marked as resolved'; + thread.label = vscode.l10n.t('Marked as resolved'); return; } @@ -131,34 +195,40 @@ export function updateCommentThreadLabel(thread: GHPRCommentThread) { const participantsList = uniqBy(thread.comments as vscode.Comment[], comment => comment.author.name) .map(comment => `@${comment.author.name}`) .join(', '); - thread.label = `Participants: ${participantsList}`; + thread.label = vscode.l10n.t('Participants: {0}', participantsList); } else { - thread.label = 'Start discussion'; + thread.label = vscode.l10n.t('Start discussion'); } } -export function generateCommentReactions(reactions: Reaction[] | undefined) { - return getReactionGroup().map(reaction => { +export function updateCommentReactions(comment: vscode.Comment, reactions: Reaction[] | undefined) { + let reactionsHaveUpdates = false; + const previousReactions = comment.reactions; + const newReactions = getReactionGroup().map((reaction, index) => { if (!reactions) { return { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; } const matchedReaction = reactions.find(re => re.label === reaction.label); - + let newReaction: vscode.CommentReaction; if (matchedReaction) { - return { + newReaction = { label: matchedReaction.label, authorHasReacted: matchedReaction.viewerHasReacted, count: matchedReaction.count, iconPath: reaction.icon || '', + reactors: matchedReaction.reactors }; } else { - return { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; + newReaction = { label: reaction.label, authorHasReacted: false, count: 0, iconPath: reaction.icon || '' }; + } + if (!reactionsHaveUpdates && (!previousReactions || (previousReactions[index].authorHasReacted !== newReaction.authorHasReacted) || (previousReactions[index].count !== newReaction.count))) { + reactionsHaveUpdates = true; } + return newReaction; }); -} -export function updateCommentReactions(comment: vscode.Comment, reactions: Reaction[] | undefined) { - comment.reactions = generateCommentReactions(reactions); + comment.reactions = newReactions; + return reactionsHaveUpdates; } export function updateCommentReviewState(thread: GHPRCommentThread, newDraftMode: boolean) { @@ -168,7 +238,7 @@ export function updateCommentReviewState(thread: GHPRCommentThread, newDraftMode thread.comments = thread.comments.map(comment => { if (comment instanceof GHPRComment) { - comment._rawComment.isDraft = false; + comment.rawComment.isDraft = false; } comment.label = undefined; @@ -177,29 +247,38 @@ export function updateCommentReviewState(thread: GHPRCommentThread, newDraftMode }); } +export function isEnterprise(provider: AuthProvider): boolean { + return provider === AuthProvider.githubEnterprise; +} + export function convertRESTUserToAccount( user: OctokitCommon.PullsListResponseItemUser, - githubRepository: GitHubRepository, + githubRepository?: GitHubRepository, ): IAccount { return { login: user.login, url: user.html_url, - avatarUrl: getAvatarWithEnterpriseFallback(user.avatar_url, user.gravatar_id ?? undefined, githubRepository.remote.authProviderId), + avatarUrl: githubRepository ? getAvatarWithEnterpriseFallback(user.avatar_url, user.gravatar_id ?? undefined, githubRepository.remote.isEnterprise) : user.avatar_url, + id: user.node_id }; } -export function convertRESTHeadToIGitHubRef(head: OctokitCommon.PullsListResponseItemHead) { +export function convertRESTHeadToIGitHubRef(head: OctokitCommon.PullsListResponseItemHead): IGitHubRef { return { label: head.label, ref: head.ref, sha: head.sha, - repo: { cloneUrl: head.repo.clone_url }, + repo: { + cloneUrl: head.repo.clone_url, + isInOrganization: !!head.repo.organization, + owner: head.repo.owner!.login, + name: head.repo.name + }, }; } export function convertRESTPullRequestToRawPullRequest( pullRequest: - | OctokitCommon.PullsCreateResponseData | OctokitCommon.PullsGetResponseData | OctokitCommon.PullsListResponseItem, githubRepository: GitHubRepository, @@ -228,6 +307,7 @@ export function convertRESTPullRequestToRawPullRequest( number, body: body ?? '', title, + titleHTML: title, url: html_url, user: convertRESTUserToAccount(user!, githubRepository), state, @@ -237,16 +317,22 @@ export function convertRESTPullRequestToRawPullRequest( : undefined, createdAt: created_at, updatedAt: updated_at, - head: convertRESTHeadToIGitHubRef(head), + head: head.repo ? convertRESTHeadToIGitHubRef(head as OctokitCommon.PullsListResponseItemHead) : undefined, base: convertRESTHeadToIGitHubRef(base), - mergeable: (pullRequest as OctokitCommon.PullsGetResponseData).mergeable - ? PullRequestMergeability.Mergeable - : PullRequestMergeability.NotMergeable, labels: labels.map(l => ({ name: '', color: '', ...l })), isDraft: draft, suggestedReviewers: [], // suggested reviewers only available through GraphQL API + projectItems: [], // projects only available through GraphQL API + commits: [], // commits only available through GraphQL API }; + // mergeable is not included in the list response, will need to fetch later + if ('mergeable' in pullRequest) { + item.mergeable = pullRequest.mergeable + ? PullRequestMergeability.Mergeable + : PullRequestMergeability.NotMergeable; + } + return item; } @@ -275,6 +361,7 @@ export function convertRESTIssueToRawPullRequest( number, body: body ?? '', title, + titleHTML: title, url: html_url, user: convertRESTUserToAccount(user!, githubRepository), state, @@ -284,9 +371,11 @@ export function convertRESTIssueToRawPullRequest( createdAt: created_at, updatedAt: updated_at, labels: labels.map(l => - typeof l === 'string' ? { name: l, color: '' } : { name: l.name ?? '', color: l.color ?? '' }, + typeof l === 'string' ? { name: l, color: '' } : { name: l.name ?? '', color: l.color ?? '', description: l.description ?? undefined }, ), - suggestedReviewers: [], // suggested reviewers only available through GraphQL API + suggestedReviewers: [], // suggested reviewers only available through GraphQL API, + projectItems: [], // projects only available through GraphQL API + commits: [], // commits only available through GraphQL API }; return item; @@ -324,33 +413,6 @@ export function parseCommentDiffHunk(comment: IComment): DiffHunk[] { return diffHunks; } -export function convertPullRequestsGetCommentsResponseItemToComment( - comment: OctokitCommon.PullsCreateReviewCommentResponseData, - githubRepository: GitHubRepository, -): IComment { - const ret: IComment = { - url: comment.url, - id: comment.id, - pullRequestReviewId: comment.pull_request_review_id ?? undefined, - diffHunk: comment.diff_hunk, - path: comment.path, - position: comment.position, - commitId: comment.commit_id, - originalPosition: comment.original_position, - originalCommitId: comment.original_commit_id, - user: convertRESTUserToAccount(comment.user!, githubRepository), - body: comment.body, - createdAt: comment.created_at, - htmlUrl: comment.html_url, - inReplyToId: comment.in_reply_to_id, - graphNodeId: comment.node_id, - }; - - const diffHunks = parseCommentDiffHunk(ret); - ret.diffHunks = diffHunks; - return ret; -} - export function convertGraphQLEventType(text: string) { switch (text) { case 'PullRequestCommit': @@ -375,9 +437,12 @@ export function convertGraphQLEventType(text: string) { } } -export function parseGraphQLReviewThread(thread: GraphQL.ReviewThread): IReviewThread { +export function parseGraphQLReviewThread(thread: GraphQL.ReviewThread, githubRepository: GitHubRepository): IReviewThread { return { id: thread.id, + prReviewDatabaseId: thread.comments.edges && thread.comments.edges.length ? + thread.comments.edges[0].node.pullRequestReview.databaseId : + undefined, isResolved: thread.isResolved, viewerCanResolve: thread.viewerCanResolve, viewerCanUnresolve: thread.viewerCanUnresolve, @@ -388,11 +453,12 @@ export function parseGraphQLReviewThread(thread: GraphQL.ReviewThread): IReviewT originalEndLine: thread.originalLine, diffSide: thread.diffSide, isOutdated: thread.isOutdated, - comments: thread.comments.nodes.map(comment => parseGraphQLComment(comment, thread.isResolved)), + comments: thread.comments.nodes.map(comment => parseGraphQLComment(comment, thread.isResolved, githubRepository)), + subjectType: thread.subjectType ?? SubjectType.LINE }; } -export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: boolean): IComment { +export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: boolean, githubRepository: GitHubRepository): IComment { const c: IComment = { id: comment.databaseId, url: comment.url, @@ -407,7 +473,7 @@ export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: commitId: comment.commit.oid, originalPosition: comment.originalPosition, originalCommitId: comment.originalCommit && comment.originalCommit.oid, - user: comment.author, + user: comment.author ? parseAuthor(comment.author, githubRepository) : undefined, createdAt: comment.createdAt, htmlUrl: comment.url, graphNodeId: comment.id, @@ -423,7 +489,7 @@ export function parseGraphQLComment(comment: GraphQL.ReviewComment, isResolved: return c; } -export function parseGraphQlIssueComment(comment: GraphQL.IssueComment): IComment { +export function parseGraphQlIssueComment(comment: GraphQL.IssueComment, githubRepository: GitHubRepository): IComment { return { id: comment.databaseId, url: comment.url, @@ -431,7 +497,7 @@ export function parseGraphQlIssueComment(comment: GraphQL.IssueComment): ICommen bodyHTML: comment.bodyHTML, canEdit: comment.viewerCanDelete, canDelete: comment.viewerCanDelete, - user: comment.author, + user: parseAuthor(comment.author, githubRepository), createdAt: comment.createdAt, htmlUrl: comment.url, graphNodeId: comment.id, @@ -446,13 +512,14 @@ export function parseGraphQLReaction(reactionGroups: GraphQL.ReactionGroup[]): R }, {} as { [key: string]: { title: string; label: string; icon?: vscode.Uri } }); const reactions = reactionGroups - .filter(group => group.users.totalCount > 0) + .filter(group => group.reactors.totalCount > 0) .map(group => { const reaction: Reaction = { label: reactionContentEmojiMapping[group.content].label, - count: group.users.totalCount, + count: group.reactors.totalCount, icon: reactionContentEmojiMapping[group.content].icon, viewerHasReacted: group.viewerHasReacted, + reactors: group.reactors.nodes.map(node => node.login) }; return reaction; @@ -472,31 +539,66 @@ function parseRef(refName: string, oid: string, repository?: GraphQL.RefReposito sha: oid, repo: { cloneUrl: repository.url, + isInOrganization: repository.isInOrganization, + owner: repository.owner.login, + name: refName }, }; } function parseAuthor( - author: { login: string; url: string; avatarUrl: string; email?: string } | null, + author: { login: string; url: string; avatarUrl: string; email?: string, id: string } | null, githubRepository: GitHubRepository, ): IAccount { if (author) { return { login: author.login, url: author.url, - avatarUrl: getAvatarWithEnterpriseFallback(author.avatarUrl, undefined, githubRepository.remote.authProviderId), - email: author.email + avatarUrl: getAvatarWithEnterpriseFallback(author.avatarUrl, undefined, githubRepository.remote.isEnterprise), + email: author.email, + id: author.id }; } else { return { login: '', url: '', + id: '' }; } } +function parseActor( + author: { login: string; url: string; avatarUrl: string; } | null, + githubRepository: GitHubRepository, +): IActor { + if (author) { + return { + login: author.login, + url: author.url, + avatarUrl: getAvatarWithEnterpriseFallback(author.avatarUrl, undefined, githubRepository.remote.isEnterprise), + }; + } else { + return { + login: '', + url: '', + }; + } +} + +export function parseProjectItems(projects: { id: string; project: { id: string; title: string; } }[] | undefined): IProjectItem[] | undefined { + if (!projects) { + return undefined; + } + return projects.map(project => { + return { + id: project.id, + project: project.project + }; + }); +} + export function parseMilestone( - milestone: { title: string; dueOn?: string; createdAt: string; id: string } | undefined, + milestone: { title: string; dueOn?: string; createdAt: string; id: string, number: number } | undefined, ): IMilestone | undefined { if (!milestone) { return undefined; @@ -506,13 +608,53 @@ export function parseMilestone( dueOn: milestone.dueOn, createdAt: milestone.createdAt, id: milestone.id, + number: milestone.number }; } -export function parseMergeability(mergeability: 'UNKNOWN' | 'MERGEABLE' | 'CONFLICTING', - mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'): PullRequestMergeability { +export function parseMergeQueueEntry(mergeQueueEntry: GraphQL.MergeQueueEntry | null | undefined): MergeQueueEntry | undefined | null { + if (!mergeQueueEntry) { + return null; + } + let state: MergeQueueState; + switch (mergeQueueEntry.state) { + case 'AWAITING_CHECKS': { + state = MergeQueueState.AwaitingChecks; + break; + } + case 'LOCKED': { + state = MergeQueueState.Locked; + break; + } + case 'QUEUED': { + state = MergeQueueState.Queued; + break; + } + case 'MERGEABLE': { + state = MergeQueueState.Mergeable; + break; + } + case 'UNMERGEABLE': { + state = MergeQueueState.Unmergeable; + break; + } + } + return { position: mergeQueueEntry.position, state, url: mergeQueueEntry.mergeQueue.url }; +} + +export function parseMergeMethod(mergeMethod: GraphQL.MergeMethod | undefined): MergeMethod | undefined { + switch (mergeMethod) { + case 'MERGE': return 'merge'; + case 'REBASE': return 'rebase'; + case 'SQUASH': return 'squash'; + } +} + +export function parseMergeability(mergeability: 'UNKNOWN' | 'MERGEABLE' | 'CONFLICTING' | undefined, + mergeStateStatus: 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE' | undefined): PullRequestMergeability { let parsed: PullRequestMergeability; switch (mergeability) { + case undefined: case 'UNKNOWN': parsed = PullRequestMergeability.Unknown; break; @@ -523,19 +665,21 @@ export function parseMergeability(mergeability: 'UNKNOWN' | 'MERGEABLE' | 'CONFL parsed = PullRequestMergeability.Conflict; break; } - if ((parsed !== PullRequestMergeability.Conflict) && (mergeStateStatus === 'BLOCKED')) { - parsed = PullRequestMergeability.NotMergeable; + if (parsed !== PullRequestMergeability.Conflict) { + if (mergeStateStatus === 'BLOCKED') { + parsed = PullRequestMergeability.NotMergeable; + } else if (mergeStateStatus === 'BEHIND') { + parsed = PullRequestMergeability.Behind; + } } return parsed; } export function parseGraphQLPullRequest( - pullRequest: GraphQL.PullRequestResponse, + graphQLPullRequest: GraphQL.PullRequest, githubRepository: GitHubRepository, ): PullRequest { - const graphQLPullRequest = pullRequest.repository.pullRequest; - - return { + const pr: PullRequest = { id: graphQLPullRequest.databaseId, graphNodeId: graphQLPullRequest.id, url: graphQLPullRequest.url, @@ -544,6 +688,7 @@ export function parseGraphQLPullRequest( body: graphQLPullRequest.body, bodyHTML: graphQLPullRequest.bodyHTML, title: graphQLPullRequest.title, + titleHTML: graphQLPullRequest.titleHTML, createdAt: graphQLPullRequest.createdAt, updatedAt: graphQLPullRequest.updatedAt, isRemoteHeadDeleted: !graphQLPullRequest.headRef, @@ -553,13 +698,73 @@ export function parseGraphQLPullRequest( user: parseAuthor(graphQLPullRequest.author, githubRepository), merged: graphQLPullRequest.merged, mergeable: parseMergeability(graphQLPullRequest.mergeable, graphQLPullRequest.mergeStateStatus), + mergeQueueEntry: parseMergeQueueEntry(graphQLPullRequest.mergeQueueEntry), + autoMerge: !!graphQLPullRequest.autoMergeRequest, + autoMergeMethod: parseMergeMethod(graphQLPullRequest.autoMergeRequest?.mergeMethod), + allowAutoMerge: graphQLPullRequest.viewerCanEnableAutoMerge || graphQLPullRequest.viewerCanDisableAutoMerge, labels: graphQLPullRequest.labels.nodes, isDraft: graphQLPullRequest.isDraft, suggestedReviewers: parseSuggestedReviewers(graphQLPullRequest.suggestedReviewers), comments: parseComments(graphQLPullRequest.comments?.nodes, githubRepository), + projectItems: parseProjectItems(graphQLPullRequest.projectItems?.nodes), milestone: parseMilestone(graphQLPullRequest.milestone), assignees: graphQLPullRequest.assignees?.nodes.map(assignee => parseAuthor(assignee, githubRepository)), + commits: parseCommits(graphQLPullRequest.commits.nodes), }; + pr.mergeCommitMeta = parseCommitMeta(graphQLPullRequest.baseRepository.mergeCommitTitle, graphQLPullRequest.baseRepository.mergeCommitMessage, pr); + pr.squashCommitMeta = parseCommitMeta(graphQLPullRequest.baseRepository.squashMergeCommitTitle, graphQLPullRequest.baseRepository.squashMergeCommitMessage, pr); + return pr; +} + +function parseCommitMeta(titleSource: GraphQL.DefaultCommitTitle | undefined, descriptionSource: GraphQL.DefaultCommitMessage | undefined, pullRequest: PullRequest): { title: string, description: string } | undefined { + if (titleSource === undefined || descriptionSource === undefined) { + return undefined; + } + + let title = ''; + let description = ''; + + switch (titleSource) { + case GraphQL.DefaultCommitTitle.prTitle: { + title = `${pullRequest.title} (#${pullRequest.number})`; + break; + } + case GraphQL.DefaultCommitTitle.mergeMessage: { + title = `Merge pull request #${pullRequest.number} from ${pullRequest.head?.label ?? ''}`; + break; + } + case GraphQL.DefaultCommitTitle.commitOrPrTitle: { + if (pullRequest.commits.length === 1) { + title = pullRequest.commits[0].message; + } else { + title = pullRequest.title; + } + break; + } + } + switch (descriptionSource) { + case GraphQL.DefaultCommitMessage.prBody: { + description = pullRequest.body; + break; + } + case GraphQL.DefaultCommitMessage.commitMessages: { + description = pullRequest.commits.map(commit => `* ${commit.message}`).join('\n\n'); + break; + } + case GraphQL.DefaultCommitMessage.prTitle: { + description = pullRequest.title; + break; + } + } + return { title, description }; +} + +function parseCommits(commits: { commit: { message: string; }; }[]): { message: string; }[] { + return commits.map(commit => { + return { + message: commit.commit.message + }; + }); } function parseComments(comments: GraphQL.AbbreviatedIssueComment[] | undefined, githubRepository: GitHubRepository) { @@ -592,45 +797,16 @@ export function parseGraphQLIssue(issue: GraphQL.PullRequest, githubRepository: body: issue.body, bodyHTML: issue.bodyHTML, title: issue.title, + titleHTML: issue.titleHTML, createdAt: issue.createdAt, updatedAt: issue.updatedAt, assignees: issue.assignees?.nodes.map(assignee => parseAuthor(assignee, githubRepository)), user: parseAuthor(issue.author, githubRepository), labels: issue.labels.nodes, - repositoryName: issue.repository?.name, - repositoryOwner: issue.repository?.owner.login, - repositoryUrl: issue.repository?.url, - }; -} - -export function parseGraphQLIssuesRequest( - pullRequest: GraphQL.PullRequest, - githubRepository: GitHubRepository, -): PullRequest { - const graphQLPullRequest = pullRequest; - - return { - id: graphQLPullRequest.databaseId, - graphNodeId: graphQLPullRequest.id, - url: graphQLPullRequest.url, - number: graphQLPullRequest.number, - state: graphQLPullRequest.state, - body: graphQLPullRequest.body, - bodyHTML: graphQLPullRequest.bodyHTML, - title: graphQLPullRequest.title, - createdAt: graphQLPullRequest.createdAt, - updatedAt: graphQLPullRequest.updatedAt, - isRemoteHeadDeleted: !graphQLPullRequest.headRef, - head: parseRef(graphQLPullRequest.headRef?.name ?? graphQLPullRequest.headRefName, graphQLPullRequest.headRefOid, graphQLPullRequest.headRepository), - isRemoteBaseDeleted: !graphQLPullRequest.baseRef, - base: parseRef(graphQLPullRequest.baseRef?.name ?? graphQLPullRequest.baseRefName, graphQLPullRequest.baseRefOid, graphQLPullRequest.baseRepository), - user: parseAuthor(graphQLPullRequest.author, githubRepository), - merged: graphQLPullRequest.merged, - mergeable: parseMergeability(graphQLPullRequest.mergeable, pullRequest.mergeStateStatus), - labels: graphQLPullRequest.labels.nodes, - isDraft: graphQLPullRequest.isDraft, - suggestedReviewers: parseSuggestedReviewers(graphQLPullRequest.suggestedReviewers), - milestone: parseMilestone(graphQLPullRequest.milestone), + repositoryName: issue.repository?.name ?? githubRepository.remote.repositoryName, + repositoryOwner: issue.repository?.owner.login ?? githubRepository.remote.owner, + repositoryUrl: issue.repository?.url ?? githubRepository.remote.url, + projectItems: parseProjectItems(issue.projectItems?.nodes), }; } @@ -648,6 +824,7 @@ function parseSuggestedReviewers( url: suggestedReviewer.reviewer.url, isAuthor: suggestedReviewer.isAuthor, isCommenter: suggestedReviewer.isCommenter, + id: suggestedReviewer.reviewer.id }; }); @@ -661,6 +838,15 @@ export function loginComparator(a: IAccount, b: IAccount) { // sensitivity: 'accent' allows case insensitive comparison return a.login.localeCompare(b.login, 'en', { sensitivity: 'accent' }); } +/** + * Used for case insensitive sort by team name + */ +export function teamComparator(a: ITeam, b: ITeam) { + const aKey = a.name ?? a.slug; + const bKey = b.name ?? b.slug; + // sensitivity: 'accent' allows case insensitive comparison + return aKey.localeCompare(bKey, 'en', { sensitivity: 'accent' }); +} export function parseGraphQLReviewEvent( review: GraphQL.SubmittedReview, @@ -668,7 +854,7 @@ export function parseGraphQLReviewEvent( ): Common.ReviewEvent { return { event: Common.EventType.Reviewed, - comments: review.comments.nodes.map(comment => parseGraphQLComment(comment, false)).filter(c => !c.inReplyToId), + comments: review.comments.nodes.map(comment => parseGraphQLComment(comment, false, githubRepository)).filter(c => !c.inReplyToId), submittedAt: review.submittedAt, body: review.body, bodyHTML: review.bodyHTML, @@ -746,7 +932,7 @@ export function parseGraphQLTimelineEvents( normalizedEvents.push({ id: mergeEv.id, event: type, - user: parseAuthor(mergeEv.actor, githubRepository), + user: parseActor(mergeEv.actor, githubRepository), createdAt: mergeEv.createdAt, mergeRef: mergeEv.mergeRef.name, sha: mergeEv.commit.oid, @@ -759,9 +945,9 @@ export function parseGraphQLTimelineEvents( const assignEv = event as GraphQL.AssignedEvent; normalizedEvents.push({ - id: assignEv.databaseId, + id: assignEv.id, event: type, - user: assignEv.user, + user: parseAuthor(assignEv.user, githubRepository), actor: assignEv.actor, }); return; @@ -771,7 +957,7 @@ export function parseGraphQLTimelineEvents( normalizedEvents.push({ id: deletedEv.id, event: type, - actor: deletedEv.actor, + actor: parseActor(deletedEv.actor, githubRepository), createdAt: deletedEv.createdAt, headRef: deletedEv.headRefName, }); @@ -788,12 +974,13 @@ export function parseGraphQLUser(user: GraphQL.UserResponse, githubRepository: G return { login: user.user.login, name: user.user.name, - avatarUrl: user.user.avatarUrl ? getAvatarWithEnterpriseFallback(user.user.avatarUrl, undefined, githubRepository.remote.authProviderId) : undefined, + avatarUrl: getAvatarWithEnterpriseFallback(user.user.avatarUrl ?? '', undefined, githubRepository.remote.isEnterprise), url: user.user.url, bio: user.user.bio, company: user.user.company, location: user.user.location, commitContributions: parseGraphQLCommitContributions(user.user.contributionsCollection), + id: user.user.id }; } @@ -816,41 +1003,49 @@ export function getReactionGroup(): { title: string; label: string; icon?: vscod const ret = [ { title: 'THUMBS_UP', + // allow-any-unicode-next-line label: '👍', icon: Resource.icons.reactions.THUMBS_UP, }, { title: 'THUMBS_DOWN', + // allow-any-unicode-next-line label: '👎', icon: Resource.icons.reactions.THUMBS_DOWN, }, { title: 'LAUGH', + // allow-any-unicode-next-line label: '😄', icon: Resource.icons.reactions.LAUGH, }, { title: 'HOORAY', + // allow-any-unicode-next-line label: '🎉', icon: Resource.icons.reactions.HOORAY, }, { title: 'CONFUSED', + // allow-any-unicode-next-line label: '😕', icon: Resource.icons.reactions.CONFUSED, }, { title: 'HEART', + // allow-any-unicode-next-line label: '❤️', icon: Resource.icons.reactions.HEART, }, { title: 'ROCKET', + // allow-any-unicode-next-line label: '🚀', icon: Resource.icons.reactions.ROCKET, }, { title: 'EYES', + // allow-any-unicode-next-line label: '👀', icon: Resource.icons.reactions.EYES, }, @@ -859,27 +1054,52 @@ export function getReactionGroup(): { title: string; label: string; icon?: vscod return ret; } +export async function restPaginate(request: R, variables: Parameters[0]): Promise { + let page = 1; + let results: T[] = []; + let hasNextPage = false; + + do { + const result = await request( + { + ...(variables as any), + per_page: 100, + page + } + ); + + results = results.concat( + result.data as T[] + ); + + hasNextPage = !!result.headers.link && result.headers.link.indexOf('rel="next"') > -1; + page += 1; + } while (hasNextPage); + + return results; +} + export function getRelatedUsersFromTimelineEvents( timelineEvents: Common.TimelineEvent[], ): { login: string; name: string }[] { const ret: { login: string; name: string }[] = []; timelineEvents.forEach(event => { - if (Common.isCommitEvent(event)) { + if (event.event === Common.EventType.Committed) { ret.push({ login: event.author.login, name: event.author.name || '', }); } - if (Common.isReviewEvent(event)) { + if (event.event === Common.EventType.Reviewed) { ret.push({ login: event.user.login, name: event.user.name ?? event.user.login, }); } - if (Common.isCommentEvent(event)) { + if (event.event === Common.EventType.Commented) { ret.push({ login: event.user.login, name: event.user.name ?? event.user.login, @@ -893,7 +1113,7 @@ export function getRelatedUsersFromTimelineEvents( export function parseGraphQLViewerPermission( viewerPermissionResponse: GraphQL.ViewerPermissionResponse, ): ViewerPermission { - if (viewerPermissionResponse && viewerPermissionResponse.repository.viewerPermission) { + if (viewerPermissionResponse && viewerPermissionResponse.repository?.viewerPermission) { if ( (Object.values(ViewerPermission) as string[]).includes(viewerPermissionResponse.repository.viewerPermission) ) { @@ -903,16 +1123,23 @@ export function parseGraphQLViewerPermission( return ViewerPermission.Unknown; } +export function isFileInRepo(repository: Repository, file: vscode.Uri): boolean { + return file.path.toLowerCase() === repository.rootUri.path.toLowerCase() || + (file.path.toLowerCase().startsWith(repository.rootUri.path.toLowerCase()) && + file.path.substring(repository.rootUri.path.length).startsWith('/')); +} + export function getRepositoryForFile(gitAPI: GitApiImpl, file: vscode.Uri): Repository | undefined { - for (const repository of gitAPI.repositories) { - if ( - file.path.toLowerCase() === repository.rootUri.path.toLowerCase() || - (file.path.toLowerCase().startsWith(repository.rootUri.path.toLowerCase()) && - file.path.substring(repository.rootUri.path.length).startsWith('/')) - ) { - return repository; + const foundRepos: Repository[] = []; + for (const repository of gitAPI.repositories.reverse()) { + if (isFileInRepo(repository, file)) { + foundRepos.push(repository); } } + if (foundRepos.length > 0) { + foundRepos.sort((a, b) => b.rootUri.path.length - a.rootUri.path.length); + return foundRepos[0]; + } return undefined; } @@ -926,11 +1153,11 @@ export function getRepositoryForFile(gitAPI: GitApiImpl, file: vscode.Uri): Repo * @param author The author of the pull request */ export function parseReviewers( - requestedReviewers: IAccount[], + requestedReviewers: (IAccount | ITeam)[], timelineEvents: Common.TimelineEvent[], author: IAccount, ): ReviewState[] { - const reviewEvents = timelineEvents.filter(Common.isReviewEvent).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(); @@ -949,13 +1176,13 @@ export function parseReviewers( } requestedReviewers.forEach(request => { - if (!seen.get(request.login)) { + if (!seen.get(reviewerId(request))) { reviewers.push({ reviewer: request, state: 'REQUESTED', }); } else { - const reviewer = reviewers.find(r => r.reviewer.login === request.login); + const reviewer = reviewers.find(r => reviewerId(r.reviewer) === reviewerId(request)); reviewer!.state = 'REQUESTED'; } }); @@ -970,12 +1197,51 @@ export function parseReviewers( return -1; } - return a.reviewer.login.toLowerCase() < b.reviewer.login.toLowerCase() ? -1 : 1; + return reviewerLabel(a.reviewer).toLowerCase() < reviewerLabel(b.reviewer).toLowerCase() ? -1 : 1; }); return reviewers; } +export function insertNewCommitsSinceReview( + timelineEvents: Common.TimelineEvent[], + latestReviewCommitOid: string | undefined, + 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); + 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; + } + } + } +} + export function getPRFetchQuery(repo: string, user: string, query: string): string { const filter = query.replace(/\$\{user\}/g, user); return `is:pull-request ${filter} type:pr repo:${repo}`; @@ -985,10 +1251,18 @@ export function isInCodespaces(): boolean { return vscode.env.remoteName === 'codespaces' && vscode.env.uiKind === vscode.UIKind.Web; } +export async function setEnterpriseUri(host: string) { + return vscode.workspace.getConfiguration(GITHUB_ENTERPRISE).update(URI, host, vscode.ConfigurationTarget.Workspace); +} + export function getEnterpriseUri(): vscode.Uri | undefined { - const config: string = vscode.workspace.getConfiguration('github-enterprise').get('uri', ''); + const config: string = vscode.workspace.getConfiguration(GITHUB_ENTERPRISE).get(URI, ''); if (config) { - return vscode.Uri.parse(config, true); + let uri = vscode.Uri.parse(config, true); + if (uri.scheme === 'http') { + uri = uri.with({ scheme: 'https' }); + } + return uri; } } @@ -1000,8 +1274,8 @@ export function generateGravatarUrl(gravatarId: string | undefined, size: number return !!gravatarId ? `https://www.gravatar.com/avatar/${gravatarId}?s=${size}&d=retro` : undefined; } -export function getAvatarWithEnterpriseFallback(avatarUrl: string, email: string | undefined, authProviderId: AuthProvider): string | undefined { - return authProviderId === AuthProvider.github ? avatarUrl : (email ? generateGravatarUrl( +export function getAvatarWithEnterpriseFallback(avatarUrl: string, email: string | undefined, isEnterpriseRemote: boolean): string | undefined { + return !isEnterpriseRemote ? avatarUrl : (email ? generateGravatarUrl( crypto.createHash('md5').update(email?.trim()?.toLowerCase()).digest('hex')) : undefined); } @@ -1012,3 +1286,92 @@ export function getPullsUrl(repo: GitHubRepository) { export function getIssuesUrl(repo: GitHubRepository) { return vscode.Uri.parse(`https://${repo.remote.host}/${repo.remote.owner}/${repo.remote.repositoryName}/issues`); } + +export function sanitizeIssueTitle(title: string): string { + const regex = /[~^:;'".,~#?%*&[\]@\\{}()/]|\/\//g; + + return title.replace(regex, '').trim().substring(0, 150).replace(/\s+/g, '-'); +} + +const VARIABLE_PATTERN = /\$\{(.*?)\}/g; +export async function variableSubstitution( + value: string, + issueModel?: IssueModel, + defaults?: PullRequestDefaults, + user?: string, +): Promise { + return value.replace(VARIABLE_PATTERN, (match: string, variable: string) => { + switch (variable) { + case 'user': + return user ? user : match; + case 'issueNumber': + return issueModel ? `${issueModel.number}` : match; + case 'issueNumberLabel': + return issueModel ? `${getIssueNumberLabel(issueModel, defaults)}` : match; + case 'issueTitle': + return issueModel ? issueModel.title : match; + case 'repository': + return defaults ? defaults.repo : match; + case 'owner': + return defaults ? defaults.owner : match; + case 'sanitizedIssueTitle': + return issueModel ? sanitizeIssueTitle(issueModel.title) : match; // check what characters are permitted + case 'sanitizedLowercaseIssueTitle': + return issueModel ? sanitizeIssueTitle(issueModel.title).toLowerCase() : match; + default: + return match; + } + }); +} + +export function getIssueNumberLabel(issue: IssueModel, repo?: PullRequestDefaults) { + const parsedIssue: ParsedIssue = { issueNumber: issue.number, owner: undefined, name: undefined }; + if ( + repo && + (repo.owner.toLowerCase() !== issue.remote.owner.toLowerCase() || + repo.repo.toLowerCase() !== issue.remote.repositoryName.toLowerCase()) + ) { + parsedIssue.owner = issue.remote.owner; + parsedIssue.name = issue.remote.repositoryName; + } + return getIssueNumberLabelFromParsed(parsedIssue); +} + +export function getIssueNumberLabelFromParsed(parsed: ParsedIssue) { + if (!parsed.owner || !parsed.name) { + return `#${parsed.issueNumber}`; + } else { + return `${parsed.owner}/${parsed.name}#${parsed.issueNumber}`; + } +} + +export function getOverrideBranch(): string | undefined { + const overrideSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(OVERRIDE_DEFAULT_BRANCH); + if (overrideSetting) { + Logger.debug('Using override setting for default branch', GitHubRepository.ID); + return overrideSetting; + } +} + +export async function findDotComAndEnterpriseRemotes(folderManagers: FolderRepositoryManager[]): Promise<{ dotComRemotes: Remote[], enterpriseRemotes: Remote[], unknownRemotes: Remote[] }> { + // Check if we have found any github.com remotes + const dotComRemotes: Remote[] = []; + const enterpriseRemotes: Remote[] = []; + const unknownRemotes: Remote[] = []; + for (const manager of folderManagers) { + for (const remote of await manager.computeAllGitHubRemotes()) { + if (remote.githubServerType === GitHubServerType.GitHubDotCom) { + dotComRemotes.push(remote); + } else if (remote.githubServerType === GitHubServerType.Enterprise) { + enterpriseRemotes.push(remote); + } + } + unknownRemotes.push(...await manager.computeAllUnknownRemotes()); + } + return { dotComRemotes, enterpriseRemotes, unknownRemotes }; +} + +export function vscodeDevPrLink(pullRequest: PullRequestModel) { + const itemUri = vscode.Uri.parse(pullRequest.html_url); + return `https://${vscode.env.appName.toLowerCase().includes('insider') ? 'insiders.' : ''}vscode.dev/github${itemUri.path}`; +} diff --git a/src/github/views.ts b/src/github/views.ts new file mode 100644 index 0000000000..373f1fd365 --- /dev/null +++ b/src/github/views.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TimelineEvent } from '../common/timelineEvent'; +import { + GithubItemStateEnum, + IAccount, + ILabel, + IMilestone, + IProjectItem, + MergeMethod, + MergeMethodsAvailability, + MergeQueueState, + PullRequestChecks, + PullRequestMergeability, + PullRequestReviewRequirement, + ReviewState, +} from './interface'; + +export enum ReviewType { + Comment = 'comment', + Approve = 'approve', + RequestChanges = 'requestChanges', +} + +export interface PullRequest { + number: number; + title: string; + titleHTML: string; + url: string; + createdAt: string; + body: string; + bodyHTML?: string; + author: IAccount; + state: GithubItemStateEnum; + events: TimelineEvent[]; + isCurrentlyCheckedOut: boolean; + isRemoteBaseDeleted?: boolean; + base: string; + isRemoteHeadDeleted?: boolean; + isLocalHeadDeleted?: boolean; + head: string; + labels: ILabel[]; + assignees: IAccount[]; + commitsCount: number; + projectItems: IProjectItem[] | undefined; + milestone: IMilestone | undefined; + repositoryDefaultBranch: string; + /** + * User can edit PR title and description (author or user with push access) + */ + canEdit: boolean; + /** + * Users with push access to repo have rights to merge/close PRs, + * edit title/description, assign reviewers/labels etc. + */ + hasWritePermission: boolean; + pendingCommentText?: string; + pendingCommentDrafts?: { [key: string]: string }; + pendingReviewType?: ReviewType; + status: PullRequestChecks | null; + reviewRequirement: PullRequestReviewRequirement | null; + mergeable: PullRequestMergeability; + defaultMergeMethod: MergeMethod; + mergeMethodsAvailability: MergeMethodsAvailability; + autoMerge?: boolean; + allowAutoMerge: boolean; + autoMergeMethod?: MergeMethod; + mergeQueueMethod: MergeMethod | undefined; + mergeQueueEntry?: { + url: string; + position: number; + state: MergeQueueState; + }; + mergeCommitMeta?: { title: string, description: string }; + squashCommitMeta?: { title: string, description: string }; + reviewers: ReviewState[]; + isDraft?: boolean; + isIssue: boolean; + isAuthor?: boolean; + continueOnGitHub: boolean; + currentUserReviewState: string; + isDarkTheme: boolean; + isEnterprise: boolean; + hasReviewDraft: boolean; + + lastReviewType?: ReviewType; + busy?: boolean; +} + +export interface ProjectItemsReply { + projectItems: IProjectItem[] | undefined; +} \ No newline at end of file diff --git a/src/integrations/gitlens/gitlens.d.ts b/src/integrations/gitlens/gitlens.d.ts index f927e13712..531a68d1c0 100644 --- a/src/integrations/gitlens/gitlens.d.ts +++ b/src/integrations/gitlens/gitlens.d.ts @@ -23,7 +23,7 @@ export interface CreatePullRequestActionContext { readonly name: string; readonly provider?: RemoteProvider; readonly url?: string; - } + } | undefined; } diff --git a/src/integrations/gitlens/gitlensImpl.ts b/src/integrations/gitlens/gitlensImpl.ts index 5cb4eda27a..bb25b0f739 100644 --- a/src/integrations/gitlens/gitlensImpl.ts +++ b/src/integrations/gitlens/gitlensImpl.ts @@ -20,7 +20,10 @@ export class GitLensIntegration implements Disposable { Disposable.from(...this._subscriptions).dispose(); } - private register(api: GitLensApi) { + private register(api: GitLensApi | undefined) { + if (!api) { + return; + } this._subscriptions.push( api.registerActionRunner('createPullRequest', { partnerId: 'ghpr', @@ -43,8 +46,8 @@ export class GitLensIntegration implements Disposable { private async onExtensionsChanged() { const extension = - extensions.getExtension>('eamodio.gitlens') ?? - extensions.getExtension>('eamodio.gitlens-insiders'); + extensions.getExtension>('eamodio.gitlens') ?? + extensions.getExtension>('eamodio.gitlens-insiders'); if (extension) { this._extensionsDisposable.dispose(); diff --git a/src/issues/currentIssue.ts b/src/issues/currentIssue.ts index a41bfa47b6..7ec94e7bc1 100644 --- a/src/issues/currentIssue.ts +++ b/src/issues/currentIssue.ts @@ -4,18 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { Repository } from '../api/api'; +import { Branch, Repository } from '../api/api'; +import { GitErrorCodes } from '../api/api1'; import { Remote } from '../common/remote'; +import { + ASSIGN_WHEN_WORKING, + ISSUE_BRANCH_TITLE, + ISSUES_SETTINGS_NAMESPACE, + USE_BRANCH_FOR_ISSUES, + WORKING_ISSUE_FORMAT_SCM, +} from '../common/settingKeys'; import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; +import { GithubItemStateEnum } from '../github/interface'; import { IssueModel } from '../github/issueModel'; +import { variableSubstitution } from '../github/utils'; import { IssueState, StateManager } from './stateManager'; -import { - BRANCH_CONFIGURATION, - BRANCH_NAME_CONFIGURATION, - ISSUES_CONFIGURATION, - SCM_MESSAGE_CONFIGURATION, - variableSubstitution, -} from './util'; export class CurrentIssue { private repoChangeDisposable: vscode.Disposable | undefined; @@ -65,15 +68,15 @@ export class CurrentIssue { return this.issueModel; } - public async startWorking(): Promise { + public async startWorking(silent: boolean = false): Promise { try { this._repoDefaults = await this.manager.getPullRequestDefaults(); - if (await this.createIssueBranch()) { + if (await this.createIssueBranch(silent)) { await this.setCommitMessageAndGitEvent(); this._onDidChangeCurrentIssueState.fire(); - const login = this.manager.getCurrentUser(this.issueModel).login; + const login = (await this.manager.getCurrentUser(this.issueModel.githubRepository)).login; if ( - vscode.workspace.getConfiguration('githubIssues').get('assignWhenWorking') && + vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(ASSIGN_WHEN_WORKING) && !this.issueModel.assignees?.find(value => value.login === login) ) { // Check that we have a repo open for this issue and only try to assign in that case. @@ -88,7 +91,7 @@ export class CurrentIssue { } } catch (e) { // leave repoDefaults undefined - vscode.window.showErrorMessage("There is no remote. Can't start working on an issue."); + vscode.window.showErrorMessage(vscode.l10n.t('There is no remote. Can\'t start working on an issue.')); } return false; } @@ -97,12 +100,21 @@ export class CurrentIssue { this.repoChangeDisposable?.dispose(); } - public async stopWorking() { + public async stopWorking(checkoutDefaultBranch: boolean) { if (this.repo) { this.repo.inputBox.value = ''; } - if (this._repoDefaults) { - await this.manager.repository.checkout(this._repoDefaults.base); + if (this._repoDefaults && checkoutDefaultBranch) { + try { + await this.manager.repository.checkout(this._repoDefaults.base); + } catch (e) { + if (e.gitErrorCode === GitErrorCodes.DirtyWorkTree) { + vscode.window.showErrorMessage( + vscode.l10n.t('Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches'), + ); + } + throw e; + } } this._onDidChangeCurrentIssueState.fire(); this.dispose(); @@ -112,19 +124,18 @@ export class CurrentIssue { return `${user}/issue${this.issueModel.number}`; } - private async branchExists(branch: string): Promise { + private async getBranch(branch: string): Promise { try { - const repoBranch = await this.manager.repository.getBranch(branch); - return !!repoBranch; + return await this.manager.repository.getBranch(branch); } catch (e) { // branch doesn't exist } - return false; + return undefined; } private async createOrCheckoutBranch(branch: string): Promise { try { - if (await this.branchExists(branch)) { + if (await this.getBranch(branch)) { await this.manager.repository.checkout(branch); } else { await this.manager.repository.createBranch(branch, true); @@ -149,7 +160,7 @@ export class CurrentIssue { private async getBranchTitle(): Promise { return ( - vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(BRANCH_NAME_CONFIGURATION) ?? + vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(ISSUE_BRANCH_TITLE) ?? this.getBasicBranchName(await this.getUser()) ); } @@ -158,7 +169,7 @@ export class CurrentIssue { const VALID_BRANCH_CHARACTERS = /[^ \\@\~\^\?\*\[]+/; const match = branch.match(VALID_BRANCH_CHARACTERS); if (match && match.length > 0 && match[0] !== branch) { - return 'Branch name cannot contain a space or the following characters: \\@~^?*['; + return vscode.l10n.t('Branch name cannot contain a space or the following characters: \\@~^?*['); } return undefined; } @@ -169,16 +180,36 @@ export class CurrentIssue { if (result === editSetting) { vscode.commands.executeCommand( 'workbench.action.openSettings', - `${ISSUES_CONFIGURATION}.${BRANCH_NAME_CONFIGURATION}`, + `${ISSUES_SETTINGS_NAMESPACE}.${ISSUE_BRANCH_TITLE}`, ); } }); } - private async createIssueBranch(): Promise { + private async offerNewBranch(branch: Branch, branchNameConfig: string, branchNameMatch: RegExpMatchArray | null | undefined): Promise { + // Check if this branch has a merged PR associated with it. + // If so, offer to create a new branch. + const pr = await this.manager.getMatchingPullRequestMetadataFromGitHub(branch, branch.upstream?.remote, branch.upstream?.name); + if (pr && (pr.model.state !== GithubItemStateEnum.Open)) { + const mergedMessage = vscode.l10n.t('The pull request for {0} has been merged. Do you want to create a new branch?', branch.name ?? 'unknown branch'); + const closedMessage = vscode.l10n.t('The pull request for {0} has been closed. Do you want to create a new branch?', branch.name ?? 'unknown branch'); + const createBranch = vscode.l10n.t('Create New Branch'); + const createNew = await vscode.window.showInformationMessage(pr.model.state === GithubItemStateEnum.Merged ? mergedMessage : closedMessage, + { + modal: true + }, createBranch); + if (createNew === createBranch) { + const number = (branchNameMatch?.length === 4 ? (Number(branchNameMatch[3]) + 1) : 1); + return `${branchNameConfig}_${number}`; + } + } + return branchNameConfig; + } + + private async createIssueBranch(silent: boolean): Promise { const createBranchConfig = this.shouldPromptForBranch ? 'prompt' - : vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(BRANCH_CONFIGURATION); + : vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(USE_BRANCH_FOR_ISSUES); if (createBranchConfig === 'off') { return true; } @@ -190,16 +221,21 @@ export class CurrentIssue { undefined, await this.getUser(), ); - if ((createBranchConfig === 'on') && this._branchName !== branchNameConfig) { - const branchExists = await this.branchExists(this._branchName!); - if (!branchExists) { - this._branchName = branchNameConfig; + const branchNameMatch = this._branchName?.match(new RegExp('^(' + branchNameConfig + ')(_)?(\\d*)')); + if ((createBranchConfig === 'on')) { + const branch = await this.getBranch(this._branchName!); + if (!branch) { + if (!branchNameMatch) { + this._branchName = branchNameConfig; + } + } else if (!silent) { + this._branchName = await this.offerNewBranch(branch, branchNameConfig, branchNameMatch); } } if (!this._branchName) { this._branchName = await vscode.window.showInputBox({ value: branchNameConfig, - prompt: 'Enter the label for the new branch.', + prompt: vscode.l10n.t('Enter the label for the new branch.'), }); } if (!this._branchName) { @@ -223,7 +259,7 @@ export class CurrentIssue { } public async getCommitMessage(): Promise { - const configuration = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(SCM_MESSAGE_CONFIGURATION); + const configuration = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(WORKING_ISSUE_FORMAT_SCM); if (typeof configuration === 'string') { return variableSubstitution(configuration, this.issueModel, this._repoDefaults); } diff --git a/src/issues/issueCompletionProvider.ts b/src/issues/issueCompletionProvider.ts index e2c55c199a..c1685c351f 100644 --- a/src/issues/issueCompletionProvider.ts +++ b/src/issues/issueCompletionProvider.ts @@ -4,20 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { + IGNORE_COMPLETION_TRIGGER, + ISSUE_COMPLETION_FORMAT_SCM, + ISSUES_SETTINGS_NAMESPACE, +} from '../common/settingKeys'; import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; import { IMilestone } from '../github/interface'; import { IssueModel } from '../github/issueModel'; import { MilestoneModel } from '../github/milestoneModel'; import { RepositoriesManager } from '../github/repositoriesManager'; +import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; import { extractIssueOriginFromQuery, NEW_ISSUE_SCHEME } from './issueFile'; import { StateManager } from './stateManager'; import { - getIssueNumberLabel, getRootUriFromScmInputUri, isComment, issueMarkdown, - ISSUES_CONFIGURATION, - variableSubstitution, } from './util'; class IssueCompletionItem extends vscode.CompletionItem { @@ -31,7 +34,7 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { private stateManager: StateManager, private repositoriesManager: RepositoriesManager, private context: vscode.ExtensionContext, - ) {} + ) { } async provideCompletionItems( document: vscode.TextDocument, @@ -39,13 +42,25 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { token: vscode.CancellationToken, context: vscode.CompletionContext, ): Promise { + let wordRange = document.getWordRangeAtPosition(position); + let wordAtPos = wordRange ? document.getText(wordRange) : undefined; + if (!wordRange || wordAtPos?.charAt(0) !== '#') { + const start = wordRange?.start ?? position; + const testWordRange = new vscode.Range(start.translate(undefined, start.character ? -1 : 0), position); + const testWord = document.getText(testWordRange); + if (testWord.charAt(0) === '#') { + wordRange = testWordRange; + wordAtPos = testWord; + } + } + // If the suggest was not triggered by the trigger character, require that the previous character be the trigger character if ( document.languageId !== 'scminput' && document.uri.scheme !== 'comment' && position.character > 0 && context.triggerKind === vscode.CompletionTriggerKind.Invoke && - !document.getText(document.getWordRangeAtPosition(position)).match(/#[0-9]*$/) + !wordAtPos?.match(/#[0-9]*$/) ) { return []; } @@ -64,22 +79,40 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { if ( context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get('ignoreCompletionTrigger', []) + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(IGNORE_COMPLETION_TRIGGER, []) .find(value => value === document.languageId) ) { return []; } - if (document.languageId !== 'scminput' && !(await isComment(document, position))) { + if ((document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !(await isComment(document, position))) { return []; } let range: vscode.Range = new vscode.Range(position, position); if (position.character - 1 >= 0) { - const wordAtPos = document.getText(new vscode.Range(position.translate(0, -1), position)); - if (wordAtPos === '#') { - range = new vscode.Range(position.translate(0, -1), position); + if (wordRange && ((wordAtPos?.charAt(0) === '#') || (document.languageId === 'scminput') || (document.languageId === 'git-commit'))) { + range = wordRange; + } + } + + // Check for owner/repo preceding the # + let filterOwnerAndRepo: { owner: string; repo: string } | undefined; + if (wordAtPos === '#' && wordRange) { + if (wordRange.start.character >= 3) { + const ownerRepoRange = new vscode.Range( + wordRange.start.with(undefined, 0), + wordRange.start + ); + const ownerRepo = document.getText(ownerRepoRange); + const ownerRepoMatch = ownerRepo.match(/([^\s]+)\/([^\s]+)/); + if (ownerRepoMatch) { + filterOwnerAndRepo = { + owner: ownerRepoMatch[1], + repo: ownerRepoMatch[2], + }; + } } } @@ -101,9 +134,7 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { } else { uri = document.uri.scheme === NEW_ISSUE_SCHEME ? extractIssueOriginFromQuery(document.uri) ?? document.uri - : document.languageId === 'scminput' - ? getRootUriFromScmInputUri(document.uri) - : document.uri; + : document.uri; } if (!uri) { return []; @@ -139,6 +170,9 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { if (issuesOrMilestones[0] instanceof IssueModel) { let index = 0; for (const issue of issuesOrMilestones) { + if (filterOwnerAndRepo && ((issue as IssueModel).remote.owner !== filterOwnerAndRepo.owner || (issue as IssueModel).remote.repositoryName !== filterOwnerAndRepo.repo)) { + continue; + } completionItems.set( getIssueNumberLabel(issue as IssueModel), await this.completionItemFromIssue(repo, issue as IssueModel, now, range, document, index++, totalIssues), @@ -148,6 +182,9 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { for (let index = 0; index < issuesOrMilestones.length; index++) { const value: MilestoneModel = issuesOrMilestones[index] as MilestoneModel; for (const issue of value.issues) { + if (filterOwnerAndRepo && ((issue as IssueModel).remote.owner !== filterOwnerAndRepo.owner || (issue as IssueModel).remote.repositoryName !== filterOwnerAndRepo.repo)) { + continue; + } completionItems.set( getIssueNumberLabel(issue), await this.completionItemFromIssue( @@ -183,9 +220,9 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { item.insertText = `[${getIssueNumberLabel(issue, repo)}](${issue.html_url})`; } else { const configuration = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get('issueCompletionFormatScm'); - if (document.uri.path.match(/scm\/git\/scm\d\/input/) && typeof configuration === 'string') { + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(ISSUE_COMPLETION_FORMAT_SCM); + if (document.uri.path.match(/git\/scm\d\/input/) && typeof configuration === 'string') { item.insertText = await variableSubstitution(configuration, issue, repo); } else { item.insertText = `${getIssueNumberLabel(issue, repo)}`; @@ -207,7 +244,7 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider { item.documentation = await issueMarkdown(item.issue, this.context, this.repositoriesManager); item.command = { command: 'issues.issueCompletion', - title: 'Issue Completion Chose,', + title: vscode.l10n.t('Issue Completion Choose,'), }; } return item; diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index 4732cdc2bc..2c728264ee 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -5,49 +5,63 @@ import * as vscode from 'vscode'; import { GitApiImpl } from '../api/api1'; +import Logger from '../common/logger'; +import { + CREATE_INSERT_FORMAT, + ENABLED, + ISSUE_COMPLETIONS, + ISSUES_SETTINGS_NAMESPACE, + QUERIES, + USER_COMPLETIONS, +} from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; import { OctokitCommon } from '../github/common'; import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; import { IssueModel } from '../github/issueModel'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { getRepositoryForFile } from '../github/utils'; +import { getRepositoryForFile, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../github/utils'; import { ReviewManager } from '../view/reviewManager'; +import { ReviewsManager } from '../view/reviewsManager'; import { CurrentIssue } from './currentIssue'; import { IssueCompletionProvider } from './issueCompletionProvider'; import { ASSIGNEES, - extractIssueOriginFromQuery, + extractMetadataFromFile, IssueFileSystemProvider, - LabelCompletionProvider, LABELS, + MILESTONE, NEW_ISSUE_FILE, NEW_ISSUE_SCHEME, + NewIssueCache, + NewIssueFileCompletionProvider, } from './issueFile'; import { IssueHoverProvider } from './issueHoverProvider'; import { openCodeLink } from './issueLinkLookup'; -import { IssuesTreeData, IssueUriTreeItem } from './issuesView'; +import { IssuesTreeData, IssueUriTreeItem, updateExpandedQueries } from './issuesView'; import { IssueTodoProvider } from './issueTodoProvider'; +import { ShareProviderManager } from './shareProviders'; import { StateManager } from './stateManager'; import { UserCompletionProvider } from './userCompletionProvider'; import { UserHoverProvider } from './userHoverProvider'; import { createGitHubLink, createGithubPermalink, - ISSUES_CONFIGURATION, + getIssue, + IssueTemplate, + LinkContext, NewIssue, + PERMALINK_COMPONENT, PermalinkInfo, pushAndCreatePR, - QUERIES_CONFIGURATION, USER_EXPRESSION, } from './util'; -const ISSUE_COMPLETIONS_CONFIGURATION = 'issueCompletions.enabled'; -const USER_COMPLETIONS_CONFIGURATION = 'userCompletions.enabled'; - const CREATING_ISSUE_FROM_FILE_CONTEXT = 'issues.creatingFromFile'; export class IssueFeatureRegistrar implements vscode.Disposable { private _stateManager: StateManager; + private _newIssueCache: NewIssueCache; + private createIssueInfo: | { document: vscode.TextDocument; @@ -60,31 +74,33 @@ export class IssueFeatureRegistrar implements vscode.Disposable { constructor( private gitAPI: GitApiImpl, private manager: RepositoriesManager, - private reviewManagers: ReviewManager[], + private reviewsManager: ReviewsManager, private context: vscode.ExtensionContext, private telemetry: ITelemetry, ) { this._stateManager = new StateManager(gitAPI, this.manager, this.context); + this._newIssueCache = new NewIssueCache(context); } async initialize() { this.context.subscriptions.push( - vscode.workspace.registerFileSystemProvider(NEW_ISSUE_SCHEME, new IssueFileSystemProvider()), + vscode.workspace.registerFileSystemProvider(NEW_ISSUE_SCHEME, new IssueFileSystemProvider(this._newIssueCache)), ); this.context.subscriptions.push( vscode.languages.registerCompletionItemProvider( { scheme: NEW_ISSUE_SCHEME }, - new LabelCompletionProvider(this.manager), + new NewIssueFileCompletionProvider(this.manager), ' ', ',', ), ); - this.context.subscriptions.push( - vscode.window.createTreeView('issues:github', { - showCollapseAll: true, - treeDataProvider: new IssuesTreeData(this._stateManager, this.manager, this.context), - }), - ); + const view = vscode.window.createTreeView('issues:github', { + showCollapseAll: true, + treeDataProvider: new IssuesTreeData(this._stateManager, this.manager, this.context), + }); + this.context.subscriptions.push(view); + this.context.subscriptions.push(view.onDidCollapseElement(e => updateExpandedQueries(this.context, e.element, false))); + this.context.subscriptions.push(view.onDidExpandElement(e => updateExpandedQueries(this.context, e.element, true))); this.context.subscriptions.push( vscode.commands.registerCommand( 'issue.createIssueFromSelection', @@ -114,12 +130,12 @@ export class IssueFeatureRegistrar implements vscode.Disposable { this.context.subscriptions.push( vscode.commands.registerCommand( 'issue.copyGithubPermalink', - (fileUri: any) => { + (context: LinkContext) => { /* __GDPR__ "issue.copyGithubPermalink" : {} */ this.telemetry.sendTelemetryEvent('issue.copyGithubPermalink'); - return this.copyPermalink(fileUri instanceof vscode.Uri ? fileUri : undefined); + return this.copyPermalink(this.manager, context); }, this, ), @@ -137,15 +153,93 @@ export class IssueFeatureRegistrar implements vscode.Disposable { this, ), ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubPermalinkWithoutRange', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyGithubPermalinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubPermalinkWithoutRange'); + return this.copyPermalink(this.manager, context, false); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubHeadLinkWithoutRange', + (fileUri: any) => { + /* __GDPR__ + "issue.copyGithubHeadLinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubHeadLinkWithoutRange'); + return this.copyHeadLink(fileUri, false); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubDevLinkWithoutRange', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyGithubDevLinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubDevLinkWithoutRange'); + return this.copyPermalink(this.manager, context, false, true, true); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubDevLink', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyGithubDevLink" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubDevLink'); + return this.copyPermalink(this.manager, context, true, true, true); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyGithubDevLinkFile', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyGithubDevLinkFile" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyGithubDevLinkFile'); + return this.copyPermalink(this.manager, context, false, true, true); + }, + this, + ), + ); this.context.subscriptions.push( vscode.commands.registerCommand( 'issue.copyMarkdownGithubPermalink', - () => { + (context: LinkContext) => { /* __GDPR__ "issue.copyMarkdownGithubPermalink" : {} */ this.telemetry.sendTelemetryEvent('issue.copyMarkdownGithubPermalink'); - return this.copyMarkdownPermalink(); + return this.copyMarkdownPermalink(this.manager, context); + }, + this, + ), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand( + 'issue.copyMarkdownGithubPermalinkWithoutRange', + (context: LinkContext) => { + /* __GDPR__ + "issue.copyMarkdownGithubPermalinkWithoutRange" : {} + */ + this.telemetry.sendTelemetryEvent('issue.copyMarkdownGithubPermalinkWithoutRange'); + return this.copyMarkdownPermalink(this.manager, context, false); }, this, ), @@ -158,11 +252,12 @@ export class IssueFeatureRegistrar implements vscode.Disposable { "issue.openGithubPermalink" : {} */ this.telemetry.sendTelemetryEvent('issue.openGithubPermalink'); - return this.openPermalink(); + return this.openPermalink(this.manager); }, this, ), ); + this.context.subscriptions.push(new ShareProviderManager(this.manager, this.gitAPI)); this.context.subscriptions.push( vscode.commands.registerCommand('issue.openIssue', (issueModel: any) => { /* __GDPR__ @@ -416,6 +511,7 @@ export class IssueFeatureRegistrar implements vscode.Disposable { { language: 'razor' }, { language: 'ruby' }, { language: 'rust' }, + { language: 'scminput' }, { language: 'scss' }, { language: 'search-result' }, { language: 'shaderlab' }, @@ -442,6 +538,7 @@ export class IssueFeatureRegistrar implements vscode.Disposable { { language: 'groovy' }, { language: 'handlebars' }, { language: 'hlsl' }, + { language: 'html' }, { language: 'ini' }, { language: 'java' }, { language: 'javascriptreact' }, @@ -456,6 +553,8 @@ export class IssueFeatureRegistrar implements vscode.Disposable { { language: 'objective-c' }, { language: 'perl' }, { language: 'perl6' }, + { language: 'typescriptreact' }, + { language: 'yml' }, '*', ]; private registerCompletionProviders() { @@ -469,17 +568,17 @@ export class IssueFeatureRegistrar implements vscode.Disposable { provider: IssueCompletionProvider, trigger: '#', disposable: undefined, - configuration: ISSUE_COMPLETIONS_CONFIGURATION, + configuration: `${ISSUE_COMPLETIONS}.${ENABLED}`, }, { provider: UserCompletionProvider, trigger: '@', disposable: undefined, - configuration: USER_COMPLETIONS_CONFIGURATION, + configuration: `${USER_COMPLETIONS}.${ENABLED}`, }, ]; for (const element of providers) { - if (vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get(element.configuration, true)) { + if (vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(element.configuration, true)) { this.context.subscriptions.push( (element.disposable = vscode.languages.registerCompletionItemProvider( this.documentFilters, @@ -492,9 +591,9 @@ export class IssueFeatureRegistrar implements vscode.Disposable { this.context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(change => { for (const element of providers) { - if (change.affectsConfiguration(`${ISSUES_CONFIGURATION}.${element.configuration}`)) { + if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${element.configuration}`)) { const newValue: boolean = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) .get(element.configuration, true); if (!newValue && element.disposable) { element.disposable.dispose(); @@ -517,99 +616,58 @@ export class IssueFeatureRegistrar implements vscode.Disposable { async createIssue() { let uri = vscode.window.activeTextEditor?.document.uri; - if (!uri) { - uri = (await this.chooseRepo('Select the repo to create the issue in.'))?.repository.rootUri; - } - if (uri) { - return this.makeNewIssueFile(uri); + let folderManager: FolderRepositoryManager | undefined = uri ? this.manager.getManagerForFile(uri) : undefined; + if (!folderManager) { + folderManager = await this.chooseRepo(vscode.l10n.t('Select the repo to create the issue in.')); + uri = folderManager?.repository.rootUri; } - } - - async createIssueFromFile() { - let text: string; - if ( - !vscode.window.activeTextEditor || - vscode.window.activeTextEditor.document.uri.scheme !== NEW_ISSUE_SCHEME - ) { + if (!folderManager || !uri) { return; } - text = vscode.window.activeTextEditor.document.getText(); - const indexOfEmptyLineWindows = text.indexOf('\r\n\r\n'); - const indexOfEmptyLineOther = text.indexOf('\n\n'); - let indexOfEmptyLine: number; - if (indexOfEmptyLineWindows < 0 && indexOfEmptyLineOther < 0) { - return; + + const template = await this.chooseTemplate(folderManager); + this._newIssueCache.clear(); + if (template) { + this.makeNewIssueFile(uri, template.title, template.body); } else { - if (indexOfEmptyLineWindows < 0) { - indexOfEmptyLine = indexOfEmptyLineOther; - } else if (indexOfEmptyLineOther < 0) { - indexOfEmptyLine = indexOfEmptyLineWindows; - } else { - indexOfEmptyLine = Math.min(indexOfEmptyLineWindows, indexOfEmptyLineOther); - } - } - const title = text.substring(0, indexOfEmptyLine); - let assignees: string[] | undefined; - text = text.substring(indexOfEmptyLine + 2).trim(); - if (text.startsWith(ASSIGNEES)) { - const lines = text.split(/\r\n|\n/, 1); - if (lines.length === 1) { - assignees = lines[0] - .substring(ASSIGNEES.length) - .split(',') - .map(value => { - value = value.trim(); - if (value.startsWith('@')) { - value = value.substring(1); - } - return value; - }); - text = text.substring(lines[0].length).trim(); - } - } - let labels: string[] | undefined; - if (text.startsWith(LABELS)) { - const lines = text.split(/\r\n|\n/, 1); - if (lines.length === 1) { - labels = lines[0] - .substring(LABELS.length) - .split(',') - .map(value => value.trim()) - .filter(label => label); - text = text.substring(lines[0].length).trim(); - } + this.makeNewIssueFile(uri); } - const body = text; - if (!title || !body) { + } + + async createIssueFromFile() { + const metadata = await extractMetadataFromFile(this.manager); + if (!metadata || !vscode.window.activeTextEditor) { return; } const createSucceeded = await this.doCreateIssue( this.createIssueInfo?.document, this.createIssueInfo?.newIssue, - title, - body, - assignees, - labels, + metadata.title, + metadata.body, + metadata.assignees, + metadata.labels, + metadata.milestone, this.createIssueInfo?.lineNumber, this.createIssueInfo?.insertIndex, - extractIssueOriginFromQuery(vscode.window.activeTextEditor.document.uri), + metadata.originUri ); this.createIssueInfo = undefined; if (createSucceeded) { await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + this._newIssueCache.clear(); } } async editQuery(query: IssueUriTreeItem) { - const config = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION); - const inspect = config.inspect<{ label: string; query: string }[]>(QUERIES_CONFIGURATION); + const config = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE, null); + const inspect = config.inspect<{ label: string; query: string }[]>(QUERIES); let command: string; if (inspect?.workspaceValue) { command = 'workbench.action.openWorkspaceSettingsFile'; } else { - const value = config.get<{ label: string; query: string }[]>(QUERIES_CONFIGURATION); + const value = config.get<{ label: string; query: string }[]>(QUERIES); if (inspect?.defaultValue && JSON.stringify(inspect?.defaultValue) === JSON.stringify(value)) { - config.update(QUERIES_CONFIGURATION, inspect.defaultValue, vscode.ConfigurationTarget.Global); + config.update(QUERIES, inspect.defaultValue, vscode.ConfigurationTarget.Global); } command = 'workbench.action.openSettingsJson'; } @@ -665,7 +723,7 @@ export class IssueFeatureRegistrar implements vscode.Disposable { let githubRepository = issueModel.githubRepository; let remote = issueModel.remote; if (!repoManager) { - repoManager = await this.chooseRepo('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 isssue in.')); if (!repoManager) { return; } @@ -673,7 +731,7 @@ export class IssueFeatureRegistrar implements vscode.Disposable { remote = githubRepository.remote; } - const remoteNameResult = await repoManager.findUpstreamForItem({githubRepository, remote}); + const remoteNameResult = await repoManager.findUpstreamForItem({ githubRepository, remote }); if (remoteNameResult.needsFork) { if ((await repoManager.tryOfferToFork(githubRepository)) === undefined) { return; @@ -683,14 +741,25 @@ export class IssueFeatureRegistrar implements vscode.Disposable { await this._stateManager.setCurrentIssue( repoManager, new CurrentIssue(issueModel, repoManager, this._stateManager, remoteNameResult.remote, needsBranchPrompt), + true ); } async startWorking(issue: any) { - if (!(issue instanceof IssueModel)) { - return; + if (issue instanceof IssueModel) { + return this.doStartWorking(this.manager.getManagerForIssueModel(issue), issue); + } else if (issue instanceof vscode.Uri) { + const match = issue.toString().match(ISSUE_OR_URL_EXPRESSION); + const parsed = parseIssueExpressionOutput(match); + const folderManager = this.manager.folderManagers.find(folderManager => + folderManager.gitHubRepositories.find(repo => repo.remote.owner === parsed?.owner && repo.remote.repositoryName === parsed.name)); + if (parsed && folderManager) { + const issueModel = await getIssue(this._stateManager, folderManager, issue.toString(), parsed); + if (issueModel) { + return this.doStartWorking(folderManager, issueModel); + } + } } - this.doStartWorking(this.manager.getManagerForIssueModel(issue), issue); } async startWorkingBranchPrompt(issueModel: any) { @@ -703,7 +772,7 @@ export class IssueFeatureRegistrar implements vscode.Disposable { async stopWorking(issueModel: any) { let folderManager = this.manager.getManagerForIssueModel(issueModel); if (!folderManager) { - folderManager = await this.chooseRepo('Choose which repository you want to stop working on this issue in.'); + folderManager = await this.chooseRepo(vscode.l10n.t('Choose which repository you want to stop working on this issue in.')); if (!folderManager) { return; } @@ -712,33 +781,33 @@ export class IssueFeatureRegistrar implements vscode.Disposable { issueModel instanceof IssueModel && this._stateManager.currentIssue(folderManager.repository.rootUri)?.issue.number === issueModel.number ) { - await this._stateManager.setCurrentIssue(folderManager, undefined); + await this._stateManager.setCurrentIssue(folderManager, undefined, true); } } private async statusBarActions(currentIssue: CurrentIssue) { - const openIssueText: string = `$(globe) Open #${currentIssue.issue.number} ${currentIssue.issue.title}`; - const pullRequestText: string = `$(git-pull-request) Create pull request for #${currentIssue.issue.number} (pushes branch)`; + const openIssueText: string = vscode.l10n.t('{0} Open #{1} {2}', '$(globe)', currentIssue.issue.number, currentIssue.issue.title); + const pullRequestText: string = vscode.l10n.t({ message: '{0} Create pull request for #{1} (pushes branch)', args: ['$(git-pull-request)', currentIssue.issue.number], comment: ['The first placeholder is an icon and shouldn\'t be localized', 'The second placeholder is the ID number of a GitHub Issue.'] }); let defaults: PullRequestDefaults | undefined; try { defaults = await currentIssue.manager.getPullRequestDefaults(); } catch (e) { // leave defaults undefined } - const stopWorkingText: string = `$(primitive-square) Stop working on #${currentIssue.issue.number}`; + const stopWorkingText: string = vscode.l10n.t('{0} Stop working on #{1}', '$(primitive-square)', currentIssue.issue.number); const choices = currentIssue.branchName && defaults ? [openIssueText, pullRequestText, stopWorkingText] : [openIssueText, pullRequestText, stopWorkingText]; const response: string | undefined = await vscode.window.showQuickPick(choices, { - placeHolder: 'Current issue options', + placeHolder: vscode.l10n.t('Current issue options'), }); switch (response) { case openIssueText: return this.openIssue(currentIssue.issue); case pullRequestText: { const reviewManager = ReviewManager.getReviewManagerForFolderManager( - this.reviewManagers, + this.reviewsManager.reviewManagers, currentIssue.manager, ); if (reviewManager) { @@ -747,7 +816,7 @@ export class IssueFeatureRegistrar implements vscode.Disposable { break; } case stopWorkingText: - return this._stateManager.setCurrentIssue(currentIssue.manager, undefined); + return this._stateManager.setCurrentIssue(currentIssue.manager, undefined, true); } } @@ -761,7 +830,7 @@ export class IssueFeatureRegistrar implements vscode.Disposable { } const choices: IssueChoice[] = currentIssues.map(currentIssue => { return { - label: `#${currentIssue.issue.number} from ${currentIssue.issue.githubRepository.remote.owner}/${currentIssue.issue.githubRepository.remote.repositoryName}`, + label: vscode.l10n.t('#{0} from {1}', currentIssue.issue.number, `${currentIssue.issue.githubRepository.remote.owner}/${currentIssue.issue.githubRepository.remote.repositoryName}`), currentIssue, }; }); @@ -810,7 +879,7 @@ export class IssueFeatureRegistrar implements vscode.Disposable { contents = `\`\`\`\n${newIssue.line}\n\`\`\`\n\n`; } } - contents += (await createGithubPermalink(this.gitAPI, newIssue)).permalink; + contents += (await createGithubPermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; return contents; } @@ -845,19 +914,19 @@ export class IssueFeatureRegistrar implements vscode.Disposable { const quickInput = vscode.window.createInputBox(); quickInput.value = titlePlaceholder ?? ''; quickInput.prompt = - 'Set the issue title. Confirm to create the issue now or use the edit button to edit the issue title and description.'; - quickInput.title = 'Create Issue'; + vscode.l10n.t('Set the issue title. Confirm to create the issue now or use the edit button to edit the issue title and description.'); + quickInput.title = vscode.l10n.t('Create Issue'); quickInput.buttons = [ { iconPath: new vscode.ThemeIcon('edit'), - tooltip: 'Edit Description', + tooltip: vscode.l10n.t('Edit Description'), }, ]; quickInput.onDidAccept(async () => { title = quickInput.value; if (title) { quickInput.busy = true; - await this.doCreateIssue(document, newIssue, title, body, assignee, undefined, lineNumber, insertIndex); + await this.doCreateIssue(document, newIssue, title, body, assignee, undefined, undefined, lineNumber, insertIndex); quickInput.busy = false; } quickInput.hide(); @@ -895,22 +964,25 @@ export class IssueFeatureRegistrar implements vscode.Disposable { const assigneeLine = `${ASSIGNEES} ${assignees && assignees.length > 0 ? assignees.map(value => '@' + value).join(', ') + ' ' : '' }`; const labelLine = `${LABELS} `; - const text = `${title ?? 'Issue Title'}\n + const milestoneLine = `${MILESTONE} `; + const cached = this._newIssueCache.get(); + const text = (cached && cached !== '') ? cached : `${title ?? vscode.l10n.t('Issue Title')}\n ${assigneeLine} -${labelLine}\n +${labelLine} +${milestoneLine}\n ${body ?? ''}\n -`; +`; await vscode.workspace.fs.writeFile(bodyPath, this.stringToUint8Array(text)); const assigneesDecoration = vscode.window.createTextEditorDecorationType({ after: { - contentText: ' Comma-separated usernames, either @username or just username.', + contentText: vscode.l10n.t(' Comma-separated usernames, either @username or just username.'), fontStyle: 'italic', color: new vscode.ThemeColor('issues.newIssueDecoration'), }, }); const labelsDecoration = vscode.window.createTextEditorDecorationType({ after: { - contentText: ' Comma-separated labels.', + contentText: vscode.l10n.t(' Comma-separated labels.'), fontStyle: 'italic', color: new vscode.ThemeColor('issues.newIssueDecoration'), }, @@ -968,12 +1040,12 @@ ${body ?? ''}\n }); if (newLabels.length > 0) { - const yes = 'Yes'; - const no = 'No'; + const yes = vscode.l10n.t('Yes'); + const no = vscode.l10n.t('No'); const promptResult = await vscode.window.showInformationMessage( - `The following labels don't exist in this repository: ${newLabels.join( + vscode.l10n.t('The following labels don\'t exist in this repository: {0}. \nDo you want to create these labels?', newLabels.join( ', ', - )}. \nDo you want to create these labels?`, + )), { modal: true }, yes, no, @@ -1018,6 +1090,57 @@ ${body ?? ''}\n return choice?.repo; } + private async chooseTemplate(folderManager: FolderRepositoryManager): Promise<{ title: string | undefined, body: string | undefined } | undefined> { + const templateUris = await folderManager.getIssueTemplates(); + if (templateUris.length === 0) { + return undefined; + } + + interface IssueChoice extends vscode.QuickPickItem { + template: IssueTemplate | undefined; + } + const templates = await Promise.all( + templateUris + .map(async uri => { + try { + const content = await vscode.workspace.fs.readFile(uri); + const text = new TextDecoder('utf-8').decode(content); + const template = this.getDataFromTemplate(text); + + return template; + } catch (e) { + Logger.warn(`Reading issue template failed: ${e}`); + return undefined; + } + }) + ); + const choices: IssueChoice[] = templates.filter(template => !!template && !!template?.name).map(template => { + return { + label: template!.name!, + description: template!.about, + template: template, + }; + }); + choices.push({ + label: vscode.l10n.t('Blank issue'), + template: undefined + }); + + const selectedTemplate = await vscode.window.showQuickPick(choices, { + placeHolder: vscode.l10n.t('Select a template for the new issue.'), + }); + + return selectedTemplate?.template; + } + + private getDataFromTemplate(template: string): IssueTemplate { + 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 body = template.match(/---([\s\S]*)---([\s\S]*)/)?.[2]; + return { title, name, about, body }; + } + private async doCreateIssue( document: vscode.TextDocument | undefined, newIssue: NewIssue | undefined, @@ -1025,6 +1148,7 @@ ${body ?? ''}\n issueBody: string | undefined, assignees: string[] | undefined, labels: string[] | undefined, + milestone: number | undefined, lineNumber: number | undefined, insertIndex: number | undefined, originUri?: vscode.Uri, @@ -1037,77 +1161,82 @@ ${body ?? ''}\n folderManager = this.manager.getManagerForFile(originUri); } if (!folderManager) { - folderManager = await this.chooseRepo('Choose where to create the issue.'); + folderManager = await this.chooseRepo(vscode.l10n.t('Choose where to create the issue.')); } - if (!folderManager) { - return false; - } - try { - origin = await folderManager.getPullRequestDefaults(); - } catch (e) { - // There is no remote - vscode.window.showErrorMessage("There is no remote. Can't create an issue."); - return false; - } - const body: string | undefined = - issueBody || newIssue?.document.isUntitled - ? issueBody - : (await createGithubPermalink(this.gitAPI, newIssue)).permalink; - const createParams: OctokitCommon.IssuesCreateParams = { - owner: origin.owner, - repo: origin.repo, - title, - body, - assignees, - labels, - }; - if (!(await this.verifyLabels(folderManager, createParams))) { - return false; - } - const issue = await folderManager.createIssue(createParams); - if (issue) { - if (document !== undefined && insertIndex !== undefined && lineNumber !== undefined) { - const edit: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); - const insertText: string = - vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get('createInsertFormat', 'number') === - 'number' - ? `#${issue.number}` - : issue.html_url; - edit.insert(document.uri, new vscode.Position(lineNumber, insertIndex), ` ${insertText}`); - await vscode.workspace.applyEdit(edit); - } else { - const copyIssueUrl = 'Copy URL'; - const openIssue = 'Open Issue'; - vscode.window.showInformationMessage('Issue created', copyIssueUrl, openIssue).then(async result => { - switch (result) { - case copyIssueUrl: - await vscode.env.clipboard.writeText(issue.html_url); - break; - case openIssue: - await vscode.env.openExternal(vscode.Uri.parse(issue.html_url)); - break; - } - }); + return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Creating issue') }, async (progress) => { + if (!folderManager) { + return false; } - this._stateManager.refreshCacheNeeded(); - return true; - } - return false; + progress.report({ message: vscode.l10n.t('Verifying that issue data is valid...') }); + try { + origin = await folderManager.getPullRequestDefaults(); + } catch (e) { + // There is no remote + vscode.window.showErrorMessage(vscode.l10n.t('There is no remote. Can\'t create an issue.')); + return false; + } + const body: string | undefined = + issueBody || newIssue?.document.isUntitled + ? issueBody + : (await createGithubPermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; + const createParams: OctokitCommon.IssuesCreateParams = { + owner: origin.owner, + repo: origin.repo, + title, + body, + assignees, + labels, + milestone + }; + if (!(await this.verifyLabels(folderManager, createParams))) { + return false; + } + progress.report({ message: vscode.l10n.t('Creating issue in {0}...', `${createParams.owner}/${createParams.repo}`) }); + const issue = await folderManager.createIssue(createParams); + if (issue) { + if (document !== undefined && insertIndex !== undefined && lineNumber !== undefined) { + const edit: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); + const insertText: string = + vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(CREATE_INSERT_FORMAT, 'number') === + 'number' + ? `#${issue.number}` + : issue.html_url; + edit.insert(document.uri, new vscode.Position(lineNumber, insertIndex), ` ${insertText}`); + await vscode.workspace.applyEdit(edit); + } else { + const copyIssueUrl = vscode.l10n.t('Copy Issue Link'); + const openIssue = vscode.l10n.t({ message: 'Open Issue', comment: 'Open the issue description in the browser to see it\'s full contents.' }); + vscode.window.showInformationMessage(vscode.l10n.t('Issue created'), copyIssueUrl, openIssue).then(async result => { + switch (result) { + case copyIssueUrl: + await vscode.env.clipboard.writeText(issue.html_url); + break; + case openIssue: + await vscode.env.openExternal(vscode.Uri.parse(issue.html_url)); + break; + } + }); + } + this._stateManager.refreshCacheNeeded(); + return true; + } + return false; + }); } - private async getPermalinkWithError(fileUri?: vscode.Uri): Promise { - const link = await createGithubPermalink(this.gitAPI, undefined, fileUri); + private async getPermalinkWithError(repositoriesManager: RepositoriesManager, includeRange: boolean, includeFile: boolean, context?: LinkContext): Promise { + const link = await createGithubPermalink(repositoriesManager, this.gitAPI, includeRange, includeFile, undefined, context); if (link.error) { - vscode.window.showWarningMessage(`Unable to create a GitHub permalink for the selection. ${link.error}`); + vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub permalink for the selection. {0}', link.error)); } return link; } - private async getHeadLinkWithError(fileUri?: vscode.Uri): Promise { - const link = await createGitHubLink(this.manager, fileUri); + private async getHeadLinkWithError(context?: vscode.Uri, includeRange?: boolean): Promise { + const link = await createGitHubLink(this.manager, context, includeRange); if (link.error) { - vscode.window.showWarningMessage(`Unable to create a GitHub link for the selection. ${link.error}`); + vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub link for the selection. {0}', link.error)); } return link; } @@ -1129,19 +1258,19 @@ ${body ?? ''}\n return linkUri.with({ authority, path: linkPath }).toString(); } - async copyPermalink(fileUri?: vscode.Uri) { - const link = await this.getPermalinkWithError(fileUri); + async copyPermalink(repositoriesManager: RepositoriesManager, context?: LinkContext, includeRange: boolean = true, includeFile: boolean = true, contextualizeLink: boolean = false) { + const link = await this.getPermalinkWithError(repositoriesManager, includeRange, includeFile, context); if (link.permalink) { - return vscode.env.clipboard.writeText( - link.originalFile ? (await this.getContextualizedLink(link.originalFile, link.permalink)) : link.permalink); + const contextualizedLink = contextualizeLink && link.originalFile ? await this.getContextualizedLink(link.originalFile, link.permalink) : link.permalink; + Logger.debug(`writing ${contextualizedLink} to the clipboard`, PERMALINK_COMPONENT); + return vscode.env.clipboard.writeText(contextualizedLink); } } - async copyHeadLink(fileUri?: vscode.Uri) { - const link = await this.getHeadLinkWithError(fileUri); + async copyHeadLink(fileUri?: vscode.Uri, includeRange = true) { + const link = await this.getHeadLinkWithError(fileUri, includeRange); if (link.permalink) { - return vscode.env.clipboard.writeText( - link.originalFile ? (await this.getContextualizedLink(link.originalFile, link.permalink)) : link.permalink); + return vscode.env.clipboard.writeText(link.permalink); } } @@ -1167,16 +1296,16 @@ ${body ?? ''}\n return undefined; } - async copyMarkdownPermalink() { - const link = await this.getPermalinkWithError(); + async copyMarkdownPermalink(repositoriesManager: RepositoriesManager, context: LinkContext, includeRange: boolean = true) { + const link = await this.getPermalinkWithError(repositoriesManager, includeRange, true, context); const selection = this.getMarkdownLinkText(); if (link.permalink && selection) { return vscode.env.clipboard.writeText(`[${selection.trim()}](${link.permalink})`); } } - async openPermalink() { - const link = await this.getPermalinkWithError(); + async openPermalink(repositoriesManager: RepositoriesManager) { + const link = await this.getPermalinkWithError(repositoriesManager, true, true); if (link.permalink) { return vscode.env.openExternal(vscode.Uri.parse(link.permalink)); } diff --git a/src/issues/issueFile.ts b/src/issues/issueFile.ts index 9544e2b59d..4003aed743 100644 --- a/src/issues/issueFile.ts +++ b/src/issues/issueFile.ts @@ -4,12 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; import { RepositoriesManager } from '../github/repositoriesManager'; export const NEW_ISSUE_SCHEME = 'newIssue'; export const NEW_ISSUE_FILE = 'NewIssue.md'; -export const ASSIGNEES = 'Assignees:'; -export const LABELS = 'Labels:'; +export const ASSIGNEES = vscode.l10n.t('Assignees:'); +export const LABELS = vscode.l10n.t('Labels:'); +export const MILESTONE = vscode.l10n.t('Milestone:'); + +const NEW_ISSUE_CACHE = 'newIssue.cache'; export function extractIssueOriginFromQuery(uri: vscode.Uri): vscode.Uri | undefined { const query = JSON.parse(uri.query); @@ -25,6 +29,8 @@ export class IssueFileSystemProvider implements vscode.FileSystemProvider { private _onDidChangeFile: vscode.EventEmitter = new vscode.EventEmitter< vscode.FileChangeEvent[] >(); + + constructor(private readonly cache: NewIssueCache) { } onDidChangeFile: vscode.Event = this._onDidChangeFile.event; watch(_uri: vscode.Uri, _options: { recursive: boolean; excludes: string[] }): vscode.Disposable { const disposable = this.onDidChangeFile(e => { @@ -45,7 +51,7 @@ export class IssueFileSystemProvider implements vscode.FileSystemProvider { readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] | Thenable<[string, vscode.FileType][]> { return []; } - createDirectory(_uri: vscode.Uri): void {} + createDirectory(_uri: vscode.Uri): void { } readFile(_uri: vscode.Uri): Uint8Array | Thenable { return this.content ?? new Uint8Array(0); } @@ -63,6 +69,7 @@ export class IssueFileSystemProvider implements vscode.FileSystemProvider { this.modifiedTime = new Date().getTime(); this._onDidChangeFile.fire([{ uri: uri, type: vscode.FileChangeType.Changed }]); } + this.cache.cache(content); } delete(uri: vscode.Uri, _options: { recursive: boolean }): void | Thenable { this.content = undefined; @@ -71,11 +78,11 @@ export class IssueFileSystemProvider implements vscode.FileSystemProvider { this._onDidChangeFile.fire([{ uri: uri, type: vscode.FileChangeType.Deleted }]); } - rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { overwrite: boolean }): void | Thenable {} + rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { overwrite: boolean }): void | Thenable { } } -export class LabelCompletionProvider implements vscode.CompletionItemProvider { - constructor(private manager: RepositoriesManager) {} +export class NewIssueFileCompletionProvider implements vscode.CompletionItemProvider { + constructor(private manager: RepositoriesManager) { } async provideCompletionItems( document: vscode.TextDocument, @@ -83,7 +90,8 @@ export class LabelCompletionProvider implements vscode.CompletionItemProvider { _token: vscode.CancellationToken, _context: vscode.CompletionContext, ): Promise { - if (!document.lineAt(position.line).text.startsWith(LABELS)) { + const line = document.lineAt(position.line).text; + if (!line.startsWith(LABELS) && !line.startsWith(MILESTONE)) { return []; } const originFile = extractIssueOriginFromQuery(document.uri); @@ -95,6 +103,17 @@ export class LabelCompletionProvider implements vscode.CompletionItemProvider { return []; } const defaults = await folderManager.getPullRequestDefaults(); + + if (line.startsWith(LABELS)) { + return this.provideLabelCompletionItems(folderManager, defaults); + } else if (line.startsWith(MILESTONE)) { + return this.provideMilestoneCompletionItems(folderManager); + } else { + return []; + } + } + + private async provideLabelCompletionItems(folderManager: FolderRepositoryManager, defaults: PullRequestDefaults): Promise { const labels = await folderManager.getLabels(undefined, defaults); return labels.map(label => { const item = new vscode.CompletionItem(label.name, vscode.CompletionItemKind.Color); @@ -103,4 +122,116 @@ export class LabelCompletionProvider implements vscode.CompletionItemProvider { return item; }); } + + private async provideMilestoneCompletionItems(folderManager: FolderRepositoryManager): Promise { + const milestones = await (await folderManager.getPullRequestDefaultRepo())?.getMilestones() ?? []; + return milestones.map(milestone => { + const item = new vscode.CompletionItem(milestone.title, vscode.CompletionItemKind.Event); + item.commitCharacters = [' ', ',']; + return item; + }); + } +} + +export class NewIssueCache { + constructor(private readonly context: vscode.ExtensionContext) { + this.clear(); + } + + public cache(issueFileContent: Uint8Array) { + this.context.workspaceState.update(NEW_ISSUE_CACHE, issueFileContent); + } + + public clear() { + this.context.workspaceState.update(NEW_ISSUE_CACHE, undefined); + } + + public get(): string | undefined { + const content = this.context.workspaceState.get(NEW_ISSUE_CACHE); + if (content) { + return new TextDecoder().decode(content); + } + } +} + +export async function extractMetadataFromFile(repositoriesManager: RepositoriesManager): Promise<{ labels: string[] | undefined, milestone: number | undefined, assignees: string[] | undefined, title: string, body: string | undefined, originUri: vscode.Uri } | undefined> { + let text: string; + if ( + !vscode.window.activeTextEditor || + vscode.window.activeTextEditor.document.uri.scheme !== NEW_ISSUE_SCHEME + ) { + return; + } + const originUri = extractIssueOriginFromQuery(vscode.window.activeTextEditor.document.uri); + if (!originUri) { + return; + } + const folderManager = repositoriesManager.getManagerForFile(originUri); + if (!folderManager) { + return; + } + const repo = await folderManager.getPullRequestDefaultRepo(); + text = vscode.window.activeTextEditor.document.getText(); + const indexOfEmptyLineWindows = text.indexOf('\r\n\r\n'); + const indexOfEmptyLineOther = text.indexOf('\n\n'); + let indexOfEmptyLine: number; + if (indexOfEmptyLineWindows < 0 && indexOfEmptyLineOther < 0) { + return; + } else { + if (indexOfEmptyLineWindows < 0) { + indexOfEmptyLine = indexOfEmptyLineOther; + } else if (indexOfEmptyLineOther < 0) { + indexOfEmptyLine = indexOfEmptyLineWindows; + } else { + indexOfEmptyLine = Math.min(indexOfEmptyLineWindows, indexOfEmptyLineOther); + } + } + const title = text.substring(0, indexOfEmptyLine); + if (!title) { + return; + } + let assignees: string[] | undefined; + text = text.substring(indexOfEmptyLine + 2).trim(); + if (text.startsWith(ASSIGNEES)) { + const lines = text.split(/\r\n|\n/, 1); + if (lines.length === 1) { + assignees = lines[0] + .substring(ASSIGNEES.length) + .split(',') + .map(value => { + value = value.trim(); + if (value.startsWith('@')) { + value = value.substring(1); + } + return value; + }); + text = text.substring(lines[0].length).trim(); + } + } + let labels: string[] | undefined; + if (text.startsWith(LABELS)) { + const lines = text.split(/\r\n|\n/, 1); + if (lines.length === 1) { + labels = lines[0] + .substring(LABELS.length) + .split(',') + .map(value => value.trim()) + .filter(label => label); + text = text.substring(lines[0].length).trim(); + } + } + let milestone: number | undefined; + if (text.startsWith(MILESTONE)) { + const lines = text.split(/\r\n|\n/, 1); + if (lines.length === 1) { + const milestoneTitle = lines[0].substring(MILESTONE.length).trim(); + if (milestoneTitle) { + const repoMilestones = await repo.getMilestones(); + milestone = repoMilestones?.find(milestone => milestone.title === milestoneTitle)?.number; + } + text = text.substring(lines[0].length).trim(); + } + } + const body = text ?? ''; + return { labels, milestone, assignees, title, body, originUri }; } diff --git a/src/issues/issueHoverProvider.ts b/src/issues/issueHoverProvider.ts index 284d07ff34..4092d13dc3 100644 --- a/src/issues/issueHoverProvider.ts +++ b/src/issues/issueHoverProvider.ts @@ -7,13 +7,11 @@ import * as vscode from 'vscode'; import { ITelemetry } from '../common/telemetry'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { RepositoriesManager } from '../github/repositoriesManager'; +import { ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; import { StateManager } from './stateManager'; import { getIssue, - ISSUE_OR_URL_EXPRESSION, issueMarkdown, - ParsedIssue, - parseIssueExpressionOutput, shouldShowHover, } from './util'; @@ -23,7 +21,7 @@ export class IssueHoverProvider implements vscode.HoverProvider { private stateManager: StateManager, private context: vscode.ExtensionContext, private telemetry: ITelemetry, - ) {} + ) { } async provideHover( document: vscode.TextDocument, @@ -52,7 +50,8 @@ export class IssueHoverProvider implements vscode.HoverProvider { if ( tryParsed && match && - tryParsed.issueNumber <= this.stateManager.maxIssueNumber(folderManager.repository.rootUri) + // Only check the max issue number if the owner/repo format isn't used here. + (tryParsed.owner || tryParsed.issueNumber <= this.stateManager.maxIssueNumber(folderManager.repository.rootUri)) ) { return this.createHover(folderManager, match[0], tryParsed, wordPosition); } diff --git a/src/issues/issueLinkLookup.ts b/src/issues/issueLinkLookup.ts index 4a23466120..f90468cd3d 100644 --- a/src/issues/issueLinkLookup.ts +++ b/src/issues/issueLinkLookup.ts @@ -38,7 +38,7 @@ export async function findCodeLinkLocally( let linkFolderManager: FolderRepositoryManager | undefined; for (const folderManager of repositoriesManager.folderManagers) { - const remotes = folderManager.getGitHubRemotes(); + const remotes = await folderManager.getGitHubRemotes(); for (const remote of remotes) { if ( owner.toLowerCase() === remote.owner.toLowerCase() && @@ -73,7 +73,7 @@ export async function findCodeLinkLocally( export async function openCodeLink(issueModel: IssueModel, repositoriesManager: RepositoriesManager) { const issueLink = findCodeLink(issueModel.body); if (!issueLink) { - vscode.window.showInformationMessage('Issue has no link.'); + vscode.window.showInformationMessage(vscode.l10n.t('Issue has no link.')); return; } const codeLink = await findCodeLinkLocally(issueLink, repositoriesManager, false); diff --git a/src/issues/issueLinkProvider.ts b/src/issues/issueLinkProvider.ts index 510a0d33b1..3410d1a837 100644 --- a/src/issues/issueLinkProvider.ts +++ b/src/issues/issueLinkProvider.ts @@ -3,16 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { EDITOR, WORD_WRAP } from '../common/settingKeys'; import { ReposManagerState } from '../github/folderRepositoryManager'; import { RepositoriesManager } from '../github/repositoriesManager'; +import { ISSUE_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; import { StateManager } from './stateManager'; import { getIssue, isComment, - ISSUE_EXPRESSION, MAX_LINE_LENGTH, - ParsedIssue, - parseIssueExpressionOutput, } from './util'; const MAX_LINE_COUNT = 2000; @@ -28,14 +27,14 @@ class IssueDocumentLink extends vscode.DocumentLink { } export class IssueLinkProvider implements vscode.DocumentLinkProvider { - constructor(private manager: RepositoriesManager, private stateManager: StateManager) {} + constructor(private manager: RepositoriesManager, private stateManager: StateManager) { } async provideDocumentLinks( document: vscode.TextDocument, _token: vscode.CancellationToken, ): Promise { const links: vscode.DocumentLink[] = []; - const wraps: boolean = vscode.workspace.getConfiguration('editor', document).get('wordWrap', 'off') !== 'off'; + 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; diff --git a/src/issues/issueTodoProvider.ts b/src/issues/issueTodoProvider.ts index dd5a05f3bb..8a79503487 100644 --- a/src/issues/issueTodoProvider.ts +++ b/src/issues/issueTodoProvider.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { ISSUE_OR_URL_EXPRESSION, ISSUES_CONFIGURATION, MAX_LINE_LENGTH } from './util'; +import { CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { ISSUE_OR_URL_EXPRESSION } from '../github/utils'; +import { MAX_LINE_LENGTH } from './util'; export class IssueTodoProvider implements vscode.CodeActionProvider { private expression: RegExp | undefined; @@ -19,7 +21,7 @@ export class IssueTodoProvider implements vscode.CodeActionProvider { } private updateTriggers() { - const triggers = vscode.workspace.getConfiguration(ISSUES_CONFIGURATION).get('createIssueTriggers', []); + const triggers = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(CREATE_ISSUE_TRIGGERS, []); this.expression = triggers.length > 0 ? new RegExp(triggers.join('|')) : undefined; } @@ -42,7 +44,7 @@ export class IssueTodoProvider implements vscode.CodeActionProvider { const search = truncatedLine.search(this.expression); if (search >= 0) { const codeAction: vscode.CodeAction = new vscode.CodeAction( - 'Create GitHub Issue', + vscode.l10n.t('Create GitHub Issue'), vscode.CodeActionKind.QuickFix, ); const indexOfWhiteSpace = truncatedLine.substring(search).search(/\s/); @@ -50,7 +52,7 @@ export class IssueTodoProvider implements vscode.CodeActionProvider { search + (indexOfWhiteSpace > 0 ? indexOfWhiteSpace : truncatedLine.match(this.expression)![0].length); codeAction.command = { - title: 'Create GitHub Issue', + title: vscode.l10n.t('Create GitHub Issue'), command: 'issue.createIssueFromSelection', arguments: [{ document, lineNumber, line, insertIndex, range }], }; diff --git a/src/issues/issuesView.ts b/src/issues/issuesView.ts index feb57f7179..52b1792af9 100644 --- a/src/issues/issuesView.ts +++ b/src/issues/issuesView.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; +import { commands, contexts } from '../common/executeCommands'; import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; import { IssueModel } from '../github/issueModel'; import { RepositoriesManager } from '../github/repositoriesManager'; @@ -62,19 +63,20 @@ export class IssuesTreeData let treeItem: IssueUriTreeItem; if (element instanceof IssueUriTreeItem) { treeItem = element; + treeItem.collapsibleState = getQueryExpandState(this.context, element, element.collapsibleState); } else if (element instanceof FolderRepositoryManager) { treeItem = new IssueUriTreeItem( element.repository.rootUri, path.basename(element.repository.rootUri.fsPath), - vscode.TreeItemCollapsibleState.Expanded, + getQueryExpandState(this.context, element, vscode.TreeItemCollapsibleState.Expanded) ); } else if (!(element instanceof IssueModel)) { treeItem = new IssueUriTreeItem( element.uri, element.milestone.title, - element.issues.length > 0 + getQueryExpandState(this.context, element, element.issues.length > 0 ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.None, + : vscode.TreeItemCollapsibleState.None) ); } else { treeItem = new IssueUriTreeItem( @@ -86,7 +88,7 @@ export class IssuesTreeData ? new vscode.ThemeIcon('issues', new vscode.ThemeColor('issues.open')) : new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('issues.closed')); if (this.stateManager.currentIssue(element.uri)?.issue.number === element.number) { - treeItem.label = `✓ ${treeItem.label!}`; + treeItem.label = `✓ ${treeItem.label as string}`; treeItem.contextValue = 'currentissue'; } else { const savedState = this.stateManager.getSavedIssueState(element.number); @@ -128,7 +130,8 @@ export class IssuesTreeData || !this.manager.folderManagers.length) { return []; } else { - return [new IssueUriTreeItem(undefined, 'Loading...')]; + commands.setContext(contexts.LOADING_ISSUES_TREE, true); + return []; } } @@ -179,3 +182,49 @@ export class IssuesTreeData } } } + +const EXPANDED_ISSUES_STATE = 'expandedIssuesState'; + +function expandStateId(element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | vscode.TreeItem | undefined) { + if (!element) { + return; + } + let id: string | undefined; + if (element instanceof IssueUriTreeItem) { + id = element.labelAsString; + } else if (element instanceof vscode.TreeItem) { + // No id needed + } else if (element instanceof FolderRepositoryManager) { + id = element.repository.rootUri.toString(); + } else if (!(element instanceof IssueModel)) { + id = element.milestone.title; + } + return id; +} + +export function updateExpandedQueries(context: vscode.ExtensionContext, element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | vscode.TreeItem | undefined, isExpanded: boolean) { + const id = expandStateId(element); + + if (id) { + const expandedQueries = new Set(context.workspaceState.get(EXPANDED_ISSUES_STATE, []) as string[]); + if (isExpanded) { + expandedQueries.add(id); + } else { + expandedQueries.delete(id); + } + context.workspaceState.update(EXPANDED_ISSUES_STATE, Array.from(expandedQueries.keys())); + } +} + +function getQueryExpandState(context: vscode.ExtensionContext, element: FolderRepositoryManager | IssueItem | MilestoneItem | IssueUriTreeItem | undefined, defaultState: vscode.TreeItemCollapsibleState = vscode.TreeItemCollapsibleState.Collapsed): vscode.TreeItemCollapsibleState { + const id = expandStateId(element); + if (id) { + const savedValue = context.workspaceState.get(EXPANDED_ISSUES_STATE); + if (!savedValue) { + return defaultState; + } + const expandedQueries = new Set(savedValue as string[]); + return expandedQueries.has(id) ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed; + } + return vscode.TreeItemCollapsibleState.None; +} diff --git a/src/issues/shareProviders.ts b/src/issues/shareProviders.ts new file mode 100644 index 0000000000..26e7d55a04 --- /dev/null +++ b/src/issues/shareProviders.ts @@ -0,0 +1,283 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as pathLib from 'path'; +import * as vscode from 'vscode'; +import { Commit, Remote, Repository } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import { fromReviewUri, Schemes } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { encodeURIComponentExceptSlashes, getBestPossibleUpstream, getOwnerAndRepo, getSimpleUpstream, getUpstreamOrigin, rangeString } from './util'; + +export class ShareProviderManager implements vscode.Disposable { + private disposables: vscode.Disposable[] = []; + + constructor(repositoryManager: RepositoriesManager, gitAPI: GitApiImpl) { + if (!vscode.window.registerShareProvider) { + return; + } + + this.disposables.push( + new GitHubDevShareProvider(repositoryManager, gitAPI), + new GitHubPermalinkShareProvider(repositoryManager, gitAPI), + new GitHubPermalinkAsMarkdownShareProvider(repositoryManager, gitAPI), + new GitHubHeadLinkShareProvider(repositoryManager, gitAPI) + ); + } + + dispose() { + this.disposables.forEach((d) => d.dispose()); + } +} + +const supportedSchemes = [Schemes.File, Schemes.Review, Schemes.Pr, Schemes.VscodeVfs]; + +abstract class AbstractShareProvider implements vscode.Disposable, vscode.ShareProvider { + private disposables: vscode.Disposable[] = []; + protected shareProviderRegistrations: vscode.Disposable[] | undefined; + + constructor( + protected repositoryManager: RepositoriesManager, + protected gitAPI: GitApiImpl, + public readonly id: string, + public readonly label: string, + public readonly priority: number, + private readonly origin = 'github.com' + ) { + this.initialize(); + } + + public dispose() { + this.disposables.forEach((d) => d.dispose()); + this.shareProviderRegistrations?.map((d) => d.dispose()); + } + + private async initialize() { + if ((await this.hasGitHubRepositories()) && this.shouldRegister()) { + this.register(); + } + + this.disposables.push(this.repositoryManager.onDidLoadAnyRepositories(async () => { + if ((await this.hasGitHubRepositories()) && this.shouldRegister()) { + this.register(); + } + })); + + this.disposables.push(this.gitAPI.onDidCloseRepository(() => { + if (!this.hasGitHubRepositories()) { + this.unregister(); + } + })); + } + + private async hasGitHubRepositories() { + for (const folderManager of this.repositoryManager.folderManagers) { + if ((await folderManager.computeAllGitHubRemotes()).length) { + return true; + } + return false; + } + } + + private register() { + if (this.shareProviderRegistrations) { + return; + } + + this.shareProviderRegistrations = supportedSchemes.map((scheme) => vscode.window.registerShareProvider({ scheme }, this)); + } + + private unregister() { + this.shareProviderRegistrations?.map((d) => d.dispose()); + this.shareProviderRegistrations = undefined; + } + + protected abstract shouldRegister(): boolean; + protected abstract getBlob(folderManager: FolderRepositoryManager, uri: vscode.Uri): Promise; + protected abstract getUpstream(repository: Repository, commit: string): Promise; + + public async provideShare(item: vscode.ShareableItem): Promise { + // Get the blob + const folderManager = this.repositoryManager.getManagerForFile(item.resourceUri); + if (!folderManager) { + throw new Error(vscode.l10n.t('Current file does not belong to an open repository.')); + } + const blob = await this.getBlob(folderManager, item.resourceUri); + + // Get the upstream + const repository = folderManager.repository; + const remote = await this.getUpstream(repository, blob); + if (!remote || !remote.fetchUrl) { + throw new Error(vscode.l10n.t('The selection may not exist on any remote.')); + } + + const origin = getUpstreamOrigin(remote, this.origin).replace(/\/$/, ''); + const path = encodeURIComponentExceptSlashes(item.resourceUri.path.substring(repository.rootUri.path.length)); + const range = getRangeSegment(item); + + return vscode.Uri.parse([ + origin, + '/', + getOwnerAndRepo(this.repositoryManager, repository, { ...remote, fetchUrl: remote.fetchUrl }), + '/blob/', + blob, + path, + range + ].join('')); + } +} + +export class GitHubDevShareProvider extends AbstractShareProvider implements vscode.ShareProvider { + constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { + super(repositoryManager, gitApi, 'githubDevLink', vscode.l10n.t('Copy github.dev Link'), 10, 'github.dev'); + } + + protected shouldRegister(): boolean { + return vscode.env.appHost === 'github.dev'; + } + + protected async getBlob(folderManager: FolderRepositoryManager): Promise { + return getHEAD(folderManager); + } + + protected async getUpstream(repository: Repository): Promise { + return getSimpleUpstream(repository); + } +} + +export class GitHubPermalinkShareProvider extends AbstractShareProvider implements vscode.ShareProvider { + constructor( + repositoryManager: RepositoriesManager, + gitApi: GitApiImpl, + id: string = 'githubComPermalink', + label: string = vscode.l10n.t('Copy GitHub Permalink'), + priority: number = 11 + ) { + super(repositoryManager, gitApi, id, label, priority); + } + + protected shouldRegister() { + return true; + } + + protected async getBlob(folderManager: FolderRepositoryManager, uri: vscode.Uri): Promise { + let commit: Commit | undefined; + let commitHash: string | undefined; + if (uri.scheme === Schemes.Review) { + commitHash = fromReviewUri(uri.query).commit; + } + + if (!commitHash) { + const repository = folderManager.repository; + try { + const log = await repository.log({ maxEntries: 1, path: uri.fsPath }); + if (log.length === 0) { + throw new Error(vscode.l10n.t('No branch on a remote contains the most recent commit for the file.')); + } + // Now that we know that the file existed at some point in the repo, use the head commit to construct the URI. + if (repository.state.HEAD?.commit && (log[0].hash !== repository.state.HEAD?.commit)) { + commit = await repository.getCommit(repository.state.HEAD.commit); + } + if (!commit) { + commit = log[0]; + } + commitHash = commit.hash; + } catch (e) { + commitHash = repository.state.HEAD?.commit; + } + } + + if (commitHash) { + return commitHash; + } + + throw new Error(); + } + + protected async getUpstream(repository: Repository, commit: string): Promise { + return getBestPossibleUpstream(this.repositoryManager, repository, (await repository.getCommit(commit)).hash); + } +} + +export class GitHubPermalinkAsMarkdownShareProvider extends GitHubPermalinkShareProvider { + + constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { + super(repositoryManager, gitApi, 'githubComPermalinkAsMarkdown', vscode.l10n.t('Copy GitHub Permalink as Markdown'), 12); + } + + async provideShare(item: vscode.ShareableItem): Promise { + const link = await super.provideShare(item); + + const text = await this.getMarkdownLinkText(item); + if (link) { + return `[${text?.trim() ?? ''}](${link.toString()})`; + } + } + + private async getMarkdownLinkText(item: vscode.ShareableItem): Promise { + 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; + } +} + +export class GitHubHeadLinkShareProvider extends AbstractShareProvider implements vscode.ShareProvider { + constructor(repositoryManager: RepositoriesManager, gitApi: GitApiImpl) { + super(repositoryManager, gitApi, 'githubComHeadLink', vscode.l10n.t('Copy GitHub HEAD Link'), 13); + } + + protected shouldRegister() { + return true; + } + + protected async getBlob(folderManager: FolderRepositoryManager): Promise { + return getHEAD(folderManager); + } + + protected async getUpstream(repository: Repository): Promise { + return getSimpleUpstream(repository); + } +} + +function getRangeSegment(item: vscode.ShareableItem): string { + if (item.resourceUri.scheme === 'vscode-notebook-cell') { + // Do not return a range or selection fragment for notebooks + // since github.com and github.dev do not support notebook deeplinks + return ''; + } + + return rangeString(item.selection); +} + +async function getHEAD(folderManager: FolderRepositoryManager) { + let branchName = folderManager.repository.state.HEAD?.name; + if (!branchName) { + // Fall back to default branch name if we are not currently on a branch + const origin = await folderManager.getOrigin(); + const metadata = await origin.getMetadata(); + branchName = metadata.default_branch; + } + + return encodeURIComponentExceptSlashes(branchName); +} diff --git a/src/issues/stateManager.ts b/src/issues/stateManager.ts index baef20713d..2c0ba4335d 100644 --- a/src/issues/stateManager.ts +++ b/src/issues/stateManager.ts @@ -7,8 +7,15 @@ import LRUCache from 'lru-cache'; import * as vscode from 'vscode'; import { Repository } from '../api/api'; import { GitApiImpl } from '../api/api1'; +import { AuthProvider } from '../common/authentication'; import { parseRepositoryRemotes } from '../common/remote'; -import { AuthProvider } from '../github/credentials'; +import { + DEFAULT, + IGNORE_MILESTONES, + ISSUES_SETTINGS_NAMESPACE, + QUERIES, + USE_BRANCH_FOR_ISSUES, +} from '../common/settingKeys'; import { FolderRepositoryManager, NO_MILESTONE, @@ -19,15 +26,8 @@ import { IAccount } from '../github/interface'; import { IssueModel } from '../github/issueModel'; import { MilestoneModel } from '../github/milestoneModel'; import { RepositoriesManager } from '../github/repositoriesManager'; +import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; import { CurrentIssue } from './currentIssue'; -import { - BRANCH_CONFIGURATION, - DEFAULT_QUERY_CONFIGURATION, - getIssueNumberLabel, - ISSUES_CONFIGURATION, - QUERIES_CONFIGURATION, - variableSubstitution, -} from './util'; // TODO: make exclude from date words configurable const excludeFromDate: string[] = ['Recovery']; @@ -35,8 +35,6 @@ const CURRENT_ISSUE_KEY = 'currentIssue'; const ISSUES_KEY = 'issues'; -const IGNORE_MILESTONES_CONFIGURATION = 'ignoreMilestones'; - export interface IssueState { branch?: string; hasDraftPR?: boolean; @@ -51,7 +49,7 @@ interface IssuesState { branches: Record; } -const DEFAULT_QUERY_CONFIGURATION_VALUE = [{ label: 'My Issues', query: 'default' }]; +const DEFAULT_QUERY_CONFIGURATION_VALUE = [{ label: vscode.l10n.t('My Issues'), query: 'default' }]; export interface MilestoneItem extends MilestoneModel { uri: vscode.Uri; @@ -99,11 +97,7 @@ export class StateManager { readonly gitAPI: GitApiImpl, private manager: RepositoriesManager, private context: vscode.ExtensionContext, - ) { - manager.folderManagers.forEach(folderManager => { - this.context.subscriptions.push(folderManager.onDidChangeRepositories(() => this.refresh())); - }); - } + ) { } private getOrCreateSingleRepoState(uri: vscode.Uri, folderManager?: FolderRepositoryManager): SingleRepoState { let state = this._singleRepoStates.get(uri.path); @@ -168,10 +162,10 @@ export class StateManager { ) { if (newBranch) { if (state.folderManager) { - await that.setCurrentIssueFromBranch(state, newBranch); + await that.setCurrentIssueFromBranch(state, newBranch, true); } } else { - await that.setCurrentIssue(state, undefined); + await that.setCurrentIssue(state, undefined, true); } } state.lastHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; @@ -206,19 +200,19 @@ export class StateManager { private async doInitialize() { this.cleanIssueState(); this._queries = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get(QUERIES_CONFIGURATION, DEFAULT_QUERY_CONFIGURATION_VALUE); + .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) + .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); if (this._queries.length === 0) { this._queries = DEFAULT_QUERY_CONFIGURATION_VALUE; } this.context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(change => { - if (change.affectsConfiguration(`${ISSUES_CONFIGURATION}.${QUERIES_CONFIGURATION}`)) { + if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${QUERIES}`)) { this._queries = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get(QUERIES_CONFIGURATION, DEFAULT_QUERY_CONFIGURATION_VALUE); + .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) + .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); this._onRefreshCacheNeeded.fire(); - } else if (change.affectsConfiguration(`${ISSUES_CONFIGURATION}.${IGNORE_MILESTONES_CONFIGURATION}`)) { + } else if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${IGNORE_MILESTONES}`)) { this._onRefreshCacheNeeded.fire(); } }), @@ -230,7 +224,10 @@ export class StateManager { await this.refresh(); }), ); + for (const folderManager of this.manager.folderManagers) { + this.context.subscriptions.push(folderManager.onDidChangeRepositories(() => this.refresh())); + const singleRepoState: SingleRepoState = this.getOrCreateSingleRepoState( folderManager.repository.rootUri, folderManager, @@ -241,7 +238,7 @@ export class StateManager { this._singleRepoStates.set(folderManager.repository.rootUri.path, singleRepoState); const branch = folderManager.repository.state.HEAD?.name; if (!singleRepoState.currentIssue && branch) { - await this.setCurrentIssueFromBranch(singleRepoState, branch); + await this.setCurrentIssueFromBranch(singleRepoState, branch, true); } } } @@ -284,7 +281,7 @@ export class StateManager { } private async getCurrentUser(authProviderId: AuthProvider): Promise { - return this.manager.credentialStore.getCurrentUser(authProviderId)?.login; + return (await this.manager.credentialStore.getCurrentUser(authProviderId))?.login; } private async setAllIssueData() { @@ -298,7 +295,7 @@ export class StateManager { let user: string | undefined; for (const query of this._queries) { let items: Promise; - if (query.query === DEFAULT_QUERY_CONFIGURATION) { + if (query.query === DEFAULT) { items = this.setMilestones(folderManager); } else { if (!defaults) { @@ -310,13 +307,14 @@ export class StateManager { } if (!user) { const enterpriseRemotes = parseRepositoryRemotes(folderManager.repository).filter( - remote => remote.authProviderId === AuthProvider['github-enterprise'] + remote => remote.isEnterprise ); - user = await this.getCurrentUser(enterpriseRemotes.length ? AuthProvider['github-enterprise'] : AuthProvider.github); + user = await this.getCurrentUser(enterpriseRemotes.length ? AuthProvider.githubEnterprise : AuthProvider.github); } items = this.setIssues( folderManager, - await variableSubstitution(query.query, undefined, defaults, user), + // Do not resolve pull request defaults as they will get resolved in the query later per repository + await variableSubstitution(query.query, undefined, undefined, user), ); } singleRepoState.issueCollection.set(query.label, items); @@ -328,7 +326,7 @@ export class StateManager { private setIssues(folderManager: FolderRepositoryManager, query: string): Promise { return new Promise(async resolve => { - const issues = await folderManager.getIssues({ fetchNextPage: false }, query); + const issues = await folderManager.getIssues(query); this._onDidChangeIssueData.fire(); resolve( issues.items.map(item => { @@ -340,10 +338,10 @@ export class StateManager { }); } - private async setCurrentIssueFromBranch(singleRepoState: SingleRepoState, branchName: string) { + private async setCurrentIssueFromBranch(singleRepoState: SingleRepoState, branchName: string, silent: boolean = false) { const createBranchConfig = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get(BRANCH_CONFIGURATION); + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(USE_BRANCH_FOR_ISSUES); if (createBranchConfig === 'off') { return; } @@ -356,7 +354,7 @@ export class StateManager { return; } if (branchName === defaults.base) { - await this.setCurrentIssue(singleRepoState, undefined); + await this.setCurrentIssue(singleRepoState, undefined, false); return; } @@ -376,6 +374,8 @@ export class StateManager { await this.setCurrentIssue( singleRepoState, new CurrentIssue(issueModel, singleRepoState.folderManager, this), + false, + silent ); } return; @@ -387,9 +387,9 @@ export class StateManager { return new Promise(async resolve => { const now = new Date(); const skipMilestones: string[] = vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get(IGNORE_MILESTONES_CONFIGURATION, []); - const milestones = await folderManager.getMilestones( + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(IGNORE_MILESTONES, []); + const milestones = await folderManager.getMilestoneIssues( { fetchNextPage: false }, skipMilestones.indexOf(NO_MILESTONE) < 0, ); @@ -457,7 +457,7 @@ export class StateManager { } private isSettingIssue: boolean = false; - async setCurrentIssue(repoState: SingleRepoState | FolderRepositoryManager, issue: CurrentIssue | undefined) { + async setCurrentIssue(repoState: SingleRepoState | FolderRepositoryManager, issue: CurrentIssue | undefined, checkoutDefaultBranch: boolean, silent: boolean = false) { if (this.isSettingIssue && issue === undefined) { return; } @@ -474,13 +474,13 @@ export class StateManager { return; } if (repoState.currentIssue) { - await repoState.currentIssue.stopWorking(); + await repoState.currentIssue.stopWorking(checkoutDefaultBranch); } if (issue) { this.context.subscriptions.push(issue.onDidChangeCurrentIssueState(() => this.updateStatusBar())); } this.context.workspaceState.update(CURRENT_ISSUE_KEY, issue?.issue.number); - if (!issue || (await issue.startWorking())) { + if (!issue || (await issue.startWorking(silent))) { repoState.currentIssue = issue; this.updateStatusBar(); } @@ -505,12 +505,12 @@ export class StateManager { } if (shouldShowStatusBarItem && !this.statusBarItem) { this.statusBarItem = vscode.window.createStatusBarItem('github.issues.status', vscode.StatusBarAlignment.Left, 0); - this.statusBarItem.name = 'GitHub Active Issue'; + this.statusBarItem.name = vscode.l10n.t('GitHub Active Issue'); } const statusBarItem = this.statusBarItem!; - statusBarItem.text = `$(issues) Issue ${currentIssues + statusBarItem.text = vscode.l10n.t('{0} Issue {1}', '$(issues)', currentIssues .map(issue => getIssueNumberLabel(issue.issue, issue.repoDefaults)) - .join(', ')}`; + .join(', ')); statusBarItem.tooltip = currentIssues.map(issue => issue.issue.title).join(', '); statusBarItem.command = 'issue.statusBar'; statusBarItem.show(); diff --git a/src/issues/userCompletionProvider.ts b/src/issues/userCompletionProvider.ts index 91a0e90625..5188e80b18 100644 --- a/src/issues/userCompletionProvider.ts +++ b/src/issues/userCompletionProvider.ts @@ -3,14 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as path from 'path'; import * as vscode from 'vscode'; -import { User } from '../github/interface'; +import Logger from '../common/logger'; +import { IGNORE_USER_COMPLETION_TRIGGER, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { TimelineEvent } from '../common/timelineEvent'; +import { fromPRUri, Schemes } from '../common/uri'; +import { compareIgnoreCase } from '../common/utils'; +import { EXTENSION_ID } from '../constants'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { IAccount, User } from '../github/interface'; import { RepositoriesManager } from '../github/repositoriesManager'; +import { getRelatedUsersFromTimelineEvents } from '../github/utils'; import { ASSIGNEES, extractIssueOriginFromQuery, NEW_ISSUE_SCHEME } from './issueFile'; import { StateManager } from './stateManager'; -import { getRootUriFromScmInputUri, isComment, ISSUES_CONFIGURATION, UserCompletion, userMarkdown } from './util'; +import { getRootUriFromScmInputUri, isComment, UserCompletion, userMarkdown } from './util'; export class UserCompletionProvider implements vscode.CompletionItemProvider { + private static readonly ID: string = 'UserCompletionProvider'; + private _gitBlameCache: { [key: string]: string } = {}; + constructor( private stateManager: StateManager, private manager: RepositoriesManager, @@ -23,13 +35,24 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { token: vscode.CancellationToken, context: vscode.CompletionContext, ): Promise { + let wordRange = document.getWordRangeAtPosition(position); + let wordAtPos = wordRange ? document.getText(wordRange) : undefined; + if (!wordRange || wordAtPos?.charAt(0) !== '@') { + const start = wordRange?.start ?? position; + const testWordRange = new vscode.Range(start.translate(undefined, start.character ? -1 : 0), position); + const testWord = document.getText(testWordRange); + if (testWord.charAt(0) === '@') { + wordRange = testWordRange; + wordAtPos = testWord; + } + } // If the suggest was not triggered by the trigger character, require that the previous character be the trigger character if ( document.languageId !== 'scminput' && document.uri.scheme !== NEW_ISSUE_SCHEME && position.character > 0 && context.triggerKind === vscode.CompletionTriggerKind.Invoke && - document.getText(new vscode.Range(position.with(undefined, position.character - 1), position)) !== '@' + wordAtPos?.charAt(0) !== '@' ) { return []; } @@ -46,38 +69,43 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { if ( context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && vscode.workspace - .getConfiguration(ISSUES_CONFIGURATION) - .get('ignoreUserCompletionTrigger', []) + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(IGNORE_USER_COMPLETION_TRIGGER, []) .find(value => value === document.languageId) ) { return []; } - if (!this.isCodeownersFiles(document.uri) && document.languageId !== 'scminput' && !(await isComment(document, position))) { + if (!this.isCodeownersFiles(document.uri) && (document.languageId !== 'scminput') && (document.languageId !== 'git-commit') && !(await isComment(document, position))) { return []; } let range: vscode.Range = new vscode.Range(position, position); if (position.character - 1 >= 0) { - const wordAtPos = document.getText(new vscode.Range(position.translate(0, -1), position)); - if (wordAtPos === '@') { - range = new vscode.Range(position.translate(0, -1), position); + if (wordRange && wordAtPos?.charAt(0) === '@') { + range = wordRange; } } - const uri = - document.uri.scheme === NEW_ISSUE_SCHEME - ? extractIssueOriginFromQuery(document.uri) ?? document.uri - : document.languageId === 'scminput' - ? getRootUriFromScmInputUri(document.uri) - : document.uri; + + let uri: vscode.Uri | undefined = document.uri; + if (document.uri.scheme === NEW_ISSUE_SCHEME) { + uri = extractIssueOriginFromQuery(document.uri) ?? document.uri; + } else if (document.languageId === 'scminput') { + uri = getRootUriFromScmInputUri(document.uri); + } else if (document.uri.scheme === Schemes.Comment) { + const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab?.input; + uri = activeTab instanceof vscode.TabInputText ? activeTab.uri : (activeTab instanceof vscode.TabInputTextDiff ? activeTab.modified : undefined); + } + if (!uri) { return []; } const repoUri = this.manager.getManagerForFile(uri)?.repository.rootUri ?? uri; - const completionItems: vscode.CompletionItem[] = []; - (await this.stateManager.getUserMap(repoUri)).forEach(item => { + let completionItems: vscode.CompletionItem[] = []; + const userMap = await this.stateManager.getUserMap(repoUri); + userMap.forEach(item => { const completionItem: UserCompletion = new UserCompletion( { label: item.login, description: item.name }, vscode.CompletionItemKind.User); completionItem.insertText = `@${item.login}`; @@ -91,6 +119,10 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { } completionItems.push(completionItem); }); + const commentSpecificSuggestions = await this.getCommentSpecificSuggestions(userMap, document, position); + if (commentSpecificSuggestions) { + completionItems = completionItems.concat(commentSpecificSuggestions); + } return completionItems; } @@ -115,9 +147,188 @@ export class UserCompletionProvider implements vscode.CompletionItemProvider { item.documentation = userMarkdown(repo, user); item.command = { command: 'issues.userCompletion', - title: 'User Completion Chosen', + title: vscode.l10n.t('User Completion Chosen'), }; } return item; } + + private cachedPrUsers: UserCompletion[] = []; + private cachedPrTimelineEvents: TimelineEvent[] = []; + private cachedForPrNumber: number | undefined; + private async getCommentSpecificSuggestions( + alreadyIncludedUsers: Map, + document: vscode.TextDocument, + position: vscode.Position) { + try { + const query = JSON.parse(document.uri.query); + if ((document.uri.scheme !== Schemes.Comment) || compareIgnoreCase(query.extensionId, EXTENSION_ID) !== 0) { + return; + } + + const wordRange = document.getWordRangeAtPosition( + position, + /@([a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})?/i, + ); + if (!wordRange || wordRange.isEmpty) { + return; + } + const activeTextEditors = vscode.window.visibleTextEditors; + if (!activeTextEditors.length) { + return; + } + + let foundRepositoryManager: FolderRepositoryManager | undefined; + + let activeTextEditor: vscode.TextEditor | undefined; + let prNumber: number | undefined; + let remoteName: string | undefined; + + for (const editor of activeTextEditors) { + foundRepositoryManager = this.manager.getManagerForFile(editor.document.uri); + if (foundRepositoryManager) { + if (foundRepositoryManager.activePullRequest) { + prNumber = foundRepositoryManager.activePullRequest.number; + remoteName = foundRepositoryManager.activePullRequest.remote.remoteName; + break; + } else if (editor.document.uri.scheme === Schemes.Pr) { + const params = fromPRUri(editor.document.uri); + prNumber = params!.prNumber; + remoteName = params!.remoteName; + break; + } + } + } + + if (!foundRepositoryManager) { + return; + } + const repositoryManager = foundRepositoryManager; + + if (prNumber && prNumber === this.cachedForPrNumber) { + return this.cachedPrUsers; + } + + let prRelatedusers: { login: string; name?: string }[] = []; + const fileRelatedUsersNames: { [key: string]: boolean } = {}; + let mentionableUsers: { [key: string]: { login: string; name?: string }[] } = {}; + + const prRelatedUsersPromise = new Promise(async resolve => { + if (prNumber && remoteName) { + Logger.debug('get Timeline Events and parse users', UserCompletionProvider.ID); + if (this.cachedForPrNumber === prNumber) { + return this.cachedPrTimelineEvents; + } + + const githubRepo = repositoryManager.gitHubRepositories.find( + repo => repo.remote.remoteName === remoteName, + ); + + if (githubRepo) { + const pr = await githubRepo.getPullRequest(prNumber); + this.cachedForPrNumber = prNumber; + this.cachedPrTimelineEvents = await pr!.getTimelineEvents(); + } + + prRelatedusers = getRelatedUsersFromTimelineEvents(this.cachedPrTimelineEvents); + resolve(); + } + + resolve(); + }); + + const fileRelatedUsersNamesPromise = new Promise(async resolve => { + if (activeTextEditors.length) { + try { + Logger.debug('git blame and parse users', UserCompletionProvider.ID); + const fsPath = path.resolve(activeTextEditors[0].document.uri.fsPath); + let blames: string | undefined; + if (this._gitBlameCache[fsPath]) { + blames = this._gitBlameCache[fsPath]; + } else { + blames = await repositoryManager.repository.blame(fsPath); + this._gitBlameCache[fsPath] = blames; + } + + const blameLines = blames.split('\n'); + + for (const line of blameLines) { + const matches = /^\w{11} \S*\s*\((.*)\s*\d{4}\-/.exec(line); + + if (matches && matches.length === 2) { + const name = matches[1].trim(); + fileRelatedUsersNames[name] = true; + } + } + } catch (err) { + Logger.debug(err, UserCompletionProvider.ID); + } + } + + resolve(); + }); + + const getMentionableUsersPromise = new Promise(async resolve => { + Logger.debug('get mentionable users', UserCompletionProvider.ID); + mentionableUsers = await repositoryManager.getMentionableUsers(); + resolve(); + }); + + await Promise.all([ + prRelatedUsersPromise, + fileRelatedUsersNamesPromise, + getMentionableUsersPromise, + ]); + + this.cachedPrUsers = []; + const prRelatedUsersMap: { [key: string]: { login: string; name?: string } } = {}; + Logger.debug('prepare user suggestions', UserCompletionProvider.ID); + + prRelatedusers.forEach(user => { + if (!prRelatedUsersMap[user.login]) { + prRelatedUsersMap[user.login] = user; + } + }); + + const secondMap: { [key: string]: boolean } = {}; + + for (const mentionableUserGroup in mentionableUsers) { + for (const user of mentionableUsers[mentionableUserGroup]) { + if (!prRelatedUsersMap[user.login] && !secondMap[user.login] && !alreadyIncludedUsers.get(user.login)) { + secondMap[user.login] = true; + + let priority = 2; + if ( + fileRelatedUsersNames[user.login] || + (user.name && fileRelatedUsersNames[user.name]) + ) { + priority = 1; + } + + if (prRelatedUsersMap[user.login]) { + priority = 0; + } + + const completionItem: UserCompletion = new UserCompletion( + { label: user.login, description: user.name }, vscode.CompletionItemKind.User); + completionItem.insertText = `@${user.login}`; + completionItem.login = user.login; + completionItem.uri = repositoryManager.repository.rootUri; + completionItem.detail = user.name; + completionItem.filterText = `@ ${user.login} ${user.name}`; + completionItem.sortText = `${priority}_${user.login}`; + if (activeTextEditor?.document.uri.scheme === NEW_ISSUE_SCHEME) { + completionItem.commitCharacters = [' ', ',']; + } + this.cachedPrUsers.push(completionItem); + } + } + } + + Logger.debug('done', UserCompletionProvider.ID); + return this.cachedPrUsers; + } catch (e) { + return []; + } + } } diff --git a/src/issues/userHoverProvider.ts b/src/issues/userHoverProvider.ts index e2a377827f..9252ff8d14 100644 --- a/src/issues/userHoverProvider.ts +++ b/src/issues/userHoverProvider.ts @@ -5,15 +5,12 @@ import * as vscode from 'vscode'; import { ITelemetry } from '../common/telemetry'; +import { JSDOC_NON_USERS, PHPDOC_NON_USERS } from '../common/user'; import { RepositoriesManager } from '../github/repositoriesManager'; import { shouldShowHover, USER_EXPRESSION, userMarkdown } from './util'; - -// https://jsdoc.app/index.html -const JSDOC_NON_USERS = ['abstract', 'access', 'alias', 'async', 'augments', 'author', 'borrows', 'callback', 'class', 'classdesc', 'constant', 'constructs', 'copyright', 'default', 'deprecated', 'description', 'enum', 'event', 'example', 'exports', 'external', 'host', 'file', 'fires', 'function', 'generator', 'global', 'hideconstructor', 'ignore', 'implements', 'inheritdoc', 'inner', 'instance', 'interface', 'kind', 'lends', 'license', 'listens', 'member', 'memberof', 'mixes', 'mixin', 'module', 'name', 'namespace', 'override', 'package', 'param', 'private', 'property', 'protected', 'public', 'readonly', 'requires', 'returns', 'see', 'since', 'static', 'summary', 'this', 'throws', 'exception', 'todo', 'tutorial', 'type', 'typedef', 'variation', 'version', 'yields', 'yield', 'link']; - export class UserHoverProvider implements vscode.HoverProvider { - constructor(private manager: RepositoriesManager, private telemetry: ITelemetry) {} + constructor(private manager: RepositoriesManager, private telemetry: ITelemetry) { } async provideHover( document: vscode.TextDocument, @@ -39,6 +36,10 @@ export class UserHoverProvider implements vscode.HoverProvider { && JSDOC_NON_USERS.indexOf(username) >= 0) { return; } + // PHP doc checks + if ((document.languageId === 'php') && PHPDOC_NON_USERS.indexOf(username) >= 0) { + return; + } return this.createHover(document.uri, username, wordPosition); } } else { diff --git a/src/issues/util.ts b/src/issues/util.ts index 04944b782c..34aeda4896 100644 --- a/src/issues/util.ts +++ b/src/issues/util.ts @@ -1,814 +1,804 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { URL, URLSearchParams } from 'url'; -import LRUCache from 'lru-cache'; -import * as marked from 'marked'; -import * as vscode from 'vscode'; -import { Commit, Ref, Remote, Repository, UpstreamRef } from '../api/api'; -import { GitApiImpl } from '../api/api1'; -import { Protocol } from '../common/protocol'; -import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; -import { GithubItemStateEnum, User } from '../github/interface'; -import { IssueModel } from '../github/issueModel'; -import { PullRequestModel } from '../github/pullRequestModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { getEnterpriseUri, getRepositoryForFile } from '../github/utils'; -import { ReviewManager } from '../view/reviewManager'; -import { CODE_PERMALINK, findCodeLinkLocally } from './issueLinkLookup'; -import { StateManager } from './stateManager'; - -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)/; - -export const USER_EXPRESSION: RegExp = /\@([^\s]+)/; - -export const MAX_LINE_LENGTH = 150; - -export type ParsedIssue = { - owner: string | undefined; - name: string | undefined; - issueNumber: number; - commentNumber?: number; -}; -export const ISSUES_CONFIGURATION: string = 'githubIssues'; -export const QUERIES_CONFIGURATION = 'queries'; -export const DEFAULT_QUERY_CONFIGURATION = 'default'; -export const BRANCH_NAME_CONFIGURATION = 'issueBranchTitle'; -export const BRANCH_CONFIGURATION = 'useBranchForIssues'; -export const SCM_MESSAGE_CONFIGURATION = 'workingIssueFormatScm'; - -export function parseIssueExpressionOutput(output: RegExpMatchArray | null): ParsedIssue | undefined { - if (!output) { - return undefined; - } - const issue: ParsedIssue = { owner: undefined, name: undefined, issueNumber: 0 }; - if (output.length === 7) { - issue.owner = output[2]; - issue.name = output[3]; - issue.issueNumber = parseInt(output[5]); - return issue; - } else if (output.length === 16) { - issue.owner = output[3] || output[11]; - issue.name = output[4] || output[12]; - issue.issueNumber = parseInt(output[7] || output[14]); - issue.commentNumber = output[9] !== undefined ? parseInt(output[9]) : undefined; - return issue; - } else { - return undefined; - } -} - -export async function getIssue( - stateManager: StateManager, - manager: FolderRepositoryManager, - issueValue: string, - parsed: ParsedIssue, -): Promise { - const alreadyResolved = stateManager.resolvedIssues.get(manager.repository.rootUri.path)?.get(issueValue); - if (alreadyResolved) { - return alreadyResolved; - } else { - let owner: string | undefined = undefined; - let name: string | undefined = undefined; - let issueNumber: number | undefined = undefined; - const remotes = manager.getGitHubRemotes(); - for (const remote of remotes) { - if (!parsed) { - const tryParse = parseIssueExpressionOutput(issueValue.match(ISSUE_OR_URL_EXPRESSION)); - if (tryParse && (!tryParse.name || !tryParse.owner)) { - owner = remote.owner; - name = remote.repositoryName; - } - } else { - owner = parsed.owner ? parsed.owner : remote.owner; - name = parsed.name ? parsed.name : remote.repositoryName; - issueNumber = parsed.issueNumber; - } - - if (owner && name && issueNumber !== undefined) { - let issue = await manager.resolveIssue(owner, name, issueNumber, !!parsed.commentNumber); - if (!issue) { - issue = await manager.resolvePullRequest(owner, name, issueNumber); - } - if (issue) { - let cached: LRUCache; - if (!stateManager.resolvedIssues.has(manager.repository.rootUri.path)) { - stateManager.resolvedIssues.set( - manager.repository.rootUri.path, - (cached = new LRUCache(50)), - ); - } else { - cached = stateManager.resolvedIssues.get(manager.repository.rootUri.path)!; - } - cached.set(issueValue, issue); - return issue; - } - } - } - } - return undefined; -} - -function repoCommitDate(user: User, repoNameWithOwner: string): string | undefined { - let date: string | undefined = undefined; - user.commitContributions.forEach(element => { - if (repoNameWithOwner.toLowerCase() === element.repoNameWithOwner.toLowerCase()) { - date = element.createdAt.toLocaleString('default', { day: 'numeric', month: 'short', year: 'numeric' }); - } - }); - return date; -} - -export class UserCompletion extends vscode.CompletionItem { - login: string; - uri: vscode.Uri; -} - -export function userMarkdown(origin: PullRequestDefaults, user: User): vscode.MarkdownString { - const markdown: vscode.MarkdownString = new vscode.MarkdownString(undefined, true); - markdown.appendMarkdown( - `![Avatar](${user.avatarUrl}|height=50,width=50) **${user.name}** [${user.login}](${user.url})`, - ); - if (user.bio) { - markdown.appendText(' \r\n' + user.bio.replace(/\r\n/g, ' ')); - } - - const date = repoCommitDate(user, origin.owner + '/' + origin.repo); - if (user.location || date) { - markdown.appendMarkdown(' \r\n\r\n---'); - } - if (user.location) { - markdown.appendMarkdown(` \r\n$(location) ${user.location}`); - } - if (date) { - markdown.appendMarkdown(` \r\n$(git-commit) Committed to this repository on ${date}`); - } - if (user.company) { - markdown.appendMarkdown(` \r\n$(jersey) Member of ${user.company}`); - } - return markdown; -} - -function convertHexToRgb(hex: string): { r: number; g: number; b: number } | undefined { - const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result - ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - } - : undefined; -} - -function makeLabel(color: string, text: string): string { - const rgbColor = convertHexToRgb(color); - let textColor: string = 'ffffff'; - if (rgbColor) { - // Color algorithm from https://stackoverflow.com/questions/1855884/determine-font-color-based-on-background-color - const luminance = (0.299 * rgbColor.r + 0.587 * rgbColor.g + 0.114 * rgbColor.b) / 255; - if (luminance > 0.5) { - textColor = '000000'; - } - } - - return `  ${text}  `; -} - -async function findAndModifyString( - text: string, - find: RegExp, - transformer: (match: RegExpMatchArray) => Promise, -): Promise { - let searchResult = text.search(find); - let position = 0; - while (searchResult >= 0 && searchResult < text.length) { - let newBodyFirstPart: string | undefined; - if (searchResult === 0 || text.charAt(searchResult - 1) !== '&') { - const match = text.substring(searchResult).match(find)!; - if (match) { - const transformed = await transformer(match); - if (transformed) { - newBodyFirstPart = text.slice(0, searchResult) + transformed; - text = newBodyFirstPart + text.slice(searchResult + match[0].length); - } - } - } - position = newBodyFirstPart ? newBodyFirstPart.length : searchResult + 1; - const newSearchResult = text.substring(position).search(find); - searchResult = newSearchResult > 0 ? position + newSearchResult : newSearchResult; - } - return text; -} - -function findLinksInIssue(body: string, issue: IssueModel): Promise { - return findAndModifyString(body, ISSUE_OR_URL_EXPRESSION, async (match: RegExpMatchArray) => { - const tryParse = parseIssueExpressionOutput(match); - if (tryParse) { - const issueNumberLabel = getIssueNumberLabelFromParsed(tryParse); // get label before setting owner and name. - if (!tryParse.owner || !tryParse.name) { - tryParse.owner = issue.remote.owner; - tryParse.name = issue.remote.repositoryName; - } - return `[${issueNumberLabel}](https://github.com/${tryParse.owner}/${tryParse.name}/issues/${tryParse.issueNumber})`; - } - return undefined; - }); -} - -async function findCodeLinksInIssue(body: string, repositoriesManager: RepositoriesManager) { - return findAndModifyString(body, CODE_PERMALINK, async (match: RegExpMatchArray) => { - const codeLink = await findCodeLinkLocally(match, repositoriesManager); - if (codeLink) { - const textDocument = await vscode.workspace.openTextDocument(codeLink?.file); - const endingTextDocumentLine = textDocument.lineAt( - codeLink.end < textDocument.lineCount ? codeLink.end : textDocument.lineCount - 1, - ); - const query = [ - codeLink.file, - { - selection: { - start: { - line: codeLink.start, - character: 0, - }, - end: { - line: codeLink.end, - character: endingTextDocumentLine.text.length, - }, - }, - }, - ]; - const openCommand = vscode.Uri.parse(`command:vscode.open?${encodeURIComponent(JSON.stringify(query))}`); - return `[${match[0]}](${openCommand} "Open ${codeLink.file.fsPath}")`; - } - return undefined; - }); -} - -export const ISSUE_BODY_LENGTH: number = 200; -export async function issueMarkdown( - issue: IssueModel, - context: vscode.ExtensionContext, - repositoriesManager: RepositoriesManager, - commentNumber?: number, -): Promise { - const markdown: vscode.MarkdownString = new vscode.MarkdownString(undefined, true); - markdown.isTrusted = true; - const date = new Date(issue.createdAt); - const ownerName = `${issue.remote.owner}/${issue.remote.repositoryName}`; - markdown.appendMarkdown( - `[${ownerName}](https://github.com/${ownerName}) on ${date.toLocaleString('default', { - day: 'numeric', - month: 'short', - year: 'numeric', - })} \n`, - ); - const title = marked - .parse(issue.title, { - renderer: new PlainTextRenderer(), - }) - .trim(); - markdown.appendMarkdown( - `${getIconMarkdown(issue)} **${title}** [#${issue.number}](${issue.html_url}) \n`, - ); - let body = marked.parse(issue.body, { - renderer: new PlainTextRenderer(), - }); - markdown.appendMarkdown(' \n'); - body = body.length > ISSUE_BODY_LENGTH ? body.substr(0, ISSUE_BODY_LENGTH) + '...' : body; - body = await findLinksInIssue(body, issue); - body = await findCodeLinksInIssue(body, repositoriesManager); - - markdown.appendMarkdown(body + ' \n'); - markdown.appendMarkdown('  \n'); - - if (issue.item.labels.length > 0) { - issue.item.labels.forEach(label => { - markdown.appendMarkdown( - `[${makeLabel(label.color, label.name)}](https://github.com/${ownerName}/labels/${encodeURIComponent( - label.name, - )}) `, - ); - }); - } - - if (issue.item.comments && commentNumber) { - for (const comment of issue.item.comments) { - if (comment.databaseId === commentNumber) { - markdown.appendMarkdown(' \r\n\r\n---\r\n'); - markdown.appendMarkdown('  \n'); - markdown.appendMarkdown( - `![Avatar](${comment.author.avatarUrl}|height=15,width=15)   **${comment.author.login}** commented`, - ); - markdown.appendMarkdown('  \n'); - let commentText = marked.parse( - comment.body.length > ISSUE_BODY_LENGTH - ? comment.body.substr(0, ISSUE_BODY_LENGTH) + '...' - : comment.body, - { renderer: new PlainTextRenderer() }, - ); - commentText = await findLinksInIssue(commentText, issue); - markdown.appendMarkdown(commentText); - } - } - } - return markdown; -} - -function getIconString(issue: IssueModel) { - switch (issue.state) { - case GithubItemStateEnum.Open: { - return issue instanceof PullRequestModel ? '$(git-pull-request)' : '$(issues)'; - } - case GithubItemStateEnum.Closed: { - return issue instanceof PullRequestModel ? '$(git-pull-request)' : '$(issue-closed)'; - } - case GithubItemStateEnum.Merged: - return '$(git-merge)'; - } -} - -function getIconMarkdown(issue: IssueModel) { - if (issue instanceof PullRequestModel) { - return getIconString(issue); - } - switch (issue.state) { - case GithubItemStateEnum.Open: { - return `$(issues)`; - } - case GithubItemStateEnum.Closed: { - return `$(issue-closed)`; - } - } -} - -export interface NewIssue { - document: vscode.TextDocument; - lineNumber: number; - line: string; - insertIndex: number; - range: vscode.Range | vscode.Selection; -} - -const HEAD = 'HEAD'; -const UPSTREAM = 1; -const UPS = 2; -const ORIGIN = 3; -const OTHER = 4; -const REMOTE_CONVENTIONS = new Map([ - ['upstream', UPSTREAM], - ['ups', UPS], - ['origin', ORIGIN], -]); - -async function getUpstream(repository: Repository, commit: Commit): Promise { - const currentRemoteName: string | undefined = - repository.state.HEAD?.upstream && !REMOTE_CONVENTIONS.has(repository.state.HEAD.upstream.remote) - ? repository.state.HEAD.upstream.remote - : undefined; - let currentRemote: Remote | undefined; - // getBranches is slow if we don't pass a very specific pattern - // so we can't just get all branches then filter/sort. - // Instead, we need to create parameters for getBranches such that there is only ever on possible return value, - // which makes it much faster. - // To do this, create very specific remote+branch patterns to look for and sort from "best" to "worst". - // Then, call getBranches with each pattern until one of them succeeds. - const remoteNames: { name: string; remote?: Remote }[] = repository.state.remotes - .map(remote => { - return { name: remote.name, remote }; - }) - .filter(value => { - // While we're already here iterating through all values, find the current remote for use later. - if (value.name === currentRemoteName) { - currentRemote = value.remote; - } - return REMOTE_CONVENTIONS.has(value.name); - }) - .sort((a, b): number => { - const aVal = REMOTE_CONVENTIONS.get(a.name) ?? OTHER; - const bVal = REMOTE_CONVENTIONS.get(b.name) ?? OTHER; - return aVal - bVal; - }); - - if (currentRemoteName) { - remoteNames.push({ name: currentRemoteName, remote: currentRemote }); - } - - const branchNames = [HEAD]; - if (repository.state.HEAD?.name && repository.state.HEAD.name !== HEAD) { - branchNames.unshift(repository.state.HEAD?.name); - } - let bestRef: Ref | undefined; - let bestRemote: Remote | undefined; - for (let branchIndex = 0; branchIndex < branchNames.length && !bestRef; branchIndex++) { - for (let remoteIndex = 0; remoteIndex < remoteNames.length && !bestRef; remoteIndex++) { - try { - const remotes = ( - await repository.getBranches({ - contains: commit.hash, - remote: true, - pattern: `remotes/${remoteNames[remoteIndex].name}/${branchNames[branchIndex]}`, - count: 1, - }) - ).filter(value => value.remote && value.name); - if (remotes && remotes.length > 0) { - bestRef = remotes[0]; - bestRemote = remoteNames[remoteIndex].remote; - } - } catch (e) { - // continue - } - } - } - - return bestRemote; -} - -function getFileAndPosition(fileUri?: vscode.Uri, positionInfo?: NewIssue): { uri: vscode.Uri | undefined, range: vscode.Range | undefined } { - let uri: vscode.Uri; - let range: vscode.Range | undefined; - if (fileUri) { - uri = fileUri; - if (vscode.window.activeTextEditor?.document.uri.fsPath === uri.fsPath) { - range = vscode.window.activeTextEditor.selection; - } - } else if (!positionInfo && vscode.window.activeTextEditor) { - uri = vscode.window.activeTextEditor.document.uri; - range = vscode.window.activeTextEditor.selection; - } else if (positionInfo) { - uri = positionInfo.document.uri; - range = positionInfo.range; - } else { - return { uri: undefined, range: undefined }; - } - return { uri, range }; -} - -export interface PermalinkInfo { - permalink: string | undefined; - error: string | undefined; - originalFile: vscode.Uri | undefined; -} - -function getSimpleUpstream(repository: Repository) { - const upstream: UpstreamRef | undefined = repository.state.HEAD?.upstream; - for (const remote of repository.state.remotes) { - // If we don't have an upstream, then just use the first remote. - if (!upstream || (upstream.remote === remote.name)) { - return remote; - } - } -} - -async function getBestPossibleUpstream(repository: Repository, commit: Commit | undefined): Promise { - const fallbackUpstream = new Promise(resolve => { - resolve(getSimpleUpstream(repository)); - }); - - let upstream: Remote | undefined = commit ? await Promise.race([ - getUpstream(repository, commit), - new Promise(resolve => { - setTimeout(() => { - resolve(fallbackUpstream); - }, 1500); - }), - ]) : await fallbackUpstream; - - if (!upstream || !upstream.fetchUrl) { - // Check fallback - upstream = await fallbackUpstream; - if (!upstream || !upstream.fetchUrl) { - return undefined; - } - } - return upstream; -} - -export async function createGithubPermalink( - gitAPI: GitApiImpl, - positionInfo?: NewIssue, - fileUri?: vscode.Uri -): Promise { - const { uri, range } = getFileAndPosition(fileUri, positionInfo); - if (!uri) { - return { permalink: undefined, error: 'No active text editor position to create permalink from.', originalFile: undefined }; - } - - const repository = getRepositoryForFile(gitAPI, uri); - if (!repository) { - return { permalink: undefined, error: "The current file isn't part of repository.", originalFile: uri }; - } - - let commit: Commit | undefined; - let commitHash: string | undefined; - try { - const log = await repository.log({ maxEntries: 1, path: uri.fsPath }); - if (log.length === 0) { - return { permalink: undefined, error: 'No branch on a remote contains the most recent commit for the file.', originalFile: uri }; - } - commit = log[0]; - commitHash = log[0].hash; - } catch (e) { - commitHash = repository.state.HEAD?.commit; - } - - const upstream = await getBestPossibleUpstream(repository, commit); - if (!upstream || !upstream.fetchUrl) { - return { permalink: undefined, error: 'The selection may not exist on any remote.', originalFile: uri }; - } - - const pathSegment = uri.path.substring(repository.rootUri.path.length); - const originOfFetchUrl = getUpstreamOrigin(upstream).replace(/\/$/, ''); - return { - permalink: `${originOfFetchUrl}/${new Protocol(upstream.fetchUrl).nameWithOwner}/blob/${commitHash - }${pathSegment}${rangeString(range)}`, - error: undefined, - originalFile: uri - }; -} - -function getUpstreamOrigin(upstream: Remote) { - let resultHost: string = 'github.com'; - const enterpriseUri = getEnterpriseUri(); - if (enterpriseUri && upstream.fetchUrl) { - // upstream's origin by https - if (upstream.fetchUrl.startsWith('https://') && !upstream.fetchUrl.startsWith('https://github.com/')) { - const host = new URL(upstream.fetchUrl).host; - if (host === enterpriseUri.authority) { - resultHost = host; - } - } - // upstream's origin by ssh - if (upstream.fetchUrl.startsWith('git@') && !upstream.fetchUrl.startsWith('git@github.com')) { - const host = upstream.fetchUrl.split('@')[1]?.split(':')[0]; - if (host === enterpriseUri.authority) { - resultHost = host; - } - } - } - return `https://${resultHost}`; -} - -function rangeString(range: vscode.Range | undefined) { - if (!range) { - return ''; - } - let hash = `#L${range.start.line + 1}`; - if (range.start.line !== range.end.line) { - hash += `-L${range.end.line + 1}`; - } - return hash; -} - -export async function createGitHubLink( - managers: RepositoriesManager, - fileUri?: vscode.Uri -): Promise { - const { uri, range } = getFileAndPosition(fileUri); - if (!uri) { - return { permalink: undefined, error: 'No active text editor position to create permalink from.', originalFile: undefined }; - } - const folderManager = managers.getManagerForFile(uri); - if (!folderManager) { - return { permalink: undefined, error: 'Current file does not belong to an open repository.', originalFile: undefined }; - } - let branchName = folderManager.repository.state.HEAD?.name; - if (!branchName) { - // Fall back to default branch name if we are not currently on a branch - const origin = await folderManager.getOrigin(); - const metadata = await origin.getMetadata(); - branchName = metadata.default_branch; - } - const upstream = getSimpleUpstream(folderManager.repository); - if (!upstream?.fetchUrl) { - return { permalink: undefined, error: 'Repository does not have any remotes.', originalFile: undefined }; - } - const pathSegment = uri.path.substring(folderManager.repository.rootUri.path.length); - return { - permalink: `https://github.com/${new Protocol(upstream.fetchUrl).nameWithOwner}/blob/${branchName - }${pathSegment}${rangeString(range)}`, - error: undefined, - originalFile: uri - }; -} - -export function sanitizeIssueTitle(title: string): string { - const regex = /[~^:;'".,~#?%*[\]@\\{}()]|\/\//g; - - return title.replace(regex, '').trim().replace(/\s+/g, '-'); -} - -const VARIABLE_PATTERN = /\$\{(.*?)\}/g; -export async function variableSubstitution( - value: string, - issueModel?: IssueModel, - defaults?: PullRequestDefaults, - user?: string, -): Promise { - return value.replace(VARIABLE_PATTERN, (match: string, variable: string) => { - switch (variable) { - case 'user': - return user ? user : match; - case 'issueNumber': - return issueModel ? `${issueModel.number}` : match; - case 'issueNumberLabel': - return issueModel ? `${getIssueNumberLabel(issueModel, defaults)}` : match; - case 'issueTitle': - return issueModel ? issueModel.title : match; - case 'repository': - return defaults ? defaults.repo : match; - case 'owner': - return defaults ? defaults.owner : match; - case 'sanitizedIssueTitle': - return issueModel ? sanitizeIssueTitle(issueModel.title) : match; // check what characters are permitted - case 'sanitizedLowercaseIssueTitle': - return issueModel ? sanitizeIssueTitle(issueModel.title).toLowerCase() : match; - default: - return match; - } - }); -} - -export function getIssueNumberLabel(issue: IssueModel, repo?: PullRequestDefaults) { - const parsedIssue: ParsedIssue = { issueNumber: issue.number, owner: undefined, name: undefined }; - if ( - repo && - (repo.owner.toLowerCase() !== issue.remote.owner.toLowerCase() || - repo.repo.toLowerCase() !== issue.remote.repositoryName.toLowerCase()) - ) { - parsedIssue.owner = issue.remote.owner; - parsedIssue.name = issue.remote.repositoryName; - } - return getIssueNumberLabelFromParsed(parsedIssue); -} - -function getIssueNumberLabelFromParsed(parsed: ParsedIssue) { - if (!parsed.owner || !parsed.name) { - return `#${parsed.issueNumber}`; - } else { - return `${parsed.owner}/${parsed.name}#${parsed.issueNumber}`; - } -} - -async function commitWithDefault(manager: FolderRepositoryManager, stateManager: StateManager, all: boolean) { - const message = await stateManager.currentIssue(manager.repository.rootUri)?.getCommitMessage(); - if (message) { - return manager.repository.commit(message, { all }); - } -} - -const commitStaged = 'Commit Staged'; -const commitAll = 'Commit All'; -export async function pushAndCreatePR( - manager: FolderRepositoryManager, - reviewManager: ReviewManager, - stateManager: StateManager, -): Promise { - if (manager.repository.state.workingTreeChanges.length > 0 || manager.repository.state.indexChanges.length > 0) { - const responseOptions: string[] = []; - if (manager.repository.state.indexChanges) { - responseOptions.push(commitStaged); - } - if (manager.repository.state.workingTreeChanges) { - responseOptions.push(commitAll); - } - const changesResponse = await vscode.window.showInformationMessage( - 'There are uncommitted changes. Do you want to commit them with the default commit message?', - { modal: true }, - ...responseOptions, - ); - switch (changesResponse) { - case commitStaged: { - await commitWithDefault(manager, stateManager, false); - break; - } - case commitAll: { - await commitWithDefault(manager, stateManager, true); - break; - } - default: - return false; - } - } - - if (manager.repository.state.HEAD?.upstream) { - await manager.repository.push(); - await reviewManager.createPullRequest(undefined); - return true; - } else { - let remote: string | undefined; - if (manager.repository.state.remotes.length === 1) { - remote = manager.repository.state.remotes[0].name; - } else if (manager.repository.state.remotes.length > 1) { - remote = await vscode.window.showQuickPick( - manager.repository.state.remotes.map(value => value.name), - { placeHolder: 'Remote to push to' }, - ); - } - if (remote) { - await manager.repository.push(remote, manager.repository.state.HEAD?.name, true); - await reviewManager.createPullRequest(undefined); - return true; - } else { - vscode.window.showWarningMessage( - 'The current repository has no remotes to push to. Please set up a remote and try again.', - ); - return false; - } - } -} - -export async function isComment(document: vscode.TextDocument, position: vscode.Position): Promise { - if (document.languageId !== 'markdown' && document.languageId !== 'plaintext') { - const tokenInfo = await vscode.languages.getTokenInformationAtPosition(document, position); - if (tokenInfo.type !== vscode.StandardTokenType.Comment) { - return false; - } - } - return true; -} - -export async function shouldShowHover(document: vscode.TextDocument, position: vscode.Position): Promise { - if (document.lineAt(position.line).range.end.character > 10000) { - return false; - } - - return isComment(document, position); -} - -export function getRootUriFromScmInputUri(uri: vscode.Uri): vscode.Uri | undefined { - const rootUri = new URLSearchParams(uri.query).get('rootUri'); - return rootUri ? vscode.Uri.parse(rootUri) : undefined; -} - -export class PlainTextRenderer extends marked.Renderer { - code(code: string): string { - return code; - } - blockquote(quote: string): string { - return quote; - } - html(_html: string): string { - return ''; - } - heading(text: string, _level: 1 | 2 | 3 | 4 | 5 | 6, _raw: string, _slugger: marked.Slugger): string { - return text + ' '; - } - hr(): string { - return ''; - } - list(body: string, _ordered: boolean, _start: number): string { - return body; - } - listitem(text: string): string { - return ' ' + text; - } - checkbox(_checked: boolean): string { - return ''; - } - paragraph(text: string): string { - return text.replace(/\/g, '\\>') + ' '; - } - table(header: string, body: string): string { - return header + ' ' + body; - } - tablerow(content: string): string { - return content; - } - tablecell( - content: string, - _flags: { - header: boolean; - align: 'center' | 'left' | 'right' | null; - }, - ): string { - return content; - } - strong(text: string): string { - return text; - } - em(text: string): string { - return text; - } - codespan(code: string): string { - return `\\\`${code}\\\``; - } - br(): string { - return ' '; - } - del(text: string): string { - return text; - } - image(_href: string, _title: string, _text: string): string { - return ''; - } - text(text: string): string { - return text; - } - link(href: string, title: string, text: string): string { - return text + ' '; - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URL } from 'url'; +import LRUCache from 'lru-cache'; +import * as marked from 'marked'; +import 'url-search-params-polyfill'; +import * as vscode from 'vscode'; +import { gitHubLabelColor } from '../../src/common/utils'; +import { Ref, Remote, Repository, UpstreamRef } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import Logger from '../common/logger'; +import { Protocol } from '../common/protocol'; +import { fromReviewUri, Schemes } from '../common/uri'; +import { FolderRepositoryManager, NoGitHubReposError, PullRequestDefaults } from '../github/folderRepositoryManager'; +import { GithubItemStateEnum, User } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { getEnterpriseUri, getIssueNumberLabelFromParsed, getRepositoryForFile, ISSUE_OR_URL_EXPRESSION, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; +import { ReviewManager } from '../view/reviewManager'; +import { CODE_PERMALINK, findCodeLinkLocally } from './issueLinkLookup'; +import { StateManager } from './stateManager'; + +export const USER_EXPRESSION: RegExp = /\@([^\s]+)/; + +export const MAX_LINE_LENGTH = 150; +export const PERMALINK_COMPONENT = 'Permalink'; + +export async function getIssue( + stateManager: StateManager, + manager: FolderRepositoryManager, + issueValue: string, + parsed: ParsedIssue, +): Promise { + const alreadyResolved = stateManager.resolvedIssues.get(manager.repository.rootUri.path)?.get(issueValue); + if (alreadyResolved) { + return alreadyResolved; + } else { + let owner: string | undefined = undefined; + let name: string | undefined = undefined; + let issueNumber: number | undefined = undefined; + const remotes = await manager.getGitHubRemotes(); + for (const remote of remotes) { + if (!parsed) { + const tryParse = parseIssueExpressionOutput(issueValue.match(ISSUE_OR_URL_EXPRESSION)); + if (tryParse && (!tryParse.name || !tryParse.owner)) { + owner = remote.owner; + name = remote.repositoryName; + } + } else { + owner = parsed.owner ? parsed.owner : remote.owner; + name = parsed.name ? parsed.name : remote.repositoryName; + issueNumber = parsed.issueNumber; + } + + if (owner && name && issueNumber !== undefined) { + let issue = await manager.resolveIssue(owner, name, issueNumber, !!parsed.commentNumber); + if (!issue) { + issue = await manager.resolvePullRequest(owner, name, issueNumber); + } + if (issue) { + let cached: LRUCache; + if (!stateManager.resolvedIssues.has(manager.repository.rootUri.path)) { + stateManager.resolvedIssues.set( + manager.repository.rootUri.path, + (cached = new LRUCache(50)), + ); + } else { + cached = stateManager.resolvedIssues.get(manager.repository.rootUri.path)!; + } + cached.set(issueValue, issue); + return issue; + } + } + } + } + return undefined; +} + +function repoCommitDate(user: User, repoNameWithOwner: string): string | undefined { + let date: string | undefined = undefined; + user.commitContributions.forEach(element => { + if (repoNameWithOwner.toLowerCase() === element.repoNameWithOwner.toLowerCase()) { + date = element.createdAt.toLocaleString('default', { day: 'numeric', month: 'short', year: 'numeric' }); + } + }); + return date; +} + +export class UserCompletion extends vscode.CompletionItem { + login: string; + uri: vscode.Uri; +} + +export function userMarkdown(origin: PullRequestDefaults, user: User): vscode.MarkdownString { + const markdown: vscode.MarkdownString = new vscode.MarkdownString(undefined, true); + markdown.appendMarkdown( + `![Avatar](${user.avatarUrl}|height=50,width=50) ${user.name ? `**${user.name}** ` : ''}[${user.login}](${user.url})`, + ); + if (user.bio) { + markdown.appendText(' \r\n' + user.bio.replace(/\r\n/g, ' ')); + } + + const date = repoCommitDate(user, origin.owner + '/' + origin.repo); + if (user.location || date) { + markdown.appendMarkdown(' \r\n\r\n---'); + } + if (user.location) { + markdown.appendMarkdown(` \r\n${vscode.l10n.t('{0} {1}', '$(location)', user.location)}`); + } + if (date) { + markdown.appendMarkdown(` \r\n${vscode.l10n.t('{0} Committed to this repository on {1}', '$(git-commit)', date)}`); + } + if (user.company) { + markdown.appendMarkdown(` \r\n${vscode.l10n.t({ message: '{0} Member of {1}', args: ['$(jersey)', user.company], comment: ['An organization that the user is a member of.', 'The first placeholder is an icon and shouldn\'t be localized.', 'The second placeholder is the name of the organization.'] })}`); + } + return markdown; +} + +function makeLabel(color: string, text: string): string { + const isDarkTheme = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; + const labelColor = gitHubLabelColor(color, isDarkTheme, true); + return `  ${text}  `; +} + +async function findAndModifyString( + text: string, + find: RegExp, + transformer: (match: RegExpMatchArray) => Promise, +): Promise { + let searchResult = text.search(find); + let position = 0; + while (searchResult >= 0 && searchResult < text.length) { + let newBodyFirstPart: string | undefined; + if (searchResult === 0 || text.charAt(searchResult - 1) !== '&') { + const match = text.substring(searchResult).match(find)!; + if (match) { + const transformed = await transformer(match); + if (transformed) { + newBodyFirstPart = text.slice(0, searchResult) + transformed; + text = newBodyFirstPart + text.slice(searchResult + match[0].length); + } + } + } + position = newBodyFirstPart ? newBodyFirstPart.length : searchResult + 1; + const newSearchResult = text.substring(position).search(find); + searchResult = newSearchResult > 0 ? position + newSearchResult : newSearchResult; + } + return text; +} + +function findLinksInIssue(body: string, issue: IssueModel): Promise { + return findAndModifyString(body, ISSUE_OR_URL_EXPRESSION, async (match: RegExpMatchArray) => { + const tryParse = parseIssueExpressionOutput(match); + if (tryParse) { + const issueNumberLabel = getIssueNumberLabelFromParsed(tryParse); // get label before setting owner and name. + if (!tryParse.owner || !tryParse.name) { + tryParse.owner = issue.remote.owner; + tryParse.name = issue.remote.repositoryName; + } + return `[${issueNumberLabel}](https://github.com/${tryParse.owner}/${tryParse.name}/issues/${tryParse.issueNumber})`; + } + return undefined; + }); +} + +async function findCodeLinksInIssue(body: string, repositoriesManager: RepositoriesManager) { + return findAndModifyString(body, CODE_PERMALINK, async (match: RegExpMatchArray) => { + const codeLink = await findCodeLinkLocally(match, repositoriesManager); + if (codeLink) { + const textDocument = await vscode.workspace.openTextDocument(codeLink?.file); + const endingTextDocumentLine = textDocument.lineAt( + codeLink.end < textDocument.lineCount ? codeLink.end : textDocument.lineCount - 1, + ); + const query = [ + codeLink.file, + { + selection: { + start: { + line: codeLink.start, + character: 0, + }, + end: { + line: codeLink.end, + character: endingTextDocumentLine.text.length, + }, + }, + }, + ]; + const openCommand = vscode.Uri.parse(`command:vscode.open?${encodeURIComponent(JSON.stringify(query))}`); + return `[${match[0]}](${openCommand} "Open ${codeLink.file.fsPath}")`; + } + return undefined; + }); +} + +export const ISSUE_BODY_LENGTH: number = 200; +export async function issueMarkdown( + issue: IssueModel, + context: vscode.ExtensionContext, + repositoriesManager: RepositoriesManager, + commentNumber?: number, +): Promise { + const markdown: vscode.MarkdownString = new vscode.MarkdownString(undefined, true); + markdown.supportHtml = true; + const date = new Date(issue.createdAt); + const ownerName = `${issue.remote.owner}/${issue.remote.repositoryName}`; + markdown.appendMarkdown( + `[${ownerName}](https://github.com/${ownerName}) on ${date.toLocaleString('default', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} \n`, + ); + const title = marked + .parse(issue.title, { + renderer: new PlainTextRenderer(), + }) + .trim(); + markdown.appendMarkdown( + `${getIconMarkdown(issue)} **${title}** [#${issue.number}](${issue.html_url}) \n`, + ); + let body = marked.parse(issue.body, { + renderer: new PlainTextRenderer(), + }); + markdown.appendMarkdown(' \n'); + body = body.length > ISSUE_BODY_LENGTH ? body.substr(0, ISSUE_BODY_LENGTH) + '...' : body; + body = await findLinksInIssue(body, issue); + body = await findCodeLinksInIssue(body, repositoriesManager); + + markdown.appendMarkdown(body + ' \n'); + markdown.appendMarkdown('  \n'); + + if (issue.item.labels.length > 0) { + issue.item.labels.forEach(label => { + markdown.appendMarkdown( + `[${makeLabel(label.color, label.name)}](https://github.com/${ownerName}/labels/${encodeURIComponent( + label.name, + )}) `, + ); + }); + } + + if (issue.item.comments && commentNumber) { + for (const comment of issue.item.comments) { + if (comment.databaseId === commentNumber) { + markdown.appendMarkdown(' \r\n\r\n---\r\n'); + markdown.appendMarkdown('  \n'); + markdown.appendMarkdown( + `![Avatar](${comment.author.avatarUrl}|height=15,width=15)   **${comment.author.login}** commented`, + ); + markdown.appendMarkdown('  \n'); + let commentText = marked.parse( + comment.body.length > ISSUE_BODY_LENGTH + ? comment.body.substr(0, ISSUE_BODY_LENGTH) + '...' + : comment.body, + { renderer: new PlainTextRenderer() }, + ); + commentText = await findLinksInIssue(commentText, issue); + markdown.appendMarkdown(commentText); + } + } + } + return markdown; +} + +function getIconString(issue: IssueModel) { + switch (issue.state) { + case GithubItemStateEnum.Open: { + return issue instanceof PullRequestModel ? '$(git-pull-request)' : '$(issues)'; + } + case GithubItemStateEnum.Closed: { + return issue instanceof PullRequestModel ? '$(git-pull-request)' : '$(issue-closed)'; + } + case GithubItemStateEnum.Merged: + return '$(git-merge)'; + } +} + +function getIconMarkdown(issue: IssueModel) { + if (issue instanceof PullRequestModel) { + return getIconString(issue); + } + switch (issue.state) { + case GithubItemStateEnum.Open: { + return `$(issues)`; + } + case GithubItemStateEnum.Closed: { + return `$(issue-closed)`; + } + } +} + +export interface NewIssue { + document: vscode.TextDocument; + lineNumber: number; + line: string; + insertIndex: number; + range: vscode.Range | vscode.Selection; +} + +export interface IssueTemplate { + name: string | undefined, + about: string | undefined, + title: string | undefined, + body: string | undefined +} + +const HEAD = 'HEAD'; +const UPSTREAM = 1; +const UPS = 2; +const ORIGIN = 3; +const OTHER = 4; +const REMOTE_CONVENTIONS = new Map([ + ['upstream', UPSTREAM], + ['ups', UPS], + ['origin', ORIGIN], +]); + +async function getUpstream(repositoriesManager: RepositoriesManager, repository: Repository, commitHash: string): Promise { + const currentRemoteName: string | undefined = + repository.state.HEAD?.upstream && !REMOTE_CONVENTIONS.has(repository.state.HEAD.upstream.remote) + ? repository.state.HEAD.upstream.remote + : undefined; + let currentRemote: Remote | undefined; + // getBranches is slow if we don't pass a very specific pattern + // so we can't just get all branches then filter/sort. + // Instead, we need to create parameters for getBranches such that there is only ever on possible return value, + // which makes it much faster. + // To do this, create very specific remote+branch patterns to look for and sort from "best" to "worst". + // Then, call getBranches with each pattern until one of them succeeds. + const remoteNames: { name: string; remote?: Remote }[] = repository.state.remotes + .map(remote => { + return { name: remote.name, remote }; + }) + .filter(value => { + // While we're already here iterating through all values, find the current remote for use later. + if (value.name === currentRemoteName) { + currentRemote = value.remote; + } + return REMOTE_CONVENTIONS.has(value.name); + }) + .sort((a, b): number => { + const aVal = REMOTE_CONVENTIONS.get(a.name) ?? OTHER; + const bVal = REMOTE_CONVENTIONS.get(b.name) ?? OTHER; + return aVal - bVal; + }); + + if (currentRemoteName) { + remoteNames.push({ name: currentRemoteName, remote: currentRemote }); + } + + const branchNames = [HEAD]; + if (repository.state.HEAD?.name && repository.state.HEAD.name !== HEAD) { + branchNames.unshift(repository.state.HEAD?.name); + } + let defaultBranch: PullRequestDefaults | undefined; + try { + defaultBranch = await repositoriesManager.getManagerForFile(repository.rootUri)?.getPullRequestDefaults(); + } catch (e) { + if (!(e instanceof NoGitHubReposError)) { + throw e; + } + } + if (defaultBranch) { + branchNames.push(defaultBranch.base); + } + let bestRef: Ref | undefined; + let bestRemote: Remote | undefined; + for (let branchIndex = 0; branchIndex < branchNames.length && !bestRef; branchIndex++) { + for (let remoteIndex = 0; remoteIndex < remoteNames.length && !bestRef; remoteIndex++) { + try { + const remotes = ( + await repository.getBranches({ + contains: commitHash, + remote: true, + pattern: `remotes/${remoteNames[remoteIndex].name}/${branchNames[branchIndex]}`, + count: 1, + }) + ).filter(value => value.remote && value.name); + if (remotes && remotes.length > 0) { + bestRef = remotes[0]; + bestRemote = remoteNames[remoteIndex].remote; + } + } catch (e) { + // continue + } + } + } + + return bestRemote; +} + +function extractContext(context: LinkContext): { fileUri: vscode.Uri | undefined, lineNumber: number | undefined } { + if (context instanceof vscode.Uri) { + return { fileUri: context, lineNumber: undefined }; + } else if (context !== undefined && 'lineNumber' in context && 'uri' in context) { + return { fileUri: context.uri, lineNumber: context.lineNumber }; + } else { + return { fileUri: undefined, lineNumber: undefined }; + } +} + +function getFileAndPosition(context: LinkContext, positionInfo?: NewIssue): { uri: vscode.Uri | undefined, range: vscode.Range | vscode.NotebookRange | undefined } { + Logger.debug(`getting file and position`, PERMALINK_COMPONENT); + let uri: vscode.Uri; + let range: vscode.Range | vscode.NotebookRange | undefined; + + const { fileUri, lineNumber } = extractContext(context); + + if (fileUri) { + uri = fileUri; + if (vscode.window.activeTextEditor?.document.uri.fsPath === uri.fsPath && !vscode.window.activeNotebookEditor) { + if (lineNumber !== undefined && (vscode.window.activeTextEditor.selection.isEmpty || !vscode.window.activeTextEditor.selection.contains(new vscode.Position(lineNumber - 1, 0)))) { + range = new vscode.Range(new vscode.Position(lineNumber - 1, 0), new vscode.Position(lineNumber - 1, 1)); + } else { + range = vscode.window.activeTextEditor.selection; + } + } + } else if (!positionInfo && vscode.window.activeTextEditor) { + uri = vscode.window.activeTextEditor.document.uri; + range = vscode.window.activeTextEditor.selection; + } else if (!positionInfo && vscode.window.activeNotebookEditor) { + uri = vscode.window.activeNotebookEditor.notebook.uri; + range = vscode.window.activeNotebookEditor.selection; + } else if (!positionInfo && vscode.window.tabGroups.activeTabGroup.activeTab?.input instanceof vscode.TabInputCustom) { + uri = vscode.window.tabGroups.activeTabGroup.activeTab.input.uri; + } else if (positionInfo) { + uri = positionInfo.document.uri; + range = positionInfo.range; + } else { + return { uri: undefined, range: undefined }; + } + Logger.debug(`got file and position: ${uri.fsPath} ${range?.start ? (range.start instanceof vscode.Position ? `${range.start.line}:${range.start.character}` : range.start) : 'unknown'}`, PERMALINK_COMPONENT); + return { uri, range }; +} + +export interface PermalinkInfo { + permalink: string | undefined; + error: string | undefined; + originalFile: vscode.Uri | undefined; +} + +export function getSimpleUpstream(repository: Repository) { + const upstream: UpstreamRef | undefined = repository.state.HEAD?.upstream; + for (const remote of repository.state.remotes) { + // If we don't have an upstream, then just use the first remote. + if (!upstream || (upstream.remote === remote.name)) { + return remote; + } + } +} + +export async function getBestPossibleUpstream(repositoriesManager: RepositoriesManager, repository: Repository, commitHash: string | undefined): Promise { + const fallbackUpstream = new Promise(resolve => { + resolve(getSimpleUpstream(repository)); + }); + + let upstream: Remote | undefined = commitHash ? await Promise.race([ + getUpstream(repositoriesManager, repository, commitHash), + new Promise(resolve => { + setTimeout(() => { + resolve(fallbackUpstream); + }, 1500); + }), + ]) : await fallbackUpstream; + + if (!upstream || !upstream.fetchUrl) { + // Check fallback + upstream = await fallbackUpstream; + if (!upstream || !upstream.fetchUrl) { + return undefined; + } + } + return upstream; +} + +export function getOwnerAndRepo(repositoriesManager: RepositoriesManager, repository: Repository, upstream: Remote & { fetchUrl: string }): string { + const folderManager = repositoriesManager.getManagerForFile(repository.rootUri); + // Find the GitHub repository that matches the chosen upstream remote + const githubRepository = folderManager?.gitHubRepositories.find(githubRepository => { + return githubRepository.remote.remoteName === upstream.name; + }); + if (githubRepository) { + return `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}`; + } else { + return new Protocol(upstream.fetchUrl).nameWithOwner; + } +} + +export async function createGithubPermalink( + repositoriesManager: RepositoriesManager, + gitAPI: GitApiImpl, + includeRange: boolean, + includeFile: boolean, + positionInfo?: NewIssue, + context?: LinkContext +): Promise { + return vscode.window.withProgress({ location: vscode.ProgressLocation.Window }, async (progress) => { + progress.report({ message: vscode.l10n.t('Creating permalink...') }); + 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 }; + } + + const repository = getRepositoryForFile(gitAPI, uri); + if (!repository) { + return { permalink: undefined, error: vscode.l10n.t('The current file isn\'t part of repository.'), originalFile: uri }; + } + + let commitHash: string | undefined; + if (uri.scheme === Schemes.Review) { + commitHash = fromReviewUri(uri.query).commit; + } + + if (!commitHash) { + try { + const log = await repository.log({ maxEntries: 1, path: uri.fsPath }); + if (log.length === 0) { + return { permalink: undefined, error: vscode.l10n.t('No branch on a remote contains the most recent commit for the file.'), originalFile: uri }; + } + // Now that we know that the file existed at some point in the repo, use the head commit to construct the URI. + if (repository.state.HEAD?.commit && (log[0].hash !== repository.state.HEAD?.commit)) { + commitHash = repository.state.HEAD.commit; + } else { + commitHash = log[0].hash; + } + } catch (e) { + commitHash = repository.state.HEAD?.commit; + } + } + + Logger.debug(`commit hash: ${commitHash}`, PERMALINK_COMPONENT); + + const rawUpstream = await getBestPossibleUpstream(repositoriesManager, repository, commitHash); + if (!rawUpstream || !rawUpstream.fetchUrl) { + return { permalink: undefined, error: vscode.l10n.t('The selection may not exist on any remote.'), originalFile: uri }; + } + const upstream: Remote & { fetchUrl: string } = rawUpstream as any; + + Logger.debug(`upstream: ${upstream.fetchUrl}`, PERMALINK_COMPONENT); + + const encodedPathSegment = encodeURIComponentExceptSlashes(uri.path.substring(repository.rootUri.path.length)); + const originOfFetchUrl = getUpstreamOrigin(rawUpstream).replace(/\/$/, ''); + const result = { + permalink: (`${originOfFetchUrl}/${getOwnerAndRepo(repositoriesManager, repository, upstream)}/blob/${commitHash + }${includeFile ? `${encodedPathSegment}${includeRange ? rangeString(range) : ''}` : ''}`), + error: undefined, + originalFile: uri + }; + Logger.debug(`permalink generated: ${result.permalink}`, PERMALINK_COMPONENT); + return result; + }); +} + +export function getUpstreamOrigin(upstream: Remote, resultHost: string = 'github.com') { + const enterpriseUri = getEnterpriseUri(); + let fetchUrl = upstream.fetchUrl; + if (enterpriseUri && fetchUrl) { + // upstream's origin by https + if (fetchUrl.startsWith('https://') && !fetchUrl.startsWith('https://github.com/')) { + const host = new URL(fetchUrl).host; + if (host.startsWith(enterpriseUri.authority) || !host.includes('github.com')) { + resultHost = enterpriseUri.authority; + } + } + if (fetchUrl.startsWith('ssh://')) { + fetchUrl = fetchUrl.substr('ssh://'.length); + } + // upstream's origin by ssh + if (fetchUrl.startsWith('git@') && !fetchUrl.startsWith('git@github.com')) { + const host = fetchUrl.split('@')[1]?.split(':')[0]; + if (host.startsWith(enterpriseUri.authority) || !host.includes('github.com')) { + resultHost = enterpriseUri.authority; + } + } + } + return `https://${resultHost}`; +} + +export function encodeURIComponentExceptSlashes(path: string) { + // There may be special characters like # and whitespace in the path. + // These characters are not escaped by encodeURI(), so it is not sufficient to + // feed the full URI to encodeURI(). + // Additonally, if we feed the full path into encodeURIComponent(), + // this will also encode the path separators, leading to an invalid path. + // Therefore, split on the path separator and encode each segment individually. + return path.split('/').map((segment) => encodeURIComponent(segment)).join('/'); +} + +export function rangeString(range: vscode.Range | vscode.NotebookRange | undefined) { + if (!range || (range instanceof vscode.NotebookRange)) { + return ''; + } + let hash = `#L${range.start.line + 1}`; + if (range.start.line !== range.end.line) { + hash += `-L${range.end.line + 1}`; + } + return hash; +} + +interface EditorLineNumberContext { + uri: vscode.Uri; + lineNumber: number; +} +export type LinkContext = vscode.Uri | EditorLineNumberContext | undefined; + +export async function createGitHubLink( + managers: RepositoriesManager, + context: LinkContext, + includeRange?: boolean +): Promise { + const { uri, range } = getFileAndPosition(context); + if (!uri) { + return { permalink: undefined, error: vscode.l10n.t('No active text editor position to create permalink from.'), originalFile: undefined }; + } + const folderManager = managers.getManagerForFile(uri); + if (!folderManager) { + return { permalink: undefined, error: vscode.l10n.t('Current file does not belong to an open repository.'), originalFile: undefined }; + } + let branchName = folderManager.repository.state.HEAD?.name; + if (!branchName) { + // Fall back to default branch name if we are not currently on a branch + const origin = await folderManager.getOrigin(); + const metadata = await origin.getMetadata(); + branchName = metadata.default_branch; + } + const upstream = getSimpleUpstream(folderManager.repository); + if (!upstream?.fetchUrl) { + return { permalink: undefined, error: vscode.l10n.t('Repository does not have any remotes.'), originalFile: undefined }; + } + const pathSegment = uri.path.substring(folderManager.repository.rootUri.path.length); + const originOfFetchUrl = getUpstreamOrigin(upstream).replace(/\/$/, ''); + const encodedBranchAndFilePath = encodeURIComponentExceptSlashes(`${branchName}${pathSegment}`); + return { + permalink: (`${originOfFetchUrl}/${new Protocol(upstream.fetchUrl).nameWithOwner}/blob/${encodedBranchAndFilePath + }${includeRange ? rangeString(range) : ''}`), + error: undefined, + originalFile: uri + }; +} + +async function commitWithDefault(manager: FolderRepositoryManager, stateManager: StateManager, all: boolean) { + const message = await stateManager.currentIssue(manager.repository.rootUri)?.getCommitMessage(); + if (message) { + return manager.repository.commit(message, { all }); + } +} + +const commitStaged = vscode.l10n.t('Commit Staged'); +const commitAll = vscode.l10n.t('Commit All'); +export async function pushAndCreatePR( + manager: FolderRepositoryManager, + reviewManager: ReviewManager, + stateManager: StateManager, +): Promise { + if (manager.repository.state.workingTreeChanges.length > 0 || manager.repository.state.indexChanges.length > 0) { + const responseOptions: string[] = []; + if (manager.repository.state.indexChanges) { + responseOptions.push(commitStaged); + } + if (manager.repository.state.workingTreeChanges) { + responseOptions.push(commitAll); + } + const changesResponse = await vscode.window.showInformationMessage( + vscode.l10n.t('There are uncommitted changes. Do you want to commit them with the default commit message?'), + { modal: true }, + ...responseOptions, + ); + switch (changesResponse) { + case commitStaged: { + await commitWithDefault(manager, stateManager, false); + break; + } + case commitAll: { + await commitWithDefault(manager, stateManager, true); + break; + } + default: + return false; + } + } + + if (manager.repository.state.HEAD?.upstream) { + await manager.repository.push(); + await reviewManager.createPullRequest(undefined); + return true; + } else { + let remote: string | undefined; + if (manager.repository.state.remotes.length === 1) { + remote = manager.repository.state.remotes[0].name; + } else if (manager.repository.state.remotes.length > 1) { + remote = await vscode.window.showQuickPick( + manager.repository.state.remotes.map(value => value.name), + { placeHolder: vscode.l10n.t('Remote to push to') }, + ); + } + if (remote) { + await manager.repository.push(remote, manager.repository.state.HEAD?.name, true); + await reviewManager.createPullRequest(undefined); + return true; + } else { + vscode.window.showWarningMessage( + vscode.l10n.t('The current repository has no remotes to push to. Please set up a remote and try again.'), + ); + return false; + } + } +} + +export async function isComment(document: vscode.TextDocument, position: vscode.Position): Promise { + if (document.languageId !== 'markdown' && document.languageId !== 'plaintext') { + const tokenInfo = await vscode.languages.getTokenInformationAtPosition(document, position); + if (tokenInfo.type !== vscode.StandardTokenType.Comment) { + return false; + } + } + return true; +} + +export async function shouldShowHover(document: vscode.TextDocument, position: vscode.Position): Promise { + if (document.lineAt(position.line).range.end.character > 10000) { + return false; + } + + return isComment(document, position); +} + +export function getRootUriFromScmInputUri(uri: vscode.Uri): vscode.Uri | undefined { + const rootUri = new URLSearchParams(uri.query).get('rootUri'); + return rootUri ? vscode.Uri.parse(rootUri) : undefined; +} + +export class PlainTextRenderer extends marked.Renderer { + code(code: string): string { + return code; + } + blockquote(quote: string): string { + return quote; + } + html(_html: string): string { + return ''; + } + heading(text: string, _level: 1 | 2 | 3 | 4 | 5 | 6, _raw: string, _slugger: marked.Slugger): string { + return text + ' '; + } + hr(): string { + return ''; + } + list(body: string, _ordered: boolean, _start: number): string { + return body; + } + listitem(text: string): string { + return ' ' + text; + } + checkbox(_checked: boolean): string { + return ''; + } + paragraph(text: string): string { + return text.replace(/\/g, '\\\>') + ' '; + } + table(header: string, body: string): string { + return header + ' ' + body; + } + tablerow(content: string): string { + return content; + } + tablecell( + content: string, + _flags: { + header: boolean; + align: 'center' | 'left' | 'right' | null; + }, + ): string { + return content; + } + strong(text: string): string { + return text; + } + em(text: string): string { + return text; + } + codespan(code: string): string { + return `\\\`${code}\\\``; + } + br(): string { + return ' '; + } + del(text: string): string { + return text; + } + image(_href: string, _title: string, _text: string): string { + return ''; + } + text(text: string): string { + return text; + } + link(href: string, title: string, text: string): string { + return text + ' '; + } +} diff --git a/src/test/browser/runTests.ts b/src/test/browser/runTests.ts index 58643ac9d0..fc9e45ff83 100644 --- a/src/test/browser/runTests.ts +++ b/src/test/browser/runTests.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import * as path from 'path'; import { BrowserType, runTests } from '@vscode/test-web'; @@ -19,6 +24,7 @@ async function go() { extensionDevelopmentPath, extensionTestsPath, waitForDebugger: waitForDebugger ? Number(waitForDebugger.slice(attachArgName.length)) : undefined, + quality: 'stable' }); } catch (e) { console.log(e); diff --git a/src/test/builders/graphql/latestReviewCommitBuilder.ts b/src/test/builders/graphql/latestReviewCommitBuilder.ts new file mode 100644 index 0000000000..a51807ae01 --- /dev/null +++ b/src/test/builders/graphql/latestReviewCommitBuilder.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. + *--------------------------------------------------------------------------------------------*/ + +import { createBuilderClass, createLink } from '../base'; +import { LatestReviewCommitResponse } from '../../../github/graphql'; + +import { RateLimitBuilder } from './rateLimitBuilder'; + +type Repository = LatestReviewCommitResponse['repository']; +type PullRequest = Repository['pullRequest']; +type ViewerLatestReview = PullRequest['viewerLatestReview']; +type Commit = ViewerLatestReview['commit']; + +export const LatestReviewCommitBuilder = createBuilderClass()({ + repository: createLink()({ + pullRequest: createLink()({ + viewerLatestReview: createLink()({ + commit: createLink()({ + oid: { default: 'abc' }, + }), + }), + }), + }), + rateLimit: { linked: RateLimitBuilder }, +}); + +export type LatestReviewCommitBuilder = InstanceType; diff --git a/src/test/builders/graphql/pullRequestBuilder.ts b/src/test/builders/graphql/pullRequestBuilder.ts index a0943f5c8b..1483f95fce 100644 --- a/src/test/builders/graphql/pullRequestBuilder.ts +++ b/src/test/builders/graphql/pullRequestBuilder.ts @@ -1,15 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { createBuilderClass, createLink } from '../base'; -import { PullRequestResponse, Ref, RefRepository } from '../../../github/graphql'; +import { BaseRefRepository, DefaultCommitMessage, DefaultCommitTitle, PullRequestResponse, Ref, RefRepository } from '../../../github/graphql'; import { RateLimitBuilder } from './rateLimitBuilder'; const RefRepositoryBuilder = createBuilderClass()({ + isInOrganization: { default: false }, owner: createLink()({ login: { default: 'me' }, }), url: { default: 'https://github.com/owner/repo' }, }); +const BaseRefRepositoryBuilder = createBuilderClass()({ + isInOrganization: { default: false }, + owner: createLink()({ + login: { default: 'me' }, + }), + url: { default: 'https://github.com/owner/repo' }, + mergeCommitMessage: { default: DefaultCommitMessage.commitMessages }, + mergeCommitTitle: { default: DefaultCommitTitle.mergeMessage }, + squashMergeCommitMessage: { default: DefaultCommitMessage.prBody }, + squashMergeCommitTitle: { default: DefaultCommitTitle.prTitle }, +}); + const RefBuilder = createBuilderClass()({ name: { default: 'main' }, repository: { linked: RefRepositoryBuilder }, @@ -22,6 +40,7 @@ type Repository = PullRequestResponse['repository']; type PullRequest = Repository['pullRequest']; type Author = PullRequest['author']; type AssigneesConn = PullRequest['assignees']; +type CommitsConn = PullRequest['commits']; type LabelConn = PullRequest['labels']; export const PullRequestBuilder = createBuilderClass()({ @@ -35,14 +54,16 @@ export const PullRequestBuilder = createBuilderClass()({ body: { default: '**markdown**' }, bodyHTML: { default: '

markdown

' }, title: { default: 'plz merge' }, + titleHTML: { default: 'plz merge' }, assignees: createLink()({ nodes: { default: [ { - avatarUrl: undefined, - email: undefined, + avatarUrl: '', + email: '', login: 'me', url: 'https://github.com/me', + id: '123' }, ], }, @@ -51,6 +72,7 @@ export const PullRequestBuilder = createBuilderClass()({ login: { default: 'me' }, url: { default: 'https://github.com/me' }, avatarUrl: { default: 'https://avatars3.githubusercontent.com/u/17565?v=4' }, + id: { default: '123' }, }), createdAt: { default: '2019-01-01T10:00:00Z' }, updatedAt: { default: '2019-01-01T11:00:00Z' }, @@ -59,9 +81,9 @@ export const PullRequestBuilder = createBuilderClass()({ headRefOid: { default: '0000000000000000000000000000000000000000' }, headRepository: { linked: RefRepositoryBuilder }, baseRef: { linked: RefBuilder }, - baseRefName: { default: 'main'}, + baseRefName: { default: 'main' }, baseRefOid: { default: '0000000000000000000000000000000000000000' }, - baseRepository: { linked: RefRepositoryBuilder }, + baseRepository: { linked: BaseRefRepositoryBuilder }, labels: createLink()({ nodes: { default: [] }, }), @@ -70,7 +92,16 @@ export const PullRequestBuilder = createBuilderClass()({ mergeStateStatus: { default: 'CLEAN' }, isDraft: { default: false }, suggestedReviewers: { default: [] }, - }), + viewerCanEnableAutoMerge: { default: false }, + viewerCanDisableAutoMerge: { default: false }, + commits: createLink()({ + nodes: { + default: [ + { commit: { message: 'commit 1' } }, + ] + } + }) + }) }), rateLimit: { linked: RateLimitBuilder }, }); diff --git a/src/test/builders/managedPullRequestBuilder.ts b/src/test/builders/managedPullRequestBuilder.ts index 7983fe215f..a1ca3865cc 100644 --- a/src/test/builders/managedPullRequestBuilder.ts +++ b/src/test/builders/managedPullRequestBuilder.ts @@ -1,6 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { PullRequestResponse as PullRequestGraphQL, TimelineEventsResponse as TimelineEventsGraphQL, + LatestReviewCommitResponse as LatestReviewCommitGraphQL } from '../../github/graphql'; import { PullRequestBuilder as PullRequestGraphQLBuilder } from './graphql/pullRequestBuilder'; import { @@ -8,6 +14,7 @@ import { PullRequestUnion as PullRequestREST, } from './rest/pullRequestBuilder'; import { TimelineEventsBuilder as TimelineEventsGraphQLBuilder } from './graphql/timelineEventsBuilder'; +import { LatestReviewCommitBuilder as LatestReviewCommitGraphQLBuilder } from './graphql/latestReviewCommitBuilder'; import { RepoUnion as RepositoryREST, RepositoryBuilder as RepositoryRESTBuilder } from './rest/repoBuilder'; import { CombinedStatusBuilder as CombinedStatusRESTBuilder } from './rest/combinedStatusBuilder'; import { ReviewRequestsBuilder as ReviewRequestsRESTBuilder } from './rest/reviewRequestsBuilder'; @@ -24,6 +31,7 @@ export interface ManagedPullRequest { TimelineEventsGraphQL, OctokitCommon.IssuesListEventsForTimelineResponseData[] >; + latestReviewCommit: ResponseFlavor; repositoryREST: RepositoryREST; combinedStatusREST: PullRequestChecks; reviewRequestsREST: OctokitCommon.PullsListRequestedReviewersResponseData; @@ -32,6 +40,7 @@ export interface ManagedPullRequest { export const ManagedGraphQLPullRequestBuilder = createBuilderClass>()({ pullRequest: { linked: PullRequestGraphQLBuilder }, timelineEvents: { linked: TimelineEventsGraphQLBuilder }, + latestReviewCommit: { linked: LatestReviewCommitGraphQLBuilder }, repositoryREST: { linked: RepositoryRESTBuilder }, combinedStatusREST: { linked: CombinedStatusRESTBuilder }, reviewRequestsREST: { linked: ReviewRequestsRESTBuilder }, @@ -42,6 +51,7 @@ export type ManagedGraphQLPullRequestBuilder = InstanceType>()({ pullRequest: { linked: PullRequestRESTBuilder }, timelineEvents: { default: [] }, + latestReviewCommit: { default: 'abc' }, repositoryREST: { linked: RepositoryRESTBuilder }, combinedStatusREST: { linked: CombinedStatusRESTBuilder }, reviewRequestsREST: { linked: ReviewRequestsRESTBuilder }, diff --git a/src/test/builders/rest/combinedStatusBuilder.ts b/src/test/builders/rest/combinedStatusBuilder.ts index 927e9c9a5b..dc87383acc 100644 --- a/src/test/builders/rest/combinedStatusBuilder.ts +++ b/src/test/builders/rest/combinedStatusBuilder.ts @@ -1,6 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { createBuilderClass } from '../base'; import { OctokitCommon } from '../../../github/common'; -import { PullRequestChecks } from '../../../github/interface'; +import { CheckState, PullRequestChecks } from '../../../github/interface'; export const StatusItemBuilder = createBuilderClass()({ url: { @@ -20,7 +25,7 @@ export const StatusItemBuilder = createBuilderClass; export const CombinedStatusBuilder = createBuilderClass()({ - state: { default: 'success' }, + state: { default: CheckState.Success }, statuses: { default: [] }, }); diff --git a/src/test/common/utils.test.ts b/src/test/common/utils.test.ts index 251e0c4def..10b43aa2dc 100644 --- a/src/test/common/utils.test.ts +++ b/src/test/common/utils.test.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { default as assert } from 'assert'; import * as utils from '../../common/utils'; import { EventEmitter } from 'vscode'; @@ -26,6 +31,11 @@ describe('utils', () => { assert.strictEqual(utils.formatError(error), 'user_id can only have one pending review per pull request'); }); + it('should not format when error message contains all information', () => { + const error = new HookError('Validation Failed: Some Validation error', []); + assert.strictEqual(utils.formatError(error), 'Validation Failed: Some Validation error'); + }); + it('should format an error with submessages that are strings', () => { const error = new HookError('Validation Failed', ['Can not approve your own pull request']); assert.strictEqual(utils.formatError(error), 'Can not approve your own pull request'); @@ -41,137 +51,4 @@ describe('utils', () => { assert.strictEqual(utils.formatError(error), 'Cannot push to this repo'); }); }); - - describe('promiseFromEvent', () => { - const hasListeners = (emitter: any) => !emitter._listeners!.isEmpty(); - - describe('without arguments', () => { - it('should return a promise for the next event', async () => { - const emitter = new EventEmitter(); - const promise = utils.promiseFromEvent(emitter.event); - emitter.fire('hello'); - emitter.fire('world'); - const value = await promise; - assert.strictEqual(value, 'hello'); - }); - - it('should unsubscribe after the promise resolves', async () => { - const emitter = new EventEmitter(); - const promise = utils.promiseFromEvent(emitter.event); - assert(hasListeners(emitter), 'should subscribe'); - emitter.fire('hello'); - await promise; - assert(!hasListeners(emitter), 'should unsubscribe'); - }); - }); - - describe('with an adapter', () => { - const count: utils.PromiseAdapter = (str, resolve, reject) => - str.length <= 4 ? resolve(str.length) : reject(new Error('the string is too damn long')); - - it("should return a promise that uses the adapter's value", async () => { - const emitter = new EventEmitter(); - const promise = utils.promiseFromEvent(emitter.event, count); - assert(hasListeners(emitter), 'should subscribe'); - emitter.fire('hell'); - const value = await promise; - assert(!hasListeners(emitter), 'should unsubscribe'); - assert.strictEqual(value, 'hell'.length); - }); - - it('should return a promise that rejects if the adapter does', async () => { - const emitter = new EventEmitter(); - const promise = utils.promiseFromEvent(emitter.event, count); - assert(hasListeners(emitter), 'should subscribe'); - emitter.fire('hello'); - await promise.then( - () => { - throw new Error('promise should have rejected'); - }, - e => assert.strictEqual(e.message, 'the string is too damn long'), - ); - assert(!hasListeners(emitter), 'should unsubscribe'); - }); - - it('should return a promise that rejects if the adapter throws', async () => { - const emitter = new EventEmitter(); - const promise = utils.promiseFromEvent(emitter.event, () => { - throw new Error('kaboom'); - }); - assert(hasListeners(emitter), 'should subscribe'); - emitter.fire('hello'); - await promise.then( - () => { - throw new Error('promise should have rejected'); - }, - e => assert.strictEqual(e.message, 'kaboom'), - ); - assert(!hasListeners(emitter), 'should unsubscribe'); - }); - - it('should return a promise that rejects if the adapter returns a rejecting Promise', async () => { - const emitter = new EventEmitter(); - const promise = utils.promiseFromEvent(emitter.event, async () => { - throw new Error('kaboom'); - }); - assert(hasListeners(emitter), 'should subscribe'); - emitter.fire('hello'); - await promise.then( - () => { - throw new Error('promise should have rejected'); - }, - e => assert.strictEqual(e.message, 'kaboom'), - ); - assert(!hasListeners(emitter), 'should unsubscribe'); - }); - - const door: utils.PromiseAdapter = (password, resolve, reject) => - password === 'sesame' - ? resolve(true) - : password === 'mellon' - ? reject(new Error('wrong fable')) - : { - /* the door is silent */ - }; - - const tick = () => new Promise(resolve => timers.setImmediate(resolve)); - it('should stay subscribed until the adapter resolves', async () => { - const emitter = new EventEmitter(); - const promise = utils.promiseFromEvent(emitter.event, door); - let hasResolved = false; - promise.then(() => (hasResolved = true)); - emitter.fire('password'); - emitter.fire('12345'); - await tick(); - assert.strictEqual(hasResolved, false, "shouldn't have resolved yet"); - assert(hasListeners(emitter), 'should still be listening'); - emitter.fire('sesame'); - await tick(); - assert.strictEqual(hasResolved, true, 'should have resolved'); - assert(!hasListeners(emitter), 'should have unsubscribed'); - }); - - it('should stay subscribed until the adapter rejects', async () => { - const emitter = new EventEmitter(); - const promise = utils.promiseFromEvent(emitter.event, door); - let hasResolved = false, - hasRejected = false; - promise.then( - () => (hasResolved = true), - () => (hasRejected = true), - ); - emitter.fire('password'); - emitter.fire('12345'); - await tick(); - assert.strictEqual(hasResolved, false, "shouldn't resolve"); - assert.strictEqual(hasRejected, false, "shouldn't have rejected yet"); - assert(hasListeners(emitter), 'should still be listening'); - emitter.fire('mellon'); - await tick(); - assert.strictEqual(hasResolved, false, "shouldn't resolve"); - assert.strictEqual(hasRejected, true, 'should have rejected'); - assert(!hasListeners(emitter), 'should have unsubscribed'); - }); - }); - }); }); diff --git a/src/test/github/folderRepositoryManager.test.ts b/src/test/github/folderRepositoryManager.test.ts index 722c351be3..4fce6c2d98 100644 --- a/src/test/github/folderRepositoryManager.test.ts +++ b/src/test/github/folderRepositoryManager.test.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { default as assert } from 'assert'; import { createSandbox, SinonSandbox } from 'sinon'; @@ -6,7 +11,7 @@ import { MockRepository } from '../mocks/mockRepository'; import { MockTelemetry } from '../mocks/mockTelemetry'; import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; import { PullRequestModel } from '../../github/pullRequestModel'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { Protocol } from '../../common/protocol'; import { GitHubRepository } from '../../github/githubRepository'; import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; @@ -14,8 +19,8 @@ import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; import { GitApiImpl } from '../../api/api1'; import { CredentialStore } from '../../github/credentials'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; -import { MockSessionState } from '../mocks/mockSessionState'; import { Uri } from 'vscode'; +import { GitHubServerType } from '../../common/authentication'; describe('PullRequestManager', function () { let sinon: SinonSandbox; @@ -28,9 +33,9 @@ describe('PullRequestManager', function () { telemetry = new MockTelemetry(); const repository = new MockRepository(); - const credentialStore = new CredentialStore(telemetry); const context = new MockExtensionContext(); - manager = new FolderRepositoryManager(context, repository, telemetry, new GitApiImpl(), credentialStore, new MockSessionState()); + const credentialStore = new CredentialStore(telemetry, context); + manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); }); afterEach(function () { @@ -46,11 +51,11 @@ describe('PullRequestManager', function () { const url = 'https://github.com/aaa/bbb.git'; const protocol = new Protocol(url); - const remote = new Remote('origin', url, protocol); + const remote = new GitHubRemote('origin', url, protocol, GitHubServerType.GitHubDotCom); const rootUri = Uri.file('C:\\users\\test\\repo'); - const repository = new GitHubRepository(remote, rootUri, manager.credentialStore, telemetry, new MockSessionState()); + const repository = new GitHubRepository(remote, rootUri, manager.credentialStore, telemetry); const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().build(), repository); - const pr = new PullRequestModel(telemetry, repository, remote, prItem); + const pr = new PullRequestModel(manager.credentialStore, telemetry, repository, remote, prItem); manager.activePullRequest = pr; assert(changeFired.called); @@ -60,27 +65,27 @@ describe('PullRequestManager', function () { }); describe('titleAndBodyFrom', function () { - it('separates title and body', function () { - const message = 'title\n\ndescription 1\n\ndescription 2\n'; + it('separates title and body', async function () { + const message = Promise.resolve('title\n\ndescription 1\n\ndescription 2\n'); - const { title, body } = titleAndBodyFrom(message); - assert.strictEqual(title, 'title'); - assert.strictEqual(body, 'description 1\n\ndescription 2'); + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'description 1\n\ndescription 2'); }); - it('returns only title with no body', function () { - const message = 'title'; + it('returns only title with no body', async function () { + const message = Promise.resolve('title'); - const { title, body } = titleAndBodyFrom(message); - assert.strictEqual(title, 'title'); - assert.strictEqual(body, ''); + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, ''); }); - it('returns only title when body contains only whitespace', function () { - const message = 'title\n\n'; + it('returns only title when body contains only whitespace', async function () { + const message = Promise.resolve('title\n\n'); - const { title, body } = titleAndBodyFrom(message); - assert.strictEqual(title, 'title'); - assert.strictEqual(body, ''); + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, ''); }); }); diff --git a/src/test/github/githubRepository.test.ts b/src/test/github/githubRepository.test.ts index 87a5d4bbfd..493f4a99f3 100644 --- a/src/test/github/githubRepository.test.ts +++ b/src/test/github/githubRepository.test.ts @@ -1,25 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { default as assert } from 'assert'; import { SinonSandbox, createSandbox } from 'sinon'; import { CredentialStore } from '../../github/credentials'; import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; import { MockTelemetry } from '../mocks/mockTelemetry'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { Protocol } from '../../common/protocol'; import { GitHubRepository } from '../../github/githubRepository'; -import { MockSessionState } from '../mocks/mockSessionState'; import { Uri } from 'vscode'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { GitHubManager } from '../../authentication/githubServer'; +import { GitHubServerType } from '../../common/authentication'; describe('GitHubRepository', function () { let sinon: SinonSandbox; let credentialStore: CredentialStore; let telemetry: MockTelemetry; + let context: MockExtensionContext; beforeEach(function () { sinon = createSandbox(); MockCommandRegistry.install(sinon); telemetry = new MockTelemetry(); - credentialStore = new CredentialStore(telemetry); + context = new MockExtensionContext(); + credentialStore = new CredentialStore(telemetry, context); }); afterEach(function () { @@ -29,18 +38,18 @@ describe('GitHubRepository', function () { describe('isGitHubDotCom', function () { it('detects when the remote is pointing to github.com', function () { const url = 'https://github.com/some/repo'; - const remote = new Remote('origin', url, new Protocol(url)); + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); const rootUri = Uri.file('C:\\users\\test\\repo'); - const dotcomRepository = new GitHubRepository(remote, rootUri, credentialStore, telemetry, new MockSessionState()); - assert(dotcomRepository.isGitHubDotCom); + const dotcomRepository = new GitHubRepository(remote, rootUri, credentialStore, telemetry); + assert(GitHubManager.isGithubDotCom(Uri.parse(remote.url).authority)); }); it('detects when the remote is pointing somewhere other than github.com', function () { const url = 'https://github.enterprise.horse/some/repo'; - const remote = new Remote('origin', url, new Protocol(url)); + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); const rootUri = Uri.file('C:\\users\\test\\repo'); - const dotcomRepository = new GitHubRepository(remote, rootUri, credentialStore, telemetry, new MockSessionState()); - assert(!dotcomRepository.isGitHubDotCom); + const dotcomRepository = new GitHubRepository(remote, rootUri, credentialStore, telemetry); + // assert(! dotcomRepository.isGitHubDotCom); }); }); }); diff --git a/src/test/github/pullRequestGitHelper.test.ts b/src/test/github/pullRequestGitHelper.test.ts index 9f4660f31e..8779e56c9e 100644 --- a/src/test/github/pullRequestGitHelper.test.ts +++ b/src/test/github/pullRequestGitHelper.test.ts @@ -1,10 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { default as assert } from 'assert'; import { MockRepository } from '../mocks/mockRepository'; import { PullRequestGitHelper } from '../../github/pullRequestGitHelper'; import { PullRequestModel } from '../../github/pullRequestModel'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { Protocol } from '../../common/protocol'; import { CredentialStore } from '../../github/credentials'; import { MockTelemetry } from '../mocks/mockTelemetry'; @@ -14,12 +19,15 @@ import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; import { RefType } from '../../api/api1'; import { RepositoryBuilder } from '../builders/rest/repoBuilder'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { GitHubServerType } from '../../common/authentication'; describe('PullRequestGitHelper', function () { let sinon: SinonSandbox; let repository: MockRepository; let telemetry: MockTelemetry; let credentialStore: CredentialStore; + let context: MockExtensionContext; beforeEach(function () { sinon = createSandbox(); @@ -28,7 +36,8 @@ describe('PullRequestGitHelper', function () { repository = new MockRepository(); telemetry = new MockTelemetry(); - credentialStore = new CredentialStore(telemetry); + context = new MockExtensionContext(); + credentialStore = new CredentialStore(telemetry, context); }); afterEach(function () { @@ -38,7 +47,7 @@ describe('PullRequestGitHelper', function () { describe('checkoutFromFork', function () { it('fetches, checks out, and configures a branch from a fork', async function () { const url = 'git@github.com:owner/name.git'; - const remote = new Remote('elsewhere', url, new Protocol(url)); + const remote = new GitHubRemote('elsewhere', url, new Protocol(url), GitHubServerType.GitHubDotCom); const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); const prItem = convertRESTPullRequestToRawPullRequest( @@ -59,13 +68,13 @@ describe('PullRequestGitHelper', function () { repository.expectFetch('you', 'my-branch:pr/me/100', 1); repository.expectPull(true); - const pullRequest = new PullRequestModel(telemetry, gitHubRepository, remote, prItem); + const pullRequest = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem); if (!pullRequest.isResolved()) { assert(false, 'pull request head not resolved successfully'); } - await PullRequestGitHelper.checkoutFromFork(repository, pullRequest, undefined); + await PullRequestGitHelper.checkoutFromFork(repository, pullRequest, undefined, { report: () => undefined }); assert.deepStrictEqual(repository.state.remotes, [ { diff --git a/src/test/github/pullRequestModel.test.ts b/src/test/github/pullRequestModel.test.ts index 2d67949290..7a976af689 100644 --- a/src/test/github/pullRequestModel.test.ts +++ b/src/test/github/pullRequestModel.test.ts @@ -1,10 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { default as assert } from 'assert'; import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; import { CredentialStore } from '../../github/credentials'; import { PullRequestModel } from '../../github/pullRequestModel'; import { GithubItemStateEnum } from '../../github/interface'; import { Protocol } from '../../common/protocol'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; import { SinonSandbox, createSandbox } from 'sinon'; import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; @@ -13,11 +18,13 @@ import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; import { NetworkStatus } from 'apollo-client'; import { Resource } from '../../common/resources'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; -const queries = require('../../github/queries.gql'); +import { GitHubServerType } from '../../common/authentication'; +import { mergeQuerySchemaWithShared } from '../../github/common'; +const queries = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; const telemetry = new MockTelemetry(); const protocol = new Protocol('https://github.com/github/test.git'); -const remote = new Remote('test', 'github/test', protocol); +const remote = new GitHubRemote('test', 'github/test', protocol, GitHubServerType.GitHubDotCom); const reviewThreadResponse = { id: '1', @@ -56,9 +63,9 @@ describe('PullRequestModel', function () { sinon = createSandbox(); MockCommandRegistry.install(sinon); - credentials = new CredentialStore(telemetry); - repo = new MockGitHubRepository(remote, credentials, telemetry, sinon); context = new MockExtensionContext(); + credentials = new CredentialStore(telemetry, context); + repo = new MockGitHubRepository(remote, credentials, telemetry, sinon); Resource.initialize(context); }); @@ -71,21 +78,21 @@ describe('PullRequestModel', function () { it('should return `state` properly as `open`', function () { const pr = new PullRequestBuilder().state('open').build(); - const open = new PullRequestModel(telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); assert.strictEqual(open.state, GithubItemStateEnum.Open); }); it('should return `state` properly as `closed`', function () { const pr = new PullRequestBuilder().state('closed').build(); - const open = new PullRequestModel(telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); assert.strictEqual(open.state, GithubItemStateEnum.Closed); }); it('should return `state` properly as `merged`', function () { const pr = new PullRequestBuilder().merged(true).state('closed').build(); - const open = new PullRequestModel(telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); + const open = new PullRequestModel(credentials, telemetry, repo, remote, convertRESTPullRequestToRawPullRequest(pr, repo)); assert.strictEqual(open.state, GithubItemStateEnum.Merged); }); @@ -94,6 +101,7 @@ describe('PullRequestModel', function () { it('should update the cache when then cache is initialized', async function () { const pr = new PullRequestBuilder().build(); const model = new PullRequestModel( + credentials, telemetry, repo, remote, @@ -117,6 +125,9 @@ describe('PullRequestModel', function () { nodes: [ reviewThreadResponse ], + pageInfo: { + hasNextPage: false + } }, }, }, diff --git a/src/test/github/pullRequestOverview.test.ts b/src/test/github/pullRequestOverview.test.ts index 83f20ba4f8..bf80c1edae 100644 --- a/src/test/github/pullRequestOverview.test.ts +++ b/src/test/github/pullRequestOverview.test.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { default as assert } from 'assert'; import * as vscode from 'vscode'; import { SinonSandbox, createSandbox, match as sinonMatch } from 'sinon'; @@ -8,7 +13,6 @@ import { MockRepository } from '../mocks/mockRepository'; import { PullRequestOverviewPanel } from '../../github/pullRequestOverview'; import { PullRequestModel } from '../../github/pullRequestModel'; import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; -import { Remote } from '../../common/remote'; import { Protocol } from '../../common/protocol'; import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; @@ -16,7 +20,9 @@ import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; import { GitApiImpl } from '../../api/api1'; import { CredentialStore } from '../../github/credentials'; -import { MockSessionState } from '../mocks/mockSessionState'; +import { GitHubServerType } from '../../common/authentication'; +import { GitHubRemote } from '../../common/remote'; +import { CheckState } from '../../github/interface'; const EXTENSION_URI = vscode.Uri.joinPath(vscode.Uri.file(__dirname), '../../..'); @@ -24,9 +30,10 @@ describe('PullRequestOverview', function () { let sinon: SinonSandbox; let pullRequestManager: FolderRepositoryManager; let context: MockExtensionContext; - let remote: Remote; + let remote: GitHubRemote; let repo: MockGitHubRepository; let telemetry: MockTelemetry; + let credentialStore: CredentialStore; beforeEach(async function () { sinon = createSandbox(); @@ -35,11 +42,11 @@ describe('PullRequestOverview', function () { const repository = new MockRepository(); telemetry = new MockTelemetry(); - const credentialStore = new CredentialStore(telemetry); - pullRequestManager = new FolderRepositoryManager(context, repository, telemetry, new GitApiImpl(), credentialStore, new MockSessionState()); + credentialStore = new CredentialStore(telemetry, context); + pullRequestManager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); const url = 'https://github.com/aaa/bbb'; - remote = new Remote('origin', url, new Protocol(url)); + remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); repo = new MockGitHubRepository(remote, pullRequestManager.credentialStore, telemetry, sinon); }); @@ -67,7 +74,7 @@ describe('PullRequestOverview', function () { }); const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); - const prModel = new PullRequestModel(telemetry, repo, remote, prItem); + const prModel = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem); await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel); @@ -100,23 +107,24 @@ describe('PullRequestOverview', function () { }); const prItem0 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); - const prModel0 = new PullRequestModel(telemetry, repo, remote, prItem0); + const prModel0 = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem0); const resolveStub = sinon.stub(pullRequestManager, 'resolvePullRequest').resolves(prModel0); sinon.stub(prModel0, 'getReviewRequests').resolves([]); sinon.stub(prModel0, 'getTimelineEvents').resolves([]); - sinon.stub(prModel0, 'getStatusChecks').resolves({ state: 'pending', statuses: [] }); + sinon.stub(prModel0, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel0); const panel0 = PullRequestOverviewPanel.currentPanel; assert.notStrictEqual(panel0, undefined); assert.strictEqual(createWebviewPanel.callCount, 1); + assert.strictEqual(panel0!.getCurrentTitle(), 'Pull Request #1000'); const prItem1 = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(2000).build(), repo); - const prModel1 = new PullRequestModel(telemetry, repo, remote, prItem1); + const prModel1 = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem1); resolveStub.resolves(prModel1); sinon.stub(prModel1, 'getReviewRequests').resolves([]); sinon.stub(prModel1, 'getTimelineEvents').resolves([]); - sinon.stub(prModel1, 'getStatusChecks').resolves({ state: 'pending', statuses: [] }); + sinon.stub(prModel1, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel1); assert.strictEqual(panel0, PullRequestOverviewPanel.currentPanel); diff --git a/src/test/github/utils.test.ts b/src/test/github/utils.test.ts index 0f365d75b5..71c93c0851 100644 --- a/src/test/github/utils.test.ts +++ b/src/test/github/utils.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { default as assert } from 'assert'; -import { getPRFetchQuery } from '../../github/utils'; +import { getPRFetchQuery, sanitizeIssueTitle } from '../../github/utils'; describe('utils', () => { @@ -17,4 +17,29 @@ describe('utils', () => { assert.strictEqual(result, 'is:pull-request reviewed-by:rmacfarlane -author:rmacfarlane type:pr repo:microsoft/vscode-pull-request-github'); }); }); + + describe('sanitizeIssueTitle', () => { + [ + { input: 'Issue', expected: 'Issue' }, + { input: 'Issue A', expected: 'Issue-A' }, + { input: 'Issue A', expected: 'Issue-A' }, + { input: 'Issue A', expected: 'Issue-A' }, + { input: 'Issue @ A', expected: 'Issue-A' }, + { input: "Issue 'A'", expected: 'Issue-A' }, + { input: 'Issue "A"', expected: 'Issue-A' }, + { input: '@Issue "A"', expected: 'Issue-A' }, + { input: 'Issue "A"%', expected: 'Issue-A' }, + { input: 'Issue .A', expected: 'Issue-A' }, + { input: 'Issue ,A', expected: 'Issue-A' }, + { input: 'Issue :A', expected: 'Issue-A' }, + { input: 'Issue ;A', expected: 'Issue-A' }, + { input: 'Issue ~A', expected: 'Issue-A' }, + { input: 'Issue #A', expected: 'Issue-A' }, + ].forEach(testCase => { + it(`Transforms '${testCase.input}' into '${testCase.expected}'`, () => { + const actual = sanitizeIssueTitle(testCase.input); + assert.strictEqual(actual, testCase.expected); + }); + }); + }); }); \ No newline at end of file diff --git a/src/test/issues/issuesUtils.test.ts b/src/test/issues/issuesUtils.test.ts index ba6cdbc3b5..90c45e0fa0 100644 --- a/src/test/issues/issuesUtils.test.ts +++ b/src/test/issues/issuesUtils.test.ts @@ -1,5 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { default as assert } from 'assert'; -import { parseIssueExpressionOutput, sanitizeIssueTitle, ISSUE_OR_URL_EXPRESSION } from '../../issues/util'; +import { parseIssueExpressionOutput, ISSUE_OR_URL_EXPRESSION } from '../../github/utils'; describe('Issues utilities', function () { it('regular expressions', async function () { @@ -44,29 +49,4 @@ describe('Issues utilities', function () { const notIssueParsed = parseIssueExpressionOutput(notIssue.match(ISSUE_OR_URL_EXPRESSION)); assert.strictEqual(notIssueParsed, undefined); }); - - describe('sanitizeIssueTitle', () => { - [ - { input: 'Issue', expected: 'Issue' }, - { input: 'Issue A', expected: 'Issue-A' }, - { input: 'Issue A', expected: 'Issue-A' }, - { input: 'Issue A', expected: 'Issue-A' }, - { input: 'Issue @ A', expected: 'Issue-A' }, - { input: "Issue 'A'", expected: 'Issue-A' }, - { input: 'Issue "A"', expected: 'Issue-A' }, - { input: '@Issue "A"', expected: 'Issue-A' }, - { input: 'Issue "A"%', expected: 'Issue-A' }, - { input: 'Issue .A', expected: 'Issue-A' }, - { input: 'Issue ,A', expected: 'Issue-A' }, - { input: 'Issue :A', expected: 'Issue-A' }, - { input: 'Issue ;A', expected: 'Issue-A' }, - { input: 'Issue ~A', expected: 'Issue-A' }, - { input: 'Issue #A', expected: 'Issue-A' }, - ].forEach(testCase => { - it(`Transforms '${testCase.input}' into '${testCase.expected}'`, () => { - const actual = sanitizeIssueTitle(testCase.input); - assert.strictEqual(actual, testCase.expected); - }); - }); - }); }); diff --git a/src/test/mocks/mockGitHubRepository.ts b/src/test/mocks/mockGitHubRepository.ts index 9a719db604..27ef898430 100644 --- a/src/test/mocks/mockGitHubRepository.ts +++ b/src/test/mocks/mockGitHubRepository.ts @@ -1,9 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { SinonSandbox } from 'sinon'; import { QueryOptions, ApolloQueryResult, FetchResult, MutationOptions, NetworkStatus, OperationVariables } from 'apollo-boost'; import { GitHubRepository } from '../../github/githubRepository'; import { QueryProvider } from './queryProvider'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { CredentialStore } from '../../github/credentials'; import { RepositoryBuilder } from '../builders/rest/repoBuilder'; import { UserBuilder } from '../builders/rest/userBuilder'; @@ -13,20 +18,21 @@ import { ManagedPullRequest, } from '../builders/managedPullRequestBuilder'; import { MockTelemetry } from './mockTelemetry'; -import { MockSessionState } from './mockSessionState'; import { Uri } from 'vscode'; -const queries = require('../../github/queries.gql'); +import { LoggingOctokit, RateLogger } from '../../github/loggingOctokit'; +import { mergeQuerySchemaWithShared } from '../../github/common'; +const queries = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; export class MockGitHubRepository extends GitHubRepository { readonly queryProvider: QueryProvider; - constructor(remote: Remote, credentialStore: CredentialStore, telemetry: MockTelemetry, sinon: SinonSandbox) { - super(remote, Uri.file('C:\\users\\test\\repo'),credentialStore, telemetry, new MockSessionState()); + constructor(remote: GitHubRemote, credentialStore: CredentialStore, telemetry: MockTelemetry, sinon: SinonSandbox) { + super(remote, Uri.file('C:\\users\\test\\repo'), credentialStore, telemetry); this.queryProvider = new QueryProvider(sinon); this._hub = { - octokit: this.queryProvider.octokit, + octokit: new LoggingOctokit(this.queryProvider.octokit, new RateLogger(new MockTelemetry(), true)), graphql: null, }; @@ -90,6 +96,18 @@ export class MockGitHubRepository extends GitHubRepository { { data: responses.timelineEvents, loading: false, stale: false, networkStatus: NetworkStatus.ready }, ); + this.queryProvider.expectGraphQLQuery( + { + query: queries.LatestReviewCommit, + variables: { + owner: this.remote.owner, + name: this.remote.repositoryName, + number: prNumber, + } + }, + { data: responses.latestReviewCommit, loading: false, stale: false, networkStatus: NetworkStatus.ready }, + ) + this._addPullRequestCommon(prNumber, headRef && headRef.target.oid, responses); return responses; diff --git a/src/test/mocks/mockRepository.ts b/src/test/mocks/mockRepository.ts index 0071b7231a..8d719ac72f 100644 --- a/src/test/mocks/mockRepository.ts +++ b/src/test/mocks/mockRepository.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { Uri } from 'vscode'; import { RefType } from '../../api/api1'; @@ -13,6 +18,7 @@ import type { Ref, BranchQuery, FetchOptions, + RefQuery, } from '../../api/api'; type Mutable = { @@ -20,6 +26,9 @@ type Mutable = { }; export class MockRepository implements Repository { + add(paths: string[]): Promise { + return Promise.reject(new Error(`Unexpected add(${paths.join(', ')})`)); + } commit(message: string, opts?: CommitOptions): Promise { return Promise.reject(new Error(`Unexpected commit(${message}, ${opts})`)); } @@ -50,11 +59,15 @@ export class MockRepository implements Repository { getMergeBase(ref1: string, ref2: string): Promise { return Promise.reject(new Error(`Unexpected getMergeBase(${ref1}, ${ref2})`)); } + async getRefs(_query: RefQuery, _cancellationToken?: any): Promise { + // ignore the query + return this._state.refs; + } log(options?: any): Promise { return Promise.reject(new Error(`Unexpected log(${options})`)); } - private _state: Mutable = { + private _state: Mutable = { HEAD: undefined, refs: [], remotes: [], diff --git a/src/test/view/prsTree.test.ts b/src/test/view/prsTree.test.ts index 7a09e3166d..e22fcc5b75 100644 --- a/src/test/view/prsTree.test.ts +++ b/src/test/view/prsTree.test.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import * as vscode from 'vscode'; import { SinonSandbox, createSandbox } from 'sinon'; import { default as assert } from 'assert'; @@ -13,14 +18,17 @@ import { MockCommandRegistry } from '../mocks/mockCommandRegistry'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; import { PullRequestGitHelper } from '../../github/pullRequestGitHelper'; import { PullRequestModel } from '../../github/pullRequestModel'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { Protocol } from '../../common/protocol'; import { CredentialStore, GitHub } from '../../github/credentials'; import { parseGraphQLPullRequest } from '../../github/utils'; import { Resource } from '../../common/resources'; import { GitApiImpl } from '../../api/api1'; import { RepositoriesManager } from '../../github/repositoriesManager'; -import { MockSessionState } from '../mocks/mockSessionState'; +import { LoggingOctokit, RateLogger } from '../../github/loggingOctokit'; +import { GitHubServerType } from '../../common/authentication'; +import { DataUri } from '../../common/uri'; +import { IAccount, ITeam } from '../../github/interface'; describe('GitHub Pull Requests view', function () { let sinon: SinonSandbox; @@ -28,6 +36,7 @@ describe('GitHub Pull Requests view', function () { let telemetry: MockTelemetry; let provider: PullRequestsTreeDataProvider; let credentialStore: CredentialStore; + let reposManager: RepositoriesManager; beforeEach(function () { sinon = createSandbox(); @@ -36,19 +45,23 @@ describe('GitHub Pull Requests view', function () { context = new MockExtensionContext(); telemetry = new MockTelemetry(); - provider = new PullRequestsTreeDataProvider(telemetry); - credentialStore = new CredentialStore(telemetry); + reposManager = new RepositoriesManager( + credentialStore, + telemetry, + ); + provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager); + credentialStore = new CredentialStore(telemetry, context); // For tree view unit tests, we don't test the authentication flow, so `showSignInNotification` returns // a dummy GitHub/Octokit object. sinon.stub(credentialStore, 'showSignInNotification').callsFake(async () => { const github: GitHub = { - octokit: new Octokit({ + octokit: new LoggingOctokit(new Octokit({ request: {}, baseUrl: 'https://github.com', userAgent: 'GitHub VSCode Pull Requests', previews: ['shadow-cat-preview'], - }), + }), new RateLogger(telemetry, true)), graphql: null, }; @@ -80,51 +93,32 @@ describe('GitHub Pull Requests view', function () { assert.strictEqual(rootNodes.length, 0); }); - it('displays a message when repositories have not yet been initialized', async function () { + it('has no children when repositories have not yet been initialized', async function () { const repository = new MockRepository(); repository.addRemote('origin', 'git@github.com:aaa/bbb'); - const mockSessionState = new MockSessionState(); - const manager = new RepositoriesManager( - [new FolderRepositoryManager(context, repository, telemetry, new GitApiImpl(), credentialStore, mockSessionState)], - credentialStore, - telemetry, - mockSessionState - ); - provider.initialize(manager); + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore)); + provider.initialize([], credentialStore); const rootNodes = await provider.getChildren(); - assert.strictEqual(rootNodes.length, 1); - - const [onlyNode] = rootNodes; - const onlyItem = onlyNode.getTreeItem(); - assert.strictEqual(onlyItem.collapsibleState, vscode.TreeItemCollapsibleState.None); - assert.strictEqual(onlyItem.label, 'Loading...'); - assert.strictEqual(onlyItem.command, undefined); + assert.strictEqual(rootNodes.length, 0); }); it('opens the viewlet and displays the default categories', async function () { const repository = new MockRepository(); repository.addRemote('origin', 'git@github.com:aaa/bbb'); - const mockSessionState = new MockSessionState(); - - const manager = new RepositoriesManager( - [new FolderRepositoryManager(context, repository, telemetry, new GitApiImpl(), credentialStore, mockSessionState)], - credentialStore, - telemetry, - mockSessionState - ); - + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore)); sinon.stub(credentialStore, 'isAuthenticated').returns(true); - await manager.folderManagers[0].updateRepositories(); - provider.initialize(manager); + await reposManager.folderManagers[0].updateRepositories(); + provider.initialize([], credentialStore); const rootNodes = await provider.getChildren(); // All but the last category are expected to be collapsed - assert(rootNodes.slice(0, rootNodes.length - 1).every(n => n.getTreeItem().collapsibleState === vscode.TreeItemCollapsibleState.Collapsed)); - assert(rootNodes[rootNodes.length - 1].getTreeItem().collapsibleState === vscode.TreeItemCollapsibleState.Expanded); + const treeItems = await Promise.all(rootNodes.map(node => node.getTreeItem())); + assert(treeItems.slice(0, treeItems.length - 1).every(n => n.collapsibleState === vscode.TreeItemCollapsibleState.Collapsed)); + assert(treeItems[treeItems.length - 1].collapsibleState === vscode.TreeItemCollapsibleState.Expanded); assert.deepStrictEqual( - rootNodes.map(n => n.getTreeItem().label), + treeItems.map(n => n.label), ['Local Pull Request Branches', 'Waiting For My Review', 'Assigned To Me', 'Created By Me', 'All Open'], ); }); @@ -132,7 +126,7 @@ describe('GitHub Pull Requests view', function () { describe('Local Pull Request Branches', function () { it('creates a node for each local pull request', async function () { const url = 'git@github.com:aaa/bbb'; - const remote = new Remote('origin', url, new Protocol(url)); + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); gitHubRepository.buildMetadata(m => { m.clone_url('https://github.com/aaa/bbb'); @@ -144,19 +138,19 @@ describe('GitHub Pull Requests view', function () { r.pullRequest(p => { p.number(1111); p.title('zero'); - p.author(a => a.login('me').avatarUrl('https://avatars.com/me.jpg')); + p.author(a => a.login('me').avatarUrl('https://avatars.com/me.jpg').url('https://github.com/me')); p.baseRef!(b => b.repository(br => br.url('https://github.com/aaa/bbb'))); p.baseRepository(r => r.url('https://github.com/aaa/bbb')); }), - ); - }); - }).pullRequest; - const prItem0 = parseGraphQLPullRequest(pr0, gitHubRepository); - const pullRequest0 = new PullRequestModel(telemetry, gitHubRepository, remote, prItem0); - - const pr1 = gitHubRepository.addGraphQLPullRequest(builder => { - builder.pullRequest(pr => { - pr.repository(r => + ); + }); + }).pullRequest; + const prItem0 = parseGraphQLPullRequest(pr0.repository.pullRequest, gitHubRepository); + const pullRequest0 = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem0); + + const pr1 = gitHubRepository.addGraphQLPullRequest(builder => { + builder.pullRequest(pr => { + pr.repository(r => r.pullRequest(p => { p.number(2222); p.title('one'); @@ -167,8 +161,8 @@ describe('GitHub Pull Requests view', function () { ); }); }).pullRequest; - const prItem1 = parseGraphQLPullRequest(pr1, gitHubRepository); - const pullRequest1 = new PullRequestModel(telemetry, gitHubRepository, remote, prItem1); + const prItem1 = parseGraphQLPullRequest(pr1.repository.pullRequest, gitHubRepository); + const pullRequest1 = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem1); const repository = new MockRepository(); await repository.addRemote(remote.remoteName, remote.url); @@ -180,39 +174,45 @@ describe('GitHub Pull Requests view', function () { await repository.createBranch('non-pr-branch', false); - const manager = new FolderRepositoryManager(context, repository, telemetry, new GitApiImpl(), credentialStore, new MockSessionState()); - const reposManager = new RepositoriesManager([manager], credentialStore, telemetry, new MockSessionState()); + const manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); + reposManager.insertFolderManager(manager); sinon.stub(manager, 'createGitHubRepository').callsFake((r, cs) => { assert.deepStrictEqual(r, remote); assert.strictEqual(cs, credentialStore); - return gitHubRepository; + return Promise.resolve(gitHubRepository); }); sinon.stub(credentialStore, 'isAuthenticated').returns(true); + sinon.stub(DataUri, 'avatarCirclesAsImageDataUris').callsFake((context: vscode.ExtensionContext, users: (IAccount | ITeam)[], height: number, width: number, localOnly?: boolean) => { + return Promise.resolve(users.map(user => user.avatarUrl ? vscode.Uri.parse(user.avatarUrl) : undefined)); + }); await manager.updateRepositories(); - provider.initialize(reposManager); + provider.initialize([], credentialStore); manager.activePullRequest = pullRequest1; const rootNodes = await provider.getChildren(); - const localNode = rootNodes.find(node => node.getTreeItem().label === 'Local Pull Request Branches'); + const rootTreeItems = await Promise.all(rootNodes.map(node => node.getTreeItem())); + const localNode = rootNodes.find((_node, index) => rootTreeItems[index].label === 'Local Pull Request Branches'); assert(localNode); + // Need to call getChildren twice to get past the quick render with an empty list + await localNode!.getChildren(); const localChildren = await localNode!.getChildren(); assert.strictEqual(localChildren.length, 2); - const [localItem0, localItem1] = localChildren.map(node => node.getTreeItem()); + const [localItem0, localItem1] = await Promise.all(localChildren.map(node => node.getTreeItem())); - assert.strictEqual(localItem0.label, '#1111: zero'); + assert.strictEqual(localItem0.label, 'zero'); assert.strictEqual(localItem0.tooltip, 'zero by @me'); assert.strictEqual(localItem0.description, 'by @me'); assert.strictEqual(localItem0.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); assert.strictEqual(localItem0.contextValue, 'pullrequest:local:nonactive'); - assert.deepStrictEqual(localItem0.iconPath!.toString(), 'https://avatars.com/me.jpg&s=64'); + assert.deepStrictEqual(localItem0.iconPath!.toString(), 'https://avatars.com/me.jpg'); - assert.strictEqual(localItem1.label, '✓ #2222: one'); + assert.strictEqual(localItem1.label, '✓ one'); assert.strictEqual(localItem1.tooltip, 'Current Branch * one by @you'); assert.strictEqual(localItem1.description, 'by @you'); assert.strictEqual(localItem1.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); assert.strictEqual(localItem1.contextValue, 'pullrequest:local:active'); - assert.deepStrictEqual(localItem1.iconPath!.toString(), 'https://avatars.com/you.jpg&s=64'); + assert.deepStrictEqual(localItem1.iconPath!.toString(), 'https://avatars.com/you.jpg'); }); }); }); diff --git a/src/test/view/reviewCommentController.test.ts b/src/test/view/reviewCommentController.test.ts index 2c36876ab2..dc5b86dcb9 100644 --- a/src/test/view/reviewCommentController.test.ts +++ b/src/test/view/reviewCommentController.test.ts @@ -20,23 +20,27 @@ import { PullRequestBuilder } from '../builders/rest/pullRequestBuilder'; import { convertRESTPullRequestToRawPullRequest } from '../../github/utils'; import { PullRequestModel } from '../../github/pullRequestModel'; import { Protocol } from '../../common/protocol'; -import { Remote } from '../../common/remote'; +import { GitHubRemote, Remote } from '../../common/remote'; import { GHPRCommentThread } from '../../github/prComment'; import { DiffLine } from '../../common/diffHunk'; import { MockGitHubRepository } from '../mocks/mockGitHubRepository'; import { GitApiImpl } from '../../api/api1'; -import { DiffSide } from '../../common/comment'; +import { DiffSide, SubjectType } from '../../common/comment'; import { ReviewManager, ShowPullRequest } from '../../view/reviewManager'; import { PullRequestChangesTreeDataProvider } from '../../view/prChangesTreeDataProvider'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; -import { MockSessionState } from '../mocks/mockSessionState'; import { ReviewModel } from '../../view/reviewModel'; import { Resource } from '../../common/resources'; import { RepositoriesManager } from '../../github/repositoriesManager'; -const schema = require('../../github/queries.gql'); +import { GitFileChangeModel } from '../../view/fileChangeModel'; +import { WebviewViewCoordinator } from '../../view/webviewViewCoordinator'; +import { GitHubServerType } from '../../common/authentication'; +import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; +import { mergeQuerySchemaWithShared } from '../../github/common'; +const schema = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any; const protocol = new Protocol('https://github.com/github/test.git'); -const remote = new Remote('test', 'github/test', protocol); +const remote = new GitHubRemote('test', 'github/test', protocol, GitHubServerType.GitHubDotCom); class TestReviewCommentController extends ReviewCommentController { public workspaceFileChangeCommentThreads() { @@ -54,26 +58,30 @@ describe('ReviewCommentController', function () { let activePullRequest: PullRequestModel; let githubRepo: MockGitHubRepository; let reviewManager: ReviewManager; + let reposManager: RepositoriesManager; beforeEach(async function () { sinon = createSandbox(); MockCommandRegistry.install(sinon); telemetry = new MockTelemetry(); - credentialStore = new CredentialStore(telemetry); + const context = new MockExtensionContext(); + credentialStore = new CredentialStore(telemetry, context); repository = new MockRepository(); repository.addRemote('origin', 'git@github.com:aaa/bbb'); - - provider = new PullRequestsTreeDataProvider(telemetry); - const context = new MockExtensionContext(); + reposManager = new RepositoriesManager(credentialStore, telemetry); + provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager); + const activePrViewCoordinator = new WebviewViewCoordinator(context); + const createPrHelper = new CreatePullRequestHelper(); Resource.initialize(context); const gitApiImpl = new GitApiImpl(); - manager = new FolderRepositoryManager(context, repository, telemetry, gitApiImpl, credentialStore, new MockSessionState()); - const tree = new PullRequestChangesTreeDataProvider(context, gitApiImpl, new RepositoriesManager([manager], credentialStore, telemetry, new MockSessionState())); - reviewManager = new ReviewManager(context, repository, manager, telemetry, tree, new ShowPullRequest(), new MockSessionState()); + manager = new FolderRepositoryManager(0, context, repository, telemetry, gitApiImpl, credentialStore); + reposManager.insertFolderManager(manager); + const tree = new PullRequestChangesTreeDataProvider(context, gitApiImpl, reposManager); + reviewManager = new ReviewManager(0, context, repository, manager, telemetry, tree, provider, new ShowPullRequest(), activePrViewCoordinator, createPrHelper, gitApiImpl); sinon.stub(manager, 'createGitHubRepository').callsFake((r, cStore) => { - return new MockGitHubRepository(r, cStore, telemetry, sinon); + return Promise.resolve(new MockGitHubRepository(GitHubRemote.remoteAsGitHub(r, GitHubServerType.GitHubDotCom), cStore, telemetry, sinon)); }); sinon.stub(credentialStore, 'isAuthenticated').returns(false); await manager.updateRepositories(); @@ -81,6 +89,7 @@ describe('ReviewCommentController', function () { const pr = new PullRequestBuilder().build(); githubRepo = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); activePullRequest = new PullRequestModel( + credentialStore, telemetry, githubRepo, remote, @@ -95,8 +104,7 @@ describe('ReviewCommentController', function () { }); function createLocalFileChange(uri: vscode.Uri, fileName: string, rootUri: vscode.Uri): GitFileChangeNode { - return new GitFileChangeNode( - provider, + const gitFileChangeModel = new GitFileChangeModel( manager, activePullRequest, { @@ -104,33 +112,40 @@ describe('ReviewCommentController', function () { fileName, blobUrl: 'https://example.com', diffHunks: - [ - { - oldLineNumber: 22, - oldLength: 5, - newLineNumber: 22, - newLength: 11, - positionInHunk: 0, - diffLines: [ - new DiffLine(3, -1, -1, 0, '@@ -22,5 +22,11 @@', true), - new DiffLine(0, 22, 22, 1, " 'title': 'Papayas',", true), - new DiffLine(0, 23, 23, 2, " 'title': 'Papayas',", true), - new DiffLine(0, 24, 24, 3, " 'title': 'Papayas',", true), - new DiffLine(1, -1, 25, 4, '+ {', true), - new DiffLine(1, -1, 26, 5, '+ {', true), - new DiffLine(1, -1, 27, 6, '+ {', true), - new DiffLine(1, -1, 28, 7, '+ {', true), - new DiffLine(1, -1, 29, 8, '+ {', true), - new DiffLine(1, -1, 30, 9, '+ {', true), - new DiffLine(0, 25, 31, 10, '+ {', true), - new DiffLine(0, 26, 32, 11, '+ {', true), - ], - }, - ] + [ + { + oldLineNumber: 22, + oldLength: 5, + newLineNumber: 22, + newLength: 11, + positionInHunk: 0, + diffLines: [ + new DiffLine(3, -1, -1, 0, '@@ -22,5 +22,11 @@', true), + new DiffLine(0, 22, 22, 1, " 'title': 'Papayas',", true), + new DiffLine(0, 23, 23, 2, " 'title': 'Papayas',", true), + new DiffLine(0, 24, 24, 3, " 'title': 'Papayas',", true), + new DiffLine(1, -1, 25, 4, '+ {', true), + new DiffLine(1, -1, 26, 5, '+ {', true), + new DiffLine(1, -1, 27, 6, '+ {', true), + new DiffLine(1, -1, 28, 7, '+ {', true), + new DiffLine(1, -1, 29, 8, '+ {', true), + new DiffLine(1, -1, 30, 9, '+ {', true), + new DiffLine(0, 25, 31, 10, '+ {', true), + new DiffLine(0, 26, 32, 11, '+ {', true), + ], + }, + ] }, uri, toReviewUri(uri, fileName, undefined, '1', false, { base: true }, rootUri), - 'abcd', + 'abcd' + ); + + return new GitFileChangeNode( + provider, + manager, + activePullRequest, + gitFileChangeModel ); } @@ -144,7 +159,7 @@ describe('ReviewCommentController', function () { label: 'Start discussion', state: vscode.CommentThreadState.Unresolved, canReply: false, - dispose: () => {}, + dispose: () => { }, }; } @@ -154,7 +169,7 @@ describe('ReviewCommentController', function () { const localFileChanges = [createLocalFileChange(uri, fileName, repository.rootUri)]; const reviewModel = new ReviewModel(); reviewModel.localFileChanges = localFileChanges; - const reviewCommentController = new TestReviewCommentController(reviewManager, manager, repository, reviewModel, new MockSessionState()); + const reviewCommentController = new TestReviewCommentController(reviewManager, manager, repository, reviewModel); sinon.stub(activePullRequest, 'validateDraftMode').returns(Promise.resolve(false)); sinon.stub(activePullRequest, 'getReviewThreads').returns( @@ -182,14 +197,16 @@ describe('ReviewCommentController', function () { graphNodeId: '', } ], + subjectType: SubjectType.LINE }, ]), ); - sinon.stub(manager, 'getCurrentUser').returns({ + sinon.stub(manager, 'getCurrentUser').returns(Promise.resolve({ login: 'rmacfarlane', url: 'https://github.com/rmacfarlane', - }); + id: '123' + })); sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns({ uri: repository.rootUri, @@ -217,7 +234,6 @@ describe('ReviewCommentController', function () { manager, repository, reviewModel, - new MockSessionState() ); const thread = createGHPRCommentThread('review-1.1', uri); @@ -225,10 +241,11 @@ describe('ReviewCommentController', function () { sinon.stub(activePullRequest, 'getReviewThreads').returns(Promise.resolve([])); sinon.stub(activePullRequest, 'getPendingReviewId').returns(Promise.resolve(undefined)); - sinon.stub(manager, 'getCurrentUser').returns({ + sinon.stub(manager, 'getCurrentUser').returns(Promise.resolve({ login: 'rmacfarlane', url: 'https://github.com/rmacfarlane', - }); + id: '123' + })); sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns({ uri: repository.rootUri, @@ -258,7 +275,8 @@ describe('ReviewCommentController', function () { pullRequestReviewId: undefined, startLine: undefined, line: 22, - side: 'RIGHT' + side: 'RIGHT', + subjectType: 'LINE' } } }, @@ -276,6 +294,7 @@ describe('ReviewCommentController', function () { originalLine: 22, diffSide: 'RIGHT', isOutdated: false, + subjectType: 'LINE', comments: { nodes: [ { diff --git a/src/view/compareChangesTreeDataProvider.ts b/src/view/compareChangesTreeDataProvider.ts index 499cc1383e..de13f45b1d 100644 --- a/src/view/compareChangesTreeDataProvider.ts +++ b/src/view/compareChangesTreeDataProvider.ts @@ -3,198 +3,420 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as buffer from 'buffer'; +import * as pathLib from 'path'; import * as vscode from 'vscode'; -import { Repository } from '../api/api'; +import { Change, Commit } from '../api/api'; +import { Status } from '../api/api1'; import { getGitChangeType } from '../common/diffHunk'; +import { GitChangeType } from '../common/file'; import Logger from '../common/logger'; -import { fromGitHubURI, Schemes } from '../common/uri'; +import { Schemes } from '../common/uri'; +import { dateFromNow, toDisposable } from '../common/utils'; +import { OctokitCommon } from '../github/common'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; -import { GitHubRepository } from '../github/githubRepository'; -import { ReadonlyFileSystemProvider } from './readonlyFileSystemProvider'; +import { CreatePullRequestDataModel } from './createPullRequestDataModel'; +import { GitContentProvider, GitHubContentProvider } from './gitHubContentProvider'; import { GitHubFileChangeNode } from './treeNodes/fileChangeNode'; -import { TreeNode } from './treeNodes/treeNode'; +import { BaseTreeNode, TreeNode } from './treeNodes/treeNode'; + +export function getGitChangeTypeFromApi(status: Status): GitChangeType { + switch (status) { + case Status.DELETED: + return GitChangeType.DELETE; + case Status.ADDED_BY_US: + return GitChangeType.ADD; + case Status.INDEX_RENAMED: + return GitChangeType.RENAME; + case Status.MODIFIED: + return GitChangeType.MODIFY; + default: + return GitChangeType.UNKNOWN; + } +} +class GitHubCommitNode extends TreeNode { + getTreeItem(): vscode.TreeItem | Promise { + return { + label: this.commit.commit.message, + description: this.commit.commit.author?.date ? dateFromNow(new Date(this.commit.commit.author.date)) : undefined, + iconPath: new vscode.ThemeIcon('git-commit'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed + }; + } -export class CompareChangesTreeProvider implements vscode.TreeDataProvider { - private _view: vscode.TreeView; + async getChildren(): Promise { + if (!this.model.gitHubRepository) { + return []; + } + const { octokit, remote } = await this.model.gitHubRepository.ensure(); + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base: this.parentRef, + head: this.commit.sha, + }); + + const rawFiles = data.files; + + if (!rawFiles) { + return []; + } + return rawFiles.map(file => { + return new GitHubFileChangeNode( + this, + file.filename, + file.previous_filename, + getGitChangeType(file.status), + this.parentRef, + this.commit.sha, + false, + ); + }); + } + + constructor(private readonly model: CreatePullRequestDataModel, private readonly commit: OctokitCommon.CompareCommits['commits'][0], private readonly parentRef) { + super(); + } +} + +class GitCommitNode extends TreeNode { + getTreeItem(): vscode.TreeItem | Promise { + return { + label: this.commit.message, + description: this.commit.authorDate ? dateFromNow(new Date(this.commit.authorDate)) : undefined, + iconPath: new vscode.ThemeIcon('git-commit'), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed + }; + } + + async getChildren(): Promise { + const changes = await this.folderRepoManager.repository.diffBetween(this.parentRef, this.commit.hash); + + return changes.map(change => { + const filename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.uri.path); + const previousFilename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.originalUri.path); + return new GitHubFileChangeNode( + this, + filename, + previousFilename, + getGitChangeTypeFromApi(change.status), + this.parentRef, + this.commit.hash, + true, + ); + }); + } + + constructor(private readonly commit: Commit, private readonly folderRepoManager: FolderRepositoryManager, private readonly parentRef) { + super(); + } +} +abstract class CompareChangesTreeProvider implements vscode.TreeDataProvider, BaseTreeNode { + private _view: vscode.TreeView; + private _children: TreeNode[] | undefined; private _onDidChangeTreeData = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - private _contentProvider: GitHubContentProvider | undefined; - private _disposables: vscode.Disposable[] = []; - private _gitHubRepository: GitHubRepository | undefined; - get view(): vscode.TreeView { return this._view; } + set view(view: vscode.TreeView) { + this._view = view; + } + constructor( - private readonly repository: Repository, - private baseOwner: string, - public baseBranchName: string, - private _compareOwner: string, - private compareBranchName: string, - private compareHasUpstream: boolean, - private folderRepoManager: FolderRepositoryManager, + protected readonly model: CreatePullRequestDataModel ) { - this._view = vscode.window.createTreeView('github:compareChanges', { - treeDataProvider: this, - }); - - this._gitHubRepository = this.folderRepoManager.gitHubRepositories.find( - repo => repo.remote.owner === this._compareOwner, - ); + this._disposables.push(model.onDidChange(() => { + this._onDidChangeTreeData.fire(); + })); + } - this._disposables.push(this._view); + async reveal(treeNode: TreeNode, options?: { select?: boolean; focus?: boolean; expand?: boolean }): Promise { + return this._view.reveal(treeNode, options); } - updateBaseBranch(branch: string): void { - this.baseBranchName = branch; + refresh(): void { this._onDidChangeTreeData.fire(); } - updateBaseOwner(owner: string) { - this.baseOwner = owner; - this._onDidChangeTreeData.fire(); + getTreeItem(element: TreeNode): vscode.TreeItem | Thenable { + return element.getTreeItem(); } - async reveal(treeNode: TreeNode, options?: { select?: boolean, focus?: boolean, expand?: boolean }): Promise { - return this._view.reveal(treeNode, options); + protected async getRawGitHubData() { + try { + const rawFiles = await this.model.gitHubFiles(); + const rawCommits = await this.model.gitHubCommits(); + const mergeBase = await this.model.gitHubMergeBase(); + + if (!rawFiles?.length || !rawCommits?.length) { + (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.getCompareBranch())); + return {}; + } else if (this._isDisposed) { + return {}; + } else { + this.view.message = undefined; + } + + return { rawFiles, rawCommits, mergeBase }; + } catch (e) { + if ('name' in e && e.name === 'HttpError' && e.status === 404) { + (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('The upstream branch `{0}` does not exist on GitHub', this.model.baseBranch)); + } + return {}; + } } - refresh(): void { - this._onDidChangeTreeData.fire(); + protected abstract getGitHubChildren(element?: TreeNode): Promise; + + protected abstract getGitChildren(element?: TreeNode): Promise; + + get children(): TreeNode[] | undefined { + return this._children; } - private async updateHasUpstream(branch: string): Promise { - // Currently, the list of selectable compare branches it those on GitHub, - // plus the current branch which may not be published yet. Check the - // status of the current branch using local git, otherwise assume it is from - // GitHub. - if (this.repository.state.HEAD?.name === branch) { - const compareBranch = await this.repository.getBranch(branch); - this.compareHasUpstream = !!compareBranch.upstream; - } else { - this.compareHasUpstream = true; + async getChildren(element?: TreeNode) { + try { + if (await this.model.getCompareHasUpstream()) { + this._children = await this.getGitHubChildren(element); + } else { + this._children = await this.getGitChildren(element); + } + } catch (e) { + Logger.error(`Comparing changes failed: ${e}`); + return []; } + return this._children; } - async updateCompareBranch(branch?: string): Promise { - if (branch) { - await this.updateHasUpstream(branch); - this.compareBranchName = branch; - } - this._onDidChangeTreeData.fire(); + protected _isDisposed: boolean = false; + dispose() { + this._isDisposed = true; + this._disposables.forEach(d => d.dispose()); + this._view.dispose(); } - get compareOwner(): string { - return this._compareOwner; + public static closeTabs() { + vscode.window.tabGroups.all.forEach(group => group.tabs.forEach(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.modified.scheme === Schemes.GithubPr) || (tab.input.modified.scheme === Schemes.GitPr)) { + vscode.window.tabGroups.close(tab); + } + } + })); } +} - set compareOwner(owner: string) { - this._gitHubRepository = this.folderRepoManager.gitHubRepositories.find( - repo => repo.remote.owner === owner, - ); +class CompareChangesFilesTreeProvider extends CompareChangesTreeProvider { + constructor( + model: CreatePullRequestDataModel, + private folderRepoManager: FolderRepositoryManager, + ) { + super(model); + } - if (this._contentProvider && this._gitHubRepository) { - this._contentProvider.gitHubRepository = this._gitHubRepository; + protected async getGitHubChildren(element?: TreeNode) { + if (element) { + return element.getChildren(); } - this._compareOwner = owner; - this._onDidChangeTreeData.fire(); + const { rawFiles, mergeBase } = await this.getRawGitHubData(); + if (rawFiles && mergeBase) { + return rawFiles.map(file => { + return new GitHubFileChangeNode( + this, + file.filename, + file.previous_filename, + getGitChangeType(file.status), + mergeBase, + this.model.getCompareBranch(), + false, + ); + }); + } } - getTreeItem(element: TreeNode): vscode.TreeItem | Thenable { - return element.getTreeItem(); + private async getGitFileChildren(diff: Change[]) { + return diff.map(change => { + const filename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.uri.path); + const previousFilename = pathLib.posix.relative(this.folderRepoManager.repository.rootUri.path, change.originalUri.path); + return new GitHubFileChangeNode( + this, + filename, + previousFilename, + getGitChangeTypeFromApi(change.status), + this.model.baseBranch, + this.model.getCompareBranch(), + true, + ); + }); } - async getChildren() { - // If no upstream, show error. - if (!this.compareHasUpstream) { - vscode.commands.executeCommand('setContext', 'github:noUpstream', true); - this._view.message = undefined; - return []; + protected async getGitChildren(element?: TreeNode) { + if (!element) { + const diff = await this.model.gitFiles(); + if (diff.length === 0) { + (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.getCompareBranch())); + return []; + } else if (!(await this.model.getCompareHasUpstream())) { + const message = new vscode.MarkdownString(vscode.l10n.t({ message: 'Branch `{0}` has not been pushed yet. [Publish branch](command:git.publish) to see all changes.', args: [this.model.getCompareBranch()], comment: "{Locked='](command:git.publish)'}" })); + message.isTrusted = { enabledCommands: ['git.publish'] }; + (this.view as vscode.TreeView2).message = message; + } else if (this._isDisposed) { + return []; + } else { + this.view.message = undefined; + } + + return this.getGitFileChildren(diff); } else { - vscode.commands.executeCommand('setContext', 'github:noUpstream', false); + return element.getChildren(); } - if (!this._gitHubRepository) { - return []; - } + } +} - if (!this._contentProvider) { - this._contentProvider = new GitHubContentProvider(this._gitHubRepository); - this._disposables.push( - vscode.workspace.registerFileSystemProvider(Schemes.GithubPr, this._contentProvider, { isReadonly: true })); - } +class CompareChangesCommitsTreeProvider extends CompareChangesTreeProvider { + constructor( + model: CreatePullRequestDataModel, + private readonly folderRepoManager: FolderRepositoryManager + ) { + super(model); + } - const { octokit, remote } = await this._gitHubRepository.ensure(); + protected async getGitHubChildren(element?: TreeNode) { + if (element) { + return element.getChildren(); + } - try { - const { data } = await octokit.repos.compareCommits({ - repo: remote.repositoryName, - owner: remote.owner, - base: `${this.baseOwner}:${this.baseBranchName}`, - head: `${this.compareOwner}:${this.compareBranchName}`, + const { rawCommits } = await this.getRawGitHubData(); + if (rawCommits) { + return rawCommits.map((commit, index) => { + return new GitHubCommitNode(this.model, commit, index === 0 ? this.model.baseBranch : rawCommits[index - 1].sha); }); + } + } - if (!data.files.length) { - this._view.message = `There are no commits between the base '${this.baseBranchName}' branch and the comparing '${this.compareBranchName}' branch`; - } else { - this._view.message = undefined; - } + protected async getGitChildren(element?: TreeNode) { + if (element) { + return element.getChildren(); + } - return data.files.map(file => { - return new GitHubFileChangeNode( - this, - file.filename, - file.previous_filename, - getGitChangeType(file.status), - data.merge_base_commit.sha, - this.compareBranchName, - ); - }); - } catch (e) { - Logger.appendLine(`Comparing changes failed: ${e}`); + const log = await this.model.gitCommits(); + if (log.length === 0) { + (this.view as vscode.TreeView2).message = new vscode.MarkdownString(vscode.l10n.t('There are no commits between the base `{0}` branch and the comparing `{1}` branch', this.model.baseBranch, this.model.getCompareBranch())); + return []; + } else if (this._isDisposed) { return []; + } else { + this.view.message = undefined; } - } - dispose() { - this._disposables.forEach(d => d.dispose()); - this._contentProvider = undefined; + return log.reverse().map((commit, index) => { + return new GitCommitNode(commit, this.folderRepoManager, index === 0 ? this.model.baseBranch : log[index - 1].hash); + }); } } -/** - * Provides file contents for documents with GITHUB_FILE_SCHEME (githubpr) scheme. Contents are fetched from GitHub based on - * information in the document's query string. - */ -class GitHubContentProvider extends ReadonlyFileSystemProvider { - constructor(public gitHubRepository: GitHubRepository) { - super(); +export class CompareChanges implements vscode.Disposable { + private _filesView: vscode.TreeView; + private _filesDataProvider: CompareChangesFilesTreeProvider; + private _commitsView: vscode.TreeView; + private _commitsDataProvider: CompareChangesCommitsTreeProvider; + + private _gitHubcontentProvider: GitHubContentProvider | undefined; + private _gitcontentProvider: GitContentProvider | undefined; + + private _disposables: vscode.Disposable[] = []; + + constructor( + private folderRepoManager: FolderRepositoryManager, + private model: CreatePullRequestDataModel + ) { + + this._filesDataProvider = new CompareChangesFilesTreeProvider(model, folderRepoManager); + this._filesView = vscode.window.createTreeView('github:compareChangesFiles', { + treeDataProvider: this._filesDataProvider + }); + this._filesDataProvider.view = this._filesView; + this._commitsDataProvider = new CompareChangesCommitsTreeProvider(model, folderRepoManager); + this._commitsView = vscode.window.createTreeView('github:compareChangesCommits', { + treeDataProvider: this._commitsDataProvider + }); + this._commitsDataProvider.view = this._commitsView; + this._disposables.push(this._filesDataProvider); + this._disposables.push(this._filesView); + this._disposables.push(this._commitsDataProvider); + this._disposables.push(this._commitsView); + + this.initialize(); + } + + updateBaseBranch(branch: string): void { + this.model.baseBranch = branch; } - async readFile(uri: any): Promise { - const params = fromGitHubURI(uri); - if (!params || params.isEmpty) { - return new TextEncoder().encode(''); + updateBaseOwner(owner: string) { + this.model.baseOwner = owner; + } + + async updateCompareBranch(branch?: string): Promise { + this.model.setCompareBranch(branch); + } + + set compareOwner(owner: string) { + this.model.compareOwner = owner; + } + + private initialize() { + if (!this.model.gitHubRepository) { + return; } - const { octokit, remote } = await this.gitHubRepository.ensure(); - const fileContent = await octokit.repos.getContent({ - owner: remote.owner, - repo: remote.repositoryName, - path: params.fileName, - ref: params.branch, - }); + if (!this._gitHubcontentProvider) { + try { + this._gitHubcontentProvider = new GitHubContentProvider(this.model.gitHubRepository); + this._gitcontentProvider = new GitContentProvider(this.folderRepoManager); + this._disposables.push( + vscode.workspace.registerFileSystemProvider(Schemes.GithubPr, this._gitHubcontentProvider, { + isReadonly: true, + }), + ); + this._disposables.push( + vscode.workspace.registerFileSystemProvider(Schemes.GitPr, this._gitcontentProvider, { + isReadonly: true, + }), + ); + this._disposables.push(toDisposable(() => { + CompareChangesTreeProvider.closeTabs(); + })); + } catch (e) { + // already registered + } + } + } - const contents = (fileContent.data as any).content ?? ''; - const buff = buffer.Buffer.from(contents, (fileContent.data as any).encoding); - return buff; + dispose() { + this._disposables.forEach(d => d.dispose()); + this._gitHubcontentProvider = undefined; + this._gitcontentProvider = undefined; + this._filesView.dispose(); + } + + public static closeTabs() { + vscode.window.tabGroups.all.forEach(group => group.tabs.forEach(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.modified.scheme === Schemes.GithubPr) || (tab.input.modified.scheme === Schemes.GitPr)) { + vscode.window.tabGroups.close(tab); + } + } + })); } } + + diff --git a/src/view/createPullRequestDataModel.ts b/src/view/createPullRequestDataModel.ts new file mode 100644 index 0000000000..d2910eda84 --- /dev/null +++ b/src/view/createPullRequestDataModel.ts @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Change, Commit } from '../api/api'; +import { OctokitCommon } from '../github/common'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { GitHubRepository } from '../github/githubRepository'; + +export class CreatePullRequestDataModel { + private _baseOwner: string; + private _baseBranch: string; + private _compareOwner: string; + private _compareBranch: string; + private _constructed: Promise; + private readonly _onDidChange: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidChange = this._onDidChange.event; + private _gitHubRepository: GitHubRepository | undefined; + + private _gitLog: Promise | undefined; + private _gitFiles: Change[] | undefined; + private _compareHasUpstream: boolean = false; + + private _gitHubMergeBase: string | undefined; + private _gitHubLog: OctokitCommon.Commit[] | undefined; + private _gitHubFiles: OctokitCommon.CommitFile[] | undefined; + + constructor(private readonly folderRepositoryManager: FolderRepositoryManager, baseOwner: string, baseBranch: string, compareOwner: string, compareBranch: string) { + this._baseOwner = baseOwner; + this._baseBranch = baseBranch; + this._constructed = new Promise(resolve => this.setCompareBranch(compareBranch).then(resolve)); + this.compareOwner = compareOwner; + } + + public get baseOwner(): string { + return this._baseOwner; + } + + public set baseOwner(value: string) { + if (value !== this._baseOwner) { + this._baseOwner = value; + this.update(); + } + } + + public get baseBranch(): string { + return this._baseBranch; + } + + public set baseBranch(value: string) { + if (value !== this._baseBranch) { + this._baseBranch = value; + this.update(); + } + } + + public get compareOwner(): string { + return this._compareOwner; + } + + public set compareOwner(value: string) { + if (value !== this._compareOwner) { + this._compareOwner = value; + this._gitHubRepository = this.folderRepositoryManager.gitHubRepositories.find( + repo => repo.remote.owner === this._compareOwner, + ); + this.update(); + } + } + + public getCompareBranch(): string { + return this._compareBranch; + } + + public async setCompareBranch(value: string | undefined): Promise { + const oldUpstreamValue = this._compareHasUpstream; + let changed: boolean = false; + if (value) { + changed = (await this.updateHasUpstream(value)) !== oldUpstreamValue; + } + if (this._compareBranch !== value) { + changed = true; + if (value) { + this._compareBranch = value; + } + } + if (changed) { + this.update(); + } + } + + private async updateHasUpstream(branch: string): Promise { + // Currently, the list of selectable compare branches it those on GitHub, + // plus the current branch which may not be published yet. Check the + // status of the current branch using local git, otherwise assume it is from + // GitHub. + if (this.folderRepositoryManager.repository.state.HEAD?.name === branch) { + const compareBranch = await this.folderRepositoryManager.repository.getBranch(branch); + this._compareHasUpstream = !!compareBranch.upstream; + } else { + this._compareHasUpstream = true; + } + return this._compareHasUpstream; + } + + public async getCompareHasUpstream(): Promise { + await this._constructed; + return this._compareHasUpstream; + } + + public get gitHubRepository(): GitHubRepository | undefined { + return this._gitHubRepository; + } + + private update() { + this._gitLog = undefined; + this._gitFiles = undefined; + this._gitHubLog = undefined; + this._gitHubFiles = undefined; + this._onDidChange.fire(); + } + + public async gitCommits(): Promise { + await this._constructed; + if (this._gitLog === undefined) { + const startBase = this._baseBranch; + const startCompare = this._compareBranch; + const result = this.folderRepositoryManager.repository.log({ range: `${this._baseBranch}..${this._compareBranch}` }); + if (startBase !== this._baseBranch || startCompare !== this._compareBranch) { + // The branches have changed while we were waiting for the log. We can use the result, but we shouldn't save it + return result; + } else { + this._gitLog = result; + } + } + return this._gitLog; + } + + public async gitFiles(): Promise { + await this._constructed; + if (this._gitFiles === undefined) { + const startBase = this._baseBranch; + const startCompare = this._compareBranch; + const result = await this.folderRepositoryManager.repository.diffBetween(this._baseBranch, this._compareBranch); + if (startBase !== this._baseBranch || startCompare !== this._compareBranch) { + // The branches have changed while we were waiting for the diff. We can use the result, but we shouldn't save it + return result; + } else { + this._gitFiles = result; + } + } + return this._gitFiles; + } + + public async gitHubCommits(): Promise { + await this._constructed; + if (!this._gitHubRepository) { + return []; + } + + if (this._gitHubLog === undefined) { + const { octokit, remote } = await this._gitHubRepository.ensure(); + const { data } = await octokit.call(octokit.api.repos.compareCommits, { + repo: remote.repositoryName, + owner: remote.owner, + base: `${this._baseOwner}:${this._baseBranch}`, + head: `${this._compareOwner}:${this._compareBranch}`, + }); + this._gitHubLog = data.commits; + this._gitHubFiles = data.files ?? []; + this._gitHubMergeBase = data.merge_base_commit.sha; + } + return this._gitHubLog; + } + + public async gitHubFiles(): Promise { + await this._constructed; + if (this._gitHubFiles === undefined) { + await this.gitHubCommits(); + } + return this._gitHubFiles!; + } + + public async gitHubMergeBase(): Promise { + await this._constructed; + if (this._gitHubMergeBase === undefined) { + await this.gitHubCommits(); + } + return this._gitHubMergeBase!; + } +} \ No newline at end of file diff --git a/src/view/createPullRequestHelper.ts b/src/view/createPullRequestHelper.ts index 30e33bae92..7317e8d88a 100644 --- a/src/view/createPullRequestHelper.ts +++ b/src/view/createPullRequestHelper.ts @@ -5,37 +5,31 @@ import * as vscode from 'vscode'; import { Repository } from '../api/api'; -import { CreatePullRequestViewProvider } from '../github/createPRViewProvider'; +import { ITelemetry } from '../common/telemetry'; +import { dispose } from '../common/utils'; +import { CreatePullRequestViewProviderNew } from '../github/createPRViewProviderNew'; import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; -import { CompareChangesTreeProvider } from './compareChangesTreeDataProvider'; +import { CompareChanges } from './compareChangesTreeDataProvider'; +import { CreatePullRequestDataModel } from './createPullRequestDataModel'; -export class CreatePullRequestHelper { +export class CreatePullRequestHelper implements vscode.Disposable { private _disposables: vscode.Disposable[] = []; - private _createPRViewProvider: CreatePullRequestViewProvider | undefined; - private _treeView: CompareChangesTreeProvider | undefined; + private _createPRViewProvider: CreatePullRequestViewProviderNew | undefined; + private _treeView: CompareChanges | undefined; + private _postCreateCallback: ((pullRequestModel: PullRequestModel) => Promise) | undefined; - private _onDidCreate = new vscode.EventEmitter(); - readonly onDidCreate: vscode.Event = this._onDidCreate.event; + constructor() { } - constructor(private readonly repository: Repository) {} - - private registerListeners(usingCurrentBranchAsCompare: boolean) { + private registerListeners(repository: Repository, usingCurrentBranchAsCompare: boolean) { this._disposables.push( this._createPRViewProvider!.onDone(async createdPR => { - vscode.commands.executeCommand('setContext', 'github:createPullRequest', false); - - this._createPRViewProvider?.dispose(); - this._createPRViewProvider = undefined; - - this._treeView?.dispose(); - this._treeView = undefined; - - this._disposables.forEach(d => d.dispose()); - if (createdPR) { - this._onDidCreate.fire(createdPR); + await CreatePullRequestViewProviderNew.withProgress(async () => { + return this._postCreateCallback?.(createdPR); + }); } + this.dispose(); }), ); @@ -65,11 +59,84 @@ export class CreatePullRequestHelper { }), ); + this._disposables.push( + vscode.commands.registerCommand('pr.addAssigneesToNewPr', _ => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + return this._createPRViewProvider.addAssignees(); + } + }), + ); + + this._disposables.push( + vscode.commands.registerCommand('pr.addReviewersToNewPr', _ => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + return this._createPRViewProvider.addReviewers(); + } + }), + ); + + this._disposables.push( + vscode.commands.registerCommand('pr.addLabelsToNewPr', _ => { + return this._createPRViewProvider?.addLabels(); + }), + ); + + this._disposables.push( + vscode.commands.registerCommand('pr.addMilestoneToNewPr', _ => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + return this._createPRViewProvider.addMilestone(); + } + }), + ); + + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuCreate', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(false, false, undefined); + } + }) + ); + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuDraft', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(true, false, undefined); + } + }) + ); + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuMergeWhenReady', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(false, true, undefined, true); + } + }) + ); + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuMerge', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(false, true, 'merge'); + } + }) + ); + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuSquash', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(false, true, 'squash'); + } + }) + ); + this._disposables.push( + vscode.commands.registerCommand('pr.createPrMenuRebase', () => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + this._createPRViewProvider.createFromCommand(false, true, 'rebase'); + } + }) + ); + if (usingCurrentBranchAsCompare) { this._disposables.push( - this.repository.state.onDidChange(_ => { - if (this._createPRViewProvider && this.repository.state.HEAD) { - this._createPRViewProvider.defaultCompareBranch = this.repository.state.HEAD; + repository.state.onDidChange(_ => { + if (this._createPRViewProvider && repository.state.HEAD) { + this._createPRViewProvider.defaultCompareBranch = repository.state.HEAD; this._treeView?.updateCompareBranch(); } }), @@ -104,16 +171,21 @@ export class CreatePullRequestHelper { } async create( + telemetry: ITelemetry, extensionUri: vscode.Uri, folderRepoManager: FolderRepositoryManager, compareBranch: string | undefined, + callback: (pullRequestModel: PullRequestModel) => Promise, ) { + this.reset(); + + this._postCreateCallback = callback; await folderRepoManager.loginAndUpdate(); vscode.commands.executeCommand('setContext', 'github:createPullRequest', true); const branch = ((compareBranch ? await folderRepoManager.repository.getBranch(compareBranch) : undefined) ?? - folderRepoManager.repository.state.HEAD)!; + folderRepoManager.repository.state.HEAD)!; if (!this._createPRViewProvider) { const pullRequestDefaults = await this.ensureDefaultsAreLocal( @@ -121,25 +193,23 @@ export class CreatePullRequestHelper { await folderRepoManager.getPullRequestDefaults(branch), ); - this._createPRViewProvider = new CreatePullRequestViewProvider( + const compareOrigin = await folderRepoManager.getOrigin(branch); + const model = new CreatePullRequestDataModel(folderRepoManager, pullRequestDefaults.owner, pullRequestDefaults.base, compareOrigin.remote.owner, branch.name!); + this._createPRViewProvider = new CreatePullRequestViewProviderNew( + telemetry, + model, extensionUri, folderRepoManager, pullRequestDefaults, - branch, + branch ); - const compareOrigin = await folderRepoManager.getOrigin(branch); - this._treeView = new CompareChangesTreeProvider( - this.repository, - pullRequestDefaults.owner, - pullRequestDefaults.base, - compareOrigin.remote.owner, - branch.name!, - !!branch.upstream, + this._treeView = new CompareChanges( folderRepoManager, + model ); - this.registerListeners(!compareBranch); + this.registerListeners(folderRepoManager.repository, !compareBranch); this._disposables.push( vscode.window.registerWebviewViewProvider( @@ -151,4 +221,21 @@ export class CreatePullRequestHelper { this._createPRViewProvider.show(branch); } + + private reset() { + vscode.commands.executeCommand('setContext', 'github:createPullRequest', false); + + this._createPRViewProvider?.dispose(); + this._createPRViewProvider = undefined; + + this._treeView?.dispose(); + this._treeView = undefined; + this._postCreateCallback = undefined; + + dispose(this._disposables); + } + + dispose() { + this.reset(); + } } diff --git a/src/view/fileChangeModel.ts b/src/view/fileChangeModel.ts new file mode 100644 index 0000000000..85ac9833e8 --- /dev/null +++ b/src/view/fileChangeModel.ts @@ -0,0 +1,233 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ViewedState } from '../common/comment'; +import { DiffHunk, parsePatch } from '../common/diffHunk'; +import { GitChangeType, InMemFileChange, SimpleFileChange, SlimFileChange } from '../common/file'; +import Logger from '../common/logger'; +import { resolvePath, toPRUri, toReviewUri } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { IResolvedPullRequestModel, PullRequestModel } from '../github/pullRequestModel'; + +export abstract class FileChangeModel { + protected _filePath: vscode.Uri; + get filePath(): vscode.Uri { + return this._filePath; + } + + protected _parentFilePath: vscode.Uri; + get parentFilePath(): vscode.Uri { + return this._parentFilePath; + } + + get status(): GitChangeType { + return this.change.status; + } + + get fileName(): string { + return this.change.fileName; + } + + get blobUrl(): string | undefined { + return this.change.blobUrl; + } + + private _viewed: ViewedState; + get viewed(): ViewedState { + return this._viewed; + } + + updateViewed(viewed: ViewedState) { + if (this._viewed === viewed) { + return; + } + + this._viewed = viewed; + } + + async diffHunks(): Promise { + let diffHunks: DiffHunk[] = []; + + if (this.change instanceof InMemFileChange) { + return this.change.diffHunks; + } else if (this.status !== GitChangeType.RENAME) { + try { + const commit = this.sha ?? this.pullRequest.head!.sha; + const patch = await this.folderRepoManager.repository.diffBetween(this.pullRequest.base.sha, commit, this.fileName); + diffHunks = parsePatch(patch); + } catch (e) { + Logger.error(`Failed to parse patch for outdated comments: ${e}`); + } + } + return diffHunks; + } + + constructor(public readonly pullRequest: PullRequestModel, + protected readonly folderRepoManager: FolderRepositoryManager, + public readonly change: SimpleFileChange, + public readonly sha?: string) { } +} + +export class GitFileChangeModel extends FileChangeModel { + constructor( + folderRepositoryManager: FolderRepositoryManager, + pullRequest: PullRequestModel, + change: SimpleFileChange, + filePath: vscode.Uri, + parentFilePath: vscode.Uri, + public readonly sha: string, + preload?: boolean + ) { + super(pullRequest, folderRepositoryManager, change, sha); + this._filePath = filePath; + this._parentFilePath = parentFilePath; + if (preload) { + try { + this.showBase(); + } catch (e) { + Logger.warn(`Unable to preload file content for ${filePath.fsPath} at commit ${sha}`); + } + } + } + + private _show: Promise + async showBase(): Promise { + if (!this._show) { + const commit = ((this.change instanceof InMemFileChange || this.change instanceof SlimFileChange) ? this.change.baseCommit : this.sha); + const absolutePath = vscode.Uri.joinPath(this.folderRepoManager.repository.rootUri, this.fileName).fsPath; + this._show = this.folderRepoManager.repository.show(commit, absolutePath); + } + return this._show; + } +} + +export class InMemFileChangeModel extends FileChangeModel { + get previousFileName(): string | undefined { + return this.change.previousFileName; + } + + async isPartial(): Promise { + let originalFileExist = false; + + switch (this.change.status) { + case GitChangeType.DELETE: + case GitChangeType.MODIFY: + try { + await this.folderRepoManager.repository.getObjectDetails(this.change.baseCommit, this.change.fileName); + originalFileExist = true; + } catch (err) { + /* noop */ + } + break; + case GitChangeType.RENAME: + try { + await this.folderRepoManager.repository.getObjectDetails(this.change.baseCommit, this.change.previousFileName!); + originalFileExist = true; + } catch (err) { + /* noop */ + } + break; + } + return !originalFileExist && (this.change.status !== GitChangeType.ADD); + } + + get patch(): string { + return this.change.patch; + } + + async diffHunks(): Promise { + return this.change.diffHunks; + } + + constructor(folderRepositoryManager: FolderRepositoryManager, + pullRequest: PullRequestModel & IResolvedPullRequestModel, + public readonly change: InMemFileChange, + isCurrentPR: boolean, + mergeBase: string) { + super(pullRequest, folderRepositoryManager, change); + const headCommit = pullRequest.head!.sha; + const parentFileName = change.status === GitChangeType.RENAME ? change.previousFileName! : change.fileName; + const filePath = folderRepositoryManager.repository.rootUri.with({ path: vscode.Uri.file(resolvePath(folderRepositoryManager.repository.rootUri, change.fileName)).path }); + const parentPath = folderRepositoryManager.repository.rootUri.with({ path: vscode.Uri.file(resolvePath(folderRepositoryManager.repository.rootUri, parentFileName)).path }); + this._filePath = isCurrentPR ? ((change.status === GitChangeType.DELETE) + ? toReviewUri(filePath, undefined, undefined, '', false, { base: false }, folderRepositoryManager.repository.rootUri) + : filePath) : toPRUri( + filePath, + pullRequest, + change.baseCommit, + headCommit, + change.fileName, + false, + change.status, + change.previousFileName + ); + this._parentFilePath = isCurrentPR ? (toReviewUri( + parentPath, + change.status === GitChangeType.RENAME ? change.previousFileName : change.fileName, + undefined, + change.status === GitChangeType.ADD ? '' : mergeBase, + false, + { base: true }, + folderRepositoryManager.repository.rootUri, + )) : toPRUri( + parentPath, + pullRequest, + change.baseCommit, + headCommit, + change.fileName, + true, + change.status, + change.previousFileName + ); + } +} + +export class RemoteFileChangeModel extends FileChangeModel { + public fileChangeResourceUri: vscode.Uri; + public childrenDisposables: vscode.Disposable[] = []; + + get previousFileName(): string | undefined { + return this.change.previousFileName; + } + + get blobUrl(): string { + return this.change.blobUrl; + } + + constructor( + folderRepositoryManager: FolderRepositoryManager, + public readonly change: SlimFileChange, + pullRequest: PullRequestModel, + ) { + super(pullRequest, folderRepositoryManager, change); + const headCommit = pullRequest.head!.sha; + const parentFileName = change.status === GitChangeType.RENAME ? change.previousFileName! : change.fileName; + this._filePath = toPRUri( + vscode.Uri.file( + resolvePath(folderRepositoryManager.repository.rootUri, change.fileName), + ), + pullRequest, + change.baseCommit, + headCommit, + change.fileName, + false, + change.status, + change.previousFileName + ); + this._parentFilePath = toPRUri( + vscode.Uri.file( + resolvePath(folderRepositoryManager.repository.rootUri, parentFileName), + ), + pullRequest, + change.baseCommit, + headCommit, + change.fileName, + true, + change.status, + change.previousFileName + ); + } +} \ No newline at end of file diff --git a/src/view/fileTypeDecorationProvider.ts b/src/view/fileTypeDecorationProvider.ts index 5219dc3b26..c8f10785bc 100644 --- a/src/view/fileTypeDecorationProvider.ts +++ b/src/view/fileTypeDecorationProvider.ts @@ -3,14 +3,13 @@ * 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 { ViewedState } from '../common/comment'; import { GitChangeType } from '../common/file'; -import { fromFileChangeNodeUri, fromPRUri, Schemes, toResourceUri } from '../common/uri'; +import { FileChangeNodeUriParams, fromFileChangeNodeUri, fromPRUri, PRUriParams, Schemes, toResourceUri } from '../common/uri'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; import { RepositoriesManager } from '../github/repositoriesManager'; -import { ReviewManager } from './reviewManager'; export class FileTypeDecorationProvider implements vscode.FileDecorationProvider { private _disposables: vscode.Disposable[] = []; @@ -24,7 +23,7 @@ export class FileTypeDecorationProvider implements vscode.FileDecorationProvider onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; - constructor(private _repositoriesManager: RepositoriesManager, private _reviewManagers: ReviewManager[]) { + constructor(private _repositoriesManager: RepositoriesManager) { this._disposables.push(vscode.window.registerFileDecorationProvider(this)); this._registerListeners(); } @@ -35,7 +34,7 @@ export class FileTypeDecorationProvider implements vscode.FileDecorationProvider const uri = vscode.Uri.joinPath(folderManager.repository.rootUri, change.fileName); const fileChange = model.fileChanges.get(change.fileName); if (fileChange) { - const fileChangeUri = toResourceUri(uri, model.number, change.fileName, fileChange.status); + const fileChangeUri = toResourceUri(uri, model.number, change.fileName, fileChange.status, fileChange.previousFileName); this._onDidChangeFileDecorations.fire(fileChangeUri); this._onDidChangeFileDecorations.fire(fileChangeUri.with({ scheme: folderManager.repository.rootUri.scheme })); this._onDidChangeFileDecorations.fire(fileChangeUri.with({ scheme: Schemes.Pr, authority: '' })); @@ -77,17 +76,6 @@ export class FileTypeDecorationProvider implements vscode.FileDecorationProvider } - private getViewedState(number: number, fileName: string, uri: vscode.Uri) { - const gitHubRepositories = this._repositoriesManager.getManagerForFile(uri)?.gitHubRepositories ?? []; - for (const gitHubRepo of gitHubRepositories) { - const prModel = gitHubRepo.pullRequestModels.get(number); - if (prModel) { - return prModel.fileChangeViewedState[fileName] ?? ViewedState.UNVIEWED; - } - } - return ViewedState.UNVIEWED; - } - provideFileDecoration( uri: vscode.Uri, _token: vscode.CancellationToken, @@ -98,11 +86,11 @@ export class FileTypeDecorationProvider implements vscode.FileDecorationProvider const fileChangeUriParams = fromFileChangeNodeUri(uri); if (fileChangeUriParams && fileChangeUriParams.status !== undefined) { - const viewedState = this.getViewedState(fileChangeUriParams.prNumber, fileChangeUriParams.fileName, uri); return { propagate: false, - badge: this.letter(fileChangeUriParams.status, viewedState), - color: this.color(fileChangeUriParams.status, viewedState) + badge: this.letter(fileChangeUriParams.status), + color: this.color(fileChangeUriParams.status), + tooltip: this.tooltip(fileChangeUriParams) }; } @@ -112,46 +100,54 @@ export class FileTypeDecorationProvider implements vscode.FileDecorationProvider return { propagate: false, badge: this.letter(prParams.status), - color: this.color(prParams.status) + color: this.color(prParams.status), + tooltip: this.tooltip(prParams) }; } return undefined; } - color(status: GitChangeType, viewedState?: ViewedState): vscode.ThemeColor | undefined { - if (viewedState === ViewedState.VIEWED) { - return undefined; + gitColors(status: GitChangeType): string | undefined { + switch (status) { + case GitChangeType.MODIFY: + return 'gitDecoration.modifiedResourceForeground'; + case GitChangeType.ADD: + return 'gitDecoration.addedResourceForeground'; + case GitChangeType.DELETE: + return 'gitDecoration.deletedResourceForeground'; + case GitChangeType.RENAME: + return 'gitDecoration.renamedResourceForeground'; + case GitChangeType.UNKNOWN: + return undefined; + case GitChangeType.UNMERGED: + return 'gitDecoration.conflictingResourceForeground'; } + } - let color: string | undefined; + remoteReposColors(status: GitChangeType): string | undefined { switch (status) { case GitChangeType.MODIFY: - color = 'gitDecoration.modifiedResourceForeground'; - break; + return 'remoteHub.decorations.modifiedForegroundColor'; case GitChangeType.ADD: - color = 'gitDecoration.addedResourceForeground'; - break; + return 'remoteHub.decorations.addedForegroundColor'; case GitChangeType.DELETE: - color = 'gitDecoration.deletedResourceForeground'; - break; + return 'remoteHub.decorations.deletedForegroundColor'; case GitChangeType.RENAME: - color = 'gitDecoration.renamedResourceForeground'; - break; + return 'remoteHub.decorations.incomingRenamedForegroundColor'; case GitChangeType.UNKNOWN: - color = undefined; - break; + return undefined; case GitChangeType.UNMERGED: - color = 'gitDecoration.conflictingResourceForeground'; - break; + return 'remoteHub.decorations.conflictForegroundColor'; } + } + + color(status: GitChangeType): vscode.ThemeColor | undefined { + let color: string | undefined = vscode.extensions.getExtension('vscode.git') ? this.gitColors(status) : this.remoteReposColors(status); return color ? new vscode.ThemeColor(color) : undefined; } - letter(status: GitChangeType, viewedState?: ViewedState): string { - if (viewedState === ViewedState.VIEWED) { - return '✓'; - } + letter(status: GitChangeType): string { switch (status) { case GitChangeType.MODIFY: @@ -171,6 +167,12 @@ export class FileTypeDecorationProvider implements vscode.FileDecorationProvider return ''; } + tooltip(change: FileChangeNodeUriParams | PRUriParams) { + if ((change.status === GitChangeType.RENAME) && change.previousFileName) { + return `Renamed ${change.previousFileName} to ${path.basename(change.fileName)}`; + } + } + dispose() { this._disposables.forEach(dispose => dispose.dispose()); this._gitHubReposListeners.forEach(dispose => dispose.dispose()); diff --git a/src/view/gitContentProvider.ts b/src/view/gitContentProvider.ts index fc07776b13..13a4d0ff1c 100644 --- a/src/view/gitContentProvider.ts +++ b/src/view/gitContentProvider.ts @@ -12,47 +12,24 @@ import Logger from '../common/logger'; import { fromReviewUri } from '../common/uri'; import { CredentialStore } from '../github/credentials'; import { getRepositoryForFile } from '../github/utils'; -import { ReadonlyFileSystemProvider } from './readonlyFileSystemProvider'; +import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; +import { ReviewManager } from './reviewManager'; -export class GitContentFileSystemProvider extends ReadonlyFileSystemProvider { +export class GitContentFileSystemProvider extends RepositoryFileSystemProvider { private _fallback?: (uri: vscode.Uri) => Promise; - constructor(private gitAPI: GitApiImpl, private credentialStore: CredentialStore) { - super(); + constructor(gitAPI: GitApiImpl, credentialStore: CredentialStore, private readonly reviewManagers: ReviewManager[]) { + super(gitAPI, credentialStore); } - private async waitForRepos(milliseconds: number): Promise { - Logger.appendLine('Waiting for repositories.', 'GitContentFileSystemProvider'); - let eventDisposable: vscode.Disposable | undefined = undefined; - const openPromise = new Promise(resolve => { - eventDisposable = this.gitAPI.onDidOpenRepository(() => { - Logger.appendLine('Found at least one repository.', 'GitContentFileSystemProvider'); - eventDisposable?.dispose(); - eventDisposable = undefined; - resolve(); - }); - }); - let timeout: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise(resolve => { - timeout = setTimeout(() => { - Logger.appendLine('Timed out while waiting for repositories.', 'GitContentFileSystemProvider'); - resolve(); - }, milliseconds); - }); - await Promise.race([openPromise, timeoutPromise]); - if (timeout) { - clearTimeout(timeout); - } - if (eventDisposable) { - eventDisposable!.dispose(); - } - } - - private async waitForAuth(): Promise { - if (this.credentialStore.isAnyAuthenticated()) { - return; + private getChangeModelForFile(file: vscode.Uri) { + for (const manager of this.reviewManagers) { + for (const change of manager.reviewModel.localFileChanges) { + if ((change.changeModel.filePath.authority === file.authority) && (change.changeModel.filePath.path === file.path)) { + return change.changeModel; + } + } } - return new Promise(resolve => this.credentialStore.onDidGetSession(() => resolve())); } private async getRepositoryForFile(file: vscode.Uri): Promise { @@ -82,14 +59,19 @@ export class GitContentFileSystemProvider extends ReadonlyFileSystemProvider { } const absolutePath = pathLib.join(repository.rootUri.fsPath, path).replace(/\\/g, '/'); - let content: string; + let content: string | undefined; try { - Logger.appendLine(`Getting repository (${repository.rootUri}) content for commit ${commit} and path ${absolutePath}`, 'GitContentFileSystemProvider'); - content = await repository.show(commit, absolutePath); + Logger.appendLine(`Getting change model (${repository.rootUri}) content for commit ${commit} and path ${absolutePath}`, 'GitContentFileSystemProvider'); + content = await this.getChangeModelForFile(uri)?.showBase(); + if (!content) { + Logger.appendLine(`Getting repository (${repository.rootUri}) content for commit ${commit} and path ${absolutePath}`, 'GitContentFileSystemProvider'); + content = await repository.show(commit, absolutePath); + } if (!content) { throw new Error(); } } catch (_) { + Logger.appendLine('Using fallback content provider.', 'GitContentFileSystemProvider'); content = await this._fallback(uri); if (!content) { // Content does not exist for the base or modified file for a file deletion or addition. @@ -98,6 +80,7 @@ export class GitContentFileSystemProvider extends ReadonlyFileSystemProvider { try { await repository.getCommit(commit); } catch (err) { + Logger.error(err); vscode.window.showErrorMessage( `We couldn't find commit ${commit} locally. You may want to sync the branch with remote. Sometimes commits can disappear after a force-push`, ); diff --git a/src/view/gitHubContentProvider.ts b/src/view/gitHubContentProvider.ts new file mode 100644 index 0000000000..03e557704e --- /dev/null +++ b/src/view/gitHubContentProvider.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as buffer from 'buffer'; +import * as vscode from 'vscode'; +import { fromGitHubURI } from '../common/uri'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { GitHubRepository } from '../github/githubRepository'; +import { ReadonlyFileSystemProvider } from './readonlyFileSystemProvider'; + +export async function getGitHubFileContent(gitHubRepository: GitHubRepository, fileName: string, branch: string): Promise { + const { octokit, remote } = await gitHubRepository.ensure(); + let fileContent: { data: { content: string; encoding: string; sha: string } } = (await octokit.call(octokit.api.repos.getContent, + { + owner: remote.owner, + repo: remote.repositoryName, + path: fileName, + ref: branch, + }, + )) as any; + let contents = fileContent.data.content ?? ''; + + // Empty contents and 'none' encoding indcates that the file has been truncated and we should get the blob. + if (contents === '' && fileContent.data.encoding === 'none') { + const fileSha = fileContent.data.sha; + fileContent = await octokit.call(octokit.api.git.getBlob, { + owner: remote.owner, + repo: remote.repositoryName, + file_sha: fileSha, + }); + contents = fileContent.data.content; + } + + const buff = buffer.Buffer.from(contents, (fileContent.data as any).encoding); + return buff; +} + +async function getGitFileContent(folderRepoManager: FolderRepositoryManager, fileName: string, branch: string, isEmpty: boolean): Promise { + let content = ''; + if (!isEmpty) { + content = await folderRepoManager.repository.show(branch, vscode.Uri.joinPath(folderRepoManager.repository.rootUri, fileName).fsPath); + } + return new TextEncoder().encode(content); +} + +/** + * Provides file contents for documents with githubpr scheme. Contents are fetched from GitHub based on + * information in the document's query string. + */ +export class GitHubContentProvider extends ReadonlyFileSystemProvider { + constructor(public gitHubRepository: GitHubRepository) { + super(); + } + + async readFile(uri: any): Promise { + const params = fromGitHubURI(uri); + if (!params || params.isEmpty) { + return new TextEncoder().encode(''); + } + + return getGitHubFileContent(this.gitHubRepository, params.fileName, params.branch); + } +} + +export class GitContentProvider extends ReadonlyFileSystemProvider { + constructor(public folderRepositoryManager: FolderRepositoryManager) { + super(); + } + + async readFile(uri: any): Promise { + const params = fromGitHubURI(uri); + if (!params || params.isEmpty) { + return new TextEncoder().encode(''); + } + + return getGitFileContent(this.folderRepositoryManager, params.fileName, params.branch, !!params.isEmpty); + } +} \ No newline at end of file diff --git a/src/view/inMemPRContentProvider.ts b/src/view/inMemPRContentProvider.ts index 31815a6d62..eb1e1ba536 100644 --- a/src/view/inMemPRContentProvider.ts +++ b/src/view/inMemPRContentProvider.ts @@ -5,12 +5,25 @@ 'use strict'; import * as vscode from 'vscode'; -import { fromPRUri } from '../common/uri'; -import { ReadonlyFileSystemProvider } from './readonlyFileSystemProvider'; +import { GitApiImpl } from '../api/api1'; +import { DiffChangeType, getModifiedContentFromDiffHunk } from '../common/diffHunk'; +import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; +import Logger from '../common/logger'; +import { fromPRUri, PRUriParams } from '../common/uri'; +import { CredentialStore } from '../github/credentials'; +import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; +import { IResolvedPullRequestModel, PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { FileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from './fileChangeModel'; +import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; -export class InMemPRFileSystemProvider extends ReadonlyFileSystemProvider { +export class InMemPRFileSystemProvider extends RepositoryFileSystemProvider { private _prFileChangeContentProviders: { [key: number]: (uri: vscode.Uri) => Promise } = {}; + constructor(private reposManagers: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore) { + super(gitAPI, credentialStore); + } + registerTextDocumentContentProvider( prNumber: number, provider: (uri: vscode.Uri) => Promise, @@ -24,23 +37,214 @@ export class InMemPRFileSystemProvider extends ReadonlyFileSystemProvider { }; } - async readFile(uri: any): Promise { - const prUriParams = fromPRUri(uri); - if (prUriParams && prUriParams.prNumber) { - const provider = this._prFileChangeContentProviders[prUriParams.prNumber]; + private resolveChanges(rawChanges: (SlimFileChange | InMemFileChange)[], pr: PullRequestModel, + folderRepositoryManager: FolderRepositoryManager, + mergeBase: string): (RemoteFileChangeModel | InMemFileChangeModel)[] { + const isCurrentPR = pr.equals(folderRepositoryManager.activePullRequest); - if (provider) { - const content = await provider(uri); - return new TextEncoder().encode(content); + return rawChanges.map(change => { + if (change instanceof SlimFileChange) { + return new RemoteFileChangeModel(folderRepositoryManager, change, pr); } + return new InMemFileChangeModel(folderRepositoryManager, + pr as (PullRequestModel & IResolvedPullRequestModel), + change, isCurrentPR, mergeBase); + }); + } + + private waitForGitHubRepos(folderRepositoryManager: FolderRepositoryManager, milliseconds: number) { + return new Promise(resolve => { + const timeout = setTimeout(() => { + disposable.dispose(); + resolve(); + }, milliseconds); + const disposable = folderRepositoryManager.onDidLoadRepositories(e => { + if (e === ReposManagerState.RepositoriesLoaded) { + clearTimeout(timeout); + disposable.dispose(); + resolve(); + } + }); + }); + } + + private async tryRegisterNewProvider(uri: vscode.Uri, prUriParams: PRUriParams) { + await this.waitForAuth(); + if ((this.gitAPI.state !== 'initialized') || (this.gitAPI.repositories.length === 0)) { + await this.waitForRepos(4000); + } + const folderRepositoryManager = this.reposManagers.getManagerForFile(uri); + if (!folderRepositoryManager) { + return; + } + let repo = folderRepositoryManager.findRepo(repo => repo.remote.remoteName === prUriParams.remoteName); + if (!repo) { + // Depending on the git provider, we might not have a GitHub repo right away, even if we already have git repos. + // This can take a long time. + await this.waitForGitHubRepos(folderRepositoryManager, 10000); + repo = folderRepositoryManager.findRepo(repo => repo.remote.remoteName === prUriParams.remoteName); + } + if (!repo) { + return; + } + const pr = await folderRepositoryManager.resolvePullRequest(repo.remote.owner, repo.remote.repositoryName, prUriParams.prNumber); + if (!pr) { + return; + } + const rawChanges = await pr.getFileChangesInfo(); + const mergeBase = pr.mergeBase; + if (!mergeBase) { + return; + } + const changes = this.resolveChanges(rawChanges, pr, folderRepositoryManager, mergeBase); + this.registerTextDocumentContentProvider(pr.number, async (uri: vscode.Uri) => { + const params = fromPRUri(uri); + if (!params) { + return ''; + } + const fileChange = changes.find( + contentChange => contentChange.fileName === params.fileName, + ); + + if (!fileChange) { + Logger.error(`Cannot find content for document ${uri.toString()}`, 'PR'); + return ''; + } + + return provideDocumentContentForChangeModel(folderRepositoryManager, pr, params, fileChange); + }); + } + + private async readFileWithProvider(uri: vscode.Uri, prNumber: number): Promise { + const provider = this._prFileChangeContentProviders[prNumber]; + if (provider) { + const content = await provider(uri); + return new TextEncoder().encode(content); + } + } + + async readFile(uri: vscode.Uri): Promise { + const prUriParams = fromPRUri(uri); + if (!prUriParams || (prUriParams.prNumber === undefined)) { + return new TextEncoder().encode(''); + } + const providerResult = await this.readFileWithProvider(uri, prUriParams.prNumber); + if (providerResult) { + return providerResult; } - return new TextEncoder().encode(''); + await this.tryRegisterNewProvider(uri, prUriParams); + return (await this.readFileWithProvider(uri, prUriParams.prNumber)) ?? new TextEncoder().encode(''); } } -const inMemPRFileSystemProvider = new InMemPRFileSystemProvider(); +let inMemPRFileSystemProvider: InMemPRFileSystemProvider | undefined; -export function getInMemPRFileSystemProvider(): InMemPRFileSystemProvider { +export function getInMemPRFileSystemProvider(initialize?: { reposManager: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore }): InMemPRFileSystemProvider | undefined { + if (!inMemPRFileSystemProvider && initialize) { + inMemPRFileSystemProvider = new InMemPRFileSystemProvider(initialize.reposManager, initialize.gitAPI, initialize.credentialStore); + } return inMemPRFileSystemProvider; +} + +export async function provideDocumentContentForChangeModel(folderRepoManager: FolderRepositoryManager, pullRequestModel: PullRequestModel, params: PRUriParams, fileChange: FileChangeModel): Promise { + if ( + (params.isBase && fileChange.status === GitChangeType.ADD) || + (!params.isBase && fileChange.status === GitChangeType.DELETE) + ) { + return ''; + } + + if ((fileChange instanceof RemoteFileChangeModel) || ((fileChange instanceof InMemFileChangeModel) && await fileChange.isPartial())) { + try { + if (params.isBase) { + return pullRequestModel.getFile( + fileChange.previousFileName || fileChange.fileName, + params.baseCommit, + ); + } else { + return pullRequestModel.getFile(fileChange.fileName, params.headCommit); + } + } catch (e) { + Logger.error(`Fetching file content failed: ${e}`, 'PR'); + vscode.window + .showWarningMessage( + 'Opening this file locally failed. Would you like to view it on GitHub?', + 'Open on GitHub', + ) + .then(result => { + if ((result === 'Open on GitHub') && fileChange.blobUrl) { + vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(fileChange.blobUrl)); + } + }); + return ''; + } + } + + if (fileChange instanceof InMemFileChangeModel) { + const readContentFromDiffHunk = + fileChange.status === GitChangeType.ADD || fileChange.status === GitChangeType.DELETE; + + if (readContentFromDiffHunk) { + if (params.isBase) { + // left + const left: string[] = []; + const diffHunks = await fileChange.diffHunks(); + for (let i = 0; i < diffHunks.length; i++) { + for (let j = 0; j < diffHunks[i].diffLines.length; j++) { + const diffLine = diffHunks[i].diffLines[j]; + if (diffLine.type === DiffChangeType.Add) { + // nothing + } else if (diffLine.type === DiffChangeType.Delete) { + left.push(diffLine.text); + } else if (diffLine.type === DiffChangeType.Control) { + // nothing + } else { + left.push(diffLine.text); + } + } + } + + return left.join('\n'); + } else { + const right: string[] = []; + const diffHunks = await fileChange.diffHunks(); + for (let i = 0; i < diffHunks.length; i++) { + for (let j = 0; j < diffHunks[i].diffLines.length; j++) { + const diffLine = diffHunks[i].diffLines[j]; + if (diffLine.type === DiffChangeType.Add) { + right.push(diffLine.text); + } else if (diffLine.type === DiffChangeType.Delete) { + // nothing + } else if (diffLine.type === DiffChangeType.Control) { + // nothing + } else { + right.push(diffLine.text); + } + } + } + + return right.join('\n'); + } + } else { + const originalFileName = + fileChange.status === GitChangeType.RENAME ? fileChange.previousFileName : fileChange.fileName; + const originalFilePath = vscode.Uri.joinPath( + folderRepoManager.repository.rootUri, + originalFileName!, + ); + const originalContent = await folderRepoManager.repository.show( + params.baseCommit, + originalFilePath.fsPath, + ); + + if (params.isBase) { + return originalContent; + } else { + return getModifiedContentFromDiffHunk(originalContent, fileChange.patch); + } + } + } + + return ''; } \ No newline at end of file diff --git a/src/view/prChangesTreeDataProvider.ts b/src/view/prChangesTreeDataProvider.ts index ea989c6179..98ed904eeb 100644 --- a/src/view/prChangesTreeDataProvider.ts +++ b/src/view/prChangesTreeDataProvider.ts @@ -7,15 +7,17 @@ import * as vscode from 'vscode'; import { GitApiImpl } from '../api/api1'; import { commands, contexts } from '../common/executeCommands'; import Logger, { PR_TREE } from '../common/logger'; -import { FILE_LIST_LAYOUT } from '../common/settingKeys'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../github/folderRepositoryManager'; +import { FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; import { RepositoriesManager } from '../github/repositoriesManager'; +import { ProgressHelper } from './progress'; import { ReviewModel } from './reviewModel'; import { DescriptionNode } from './treeNodes/descriptionNode'; import { GitFileChangeNode } from './treeNodes/fileChangeNode'; import { RepositoryChangesNode } from './treeNodes/repositoryChangesNode'; import { BaseTreeNode, TreeNode } from './treeNodes/treeNode'; +import { TreeUtils } from './treeNodes/treeUtils'; export class PullRequestChangesTreeDataProvider extends vscode.Disposable implements vscode.TreeDataProvider, BaseTreeNode { private _onDidChangeTreeData = new vscode.EventEmitter(); @@ -24,6 +26,7 @@ export class PullRequestChangesTreeDataProvider extends vscode.Disposable implem private _pullRequestManagerMap: Map = new Map(); private _view: vscode.TreeView; + private _children: TreeNode[] | undefined; public get view(): vscode.TreeView { return this._view; @@ -39,17 +42,19 @@ export class PullRequestChangesTreeDataProvider extends vscode.Disposable implem this._disposables.push( vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { this._onDidChangeTreeData.fire(); const layout = vscode.workspace - .getConfiguration(`${SETTINGS_NAMESPACE}`) + .getConfiguration(PR_SETTINGS_NAMESPACE) .get(FILE_LIST_LAYOUT); await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); - } else if (e.affectsConfiguration('git.openDiffOnClick')) { + } else if (e.affectsConfiguration(`${GIT}.${OPEN_DIFF_ON_CLICK}`)) { this._onDidChangeTreeData.fire(); } }), ); + + this._disposables.push(this._view.onDidChangeCheckboxState(TreeUtils.processCheckboxUpdates)); } refresh(treeNode?: TreeNode) { @@ -66,8 +71,8 @@ export class PullRequestChangesTreeDataProvider extends vscode.Disposable implem } this._view.title = pullRequestNumber - ? `Changes in Pull Request #${pullRequestNumber}` - : 'Changes in Pull Request'; + ? vscode.l10n.t('Changes in Pull Request #{0}', pullRequestNumber) + : (this._pullRequestManagerMap.size > 1 ? vscode.l10n.t('Changes in Pull Requests') : vscode.l10n.t('Changes in Pull Request')); } async addPrToView( @@ -75,6 +80,7 @@ export class PullRequestChangesTreeDataProvider extends vscode.Disposable implem pullRequestModel: PullRequestModel, reviewModel: ReviewModel, shouldReveal: boolean, + progress: ProgressHelper ) { Logger.appendLine(`Adding PR #${pullRequestModel.number} to tree`, PR_TREE); if (this._pullRequestManagerMap.has(pullRequestManager)) { @@ -90,7 +96,8 @@ export class PullRequestChangesTreeDataProvider extends vscode.Disposable implem this, pullRequestModel, pullRequestManager, - reviewModel + reviewModel, + progress ); this._pullRequestManagerMap.set(pullRequestManager, node); this.updateViewTitle(); @@ -122,8 +129,9 @@ export class PullRequestChangesTreeDataProvider extends vscode.Disposable implem async removePrFromView(pullRequestManager: FolderRepositoryManager) { const oldPR = this._pullRequestManagerMap.has(pullRequestManager) ? this._pullRequestManagerMap.get(pullRequestManager) : undefined; - Logger.appendLine(`Removing PR #${oldPR?.pullRequestModel.number} from tree`, PR_TREE); - + if (oldPR) { + Logger.appendLine(`Removing PR #${oldPR.pullRequestModel.number} from tree`, PR_TREE); + } oldPR?.dispose(); this._pullRequestManagerMap.delete(pullRequestManager); this.updateViewTitle(); @@ -147,19 +155,23 @@ export class PullRequestChangesTreeDataProvider extends vscode.Disposable implem try { await this._view.reveal(element, options); } catch (e) { - Logger.appendLine(e, 'PullRequestChangesTreeDataProvider'); + Logger.error(e, PR_TREE); } } + get children(): TreeNode[] | undefined { + return this._children; + } + async getChildren(element?: TreeNode): Promise { if (!element) { - const result: TreeNode[] = []; + this._children = []; if (this._pullRequestManagerMap.size >= 1) { for (const item of this._pullRequestManagerMap.values()) { - result.push(item); + this._children.push(item); } } - return result; + return this._children; } else { return await element.getChildren(); } diff --git a/src/view/prNotificationDecorationProvider.ts b/src/view/prNotificationDecorationProvider.ts new file mode 100644 index 0000000000..7fac6fb3c9 --- /dev/null +++ b/src/view/prNotificationDecorationProvider.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { fromPRNodeUri } from '../common/uri'; +import { NotificationProvider } from '../github/notifications'; + +export class PRNotificationDecorationProvider implements vscode.FileDecorationProvider { + private _disposables: vscode.Disposable[] = []; + + private _onDidChangeFileDecorations: vscode.EventEmitter = new vscode.EventEmitter< + vscode.Uri | vscode.Uri[] + >(); + onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; + + + constructor(private readonly _notificationProvider: NotificationProvider) { + this._disposables.push(vscode.window.registerFileDecorationProvider(this)); + this._disposables.push( + this._notificationProvider.onDidChangeNotifications(PRNodeUris => this._onDidChangeFileDecorations.fire(PRNodeUris)) + ); + } + + provideFileDecoration( + uri: vscode.Uri, + _token: vscode.CancellationToken, + ): vscode.ProviderResult { + if (!uri.query) { + return; + } + + const prNodeParams = fromPRNodeUri(uri); + + if (prNodeParams && this._notificationProvider.hasNotification(prNodeParams.prIdentifier)) { + return { + propagate: false, + color: new vscode.ThemeColor('pullRequests.notification'), + badge: '●', + tooltip: 'unread notification' + }; + } + + return undefined; + } + + + dispose() { + this._disposables.forEach(dispose => dispose.dispose()); + } +} diff --git a/src/view/prStatusDecorationProvider.ts b/src/view/prStatusDecorationProvider.ts new file mode 100644 index 0000000000..2388cda84c --- /dev/null +++ b/src/view/prStatusDecorationProvider.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createPRNodeUri, fromPRNodeUri, Schemes } from '../common/uri'; +import { dispose } from '../common/utils'; +import { PrsTreeModel, UnsatisfiedChecks } from './prsTreeModel'; + +export class PRStatusDecorationProvider implements vscode.FileDecorationProvider, vscode.Disposable { + private _disposables: vscode.Disposable[] = []; + + private _onDidChangeFileDecorations: vscode.EventEmitter = new vscode.EventEmitter< + vscode.Uri | vscode.Uri[] + >(); + onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; + + constructor(private readonly _prsTreeModel: PrsTreeModel) { + this._disposables.push(vscode.window.registerFileDecorationProvider(this)); + this._disposables.push( + this._prsTreeModel.onDidChangePrStatus(identifiers => { + this._onDidChangeFileDecorations.fire(identifiers.map(id => createPRNodeUri(id))); + }) + ); + } + + provideFileDecoration( + uri: vscode.Uri, + _token: vscode.CancellationToken, + ): vscode.ProviderResult { + if (uri.scheme !== Schemes.PRNode) { + return; + } + const params = fromPRNodeUri(uri); + if (!params) { + return; + } + const status = this._prsTreeModel.cachedPRStatus(params.prIdentifier); + if (!status) { + return; + } + + return this._getDecoration(status.status) as vscode.FileDecoration; + } + + private _getDecoration(status: UnsatisfiedChecks): vscode.FileDecoration2 | undefined { + if ((status & UnsatisfiedChecks.CIFailed) && (status & UnsatisfiedChecks.ReviewRequired)) { + return { + propagate: false, + badge: new vscode.ThemeIcon('close', new vscode.ThemeColor('list.errorForeground')), + tooltip: 'Review required and some checks have failed' + }; + } else if (status & UnsatisfiedChecks.CIFailed) { + return { + propagate: false, + badge: new vscode.ThemeIcon('close', new vscode.ThemeColor('list.errorForeground')), + tooltip: 'Some checks have failed' + }; + } else if (status & UnsatisfiedChecks.ChangesRequested) { + return { + propagate: false, + badge: new vscode.ThemeIcon('request-changes', new vscode.ThemeColor('list.errorForeground')), + tooltip: 'Changes requested' + }; + } else if (status & UnsatisfiedChecks.CIPending) { + return { + propagate: false, + badge: new vscode.ThemeIcon('sync', new vscode.ThemeColor('list.warningForeground')), + tooltip: 'Checks pending' + }; + } else if (status & UnsatisfiedChecks.ReviewRequired) { + return { + propagate: false, + badge: new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor('list.warningForeground')), + tooltip: 'Review required' + }; + } else if (status === UnsatisfiedChecks.None) { + return { + propagate: false, + badge: new vscode.ThemeIcon('check-all', new vscode.ThemeColor('issues.open')), + tooltip: 'All checks passed' + }; + } + + } + + dispose() { + dispose(this._disposables); + } + +} \ No newline at end of file diff --git a/src/view/progress.ts b/src/view/progress.ts new file mode 100644 index 0000000000..04ef0370bf --- /dev/null +++ b/src/view/progress.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export class ProgressHelper { + private _progress: Promise = Promise.resolve(); + private _endProgress: vscode.EventEmitter = new vscode.EventEmitter(); + + get progress(): Promise { + return this._progress; + } + startProgress() { + this.endProgress(); + this._progress = new Promise(resolve => { + const disposable = this._endProgress.event(() => { + disposable.dispose(); + resolve(); + }); + }); + } + + endProgress() { + this._endProgress.fire(); + } +} \ No newline at end of file diff --git a/src/view/prsTreeDataProvider.ts b/src/view/prsTreeDataProvider.ts index 469a77e09a..6255bfebba 100644 --- a/src/view/prsTreeDataProvider.ts +++ b/src/view/prsTreeDataProvider.ts @@ -4,16 +4,27 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { FILE_LIST_LAYOUT } from '../common/settingKeys'; +import { AuthProvider } from '../common/authentication'; +import { commands, contexts } from '../common/executeCommands'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE, QUERIES, REMOTES } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; import { EXTENSION_ID } from '../constants'; -import { REMOTES_SETTING, ReposManagerState, SETTINGS_NAMESPACE } from '../github/folderRepositoryManager'; +import { CredentialStore } from '../github/credentials'; +import { FolderRepositoryManager, ReposManagerState } from '../github/folderRepositoryManager'; +import { PRType } from '../github/interface'; +import { NotificationProvider } from '../github/notifications'; +import { PullRequestModel } from '../github/pullRequestModel'; import { RepositoriesManager } from '../github/repositoriesManager'; +import { findDotComAndEnterpriseRemotes } from '../github/utils'; +import { PRStatusDecorationProvider } from './prStatusDecorationProvider'; +import { PrsTreeModel } from './prsTreeModel'; +import { ReviewModel } from './reviewModel'; import { DecorationProvider } from './treeDecorationProvider'; import { CategoryTreeNode, PRCategoryActionNode, PRCategoryActionType } from './treeNodes/categoryNode'; import { InMemFileChangeNode } from './treeNodes/fileChangeNode'; -import { BaseTreeNode, TreeNode } from './treeNodes/treeNode'; -import { QUERIES_SETTING, WorkspaceFolderNode } from './treeNodes/workspaceFolderNode'; +import { BaseTreeNode, EXPANDED_QUERIES_STATE, TreeNode } from './treeNodes/treeNode'; +import { TreeUtils } from './treeNodes/treeUtils'; +import { WorkspaceFolderNode } from './treeNodes/workspaceFolderNode'; export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider, BaseTreeNode, vscode.Disposable { private _onDidChangeTreeData = new vscode.EventEmitter(); @@ -23,20 +34,29 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider; - private _reposManager: RepositoriesManager | undefined; private _initialized: boolean = false; + public notificationProvider: NotificationProvider; + private _prsTreeModel: PrsTreeModel; get view(): vscode.TreeView { return this._view; } - constructor(private _telemetry: ITelemetry) { + constructor(private _telemetry: ITelemetry, private readonly _context: vscode.ExtensionContext, private readonly _reposManager: RepositoriesManager) { this._disposables = []; + this._prsTreeModel = new PrsTreeModel(this._telemetry, this._reposManager); + this._disposables.push(this._prsTreeModel); + this._disposables.push(this._prsTreeModel.onDidChangeData(folderManager => folderManager ? this.refreshRepo(folderManager) : this.refresh())); + this._disposables.push(new PRStatusDecorationProvider(this._prsTreeModel)); this._disposables.push(vscode.window.registerFileDecorationProvider(DecorationProvider)); this._disposables.push( vscode.commands.registerCommand('pr.refreshList', _ => { + this._prsTreeModel.clearCache(); this._onDidChangeTreeData.fire(); }), ); @@ -54,7 +74,7 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider { @@ -82,37 +102,76 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${FILE_LIST_LAYOUT}`)) { this._onDidChangeTreeData.fire(); } }), ); + + this._disposables.push(this._view.onDidChangeCheckboxState(TreeUtils.processCheckboxUpdates)); + + this._disposables.push(this._view.onDidExpandElement(expanded => { + this._updateExpandedQueries(expanded.element, true); + })); + this._disposables.push(this._view.onDidCollapseElement(collapsed => { + this._updateExpandedQueries(collapsed.element, false); + })); + } + + private _updateExpandedQueries(element: TreeNode, isExpanded: boolean) { + if (element instanceof CategoryTreeNode) { + const expandedQueries = new Set(this._context.workspaceState.get(EXPANDED_QUERIES_STATE, []) as string[]); + if (isExpanded) { + expandedQueries.add(element.id); + } else { + expandedQueries.delete(element.id); + } + this._context.workspaceState.update(EXPANDED_QUERIES_STATE, Array.from(expandedQueries.keys())); + } + } + + public async expandPullRequest(pullRequest: PullRequestModel) { + if (this._children.length === 0) { + await this.getChildren(); + } + for (const child of this._children) { + if (child instanceof WorkspaceFolderNode) { + if (await child.expandPullRequest(pullRequest)) { + return; + } + } else if (child.type === PRType.All) { + if (await child.expandPullRequest(pullRequest)) { + return; + } + } + } } async reveal(element: TreeNode, options?: { select?: boolean, focus?: boolean, expand?: boolean }): Promise { return this._view.reveal(element, options); } - initialize(reposManager: RepositoriesManager) { + initialize(reviewModels: ReviewModel[], credentialStore: CredentialStore) { if (this._initialized) { throw new Error('Tree has already been initialized!'); } this._initialized = true; - this._reposManager = reposManager; this._disposables.push( this._reposManager.onDidChangeState(() => { - this._onDidChangeTreeData.fire(); + this.refresh(); }), ); + this._disposables.push( - ...this._reposManager.folderManagers.map(manager => { - return manager.onDidChangeRepositories(() => { - this._onDidChangeTreeData.fire(); - }); + ...reviewModels.map(model => { + return model.onDidChangeLocalFileChanges(_ => { this.refresh(); }); }), ); + this.notificationProvider = new NotificationProvider(this, credentialStore, this._reposManager); + this._disposables.push(this.notificationProvider); + this.initializeCategories(); this.refresh(); } @@ -120,7 +179,7 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider { - if (e.affectsConfiguration(`${SETTINGS_NAMESPACE}.${QUERIES_SETTING}`)) { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${QUERIES}`)) { this.refresh(); } }), @@ -131,7 +190,21 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider node.folderManager === manager); + if (node) { + this._onDidChangeTreeData.fire(node); + return; + } + } + } + + getTreeItem(element: TreeNode): vscode.TreeItem | Promise { return element.getTreeItem(); } @@ -142,20 +215,36 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider(REMOTES_SETTING); + const remotesSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(REMOTES); + let actions: PRCategoryActionNode[]; if (remotesSetting) { - return Promise.resolve([ + actions = [ new PRCategoryActionNode(this, PRCategoryActionType.NoMatchingRemotes), new PRCategoryActionNode(this, PRCategoryActionType.ConfigureRemotes), - ]); + + ]; + } else { + actions = [new PRCategoryActionNode(this, PRCategoryActionType.NoRemotes)]; + } + + const { enterpriseRemotes } = this._reposManager ? await findDotComAndEnterpriseRemotes(this._reposManager?.folderManagers) : { enterpriseRemotes: [] }; + if ((enterpriseRemotes.length > 0) && !this._reposManager?.credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) { + actions.push(new PRCategoryActionNode(this, PRCategoryActionType.LoginEnterprise)); } - return Promise.resolve([new PRCategoryActionNode(this, PRCategoryActionType.NoRemotes)]); + return actions; + } + + async cachedChildren(element?: WorkspaceFolderNode | CategoryTreeNode): Promise { + if (!element) { + return this._children; + } + return element.cachedChildren(); } async getChildren(element?: TreeNode): Promise { @@ -164,24 +253,29 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider manager.getGitHubRemotes().length > 0).length === 0) { + const remotes = await Promise.all(this._reposManager.folderManagers.map(manager => manager.getGitHubRemotes())); + if ((this._reposManager.folderManagers.filter((_manager, index) => remotes[index].length > 0).length === 0)) { return this.needsRemotes(); } if (!element) { - if (this._childrenDisposables && this._childrenDisposables.length) { - this._childrenDisposables.forEach(dispose => dispose.dispose()); + if (this._children && this._children.length) { + this._children.forEach(dispose => dispose.dispose()); } - let result: TreeNode[]; + let result: WorkspaceFolderNode[] | CategoryTreeNode[]; if (this._reposManager.folderManagers.length === 1) { result = WorkspaceFolderNode.getCategoryTreeNodes( this._reposManager.folderManagers[0], this._telemetry, this, + this.notificationProvider, + this._context, + this._prsTreeModel ); } else { result = this._reposManager.folderManagers.map( @@ -191,17 +285,19 @@ export class PullRequestsTreeDataProvider implements vscode.TreeDataProvider manager.repository.state.remotes.length > 0).length === - 0 + this._reposManager.folderManagers.filter(manager => manager.repository.state.remotes.length > 0).length === 0 ) { return Promise.resolve([new PRCategoryActionNode(this, PRCategoryActionType.Empty)]); } diff --git a/src/view/prsTreeModel.ts b/src/view/prsTreeModel.ts new file mode 100644 index 0000000000..9e90c18393 --- /dev/null +++ b/src/view/prsTreeModel.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { PR_SETTINGS_NAMESPACE, USE_REVIEW_MODE } from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; +import { createPRNodeIdentifier } from '../common/uri'; +import { dispose } from '../common/utils'; +import { FolderRepositoryManager, ItemsResponseResult } from '../github/folderRepositoryManager'; +import { CheckState, PRType, PullRequestChecks, PullRequestReviewRequirement } from '../github/interface'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; + +export enum UnsatisfiedChecks { + None = 0, + ReviewRequired = 1 << 0, + ChangesRequested = 1 << 1, + CIFailed = 1 << 2, + CIPending = 1 << 3 +} + +interface PRStatusChange { + pullRequest: PullRequestModel; + status: UnsatisfiedChecks; +} + +export class PrsTreeModel implements vscode.Disposable { + private readonly _disposables: vscode.Disposable[] = []; + private _activePRDisposables: Map = new Map(); + private readonly _onDidChangePrStatus: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidChangePrStatus = this._onDidChangePrStatus.event; + private readonly _onDidChangeData: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidChangeData = this._onDidChangeData.event; + + // Key is identifier from createPRNodeUri + private readonly _queriedPullRequests: Map = new Map(); + + private _cachedPRs: Map>> = new Map(); + + constructor(private _telemetry: ITelemetry, private readonly _reposManager: RepositoriesManager) { + const repoEvents = (manager: FolderRepositoryManager) => { + return [ + manager.onDidChangeActivePullRequest(() => { + this.clearRepo(manager); + if (this._activePRDisposables.has(manager)) { + dispose(this._activePRDisposables.get(manager)!); + this._activePRDisposables.delete(manager); + } + if (manager.activePullRequest) { + this._activePRDisposables.set(manager, [ + manager.activePullRequest.onDidChangeComments(() => { + this.clearRepo(manager); + })]); + } + })]; + }; + + this._disposables.push( + ...(this._reposManager.folderManagers.map(manager => { + return repoEvents(manager); + }).flat()), + ); + this._disposables.push(this._reposManager.onDidChangeFolderRepositories((changed) => { + if (changed.added) { + this._disposables.push(...repoEvents(changed.added)); + this._onDidChangeData.fire(changed.added); + } + })); + } + + public cachedPRStatus(identifier: string): PRStatusChange | undefined { + return this._queriedPullRequests.get(identifier); + } + + public clearCache() { + this._cachedPRs.clear(); + this._onDidChangeData.fire(); + } + + public clearRepo(folderRepoManager: FolderRepositoryManager) { + this._cachedPRs.delete(folderRepoManager); + this._onDidChangeData.fire(folderRepoManager); + } + + private async _getChecks(pullRequests: PullRequestModel[]) { + // If there are too many pull requests then we could hit our internal rate limit + // or even GitHub's secondary rate limit. If there are more than 100 PRs, + // chunk them into 100s. + let checks: [PullRequestChecks | null, PullRequestReviewRequirement | null][] = []; + for (let i = 0; i < pullRequests.length; i += 100) { + const sliceEnd = (i + 100 < pullRequests.length) ? i + 100 : pullRequests.length; + checks.push(...await Promise.all(pullRequests.slice(i, sliceEnd).map(pullRequest => { + return pullRequest.getStatusChecks(); + }))); + } + + const changedStatuses: string[] = []; + for (let i = 0; i < pullRequests.length; i++) { + const pullRequest = pullRequests[i]; + const [check, reviewRequirement] = checks[i]; + let newStatus: UnsatisfiedChecks = UnsatisfiedChecks.None; + + if (reviewRequirement) { + if (reviewRequirement.state === CheckState.Failure) { + newStatus |= UnsatisfiedChecks.ReviewRequired; + } else if (reviewRequirement.state == CheckState.Pending) { + newStatus |= UnsatisfiedChecks.ChangesRequested; + } + } + + if (!check || check.state === CheckState.Unknown) { + continue; + } + if (check.state !== CheckState.Success) { + for (const status of check.statuses) { + if (status.state === CheckState.Failure) { + newStatus |= UnsatisfiedChecks.CIFailed; + } else if (status.state === CheckState.Pending) { + newStatus |= UnsatisfiedChecks.CIPending; + } + } + if (newStatus === UnsatisfiedChecks.None) { + newStatus |= UnsatisfiedChecks.CIPending; + } + } + const identifier = createPRNodeIdentifier(pullRequest); + const oldState = this._queriedPullRequests.get(identifier); + if ((oldState === undefined) || (oldState.status !== newStatus)) { + const newState = { pullRequest, status: newStatus }; + changedStatuses.push(identifier); + this._queriedPullRequests.set(identifier, newState); + } + } + this._onDidChangePrStatus.fire(changedStatuses); + } + + private getFolderCache(folderRepoManager: FolderRepositoryManager): Map> { + let cache = this._cachedPRs.get(folderRepoManager); + if (!cache) { + cache = new Map(); + this._cachedPRs.set(folderRepoManager, cache); + } + return cache; + } + + async getLocalPullRequests(folderRepoManager: FolderRepositoryManager, update?: boolean) { + const cache = this.getFolderCache(folderRepoManager); + if (!update && cache.has(PRType.LocalPullRequest)) { + return cache.get(PRType.LocalPullRequest)!; + } + + const useReviewConfiguration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE) + .get<{ merged: boolean, closed: boolean }>(USE_REVIEW_MODE, { merged: true, closed: false }); + + const prs = (await folderRepoManager.getLocalPullRequests()) + .filter(pr => pr.isOpen || (pr.isClosed && useReviewConfiguration.closed) || (pr.isMerged && useReviewConfiguration.merged)); + cache.set(PRType.LocalPullRequest, { hasMorePages: false, hasUnsearchedRepositories: false, items: prs }); + + /* __GDPR__ + "pr.expand.local" : {} + */ + this._telemetry.sendTelemetryEvent('pr.expand.local'); + // Don't await this._getChecks. It fires an event that will be listened to. + this._getChecks(prs); + return { hasMorePages: false, hasUnsearchedRepositories: false, items: prs }; + } + + async getPullRequestsForQuery(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean, query: string, update?: boolean): Promise> { + const cache = this.getFolderCache(folderRepoManager); + if (!update && cache.has(query)) { + return cache.get(query)!; + } + + const prs = await folderRepoManager.getPullRequests( + PRType.Query, + { fetchNextPage }, + query, + ); + cache.set(query, prs); + + /* __GDPR__ + "pr.expand.query" : {} + */ + this._telemetry.sendTelemetryEvent('pr.expand.query'); + // Don't await this._getChecks. It fires an event that will be listened to. + this._getChecks(prs.items); + return prs; + } + + async getAllPullRequests(folderRepoManager: FolderRepositoryManager, fetchNextPage: boolean, update?: boolean): Promise> { + const cache = this.getFolderCache(folderRepoManager); + if (!update && cache.has(PRType.All) && !fetchNextPage) { + return cache.get(PRType.All)!; + } + + const prs = await folderRepoManager.getPullRequests( + PRType.All, + { fetchNextPage } + ); + cache.set(PRType.All, prs); + + /* __GDPR__ + "pr.expand.all" : {} + */ + this._telemetry.sendTelemetryEvent('pr.expand.all'); + // Don't await this._getChecks. It fires an event that will be listened to. + this._getChecks(prs.items); + return prs; + } + + dispose() { + dispose(this._disposables); + dispose(Array.from(this._activePRDisposables.values()).flat()); + } + +} \ No newline at end of file diff --git a/src/view/pullRequestCommentController.ts b/src/view/pullRequestCommentController.ts index 2a40bcbe81..205d03afc8 100644 --- a/src/view/pullRequestCommentController.ts +++ b/src/view/pullRequestCommentController.ts @@ -7,9 +7,7 @@ import * as path from 'path'; import { v4 as uuid } from 'uuid'; import * as vscode from 'vscode'; import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; -import { DiffSide, IComment } from '../common/comment'; -import Logger from '../common/logger'; -import { ISessionState } from '../common/sessionState'; +import { DiffSide, IComment, SubjectType } from '../common/comment'; import { fromPRUri, Schemes } from '../common/uri'; import { groupBy } from '../common/utils'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; @@ -37,23 +35,25 @@ export class PullRequestCommentController implements CommentHandler, CommentReac */ private _closedEditorCachedThreads: Set = new Set(); private _disposables: vscode.Disposable[] = []; + private readonly _context: vscode.ExtensionContext; constructor( private pullRequestModel: PullRequestModel, private _folderReposManager: FolderRepositoryManager, private _commentController: vscode.CommentController, - private readonly _sessionState: ISessionState ) { + this._context = _folderReposManager.context; this._commentHandlerId = uuid(); registerCommentHandler(this._commentHandlerId, this); if (this.pullRequestModel.reviewThreadsCacheReady) { - this.initializeThreadsInOpenEditors(); - this.registerListeners(); + this.initializeThreadsInOpenEditors().then(() => { + this.registerListeners(); + }); } else { - const reviewThreadsDisposable = this.pullRequestModel.onDidChangeReviewThreads(() => { + const reviewThreadsDisposable = this.pullRequestModel.onDidChangeReviewThreads(async () => { reviewThreadsDisposable.dispose(); - this.initializeThreadsInOpenEditors(); + await this.initializeThreadsInOpenEditors(); this.registerListeners(); }); } @@ -64,7 +64,7 @@ export class PullRequestCommentController implements CommentHandler, CommentReac this._disposables.push( vscode.window.onDidChangeVisibleTextEditors(async e => { - this.onDidChangeOpenEditors(e); + return this.onDidChangeOpenEditors(e); }), ); @@ -83,23 +83,6 @@ export class PullRequestCommentController implements CommentHandler, CommentReac this.refreshContextKey(e); }), ); - - this._disposables.push( - this._sessionState.onDidChangeCommentsExpandState(expand => { - for (const reviewThread of this.pullRequestModel.reviewThreadsCache) { - const key = this.getCommentThreadCacheKey(reviewThread.path, reviewThread.diffSide === DiffSide.LEFT); - const cachedThread = this._commentThreadCache[key]; - if (!cachedThread) { - Logger.appendLine(`PullRequestCommentController> Thread with ID ${key} is no longer in cache`); - continue; - } - const index = cachedThread.findIndex(t => t.gitHubThreadId === reviewThread.id); - if (index > -1) { - const matchingThread = cachedThread[index]; - updateThread(matchingThread, reviewThread, expand); - } - } - })); } private refreshContextKey(editor: vscode.TextEditor | undefined): void { @@ -120,7 +103,7 @@ export class PullRequestCommentController implements CommentHandler, CommentReac this.setContextKey(this.pullRequestModel.hasPendingReview); } - private getPREditors(editors: vscode.TextEditor[]): vscode.TextEditor[] { + private getPREditors(editors: readonly vscode.TextEditor[]): vscode.TextEditor[] { return editors.filter(editor => { if (editor.document.uri.scheme !== Schemes.Pr) { return false; @@ -156,10 +139,11 @@ export class PullRequestCommentController implements CommentHandler, CommentReac return uncachedEditors; } - private addThreadsForEditors(newEditors: vscode.TextEditor[]): void { + private async addThreadsForEditors(newEditors: vscode.TextEditor[]): Promise { const editors = this.tryUsedCachedEditor(newEditors); const reviewThreads = this.pullRequestModel.reviewThreadsCache; const threadsByPath = groupBy(reviewThreads, thread => thread.path); + const currentUser = await this._folderReposManager.getCurrentUser(); editors.forEach(editor => { const { fileName, isBase } = fromPRUri(editor.document.uri)!; if (threadsByPath[fileName]) { @@ -172,23 +156,26 @@ export class PullRequestCommentController implements CommentHandler, CommentReac ) .map(thread => { const endLine = thread.endLine - 1; - const range = threadRange(thread.startLine - 1, endLine, editor.document.lineAt(endLine).range.end.character); + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, endLine, editor.document.lineAt(endLine).range.end.character); return createVSCodeCommentThreadForReviewThread( + this._context, editor.document.uri, range, thread, this._commentController, + currentUser.login, + this.pullRequestModel.githubRepository ); }); } }); } - private initializeThreadsInOpenEditors(): void { + private async initializeThreadsInOpenEditors(): Promise { const prEditors = this.getPREditors(vscode.window.visibleTextEditors); this._openPREditors = prEditors; - this.addThreadsForEditors(prEditors); + return this.addThreadsForEditors(prEditors); } private cleanCachedEditors() { @@ -221,7 +208,7 @@ export class PullRequestCommentController implements CommentHandler, CommentReac }); } - private onDidChangeOpenEditors(editors: vscode.TextEditor[]): void { + private async onDidChangeOpenEditors(editors: readonly vscode.TextEditor[]): Promise { const prEditors = this.getPREditors(editors); const removed = this._openPREditors.filter(x => !prEditors.includes(x)); this.addCachedEditors(removed); @@ -230,16 +217,16 @@ export class PullRequestCommentController implements CommentHandler, CommentReac const added = prEditors.filter(x => !this._openPREditors.includes(x)); this._openPREditors = prEditors; if (added.length) { - this.addThreadsForEditors(added); + await this.addThreadsForEditors(added); } } private onDidChangeReviewThreads(e: ReviewThreadChangeEvent): void { - e.added.forEach(thread => { + e.added.forEach(async (thread) => { const fileName = thread.path; const index = this._pendingCommentThreadAdds.findIndex(t => { const samePath = this.gitRelativeRootPath(t.uri.path) === thread.path; - const sameLine = t.range.end.line + 1 === thread.endLine; + const sameLine = (t.range === undefined && thread.subjectType === SubjectType.FILE) || (t.range && t.range.end.line + 1 === thread.endLine); return samePath && sameLine; }); @@ -247,8 +234,8 @@ export class PullRequestCommentController implements CommentHandler, CommentReac if (index > -1) { newThread = this._pendingCommentThreadAdds[index]; newThread.gitHubThreadId = thread.id; - newThread.comments = thread.comments.map(c => new GHPRComment(c, newThread!)); - updateThreadWithRange(newThread, thread); + newThread.comments = thread.comments.map(c => new GHPRComment(this._context, c, newThread!, this.pullRequestModel.githubRepository)); + updateThreadWithRange(this._context, newThread, thread, this.pullRequestModel.githubRepository); this._pendingCommentThreadAdds.splice(index, 1); } else { const openPREditors = this.getPREditors(vscode.window.visibleTextEditors); @@ -262,13 +249,16 @@ export class PullRequestCommentController implements CommentHandler, CommentReac if (matchingEditor) { const endLine = thread.endLine - 1; - const range = threadRange(thread.startLine - 1, endLine, matchingEditor.document.lineAt(endLine).range.end.character); + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, endLine, matchingEditor.document.lineAt(endLine).range.end.character); newThread = createVSCodeCommentThreadForReviewThread( + this._context, matchingEditor.document.uri, range, thread, this._commentController, + (await this._folderReposManager.getCurrentUser()).login, + this.pullRequestModel.githubRepository ); } } @@ -286,10 +276,10 @@ export class PullRequestCommentController implements CommentHandler, CommentReac e.changed.forEach(thread => { const key = this.getCommentThreadCacheKey(thread.path, thread.diffSide === DiffSide.LEFT); - const index = this._commentThreadCache[key].findIndex(t => t.gitHubThreadId === thread.id); + const index = this._commentThreadCache[key] ? this._commentThreadCache[key].findIndex(t => t.gitHubThreadId === thread.id) : -1; if (index > -1) { const matchingThread = this._commentThreadCache[key][index]; - updateThread(matchingThread, thread); + updateThread(this._context, matchingThread, thread, this.pullRequestModel.githubRepository); } }); @@ -335,7 +325,7 @@ export class PullRequestCommentController implements CommentHandler, CommentReac : inDraft !== undefined ? inDraft : this.pullRequestModel.hasPendingReview; - const temporaryCommentId = this.optimisticallyAddComment(thread, input, isDraft); + const temporaryCommentId = await this.optimisticallyAddComment(thread, input, isDraft); try { if (hasExistingComments) { @@ -347,8 +337,8 @@ export class PullRequestCommentController implements CommentHandler, CommentReac await this.pullRequestModel.createReviewThread( input, fileName, - thread.range.start.line + 1, - thread.range.end.line + 1, + thread.range ? (thread.range.start.line + 1) : undefined, + thread.range ? (thread.range.end.line + 1) : undefined, side, isSingleComment, ); @@ -380,15 +370,15 @@ export class PullRequestCommentController implements CommentHandler, CommentReac private reply(thread: GHPRCommentThread, input: string, isSingleComment: boolean): Promise { const replyingTo = thread.comments[0]; if (replyingTo instanceof GHPRComment) { - return this.pullRequestModel.createCommentReply(input, replyingTo._rawComment.graphNodeId, isSingleComment); + return this.pullRequestModel.createCommentReply(input, replyingTo.rawComment.graphNodeId, isSingleComment); } else { // TODO can we do better? throw new Error('Cannot respond to temporary comment'); } } - private optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): number { - const currentUser = this._folderReposManager.getCurrentUser(this.pullRequestModel); + private async optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): Promise { + const currentUser = await this._folderReposManager.getCurrentUser(this.pullRequestModel.githubRepository); const temporaryComment = new TemporaryComment( thread, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, @@ -409,10 +399,10 @@ export class PullRequestCommentController implements CommentHandler, CommentReac public async editComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise { if (comment instanceof GHPRComment) { - const temporaryCommentId = this.optimisticallyEditComment(thread, comment); + const temporaryCommentId = await this.optimisticallyEditComment(thread, comment); try { await this.pullRequestModel.editReviewComment( - comment._rawComment, + comment.rawComment, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, ); } catch (e) { @@ -420,7 +410,7 @@ export class PullRequestCommentController implements CommentHandler, CommentReac thread.comments = thread.comments.map(c => { if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - return new GHPRComment(comment._rawComment, thread); + return new GHPRComment(this._context, comment.rawComment, thread); } return c; @@ -454,14 +444,14 @@ export class PullRequestCommentController implements CommentHandler, CommentReac // #region Review public async startReview(thread: GHPRCommentThread, input: string): Promise { const hasExistingComments = thread.comments.length; - const temporaryCommentId = this.optimisticallyAddComment(thread, input, true); + const temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); try { if (!hasExistingComments) { const fileName = this.gitRelativeRootPath(thread.uri.path); const side = this.getCommentSide(thread); this._pendingCommentThreadAdds.push(thread); - await this.pullRequestModel.createReviewThread(input, fileName, thread.range.start.line + 1, thread.range.end.line + 1, side); + await this.pullRequestModel.createReviewThread(input, fileName, thread.range ? (thread.range.start.line + 1) : undefined, thread.range ? (thread.range.end.line + 1) : undefined, side); } else { await this.reply(thread, input, false); } @@ -491,8 +481,8 @@ export class PullRequestCommentController implements CommentHandler, CommentReac this._folderReposManager.telemetry.sendTelemetryEvent('pr.openDescription'); } - private optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): number { - const currentUser = this._folderReposManager.getCurrentUser(this.pullRequestModel); + private async optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): Promise { + const currentUser = await this._folderReposManager.getCurrentUser(this.pullRequestModel.githubRepository); const comment = new TemporaryComment(thread, input, inDraft, currentUser); this.updateCommentThreadComments(thread, [...thread.comments, comment]); return comment.id; @@ -542,9 +532,9 @@ export class PullRequestCommentController implements CommentHandler, CommentReac !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted) ) { // add reaction - await this.pullRequestModel.addCommentReaction(comment._rawComment.graphNodeId, reaction); + await this.pullRequestModel.addCommentReaction(comment.rawComment.graphNodeId, reaction); } else { - await this.pullRequestModel.deleteCommentReaction(comment._rawComment.graphNodeId, reaction); + await this.pullRequestModel.deleteCommentReaction(comment.rawComment.graphNodeId, reaction); } } diff --git a/src/view/pullRequestCommentControllerRegistry.ts b/src/view/pullRequestCommentControllerRegistry.ts index 85890a3217..64e09e48bd 100644 --- a/src/view/pullRequestCommentControllerRegistry.ts +++ b/src/view/pullRequestCommentControllerRegistry.ts @@ -5,7 +5,6 @@ 'use strict'; import * as vscode from 'vscode'; -import { ISessionState } from '../common/sessionState'; import { fromPRUri } from '../common/uri'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { GHPRComment } from '../github/prComment'; @@ -14,16 +13,17 @@ import { CommentReactionHandler } from '../github/utils'; import { PullRequestCommentController } from './pullRequestCommentController'; interface PullRequestCommentHandlerInfo { - handler: PullRequestCommentController & CommentReactionHandler ; + handler: PullRequestCommentController & CommentReactionHandler; refCount: number; dispose: () => void; } export class PRCommentControllerRegistry implements vscode.CommentingRangeProvider, CommentReactionHandler, vscode.Disposable { - private _prCommentHandlers: { [key: number]: PullRequestCommentHandlerInfo } = { }; - private _prCommentingRangeProviders: { [key: number]: vscode.CommentingRangeProvider } = { }; + private _prCommentHandlers: { [key: number]: PullRequestCommentHandlerInfo } = {}; + private _prCommentingRangeProviders: { [key: number]: vscode.CommentingRangeProvider2 } = {}; + private _activeChangeListeners: Map = new Map(); - constructor(public commentsController: vscode.CommentController, private readonly _sessionState: ISessionState) { + constructor(public commentsController: vscode.CommentController) { this.commentsController.commentingRangeProvider = this; this.commentsController.reactionHandler = this.toggleReaction.bind(this); } @@ -62,13 +62,28 @@ export class PRCommentControllerRegistry implements vscode.CommentingRangeProvid return toggleReaction(comment, reaction); } + public unregisterCommentController(prNumber: number): void { + if (this._prCommentHandlers[prNumber]) { + this._prCommentHandlers[prNumber].dispose(); + delete this._prCommentHandlers[prNumber]; + } + } + public registerCommentController(prNumber: number, pullRequestModel: PullRequestModel, folderRepositoryManager: FolderRepositoryManager): vscode.Disposable { if (this._prCommentHandlers[prNumber]) { this._prCommentHandlers[prNumber].refCount += 1; return this._prCommentHandlers[prNumber]; } - const handler = new PullRequestCommentController(pullRequestModel, folderRepositoryManager, this.commentsController, this._sessionState); + if (!this._activeChangeListeners.has(folderRepositoryManager)) { + this._activeChangeListeners.set(folderRepositoryManager, folderRepositoryManager.onDidChangeActivePullRequest(e => { + if (e.old) { + this._prCommentHandlers[e.old]?.dispose(); + } + })); + } + + const handler = new PullRequestCommentController(pullRequestModel, folderRepositoryManager, this.commentsController); this._prCommentHandlers[prNumber] = { handler, refCount: 1, @@ -88,7 +103,7 @@ export class PRCommentControllerRegistry implements vscode.CommentingRangeProvid return this._prCommentHandlers[prNumber]; } - public registerCommentingRangeProvider(prNumber: number, provider: vscode.CommentingRangeProvider): vscode.Disposable { + public registerCommentingRangeProvider(prNumber: number, provider: vscode.CommentingRangeProvider2): vscode.Disposable { this._prCommentingRangeProviders[prNumber] = provider; return { diff --git a/src/view/repositoryFileSystemProvider.ts b/src/view/repositoryFileSystemProvider.ts new file mode 100644 index 0000000000..01c15d9d1c --- /dev/null +++ b/src/view/repositoryFileSystemProvider.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { GitApiImpl } from '../api/api1'; +import Logger from '../common/logger'; +import { CredentialStore } from '../github/credentials'; +import { ReadonlyFileSystemProvider } from './readonlyFileSystemProvider'; + +export abstract class RepositoryFileSystemProvider extends ReadonlyFileSystemProvider { + constructor(protected gitAPI: GitApiImpl, protected credentialStore: CredentialStore) { + super(); + } + + protected async waitForRepos(milliseconds: number): Promise { + Logger.appendLine('Waiting for repositories.', 'RepositoryFileSystemProvider'); + let eventDisposable: vscode.Disposable | undefined = undefined; + const openPromise = new Promise(resolve => { + eventDisposable = this.gitAPI.onDidOpenRepository(() => { + Logger.appendLine('Found at least one repository.', 'RepositoryFileSystemProvider'); + eventDisposable?.dispose(); + eventDisposable = undefined; + resolve(); + }); + }); + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise(resolve => { + timeout = setTimeout(() => { + Logger.appendLine('Timed out while waiting for repositories.', 'RepositoryFileSystemProvider'); + resolve(); + }, milliseconds); + }); + await Promise.race([openPromise, timeoutPromise]); + if (timeout) { + clearTimeout(timeout); + } + if (eventDisposable) { + (eventDisposable as vscode.Disposable).dispose(); + } + } + + protected async waitForAuth(): Promise { + if (this.credentialStore.isAnyAuthenticated()) { + return; + } + return new Promise(resolve => this.credentialStore.onDidGetSession(() => resolve())); + } +} \ No newline at end of file diff --git a/src/view/reviewCommentController.ts b/src/view/reviewCommentController.ts index e6254a7b27..235991eb22 100644 --- a/src/view/reviewCommentController.ts +++ b/src/view/reviewCommentController.ts @@ -8,35 +8,37 @@ import { v4 as uuid } from 'uuid'; import * as vscode from 'vscode'; import { Repository } from '../api/api'; import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; -import { DiffSide, IComment, IReviewThread } from '../common/comment'; +import { DiffSide, IReviewThread, SubjectType } from '../common/comment'; import { getCommentingRanges } from '../common/commentingRanges'; import { mapNewPositionToOld, mapOldPositionToNew } from '../common/diffPositionMapping'; import { GitChangeType } from '../common/file'; -import { ISessionState } from '../common/sessionState'; +import Logger from '../common/logger'; +import { PR_SETTINGS_NAMESPACE, PULL_BRANCH, PULL_PR_BRANCH_BEFORE_CHECKOUT } from '../common/settingKeys'; import { fromReviewUri, ReviewUriParams, Schemes, toReviewUri } from '../common/uri'; -import { formatError, groupBy, uniqBy } from '../common/utils'; +import { dispose, formatError, groupBy, uniqBy } from '../common/utils'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { GHPRComment, GHPRCommentThread, TemporaryComment } from '../github/prComment'; +import { PullRequestModel } from '../github/pullRequestModel'; import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; import { CommentReactionHandler, createVSCodeCommentThreadForReviewThread, + isFileInRepo, threadRange, updateCommentReviewState, updateCommentThreadLabel, updateThread, updateThreadWithRange, } from '../github/utils'; +import { RemoteFileChangeModel } from './fileChangeModel'; import { ReviewManager } from './reviewManager'; import { ReviewModel } from './reviewModel'; import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; export class ReviewCommentController - implements vscode.Disposable, CommentHandler, vscode.CommentingRangeProvider, CommentReactionHandler { + implements vscode.Disposable, CommentHandler, vscode.CommentingRangeProvider2, CommentReactionHandler { + private static readonly ID = 'ReviewCommentController'; private _localToDispose: vscode.Disposable[] = []; - private _onDidChangeComments = new vscode.EventEmitter(); - public onDidChangeComments = this._onDidChangeComments.event; - private _commentHandlerId: string; private _commentController: vscode.CommentController; @@ -53,19 +55,20 @@ export class ReviewCommentController protected _visibleNormalTextEditors: vscode.TextEditor[] = []; private _pendingCommentThreadAdds: GHPRCommentThread[] = []; + private readonly _context: vscode.ExtensionContext; constructor( private _reviewManager: ReviewManager, private _reposManager: FolderRepositoryManager, private _repository: Repository, private _reviewModel: ReviewModel, - private readonly _sessionState: ISessionState ) { + this._context = this._reposManager.context; this._commentController = vscode.comments.createCommentController( - `github-review-${_reposManager.activePullRequest!.number}`, - _reposManager.activePullRequest!.title, + `github-review-${_reposManager.activePullRequest?.remote.owner}-${_reposManager.activePullRequest?.remote.owner}-${_reposManager.activePullRequest!.number}`, + vscode.l10n.t('Pull Request ({0})', _reposManager.activePullRequest!.title), ); - this._commentController.commentingRangeProvider = this; + this._commentController.commentingRangeProvider = this as vscode.CommentingRangeProvider; this._commentController.reactionHandler = this.toggleReaction.bind(this); this._localToDispose.push(this._commentController); this._commentHandlerId = uuid(); @@ -88,7 +91,7 @@ export class ReviewCommentController * @param thread The comment thread information from GitHub. * @returns A GHPRCommentThread that has been created on an editor. */ - private createOutdatedCommentThread(path: string, thread: IReviewThread): GHPRCommentThread { + private async createOutdatedCommentThread(path: string, thread: IReviewThread): Promise { const commit = thread.comments[0].originalCommitId!; const uri = vscode.Uri.file(nodePath.join(`commit~${commit.substr(0, 8)}`, path)); const reviewUri = toReviewUri( @@ -101,8 +104,8 @@ export class ReviewCommentController this._repository.rootUri, ); - const range = threadRange(thread.originalStartLine - 1, thread.originalEndLine - 1); - return createVSCodeCommentThreadForReviewThread(reviewUri, range, thread, this._commentController); + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.originalStartLine - 1, thread.originalEndLine - 1); + return createVSCodeCommentThreadForReviewThread(this._context, reviewUri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); } /** @@ -126,8 +129,16 @@ export class ReviewCommentController endLine = mapOldPositionToNew(localDiff, endLine); } - const range = threadRange(startLine - 1, endLine - 1); - return createVSCodeCommentThreadForReviewThread(uri, range, thread, this._commentController); + let range: vscode.Range | undefined; + if (thread.subjectType !== SubjectType.FILE) { + const adjustedStartLine = startLine - 1; + const adjustedEndLine = endLine - 1; + if (adjustedStartLine < 0 || adjustedEndLine < 0) { + Logger.error(`Mapped new position for workspace comment thread is invalid. Original: (${thread.startLine}, ${thread.endLine}) New: (${adjustedStartLine}, ${adjustedEndLine})`); + } + range = threadRange(adjustedStartLine, adjustedEndLine); + } + return createVSCodeCommentThreadForReviewThread(this._context, uri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); } /** @@ -138,7 +149,7 @@ export class ReviewCommentController * @param thread The comment thread information from GitHub. * @returns A GHPRCommentThread that has been created on an editor. */ - private createReviewCommentThread(uri: vscode.Uri, path: string, thread: IReviewThread): GHPRCommentThread { + private async createReviewCommentThread(uri: vscode.Uri, path: string, thread: IReviewThread): Promise { if (!this._reposManager.activePullRequest?.mergeBase) { throw new Error('Cannot create review comment thread without an active pull request base.'); } @@ -152,11 +163,25 @@ export class ReviewCommentController this._repository.rootUri, ); - const range = threadRange(thread.startLine - 1, thread.endLine - 1); - return createVSCodeCommentThreadForReviewThread(reviewUri, range, thread, this._commentController); + const range = thread.subjectType === SubjectType.FILE ? undefined : threadRange(thread.startLine - 1, thread.endLine - 1); + return createVSCodeCommentThreadForReviewThread(this._context, reviewUri, range, thread, this._commentController, (await this._reposManager.getCurrentUser()).login, this._reposManager.activePullRequest?.githubRepository); } private async doInitializeCommentThreads(reviewThreads: IReviewThread[]): Promise { + // First clean up all the old comments. + for (const key in this._workspaceFileChangeCommentThreads) { + dispose(this._workspaceFileChangeCommentThreads[key]); + } + this._workspaceFileChangeCommentThreads = {}; + for (const key in this._reviewSchemeFileChangeCommentThreads) { + dispose(this._reviewSchemeFileChangeCommentThreads[key]); + } + this._reviewSchemeFileChangeCommentThreads = {}; + for (const key in this._obsoleteFileChangeCommentThreads) { + dispose(this._obsoleteFileChangeCommentThreads[key]); + } + this._obsoleteFileChangeCommentThreads = {}; + const threadsByPath = groupBy(reviewThreads, thread => thread.path); Object.keys(threadsByPath).forEach(path => { @@ -172,12 +197,12 @@ export class ReviewCommentController const threadPromises = threads.map(async thread => { if (thread.isOutdated) { - outdatedCommentThreads.push(this.createOutdatedCommentThread(path, thread)); + outdatedCommentThreads.push(await this.createOutdatedCommentThread(path, thread)); } else { if (thread.diffSide === DiffSide.RIGHT) { rightSideCommentThreads.push(await this.createWorkspaceCommentThread(uri, path, thread)); } else { - leftSideThreads.push(this.createReviewCommentThread(uri, path, thread)); + leftSideThreads.push(await this.createReviewCommentThread(uri, path, thread)); } } }); @@ -200,8 +225,13 @@ export class ReviewCommentController } private async registerListeners(): Promise { + const activePullRequest = this._reposManager.activePullRequest; + if (!activePullRequest) { + return; + } + this._localToDispose.push( - this._reposManager.activePullRequest!.onDidChangePendingReviewState(newDraftMode => { + activePullRequest.onDidChangePendingReviewState(newDraftMode => { [ this._workspaceFileChangeCommentThreads, this._obsoleteFileChangeCommentThreads, @@ -218,7 +248,7 @@ export class ReviewCommentController ); this._localToDispose.push( - this._reposManager.activePullRequest!.onDidChangeReviewThreads(e => { + activePullRequest.onDidChangeReviewThreads(e => { e.added.forEach(async thread => { const { path } = thread; @@ -229,7 +259,7 @@ export class ReviewCommentController } const diff = await this.getContentDiff(t.uri, fileName); - const line = mapNewPositionToOld(diff, t.range.end.line); + const line = t.range ? mapNewPositionToOld(diff, t.range.end.line) : 0; const sameLine = line + 1 === thread.endLine; return sameLine; }); @@ -238,19 +268,19 @@ export class ReviewCommentController if (index > -1) { newThread = this._pendingCommentThreadAdds[index]; newThread.gitHubThreadId = thread.id; - newThread.comments = thread.comments.map(c => new GHPRComment(c, newThread)); - updateThreadWithRange(newThread, thread); + newThread.comments = thread.comments.map(c => new GHPRComment(this._context, c, newThread, activePullRequest.githubRepository)); + updateThreadWithRange(this._context, newThread, thread, activePullRequest.githubRepository); this._pendingCommentThreadAdds.splice(index, 1); } else { const fullPath = nodePath.join(this._repository.rootUri.path, path).replace(/\\/g, '/'); const uri = this._repository.rootUri.with({ path: fullPath }); if (thread.isOutdated) { - newThread = this.createOutdatedCommentThread(path, thread); + newThread = await this.createOutdatedCommentThread(path, thread); } else { if (thread.diffSide === DiffSide.RIGHT) { newThread = await this.createWorkspaceCommentThread(uri, path, thread); } else { - newThread = this.createReviewCommentThread(uri, path, thread); + newThread = await this.createReviewCommentThread(uri, path, thread); } } } @@ -275,10 +305,10 @@ export class ReviewCommentController ? this._workspaceFileChangeCommentThreads : this._reviewSchemeFileChangeCommentThreads; - const index = threadMap[thread.path].findIndex(t => t.gitHubThreadId === thread.id); + const index = threadMap[thread.path] ? threadMap[thread.path].findIndex(t => t.gitHubThreadId === thread.id) : -1; if (index > -1) { const matchingThread = threadMap[thread.path][index]; - updateThread(matchingThread, thread); + updateThread(this._context, matchingThread, thread, activePullRequest.githubRepository); } }); @@ -289,7 +319,7 @@ export class ReviewCommentController ? this._workspaceFileChangeCommentThreads : this._reviewSchemeFileChangeCommentThreads; - const index = threadMap[thread.path].findIndex(t => t.gitHubThreadId === thread.id); + const index = threadMap[thread.path] ? threadMap[thread.path].findIndex(t => t.gitHubThreadId === thread.id) : -1; if (index > -1) { const matchingThread = threadMap[thread.path][index]; threadMap[thread.path].splice(index, 1); @@ -298,19 +328,15 @@ export class ReviewCommentController }); }), ); - - this._localToDispose.push( - this._sessionState.onDidChangeCommentsExpandState(expand => { - this.updateCommentExpandState(expand); - })); } public updateCommentExpandState(expand: boolean) { - if (!this._reposManager.activePullRequest) { + const activePullRequest = this._reposManager.activePullRequest; + if (!activePullRequest) { return undefined; } - function updateThreads(threads: { [key: string]: GHPRCommentThread[] }, reviewThreads: Map>) { + function updateThreads(activePullRequest: PullRequestModel, threads: { [key: string]: GHPRCommentThread[] }, reviewThreads: Map>) { if (reviewThreads.size === 0) { return; } @@ -319,7 +345,7 @@ export class ReviewCommentController const commentThreads = threads[path]; for (const commentThread of commentThreads) { const reviewThread = reviewThreadsForPath.get(commentThread.gitHubThreadId)!; - updateThread(commentThread, reviewThread, expand); + updateThread(this._context, commentThread, reviewThread, activePullRequest.githubRepository, expand); } } } @@ -327,7 +353,7 @@ export class ReviewCommentController const obsoleteReviewThreads: Map> = new Map(); const reviewSchemeReviewThreads: Map> = new Map(); const workspaceFileReviewThreads: Map> = new Map(); - for (const reviewThread of this._reposManager.activePullRequest.reviewThreadsCache) { + for (const reviewThread of activePullRequest.reviewThreadsCache) { let mapToUse: Map>; if (reviewThread.isOutdated) { mapToUse = obsoleteReviewThreads; @@ -343,9 +369,9 @@ export class ReviewCommentController } mapToUse.get(reviewThread.path)!.set(reviewThread.id, reviewThread); } - updateThreads(this._obsoleteFileChangeCommentThreads, obsoleteReviewThreads); - updateThreads(this._reviewSchemeFileChangeCommentThreads, reviewSchemeReviewThreads); - updateThreads(this._workspaceFileChangeCommentThreads, workspaceFileReviewThreads); + updateThreads(activePullRequest, this._obsoleteFileChangeCommentThreads, obsoleteReviewThreads); + updateThreads(activePullRequest, this._reviewSchemeFileChangeCommentThreads, reviewSchemeReviewThreads); + updateThreads(activePullRequest, this._workspaceFileChangeCommentThreads, workspaceFileReviewThreads); } private visibleEditorsEqual(a: vscode.TextEditor[], b: vscode.TextEditor[]): boolean { @@ -372,30 +398,24 @@ export class ReviewCommentController // #endregion - hasCommentThread(thread: vscode.CommentThread): boolean { + hasCommentThread(thread: vscode.CommentThread2): boolean { if (thread.uri.scheme === Schemes.Review) { return true; } - const currentWorkspace = vscode.workspace.getWorkspaceFolder(thread.uri); - if (!currentWorkspace) { + + if (!isFileInRepo(this._repository, thread.uri)) { return false; } - if ( - thread.uri.scheme === currentWorkspace.uri.scheme && - thread.uri.fsPath.startsWith(this._repository.rootUri.fsPath) - ) { + if (thread.uri.scheme === this._repository.rootUri.scheme) { return true; } return false; } - async provideCommentingRanges( - document: vscode.TextDocument, - _token: vscode.CancellationToken, - ): Promise { + async provideCommentingRanges(document: vscode.TextDocument, _token: vscode.CancellationToken): Promise { let query: ReviewUriParams | undefined = (document.uri.query && document.uri.query !== '') ? fromReviewUri(document.uri.query) : undefined; @@ -403,17 +423,21 @@ export class ReviewCommentController const matchedFile = this.findMatchedFileChangeForReviewDiffView(this._reviewModel.localFileChanges, document.uri); if (matchedFile) { - return getCommentingRanges(await matchedFile.diffHunks(), query.base); + Logger.debug('Found matched file for commenting ranges.', ReviewCommentController.ID); + return { ranges: getCommentingRanges(await matchedFile.changeModel.diffHunks(), query.base, ReviewCommentController.ID), fileComments: true }; } } - const currentWorkspace = vscode.workspace.getWorkspaceFolder(document.uri); - if (!currentWorkspace) { + if (!isFileInRepo(this._repository, document.uri)) { + if (document.uri.scheme !== 'output') { + Logger.debug('No commenting ranges: File is not in the current repository.', ReviewCommentController.ID); + } return; } - if (document.uri.scheme === currentWorkspace.uri.scheme) { + if (document.uri.scheme === this._repository.rootUri.scheme) { if (!this._reposManager.activePullRequest!.isResolved()) { + Logger.debug('No commenting ranges: Active PR has not been resolved.', ReviewCommentController.ID); return; } @@ -424,9 +448,10 @@ export class ReviewCommentController const ranges: vscode.Range[] = []; if (matchedFile) { - const diffHunks = await matchedFile.diffHunks(); + const diffHunks = await matchedFile.changeModel.diffHunks(); if ((matchedFile.status === GitChangeType.RENAME) && (diffHunks.length === 0)) { - return []; + Logger.debug('No commenting ranges: File was renamed with no diffs.', ReviewCommentController.ID); + return; } const contentDiff = await this.getContentDiff(document.uri, matchedFile.fileName); @@ -439,9 +464,18 @@ export class ReviewCommentController ranges.push(new vscode.Range(start - 1, 0, end - 1, 0)); } } + + if (ranges.length === 0) { + Logger.debug('No commenting ranges: File has diffs, but they could not be mapped to current lines.', ReviewCommentController.ID); + } + } else { + Logger.debug('No commenting ranges: File does not match any of the files in the review.', ReviewCommentController.ID); } - return ranges; + Logger.debug(`Providing ${ranges.length} commenting ranges for ${nodePath.basename(document.uri.fsPath)}.`, ReviewCommentController.ID); + return { ranges, fileComments: ranges.length > 0 }; + } else { + Logger.debug('No commenting ranges: File scheme differs from repository scheme.', ReviewCommentController.ID); } return; @@ -449,27 +483,52 @@ export class ReviewCommentController // #endregion - private async getContentDiff(uri: vscode.Uri, fileName: string): Promise { + private async getContentDiff(uri: vscode.Uri, fileName: string, retry: boolean = true): Promise { const matchedEditor = vscode.window.visibleTextEditors.find( editor => editor.document.uri.toString() === uri.toString(), ); if (!this._reposManager.activePullRequest?.head) { + Logger.error('Failed to get content diff. Cannot get content diff without an active pull request head.'); throw new Error('Cannot get content diff without an active pull request head.'); } - if (matchedEditor && matchedEditor.document.isDirty) { - const documentText = matchedEditor.document.getText(); - const details = await this._repository.getObjectDetails( - this._reposManager.activePullRequest.head.sha, - fileName, - ); - const idAtLastCommit = details.object; - const idOfCurrentText = await this._repository.hashObject(documentText); + try { + if (matchedEditor && matchedEditor.document.isDirty) { + const documentText = matchedEditor.document.getText(); + const details = await this._repository.getObjectDetails( + this._reposManager.activePullRequest.head.sha, + fileName, + ); + const idAtLastCommit = details.object; + const idOfCurrentText = await this._repository.hashObject(documentText); - // git diff - return await this._repository.diffBlobs(idAtLastCommit, idOfCurrentText); - } else { - return await this._repository.diffWith(this._reposManager.activePullRequest.head.sha, fileName); + // git diff + return await this._repository.diffBlobs(idAtLastCommit, idOfCurrentText); + } else { + return await this._repository.diffWith(this._reposManager.activePullRequest.head.sha, fileName); + } + } catch (e) { + Logger.error(`Failed to get content diff. ${formatError(e)}`); + if ((e.stderr as string | undefined)?.includes('bad object')) { + if (this._repository.state.HEAD?.upstream && retry) { + const pullSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(PULL_PR_BRANCH_BEFORE_CHECKOUT, true) && (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'never' | 'prompt' | 'always'>(PULL_BRANCH, 'prompt') === 'always'); + if (pullSetting) { + try { + await this._repository.pull(); + return this.getContentDiff(uri, fileName, false); + } catch (e) { + // No remote branch + } + } else if (this._repository.state.HEAD?.commit) { + return this._repository.diffWith(this._repository.state.HEAD.commit, fileName); + } + } + if (this._reposManager.activePullRequest.isOpen) { + vscode.window.showErrorMessage(vscode.l10n.t('Unable to get comment locations for commit {0}. This commit is not available locally and there is no remote branch.', this._reposManager.activePullRequest.head.sha)); + } + Logger.warn(`Unable to get comment locations for commit ${this._reposManager.activePullRequest.head.sha}. This commit is not available locally and there is no remote branch.`, ReviewCommentController.ID); + } + throw e; } } @@ -478,8 +537,9 @@ export class ReviewCommentController uri: vscode.Uri, ): GitFileChangeNode | undefined { const query = fromReviewUri(uri.query); - const matchedFiles = fileChanges.filter(fileChange => { - if (fileChange instanceof RemoteFileChangeNode) { + const matchedFiles = fileChanges.filter(fileChangeNode => { + const fileChange = fileChangeNode.changeModel; + if (fileChange instanceof RemoteFileChangeModel) { return false; } @@ -535,7 +595,7 @@ export class ReviewCommentController public async startReview(thread: GHPRCommentThread, input: string): Promise { const hasExistingComments = thread.comments.length; - const temporaryCommentId = this.optimisticallyAddComment(thread, input, true); + const temporaryCommentId = await this.optimisticallyAddComment(thread, input, true); try { if (!hasExistingComments) { @@ -545,24 +605,28 @@ export class ReviewCommentController // If the thread is on the workspace file, make sure the position // is properly adjusted to account for any local changes. - let startLine: number; - let endLine: number; - if (side === DiffSide.RIGHT) { - const diff = await this.getContentDiff(thread.uri, fileName); - startLine = mapNewPositionToOld(diff, thread.range.start.line); - endLine = mapNewPositionToOld(diff, thread.range.end.line); - } else { - startLine = thread.range.start.line; - endLine = thread.range.end.line; + let startLine: number | undefined = undefined; + let endLine: number | undefined = undefined; + if (thread.range) { + if (side === DiffSide.RIGHT) { + const diff = await this.getContentDiff(thread.uri, fileName); + startLine = mapNewPositionToOld(diff, thread.range.start.line); + endLine = mapNewPositionToOld(diff, thread.range.end.line); + } else { + startLine = thread.range.start.line; + endLine = thread.range.end.line; + } + startLine++; + endLine++; } - await this._reposManager.activePullRequest!.createReviewThread(input, fileName, startLine + 1, endLine + 1, side); + await this._reposManager.activePullRequest!.createReviewThread(input, fileName, startLine, endLine, side); } else { const comment = thread.comments[0]; if (comment instanceof GHPRComment) { await this._reposManager.activePullRequest!.createCommentReply( input, - comment._rawComment.graphNodeId, + comment.rawComment.graphNodeId, false, ); } else { @@ -588,8 +652,8 @@ export class ReviewCommentController } // #endregion - private optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): number { - const currentUser = this._reposManager.getCurrentUser(this._reposManager.activePullRequest!); + private async optimisticallyAddComment(thread: GHPRCommentThread, input: string, inDraft: boolean): Promise { + const currentUser = await this._reposManager.getCurrentUser(); const comment = new TemporaryComment(thread, input, inDraft, currentUser); this.updateCommentThreadComments(thread, [...thread.comments, comment]); return comment.id; @@ -600,8 +664,8 @@ export class ReviewCommentController updateCommentThreadLabel(thread); } - private optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): number { - const currentUser = this._reposManager.getCurrentUser(this._reposManager.activePullRequest!); + private async optimisticallyEditComment(thread: GHPRCommentThread, comment: GHPRComment): Promise { + const currentUser = await this._reposManager.getCurrentUser(); const temporaryComment = new TemporaryComment( thread, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, @@ -637,7 +701,7 @@ export class ReviewCommentController : inDraft !== undefined ? inDraft : this._reposManager.activePullRequest.hasPendingReview; - const temporaryCommentId = this.optimisticallyAddComment(thread, input, isDraft); + const temporaryCommentId = await this.optimisticallyAddComment(thread, input, isDraft); try { if (!hasExistingComments) { @@ -647,21 +711,25 @@ export class ReviewCommentController // If the thread is on the workspace file, make sure the position // is properly adjusted to account for any local changes. - let startLine: number; - let endLine: number; - if (side === DiffSide.RIGHT) { - const diff = await this.getContentDiff(thread.uri, fileName); - startLine = mapNewPositionToOld(diff, thread.range.start.line); - endLine = mapNewPositionToOld(diff, thread.range.end.line); - } else { - startLine = thread.range.start.line; - endLine = thread.range.end.line; + let startLine: number | undefined = undefined; + let endLine: number | undefined = undefined; + if (thread.range) { + if (side === DiffSide.RIGHT) { + const diff = await this.getContentDiff(thread.uri, fileName); + startLine = mapNewPositionToOld(diff, thread.range.start.line); + endLine = mapNewPositionToOld(diff, thread.range.end.line); + } else { + startLine = thread.range.start.line; + endLine = thread.range.end.line; + } + startLine++; + endLine++; } await this._reposManager.activePullRequest.createReviewThread( input, fileName, - startLine + 1, - endLine + 1, + startLine, + endLine, side, isSingleComment, ); @@ -670,7 +738,7 @@ export class ReviewCommentController if (comment instanceof GHPRComment) { await this._reposManager.activePullRequest.createCommentReply( input, - comment._rawComment.graphNodeId, + comment.rawComment.graphNodeId, isSingleComment, ); } else { @@ -734,16 +802,16 @@ export class ReviewCommentController } } - async editComment(thread: GHPRCommentThread, comment: GHPRComment | TemporaryComment): Promise { + async editComment(thread: GHPRCommentThread, comment: GHPRComment): Promise { if (comment instanceof GHPRComment) { - const temporaryCommentId = this.optimisticallyEditComment(thread, comment); + const temporaryCommentId = await this.optimisticallyEditComment(thread, comment); try { if (!this._reposManager.activePullRequest) { throw new Error('Unable to find active pull request'); } await this._reposManager.activePullRequest.editReviewComment( - comment._rawComment, + comment.rawComment, comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, ); } catch (e) { @@ -751,18 +819,12 @@ export class ReviewCommentController thread.comments = thread.comments.map(c => { if (c instanceof TemporaryComment && c.id === temporaryCommentId) { - return new GHPRComment(comment._rawComment, thread); + return new GHPRComment(this._context, comment.rawComment, thread); } return c; }); } - } else { - this.createOrReplyComment( - thread, - comment.body instanceof vscode.MarkdownString ? comment.body.value : comment.body, - false, - ); } } @@ -815,12 +877,12 @@ export class ReviewCommentController !comment.reactions.find(ret => ret.label === reaction.label && !!ret.authorHasReacted) ) { await this._reposManager.activePullRequest.addCommentReaction( - comment._rawComment.graphNodeId, + comment.rawComment.graphNodeId, reaction, ); } else { await this._reposManager.activePullRequest.deleteCommentReaction( - comment._rawComment.graphNodeId, + comment.rawComment.graphNodeId, reaction, ); } @@ -830,13 +892,25 @@ export class ReviewCommentController } // #endregion - public dispose() { - if (this._commentController) { - this._commentController.dispose(); + + async applySuggestion(comment: GHPRComment) { + const range = comment.parent.range; + const suggestion = comment.suggestion; + if ((suggestion === undefined) || !range) { + throw new Error('Comment doesn\'t contain a suggestion'); } - unregisterCommentHandler(this._commentHandlerId); + const editor = vscode.window.visibleTextEditors.find(editor => comment.parent.uri.toString() === editor.document.uri.toString()); + if (!editor) { + throw new Error('Cannot find the editor to apply the suggestion to.'); + } + await editor.edit(builder => { + builder.replace(range.with(undefined, new vscode.Position(range.end.line + 1, 0)), suggestion); + }); + } + public dispose() { + unregisterCommentHandler(this._commentHandlerId); this._localToDispose.forEach(d => d.dispose()); } } diff --git a/src/view/reviewManager.ts b/src/view/reviewManager.ts index d6e1c00e55..e4c9b088ab 100644 --- a/src/view/reviewManager.ts +++ b/src/view/reviewManager.ts @@ -6,32 +6,46 @@ import * as nodePath from 'path'; import * as vscode from 'vscode'; import type { Branch, Repository } from '../api/api'; -import { GitErrorCodes } from '../api/api1'; +import { GitApiImpl, GitErrorCodes } from '../api/api1'; import { openDescription } from '../commands'; import { DiffChangeType } from '../common/diffHunk'; import { commands } from '../common/executeCommands'; import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; import Logger from '../common/logger'; import { parseRepositoryRemotes, Remote } from '../common/remote'; -import { ISessionState } from '../common/sessionState'; -import { PR_SETTINGS_NAMESPACE, USE_REVIEW_MODE } from '../common/settingKeys'; +import { + COMMENTS, + FOCUSED_MODE, + IGNORE_PR_BRANCHES, + NEVER_IGNORE_DEFAULT_BRANCH, + OPEN_VIEW, + POST_CREATE, + PR_SETTINGS_NAMESPACE, + QUICK_DIFF, + USE_REVIEW_MODE, +} from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; -import { fromReviewUri, toReviewUri } from '../common/uri'; -import { formatError, groupBy } from '../common/utils'; +import { fromPRUri, fromReviewUri, KnownMediaExtensions, PRUriParams, Schemes, toReviewUri } from '../common/uri'; +import { dispose, formatError, groupBy, isPreRelease, onceEvent } from '../common/utils'; import { FOCUS_REVIEW_MODE } from '../constants'; -import { NEVER_SHOW_PULL_NOTIFICATION } from '../extensionState'; -import { PullRequestViewProvider } from '../github/activityBarViewProvider'; import { GitHubCreatePullRequestLinkProvider } from '../github/createPRLinkProvider'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../github/folderRepositoryManager'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { GitHubRepository, ViewerPermission } from '../github/githubRepository'; -import { PullRequestGitHelper } from '../github/pullRequestGitHelper'; +import { GithubItemStateEnum } from '../github/interface'; +import { PullRequestGitHelper, PullRequestMetadata } from '../github/pullRequestGitHelper'; import { IResolvedPullRequestModel, PullRequestModel } from '../github/pullRequestModel'; import { CreatePullRequestHelper } from './createPullRequestHelper'; +import { GitFileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from './fileChangeModel'; +import { getGitHubFileContent } from './gitHubContentProvider'; +import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from './inMemPRContentProvider'; import { PullRequestChangesTreeDataProvider } from './prChangesTreeDataProvider'; +import { ProgressHelper } from './progress'; +import { PullRequestsTreeDataProvider } from './prsTreeDataProvider'; import { RemoteQuickPickItem } from './quickpick'; import { ReviewCommentController } from './reviewCommentController'; import { ReviewModel } from './reviewModel'; import { GitFileChangeNode, gitFileChangeNodeFilter, RemoteFileChangeNode } from './treeNodes/fileChangeNode'; +import { WebviewViewCoordinator } from './webviewViewCoordinator'; export class ReviewManager { public static ID = 'Review'; @@ -42,20 +56,20 @@ export class ReviewManager { private _lastCommitSha?: string; private _updateMessageShown: boolean = false; private _validateStatusInProgress?: Promise; - private _reviewCommentController: ReviewCommentController; + private _reviewCommentController: ReviewCommentController | undefined; + private _quickDiffProvider: vscode.Disposable | undefined; + private _inMemGitHubContentProvider: vscode.Disposable | undefined; private _statusBarItem: vscode.StatusBarItem; private _prNumber?: number; + private _isShowingLastReviewChanges: boolean = false; private _previousRepositoryState: { HEAD: Branch | undefined; remotes: Remote[]; }; - private _webviewViewProvider: PullRequestViewProvider | undefined; - private _createPullRequestHelper: CreatePullRequestHelper | undefined; - private _switchingToReviewMode: boolean; - + private _changesSinceLastReviewProgress: ProgressHelper = new ProgressHelper(); /** * Flag set when the "Checkout" action is used and cleared on the next git * state update, once review mode has been entered. Used to disambiguate @@ -70,20 +84,24 @@ export class ReviewManager { public set switchingToReviewMode(newState: boolean) { this._switchingToReviewMode = newState; if (!newState) { - this.updateState(); + this.updateState(true); } } private _isFirstLoad = true; constructor( + private _id: number, private _context: vscode.ExtensionContext, private readonly _repository: Repository, private _folderRepoManager: FolderRepositoryManager, private _telemetry: ITelemetry, public changesInPrDataProvider: PullRequestChangesTreeDataProvider, + private _pullRequestsTree: PullRequestsTreeDataProvider, private _showPullRequest: ShowPullRequest, - private readonly _sessionState: ISessionState + private readonly _activePrViewCoordinator: WebviewViewCoordinator, + private _createPullRequestHelper: CreatePullRequestHelper, + gitApi: GitApiImpl ) { this._switchingToReviewMode = false; this._disposables = []; @@ -95,7 +113,9 @@ export class ReviewManager { this.registerListeners(); - this.updateState(true); + if (gitApi.state === 'initialized') { + this.updateState(true); + } this.pollForStatusChange(); } @@ -110,7 +130,7 @@ export class ReviewManager { return; } - let sameUpstream; + let sameUpstream: boolean | undefined; if (!oldHead || !newHead) { sameUpstream = false; @@ -149,28 +169,69 @@ export class ReviewManager { // Note that the visible changes will occur when checking out a PR. this.updateState(true); } - }), - ); - this._disposables.push( - vscode.workspace.onDidChangeConfiguration(_ => { - this.updateFocusedViewMode(); + if (oldHead && newHead) { + this.updateBaseBranchMetadata(oldHead, newHead); + } }), ); this._disposables.push( - this._folderRepoManager.onDidChangeActivePullRequest(_ => { + vscode.workspace.onDidChangeConfiguration(e => { this.updateFocusedViewMode(); + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${IGNORE_PR_BRANCHES}`)) { + this.validateState(true, false); + } }), ); + this._disposables.push(this._folderRepoManager.onDidChangeActivePullRequest(_ => { + this.updateFocusedViewMode(); + this.registerQuickDiff(); + })); + GitHubCreatePullRequestLinkProvider.registerProvider(this._disposables, this, this._folderRepoManager); } + private async updateBaseBranchMetadata(oldHead: Branch, newHead: Branch) { + if (!oldHead.commit || (oldHead.commit !== newHead.commit) || !newHead.name || !oldHead.name || (oldHead.name === newHead.name)) { + return; + } + + let githubRepository = this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.remoteName === oldHead.upstream?.remote); + if (githubRepository) { + const metadata = await githubRepository.getMetadata(); + if (metadata.fork && oldHead.name === metadata.default_branch) { + // For forks, we use the upstream repo if it's available. Otherwise, fallback to the fork. + githubRepository = this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.owner === metadata.parent?.owner?.login && repo.remote.repositoryName === metadata.parent?.name) ?? githubRepository; + } + return PullRequestGitHelper.associateBaseBranchWithBranch(this.repository, newHead.name, githubRepository.remote.owner, githubRepository.remote.repositoryName, oldHead.name); + } + } + + private registerQuickDiff() { + if (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(QUICK_DIFF)) { + if (this._quickDiffProvider) { + this._quickDiffProvider.dispose(); + this._quickDiffProvider = undefined; + } + const label = this._folderRepoManager.activePullRequest ? vscode.l10n.t('GitHub pull request #{0}', this._folderRepoManager.activePullRequest.number) : vscode.l10n.t('GitHub pull request'); + this._disposables.push(this._quickDiffProvider = vscode.window.registerQuickDiffProvider({ scheme: 'file' }, { + provideOriginalResource: (uri: vscode.Uri) => { + const changeNode = this.reviewModel.localFileChanges.find(changeNode => changeNode.changeModel.filePath.toString() === uri.toString()); + if (changeNode) { + return changeNode.changeModel.parentFilePath; + } + } + }, label, this.repository.rootUri)); + } + } + + get statusBarItem() { if (!this._statusBarItem) { this._statusBarItem = vscode.window.createStatusBarItem('github.pullrequest.status', vscode.StatusBarAlignment.Left); - this._statusBarItem.name = 'GitHub Active Pull Request'; + this._statusBarItem.name = vscode.l10n.t('GitHub Active Pull Request'); } return this._statusBarItem; @@ -193,156 +254,268 @@ export class ReviewManager { }, 1000 * 60 * 5); } - private async checkBranchUpToDate(pr: PullRequestModel & IResolvedPullRequestModel): Promise { - const branch = this._repository.state.HEAD; - if (branch) { - const remote = branch.upstream ? branch.upstream.remote : null; - const remoteBranch = branch.upstream ? branch.upstream.name : branch.name; - if (remote) { - await this._repository.fetch(remote, remoteBranch); - const canShowNotification = !this._context.globalState.get(NEVER_SHOW_PULL_NOTIFICATION, false); - if (canShowNotification && !this._updateMessageShown && - ((this._lastCommitSha && (pr.head.sha !== this._lastCommitSha)) - || (branch.behind !== undefined && branch.behind > 0)) - ) { - this._updateMessageShown = true; - const pull = 'Pull'; - const never = 'Never show again'; - const result = await vscode.window.showInformationMessage( - `There are updates available for pull request ${pr.number}: ${pr.title}.`, - {}, - pull, - never - ); - - if (result === pull) { - if (this._repository.state.HEAD?.name === branch.name) { - await this._repository.pull(); - } - this._updateMessageShown = false; - } else if (never) { - await this._context.globalState.update(NEVER_SHOW_PULL_NOTIFICATION, true); - } - } - } - } + private get id(): string { + return `${ReviewManager.ID}+${this._id}`; } - public async updateState(silent: boolean = false, openDiff: boolean = true) { + public async updateState(silent: boolean = false, updateLayout: boolean = true) { if (this.switchingToReviewMode) { return; } if (!this._validateStatusInProgress) { - Logger.appendLine('Review> Validate state in progress'); - this._validateStatusInProgress = this.validateStatueAndSetContext(silent, openDiff); + Logger.appendLine('Validate state in progress', this.id); + this._validateStatusInProgress = this.validateStatueAndSetContext(silent, updateLayout); return this._validateStatusInProgress; } else { - Logger.appendLine('Review> Queuing additional validate state'); + Logger.appendLine('Queuing additional validate state', this.id); this._validateStatusInProgress = this._validateStatusInProgress.then(async _ => { - return await this.validateStatueAndSetContext(silent, openDiff); + return await this.validateStatueAndSetContext(silent, updateLayout); }); return this._validateStatusInProgress; } } - private async validateStatueAndSetContext(silent: boolean, openDiff: boolean) { - await this.validateState(silent, openDiff); - await vscode.commands.executeCommand('setContext', 'github:stateValidated', true); + private hasShownLogRequest: boolean = false; + private async validateStatueAndSetContext(silent: boolean, updateLayout: boolean) { + // TODO @alexr00: There's a bug where validateState never returns sometimes. It's not clear what's causing this. + // This is a temporary workaround to ensure that the validateStatueAndSetContext promise always resolves. + // Additional logs have been added, and the issue is being tracked here: https://github.com/microsoft/vscode-pull-request-git/issues/5277 + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise(resolve => { + timeout = setTimeout(() => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + Logger.error('Timeout occurred while validating state.', this.id); + if (!this.hasShownLogRequest && isPreRelease(this._context)) { + this.hasShownLogRequest = true; + vscode.window.showErrorMessage(vscode.l10n.t('A known error has occurred refreshing the repository state. Please share logs from "GitHub Pull Request" in the [tracking issue]({0}).', 'https://github.com/microsoft/vscode-pull-request-github/issues/5277')); + } + } + resolve(); + }, 1000 * 60 * 2); + }); + + const validatePromise = new Promise(resolve => { + this.validateState(silent, updateLayout).then(() => { + vscode.commands.executeCommand('setContext', 'github:stateValidated', true).then(() => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + resolve(); + }); + }); + }); + + return Promise.race([validatePromise, timeoutPromise]); + } + + private async offerIgnoreBranch(currentBranchName): Promise { + const ignoreBranchStateKey = 'githubPullRequest.showOfferIgnoreBranch'; + const showOffer = this._context.workspaceState.get(ignoreBranchStateKey, true); + if (!showOffer) { + return false; + } + // Only show once per day. + const lastOfferTimeKey = 'githubPullRequest.offerIgnoreBranchTime'; + const lastOfferTime = this._context.workspaceState.get(lastOfferTimeKey, 0); + const currentTime = new Date().getTime(); + if ((currentTime - lastOfferTime) < (1000 * 60 * 60 * 24)) { // 1 day + return false; + } + const { base } = await this._folderRepoManager.getPullRequestDefaults(currentBranchName); + if (base !== currentBranchName) { + return false; + } + await this._context.workspaceState.update(lastOfferTimeKey, currentTime); + const ignore = vscode.l10n.t('Ignore Pull Request'); + const dontShow = vscode.l10n.t('Don\'t Show Again'); + const offerResult = await vscode.window.showInformationMessage( + vscode.l10n.t(`There\'s a pull request associated with the default branch '{0}'. Do you want to ignore this Pull Request?`, currentBranchName), + ignore, + dontShow); + if (offerResult === ignore) { + Logger.appendLine(`Branch ${currentBranchName} will now be ignored in ${IGNORE_PR_BRANCHES}.`, this.id); + const settingNamespace = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + const setting = settingNamespace.get(IGNORE_PR_BRANCHES, []); + setting.push(currentBranchName); + await settingNamespace.update(IGNORE_PR_BRANCHES, setting); + return true; + } else if (offerResult === dontShow) { + await this._context.workspaceState.update(ignoreBranchStateKey, false); + return false; + } + return false; + } + + private async getUpstreamUrlAndName(branch: Branch): Promise<{ url: string | undefined, branchName: string | undefined, remoteName: string | undefined }> { + if (branch.upstream) { + return { remoteName: branch.upstream.remote, branchName: branch.upstream.name, url: undefined }; + } else { + try { + const url = await this.repository.getConfig(`branch.${branch.name}.remote`); + const upstreamBranch = await this.repository.getConfig(`branch.${branch.name}.merge`); + let branchName: string | undefined; + if (upstreamBranch) { + branchName = upstreamBranch.substring('refs/heads/'.length); + } + return { url, branchName, remoteName: undefined }; + } catch (e) { + Logger.appendLine(`Failed to get upstream for branch ${branch.name} from git config.`, this.id); + return { url: undefined, branchName: undefined, remoteName: undefined }; + } + } } - private async validateState(silent: boolean, openDiff: boolean) { - Logger.appendLine('Review> Validating state...'); + private async checkGitHubForPrBranch(branch: Branch): Promise<(PullRequestMetadata & { model: PullRequestModel }) | undefined> { + const { url, branchName, remoteName } = await this.getUpstreamUrlAndName(this._repository.state.HEAD!); + const metadataFromGithub = await this._folderRepoManager.getMatchingPullRequestMetadataFromGitHub(branch, remoteName, url, branchName); + if (metadataFromGithub) { + Logger.appendLine(`Found matching pull request metadata on GitHub for current branch ${branch.name}. Repo: ${metadataFromGithub.owner}/${metadataFromGithub.repositoryName} PR: ${metadataFromGithub.prNumber}`); + await PullRequestGitHelper.associateBranchWithPullRequest( + this._repository, + metadataFromGithub.model, + branch.name!, + ); + return metadataFromGithub; + } + } + + private async resolvePullRequest(metadata: PullRequestMetadata): Promise<(PullRequestModel & IResolvedPullRequestModel) | undefined> { + try { + this._prNumber = metadata.prNumber; + + const { owner, repositoryName } = metadata; + Logger.appendLine('Resolving pull request', this.id); + const pr = await this._folderRepoManager.resolvePullRequest(owner, repositoryName, metadata.prNumber); + + if (!pr || !pr.isResolved()) { + await this.clear(true); + this._prNumber = undefined; + Logger.appendLine('This PR is no longer valid', this.id); + return; + } + return pr; + } catch (e) { + Logger.appendLine(`Pull request cannot be resolved: ${e.message}`, this.id); + } + } + + private async validateState(silent: boolean, updateLayout: boolean) { + Logger.appendLine('Validating state...', this.id); const oldLastCommitSha = this._lastCommitSha; this._lastCommitSha = undefined; await this._folderRepoManager.updateRepositories(false); if (!this._repository.state.HEAD) { - this.clear(true); + await this.clear(true); return; } const branch = this._repository.state.HEAD; + const ignoreBranches = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(IGNORE_PR_BRANCHES); + if (ignoreBranches?.find(value => value === branch.name) && ((branch.remote === 'origin') || !(await this._folderRepoManager.gitHubRepositories.find(repo => repo.remote.remoteName === branch.remote)?.getMetadata())?.fork)) { + Logger.appendLine(`Branch ${branch.name} is ignored in ${IGNORE_PR_BRANCHES}.`, this.id); + await this.clear(true); + return; + } + let matchingPullRequestMetadata = await this._folderRepoManager.getMatchingPullRequestMetadataForBranch(); if (!matchingPullRequestMetadata) { - Logger.appendLine(`Review> no matching pull request metadata found for current branch ${branch.name}`); - const metadataFromGithub = await this._folderRepoManager.getMatchingPullRequestMetadataFromGitHub(); - if (metadataFromGithub) { - await PullRequestGitHelper.associateBranchWithPullRequest( - this._repository, - metadataFromGithub.model, - branch.name!, - ); - matchingPullRequestMetadata = metadataFromGithub; - } + Logger.appendLine(`No matching pull request metadata found locally for current branch ${branch.name}`, this.id); + matchingPullRequestMetadata = await this.checkGitHubForPrBranch(branch); } if (!matchingPullRequestMetadata) { Logger.appendLine( - `Review> no matching pull request metadata found on GitHub for current branch ${branch.name}`, + `No matching pull request metadata found on GitHub for current branch ${branch.name}`, this.id ); - this.clear(true); - return; - } - - const hasPushedChanges = branch.commit !== oldLastCommitSha && branch.ahead === 0 && branch.behind === 0; - if (this._prNumber === matchingPullRequestMetadata.prNumber && !hasPushedChanges) { - vscode.commands.executeCommand('pr.refreshList'); + await this.clear(true); return; } + Logger.appendLine(`Found matching pull request metadata for current branch ${branch.name}. Repo: ${matchingPullRequestMetadata.owner}/${matchingPullRequestMetadata.repositoryName} PR: ${matchingPullRequestMetadata.prNumber}`, this.id); const remote = branch.upstream ? branch.upstream.remote : null; if (!remote) { - Logger.appendLine(`Review> current branch ${this._repository.state.HEAD.name} hasn't setup remote yet`); - this.clear(true); + Logger.appendLine(`Current branch ${this._repository.state.HEAD.name} hasn't setup remote yet`, this.id); + await this.clear(true); return; } // we switch to another PR, let's clean up first. Logger.appendLine( - `Review> current branch ${this._repository.state.HEAD.name} is associated with pull request #${matchingPullRequestMetadata.prNumber}`, + `current branch ${this._repository.state.HEAD.name} is associated with pull request #${matchingPullRequestMetadata.prNumber}`, this.id ); - this.clear(false); - this._prNumber = matchingPullRequestMetadata.prNumber; + const previousPrNumber = this._prNumber; + let pr = await this.resolvePullRequest(matchingPullRequestMetadata); + if (!pr) { + Logger.appendLine(`Unable to resolve PR #${matchingPullRequestMetadata.prNumber}`, this.id); + return; + } + Logger.appendLine(`Resolved PR #${matchingPullRequestMetadata.prNumber}, state is ${pr.state}`, this.id); + + // Check if the PR is open, if not, check if there's another PR from the same branch on GitHub + if (pr.state !== GithubItemStateEnum.Open) { + const metadataFromGithub = await this.checkGitHubForPrBranch(branch); + if (metadataFromGithub && metadataFromGithub?.prNumber !== pr.number) { + const prFromGitHub = await this.resolvePullRequest(metadataFromGithub); + if (prFromGitHub) { + pr = prFromGitHub; + } + } + } - const { owner, repositoryName } = matchingPullRequestMetadata; - Logger.appendLine('Review> Resolving pull request'); - const pr = await this._folderRepoManager.resolvePullRequest( - owner, - repositoryName, - matchingPullRequestMetadata.prNumber, - ); - if (!pr || !pr.isResolved()) { - this._prNumber = undefined; - Logger.appendLine('Review> This PR is no longer valid'); + const hasPushedChanges = branch.commit !== oldLastCommitSha && branch.ahead === 0 && branch.behind === 0; + if (previousPrNumber === pr.number && !hasPushedChanges && (this._isShowingLastReviewChanges === pr.showChangesSinceReview)) { + this._validateStatusInProgress = undefined; return; } + this._isShowingLastReviewChanges = pr.showChangesSinceReview; + if (previousPrNumber !== pr.number) { + this.clear(false); + } const useReviewConfiguration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE) .get<{ merged: boolean, closed: boolean }>(USE_REVIEW_MODE, { merged: true, closed: false }); if (pr.isClosed && !useReviewConfiguration.closed) { - this.clear(true); - Logger.appendLine('Review> This PR is closed'); + Logger.appendLine('This PR is closed', this.id); + await this.clear(true); return; } if (pr.isMerged && !useReviewConfiguration.merged) { - this.clear(true); - Logger.appendLine('Review> This PR is merged'); + Logger.appendLine('This PR is merged', this.id); + await this.clear(true); return; } + const neverIgnoreDefaultBranch = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(NEVER_IGNORE_DEFAULT_BRANCH, false); + if (!neverIgnoreDefaultBranch) { + // Do not await the result of offering to ignore the branch. + this.offerIgnoreBranch(branch.name); + } + + const previousActive = this._folderRepoManager.activePullRequest; this._folderRepoManager.activePullRequest = pr; this._lastCommitSha = pr.head.sha; if (this._isFirstLoad) { this._isFirstLoad = false; - this.checkBranchUpToDate(pr); + this._folderRepoManager.checkBranchUpToDate(pr, true); } - Logger.appendLine('Review> Fetching pull request data'); + Logger.appendLine('Fetching pull request data', this.id); + if (!silent) { + onceEvent(this._reviewModel.onDidChangeLocalFileChanges)(() => { + if (pr) { + this._upgradePullRequestEditors(pr); + } + }); + } // Don't await. Events will be fired as part of the initialization. this.initializePullRequestData(pr); await this.changesInPrDataProvider.addPrToView( @@ -350,90 +523,168 @@ export class ReviewManager { pr, this._reviewModel, this.justSwitchedToReviewMode, + this._changesSinceLastReviewProgress ); - this.justSwitchedToReviewMode = false; - Logger.appendLine(`Review> register comments provider`); + Logger.appendLine(`Register comments provider`, this.id); await this.registerCommentController(); - const isFocusMode = this._context.workspaceState.get(FOCUS_REVIEW_MODE); - if (!this._webviewViewProvider) { - this._webviewViewProvider = new PullRequestViewProvider( - this._context.extensionUri, - this._folderRepoManager, - pr, - ); - this._context.subscriptions.push( - vscode.window.registerWebviewViewProvider( - this._webviewViewProvider.viewType, - this._webviewViewProvider, - ), - ); - this._context.subscriptions.push( - vscode.commands.registerCommand('pr.refreshActivePullRequest', _ => { - this._webviewViewProvider?.refresh(); - }), - ); - } else { - await this._webviewViewProvider.updatePullRequest(pr); - } + this._activePrViewCoordinator.setPullRequest(pr, this._folderRepoManager, this, previousActive); + this._localToDispose.push( + pr.onDidChangeChangesSinceReview(async _ => { + this._changesSinceLastReviewProgress.startProgress(); + this.changesInPrDataProvider.refresh(); + await this.updateComments(); + await this.reopenNewReviewDiffs(); + this._changesSinceLastReviewProgress.endProgress(); + }) + ); + Logger.appendLine(`Register in memory content provider`, this.id); + await this.registerGitHubInMemContentProvider(); - this.statusBarItem.text = `$(git-pull-request) Pull Request #${this._prNumber}`; + this.statusBarItem.text = '$(git-pull-request) ' + vscode.l10n.t('Pull Request #{0}', pr.number); this.statusBarItem.command = { command: 'pr.openDescription', - title: 'View Pull Request Description', + title: vscode.l10n.t('View Pull Request Description'), arguments: [pr], }; - Logger.appendLine(`Review> display pull request status bar indicator and refresh pull request tree view.`); + Logger.appendLine(`Display pull request status bar indicator.`, this.id); this.statusBarItem.show(); - vscode.commands.executeCommand('pr.refreshList'); - Logger.appendLine(`Review> using focus mode = ${isFocusMode}.`); - Logger.appendLine(`Review> state validation silent = ${silent}.`); - Logger.appendLine(`Review> PR show should show = ${this._showPullRequest.shouldShow}.`); + this.layout(pr, updateLayout, this.justSwitchedToReviewMode ? false : silent); + this.justSwitchedToReviewMode = false; + + this._validateStatusInProgress = undefined; + } + + private layout(pr: PullRequestModel, updateLayout: boolean, silent: boolean) { + const isFocusMode = this._context.workspaceState.get(FOCUS_REVIEW_MODE); + + Logger.appendLine(`Using focus mode = ${isFocusMode}.`, this.id); + Logger.appendLine(`State validation silent = ${silent}.`, this.id); + Logger.appendLine(`PR show should show = ${this._showPullRequest.shouldShow}.`, this.id); + if ((!silent || this._showPullRequest.shouldShow) && isFocusMode) { - this._doFocusShow(openDiff); + this._doFocusShow(pr, updateLayout); } else if (!this._showPullRequest.shouldShow && isFocusMode) { const showPRChangedDisposable = this._showPullRequest.onChangedShowValue(shouldShow => { - Logger.appendLine(`Review> PR show value changed = ${shouldShow}.`); + Logger.appendLine(`PR show value changed = ${shouldShow}.`, this.id); if (shouldShow) { - this._doFocusShow(openDiff); + this._doFocusShow(pr, updateLayout); } showPRChangedDisposable.dispose(); }); this._localToDispose.push(showPRChangedDisposable); } + } - this._validateStatusInProgress = undefined; + private async reopenNewReviewDiffs() { + let hasOpenDiff = false; + await Promise.all(vscode.window.tabGroups.all.map(tabGroup => { + return tabGroup.tabs.map(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.original.scheme === Schemes.Review)) { + + for (const localChange of this._reviewModel.localFileChanges) { + const fileName = fromReviewUri(tab.input.original.query); + + if (localChange.fileName === fileName.path) { + hasOpenDiff = true; + vscode.window.tabGroups.close(tab).then(_ => localChange.openDiff(this._folderRepoManager, { preview: tab.isPreview })); + break; + } + } + + } + } + return Promise.resolve(undefined); + }); + }).flat()); + + if (!hasOpenDiff && this._reviewModel.localFileChanges.length) { + this._reviewModel.localFileChanges[0].openDiff(this._folderRepoManager, { preview: true }); + } } private openDiff() { if (this._reviewModel.localFileChanges.length) { - let fileChangeToShow: GitFileChangeNode | undefined; + let fileChangeToShow: GitFileChangeNode[] = []; for (const fileChange of this._reviewModel.localFileChanges) { if (fileChange.status === GitChangeType.MODIFY) { - fileChangeToShow = fileChange; - break; + if (KnownMediaExtensions.includes(nodePath.extname(fileChange.fileName))) { + fileChangeToShow.push(fileChange); + } else { + fileChangeToShow.unshift(fileChange); + break; + } } } - fileChangeToShow = fileChangeToShow ?? this._reviewModel.localFileChanges[0]; - fileChangeToShow.openDiff(this._folderRepoManager); + const change = fileChangeToShow.length ? fileChangeToShow[0] : this._reviewModel.localFileChanges[0]; + change.openDiff(this._folderRepoManager); } } - private _doFocusShow(openDiff: boolean) { - commands.executeCommand('workbench.action.focusCommentsPanel'); - this._webviewViewProvider?.show(); - if (openDiff) { - if (this._reviewModel.localFileChanges.length) { - this.openDiff(); - } else { - const localFileChangesDisposable = this._reviewModel.onDidChangeLocalFileChanges(() => { - localFileChangesDisposable.dispose(); + private _doFocusShow(pr: PullRequestModel, updateLayout: boolean) { + // Respect the setting 'comments.openView' when it's 'never'. + const shouldShowCommentsView = vscode.workspace.getConfiguration(COMMENTS).get<'never' | string>(OPEN_VIEW); + if (shouldShowCommentsView !== 'never') { + commands.executeCommand('workbench.action.focusCommentsPanel'); + } + this._activePrViewCoordinator.show(pr); + if (updateLayout) { + const focusedMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'firstDiff' | 'overview' | 'multiDiff' | false>(FOCUSED_MODE); + if (focusedMode === 'firstDiff') { + if (this._reviewModel.localFileChanges.length) { this.openDiff(); - }); + } else { + const localFileChangesDisposable = this._reviewModel.onDidChangeLocalFileChanges(() => { + localFileChangesDisposable.dispose(); + this.openDiff(); + }); + } + } else if (focusedMode === 'overview') { + return this.openDescription(); + } else if (focusedMode === 'multiDiff') { + return PullRequestModel.openChanges(this._folderRepoManager, pr); + } + } + } + + public async _upgradePullRequestEditors(pullRequest: PullRequestModel) { + // Go through all open editors and find pr scheme editors that belong to the active pull request. + // Close the editors, and reopen them from the pull request. + const reopenFilenames: Set<[PRUriParams, PRUriParams]> = new Set(); + await Promise.all(vscode.window.tabGroups.all.map(tabGroup => { + return tabGroup.tabs.map(tab => { + if (tab.input instanceof vscode.TabInputTextDiff) { + if ((tab.input.original.scheme === Schemes.Pr) && (tab.input.modified.scheme === Schemes.Pr)) { + const originalParams = fromPRUri(tab.input.original); + const modifiedParams = fromPRUri(tab.input.modified); + if ((originalParams?.prNumber === pullRequest.number) && (modifiedParams?.prNumber === pullRequest.number)) { + reopenFilenames.add([originalParams, modifiedParams]); + return vscode.window.tabGroups.close(tab); + } + } + } + return Promise.resolve(undefined); + }); + }).flat()); + const reopenPromises: Promise[] = []; + if (reopenFilenames.size) { + for (const localChange of this.reviewModel.localFileChanges) { + for (const prFileChange of reopenFilenames) { + if (Array.isArray(prFileChange)) { + const modifiedPrChange = prFileChange[1]; + if (localChange.fileName === modifiedPrChange.fileName) { + reopenPromises.push(localChange.openDiff(this._folderRepoManager, { preview: false })); + reopenFilenames.delete(prFileChange); + break; + } + } + } } } + return Promise.all(reopenPromises); } public async updateComments(): Promise { @@ -463,14 +714,14 @@ export class ReviewManager { ); if (!pr || !pr.isResolved()) { - Logger.appendLine('Review> This PR is no longer valid'); + Logger.warn('This PR is no longer valid', this.id); return; } - await this.checkBranchUpToDate(pr); + await this._folderRepoManager.checkBranchUpToDate(pr, false); await this.initializePullRequestData(pr); - await this._reviewCommentController.update(); + await this._reviewCommentController?.update(); return Promise.resolve(void 0); } @@ -503,14 +754,12 @@ export class ReviewManager { this._repository.rootUri, ); + const changeModel = new GitFileChangeModel(this._folderRepoManager, pr, change, modifiedFileUri, originalFileUri, headSha, contentChanges.length < 20); const changedItem = new GitFileChangeNode( this.changesInPrDataProvider, this._folderRepoManager, pr, - change, - modifiedFileUri, - originalFileUri, - headSha, + changeModel ); nodes.push(changedItem); } @@ -520,10 +769,10 @@ export class ReviewManager { private async initializePullRequestData(pr: PullRequestModel & IResolvedPullRequestModel): Promise { try { - const contentChanges = await pr.getFileChangesInfo(this._repository); + const contentChanges = await pr.getFileChangesInfo(); this._reviewModel.localFileChanges = await this.getLocalChangeNodes(pr, contentChanges); await Promise.all([pr.initializeReviewComments(), pr.initializeReviewThreadCache(), pr.initializePullRequestFileViewState()]); - pr.setFileViewedContext(); + this._folderRepoManager.setFileViewedContext(); const outdatedComments = pr.comments.filter(comment => !comment.position); const commitsGroup = groupBy(outdatedComments, comment => comment.originalCommitId!); @@ -535,16 +784,15 @@ export class ReviewManager { for (const fileName in commentsForFile) { const oldComments = commentsForFile[fileName]; const uri = vscode.Uri.file(nodePath.join(`commit~${commit.substr(0, 8)}`, fileName)); - const obsoleteFileChange = new GitFileChangeNode( - this.changesInPrDataProvider, + const changeModel = new GitFileChangeModel( this._folderRepoManager, pr, { status: GitChangeType.MODIFY, fileName, blobUrl: undefined, - }, - toReviewUri( + + }, toReviewUri( uri, fileName, undefined, @@ -562,7 +810,12 @@ export class ReviewManager { { base: true }, this._repository.rootUri, ), - commit, + commit); + const obsoleteFileChange = new GitFileChangeNode( + this.changesInPrDataProvider, + this._folderRepoManager, + pr, + changeModel, false, oldComments ); @@ -574,7 +827,55 @@ export class ReviewManager { return Promise.resolve(void 0); } catch (e) { - Logger.appendLine(`Review> ${e}`); + Logger.error(`Failed to initialize PR data ${e}`, this.id); + } + } + + private async registerGitHubInMemContentProvider() { + try { + this._inMemGitHubContentProvider?.dispose(); + this._inMemGitHubContentProvider = undefined; + + const pr = this._folderRepoManager.activePullRequest; + if (!pr) { + return; + } + const rawChanges = await pr.getFileChangesInfo(); + const mergeBase = pr.mergeBase; + if (!mergeBase) { + return; + } + const changes = rawChanges.map(change => { + if (change instanceof SlimFileChange) { + return new RemoteFileChangeModel(this._folderRepoManager, change, pr); + } + return new InMemFileChangeModel(this._folderRepoManager, + pr as (PullRequestModel & IResolvedPullRequestModel), + change, true, mergeBase); + }); + + this._inMemGitHubContentProvider = getInMemPRFileSystemProvider()?.registerTextDocumentContentProvider( + pr.number, + async (uri: vscode.Uri): Promise => { + const params = fromPRUri(uri); + if (!params) { + return ''; + } + const fileChange = changes.find( + contentChange => contentChange.fileName === params.fileName, + ); + + if (!fileChange) { + Logger.error(`Cannot find content for document ${uri.toString()}`, 'PR'); + return ''; + } + + return provideDocumentContentForChangeModel(this._folderRepoManager, pr, params, fileChange); + + }, + ); + } catch (e) { + Logger.error(`Failed to register in mem content provider: ${e}`, this.id); } } @@ -595,41 +896,35 @@ export class ReviewManager { } private async doRegisterCommentController() { - this._reviewCommentController = new ReviewCommentController( - this, - this._folderRepoManager, - this._repository, - this._reviewModel, - this._sessionState - ); - - await this._reviewCommentController.initialize(); + if (!this._reviewCommentController) { + this._reviewCommentController = new ReviewCommentController( + this, + this._folderRepoManager, + this._repository, + this._reviewModel, + ); - this._localToDispose.push(this._reviewCommentController); - this._localToDispose.push( - this._reviewCommentController.onDidChangeComments(comments => { - if (this._folderRepoManager.activePullRequest) { - this._folderRepoManager.activePullRequest.comments = comments; - } - }), - ); + await this._reviewCommentController.initialize(); + } } public async switch(pr: PullRequestModel): Promise { - Logger.appendLine(`Review> switch to Pull Request #${pr.number} - start`); - this.statusBarItem.text = '$(sync~spin) Switching to Review Mode'; + Logger.appendLine(`Switch to Pull Request #${pr.number} - start`, this.id); + this.statusBarItem.text = vscode.l10n.t('{0} Switching to Review Mode', '$(sync~spin)'); this.statusBarItem.command = undefined; this.statusBarItem.show(); this.switchingToReviewMode = true; try { - const didLocalCheckout = await this._folderRepoManager.checkoutExistingPullRequestBranch(pr); + await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async (progress) => { + const didLocalCheckout = await this._folderRepoManager.checkoutExistingPullRequestBranch(pr, progress); - if (!didLocalCheckout) { - await this._folderRepoManager.fetchAndCheckout(pr); - } + if (!didLocalCheckout) { + await this._folderRepoManager.fetchAndCheckout(pr, progress); + } + }); } catch (e) { - Logger.appendLine(`Review> checkout failed #${JSON.stringify(e)}`); + Logger.error(`Checkout failed #${JSON.stringify(e)}`, this.id); this.switchingToReviewMode = false; if (e.message === 'User aborted') { @@ -639,11 +934,12 @@ export class ReviewManager { e.gitErrorCode === GitErrorCodes.DirtyWorkTree )) { // for known git errors, we should provide actions for users to continue. - vscode.window.showErrorMessage( - 'Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches', - ); - } else if ((e.stderr as string)?.startsWith('fatal: couldn\'t find remote ref')) { - vscode.window.showErrorMessage('The remote branch for this pull request has been deleted. The pull request cannot be checked out.'); + vscode.window.showErrorMessage(vscode.l10n.t( + 'Your local changes would be overwritten by checkout, please commit your changes or stash them before you switch branches' + )); + } else if ((e.stderr as string)?.startsWith('fatal: couldn\'t find remote ref') && e.gitCommand === 'fetch') { + // The pull request was checked out, but the upstream branch was deleted + vscode.window.showInformationMessage('The remote branch for this pull request has been deleted. The file contents may not match the remote.'); } else { vscode.window.showErrorMessage(formatError(e)); } @@ -652,23 +948,23 @@ export class ReviewManager { this.setStatusForPr(this._folderRepoManager.activePullRequest); } else { this.statusBarItem.hide(); - this.switchingToReviewMode = false; } return; } try { - this.statusBarItem.text = `$(sync~spin) Fetching additional data: pr/${pr.number}`; + this.statusBarItem.text = '$(sync~spin) ' + vscode.l10n.t('Fetching additional data: {0}', `pr/${pr.number}`); this.statusBarItem.command = undefined; this.statusBarItem.show(); await this._folderRepoManager.fulfillPullRequestMissingInfo(pr); + this._upgradePullRequestEditors(pr); /* __GDPR__ "pr.checkout" : {} */ this._telemetry.sendTelemetryEvent('pr.checkout'); - Logger.appendLine(`Review> switch to Pull Request #${pr.number} - done`, ReviewManager.ID); + Logger.appendLine(`Switch to Pull Request #${pr.number} - done`, this.id); } finally { this.setStatusForPr(pr); await this._repository.status(); @@ -678,7 +974,7 @@ export class ReviewManager { private setStatusForPr(pr: PullRequestModel) { this.switchingToReviewMode = false; this.justSwitchedToReviewMode = true; - this.statusBarItem.text = `Pull Request #${pr.number}`; + this.statusBarItem.text = vscode.l10n.t('Pull Request #{0}', pr.number); this.statusBarItem.command = undefined; this.statusBarItem.show(); } @@ -687,14 +983,14 @@ export class ReviewManager { const potentialTargetRemotes = await this._folderRepoManager.getAllGitHubRemotes(); let selectedRemote = (await this.getRemote( potentialTargetRemotes, - `Pick a remote to publish the branch '${branch.name}' to:`, + vscode.l10n.t(`Pick a remote to publish the branch '{0}' to:`, branch.name!), ))!.remote; if (!selectedRemote || branch.name === undefined) { return; } - const githubRepo = this._folderRepoManager.createGitHubRepository( + const githubRepo = await this._folderRepoManager.createGitHubRepository( selectedRemote, this._folderRepoManager.credentialStore, ); @@ -709,7 +1005,7 @@ export class ReviewManager { if (!fork) { return; } - selectedRemote = this._folderRepoManager.getGitHubRemotes().find(element => element.remoteName === fork); + selectedRemote = (await this._folderRepoManager.getGitHubRemotes()).find(element => element.remoteName === fork); } if (!selectedRemote) { @@ -723,14 +1019,14 @@ export class ReviewManager { inputBox.ignoreFocusOut = true; inputBox.prompt = potentialTargetRemotes.length === 1 - ? `The branch '${branch.name}' is not published yet, pick a name for the upstream branch` - : 'Pick a name for the upstream branch'; + ? vscode.l10n.t(`The branch '{0}' is not published yet, pick a name for the upstream branch`, branch.name!) + : vscode.l10n.t('Pick a name for the upstream branch'); const validate = async function (value: string) { try { inputBox.busy = true; const remoteBranch = await this._reposManager.getBranch(remote, value); if (remoteBranch) { - inputBox.validationMessage = `Branch ${value} already exists in ${remote.owner}/${remote.repositoryName}`; + inputBox.validationMessage = vscode.l10n.t(`Branch '{0}' already exists in {1}`, value, `${remote.owner}/${remote.repositoryName}`); } else { inputBox.validationMessage = undefined; } @@ -752,7 +1048,7 @@ export class ReviewManager { } catch (err) { if (err.gitErrorCode === GitErrorCodes.PushRejected) { vscode.window.showWarningMessage( - `Can't push refs to remote, try running 'git pull' first to integrate with your change`, + vscode.l10n.t(`Can't push refs to remote, try running 'git pull' first to integrate with your change`), { modal: true, }, @@ -763,7 +1059,7 @@ export class ReviewManager { if (err.gitErrorCode === GitErrorCodes.RemoteConnectionError) { vscode.window.showWarningMessage( - `Could not read from remote repository '${remote.remoteName}'. Please make sure you have the correct access rights and the repository exists.`, + vscode.l10n.t(`Could not read from remote repository '{0}'. Please make sure you have the correct access rights and the repository exists.`, remote.remoteName), { modal: true, }, @@ -795,7 +1091,7 @@ export class ReviewManager { defaultUpstream?: RemoteQuickPickItem, ): Promise { if (!potentialTargetRemotes.length) { - vscode.window.showWarningMessage(`No GitHub remotes found. Add a remote and try again.`); + vscode.window.showWarningMessage(vscode.l10n.t(`No GitHub remotes found. Add a remote and try again.`)); return; } @@ -844,10 +1140,9 @@ export class ReviewManager { } public async createPullRequest(compareBranch?: string): Promise { - if (!this._createPullRequestHelper) { - this._createPullRequestHelper = new CreatePullRequestHelper(this.repository); - this._createPullRequestHelper.onDidCreate(async createdPR => { - await this.updateState(false, false); + const postCreate = async (createdPR: PullRequestModel) => { + const postCreate = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'none' | 'openOverview' | 'checkoutDefaultBranch' | 'checkoutDefaultBranchAndShow' | 'checkoutDefaultBranchAndCopy'>(POST_CREATE, 'openOverview'); + if (postCreate === 'openOverview') { const descriptionNode = this.changesInPrDataProvider.getDescriptionNode(this._folderRepoManager); await openDescription( this._context, @@ -855,11 +1150,29 @@ export class ReviewManager { createdPR, descriptionNode, this._folderRepoManager, + true ); - }); - } + } else if (postCreate.startsWith('checkoutDefaultBranch')) { + const defaultBranch = await this._folderRepoManager.getPullRequestRepositoryDefaultBranch(createdPR); + if (defaultBranch) { + if (postCreate === 'checkoutDefaultBranch') { + await this._folderRepoManager.checkoutDefaultBranch(defaultBranch); + } if (postCreate === 'checkoutDefaultBranchAndShow') { + await vscode.commands.executeCommand('pr:github.focus'); + await this._folderRepoManager.checkoutDefaultBranch(defaultBranch); + await this._pullRequestsTree.expandPullRequest(createdPR); + } else if (postCreate === 'checkoutDefaultBranchAndCopy') { + await Promise.all([ + this._folderRepoManager.checkoutDefaultBranch(defaultBranch), + vscode.env.clipboard.writeText(createdPR.html_url) + ]); + } + } + } + await this.updateState(false, false); + }; - this._createPullRequestHelper.create(this._context.extensionUri, this._folderRepoManager, compareBranch); + return this._createPullRequestHelper.create(this._telemetry, this._context.extensionUri, this._folderRepoManager, compareBranch, postCreate); } public async openDescription(): Promise { @@ -875,6 +1188,7 @@ export class ReviewManager { pullRequest, descriptionNode, this._folderRepoManager, + true ); } @@ -883,7 +1197,7 @@ export class ReviewManager { } private async updateFocusedViewMode(): Promise { - const focusedSetting = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get('focusedMode'); + const focusedSetting = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FOCUSED_MODE); if (focusedSetting) { vscode.commands.executeCommand('setContext', FOCUS_REVIEW_MODE, true); await this._context.workspaceState.update(FOCUS_REVIEW_MODE, true); @@ -893,16 +1207,17 @@ export class ReviewManager { } } - private clear(quitReviewMode: boolean) { - this._updateMessageShown = false; - this._reviewModel.clear(); - this._localToDispose.forEach(disposable => disposable.dispose()); - // Ensure file explorer decorations are removed. When switching to a different PR branch, - // comments are recalculated when getting the data and the change decoration fired then, - // so comments only needs to be emptied in this case. - this._folderRepoManager.activePullRequest?.clear(); - + private async clear(quitReviewMode: boolean) { if (quitReviewMode) { + const activePullRequest = this._folderRepoManager.activePullRequest; + if (activePullRequest) { + this._activePrViewCoordinator.removePullRequest(activePullRequest); + } + + if (this.changesInPrDataProvider) { + await this.changesInPrDataProvider.removePrFromView(this._folderRepoManager); + } + this._prNumber = undefined; this._folderRepoManager.activePullRequest = undefined; @@ -910,28 +1225,38 @@ export class ReviewManager { this._statusBarItem.hide(); } - if (this.changesInPrDataProvider) { - this.changesInPrDataProvider.removePrFromView(this._folderRepoManager); - } + this._updateMessageShown = false; + this._reviewModel.clear(); - vscode.commands.executeCommand('pr.refreshList'); + this._localToDispose.forEach(disposable => disposable.dispose()); + // Ensure file explorer decorations are removed. When switching to a different PR branch, + // comments are recalculated when getting the data and the change decoration fired then, + // so comments only needs to be emptied in this case. + activePullRequest?.clear(); + this._folderRepoManager.setFileViewedContext(); + this._validateStatusInProgress = undefined; } + + this._reviewCommentController?.dispose(); + this._reviewCommentController = undefined; + this._inMemGitHubContentProvider?.dispose(); + this._inMemGitHubContentProvider = undefined; } async provideTextDocumentContent(uri: vscode.Uri): Promise { - const { path, commit } = fromReviewUri(uri.query); + const { path, commit, base } = fromReviewUri(uri.query); let changedItems = gitFileChangeNodeFilter(this._reviewModel.localFileChanges) .filter(change => change.fileName === path) .filter( fileChange => fileChange.sha === commit || - (fileChange.parentSha ? fileChange.parentSha : `${fileChange.sha}^`) === commit, + `${fileChange.sha}^` === commit, ); if (changedItems.length) { const changedItem = changedItems[0]; const diffChangeTypeFilter = commit === changedItem.sha ? DiffChangeType.Delete : DiffChangeType.Add; - const ret = (await changedItem.diffHunks()).map(diffHunk => + const ret = (await changedItem.changeModel.diffHunks()).map(diffHunk => diffHunk.diffLines .filter(diffLine => diffLine.type !== diffChangeTypeFilter) .map(diffLine => diffLine.text), @@ -944,7 +1269,7 @@ export class ReviewManager { .filter( fileChange => fileChange.sha === commit || - (fileChange.parentSha ? fileChange.parentSha : `${fileChange.sha}^`) === commit, + `${fileChange.sha}^` === commit, ); if (changedItems.length) { @@ -970,22 +1295,29 @@ export class ReviewManager { } return ret.join('\n'); + } else if (base && commit && this._folderRepoManager.activePullRequest) { + // We can't get the content from git. Try to get it from github. + const content = await getGitHubFileContent(this._folderRepoManager.activePullRequest.githubRepository, path, commit); + return content.toString(); } } dispose() { this.clear(true); - this._disposables.forEach(d => { - d.dispose(); - }); + dispose(this._disposables); } static getReviewManagerForRepository( reviewManagers: ReviewManager[], - repository: GitHubRepository, + githubRepository: GitHubRepository, + repository?: Repository ): ReviewManager | undefined { return reviewManagers.find(reviewManager => - reviewManager._folderRepoManager.gitHubRepositories.some(repo => repo.equals(repository)), + reviewManager._folderRepoManager.gitHubRepositories.some(repo => { + // If we don't have a Repository, then just get the first GH repo that fits + // Otherwise, try to pick the review manager with the same repository. + return repo.equals(githubRepository) && (!repository || (reviewManager._folderRepoManager.repository === repository)); + }) ); } diff --git a/src/view/reviewModel.ts b/src/view/reviewModel.ts index 7675d2124d..52478da7a5 100644 --- a/src/view/reviewModel.ts +++ b/src/view/reviewModel.ts @@ -16,7 +16,7 @@ export class ReviewModel { constructor() { } get hasLocalFileChanges() { - return this._localFileChanges && (this._localFileChanges.length > 0); + return !!this._localFileChanges; } get localFileChanges(): GitFileChangeNode[] { diff --git a/src/view/reviewsManager.ts b/src/view/reviewsManager.ts index e692b6d61f..38a51d4aef 100644 --- a/src/view/reviewsManager.ts +++ b/src/view/reviewsManager.ts @@ -26,17 +26,21 @@ export class ReviewsManager { private _prsTreeDataProvider: PullRequestsTreeDataProvider, private _prFileChangesProvider: PullRequestChangesTreeDataProvider, private _telemetry: ITelemetry, - credentialStore: CredentialStore, + private _credentialStore: CredentialStore, private _gitApi: GitApiImpl, ) { this._disposables = []; - const gitContentProvider = new GitContentFileSystemProvider(_gitApi, credentialStore); + const gitContentProvider = new GitContentFileSystemProvider(_gitApi, _credentialStore, _reviewManagers); gitContentProvider.registerTextDocumentContentFallback(this.provideTextDocumentContent.bind(this)); this._disposables.push(vscode.workspace.registerFileSystemProvider(Schemes.Review, gitContentProvider, { isReadonly: true })); this.registerListeners(); this._disposables.push(this._prsTreeDataProvider); } + get reviewManagers(): ReviewManager[] { + return this._reviewManagers; + } + private registerListeners(): void { this._disposables.push( vscode.workspace.onDidChangeConfiguration(async e => { @@ -51,8 +55,8 @@ export class ReviewsManager { } this._prsTreeDataProvider.dispose(); - this._prsTreeDataProvider = new PullRequestsTreeDataProvider(this._telemetry); - this._prsTreeDataProvider.initialize(this._reposManager); + this._prsTreeDataProvider = new PullRequestsTreeDataProvider(this._telemetry, this._context, this._reposManager); + this._prsTreeDataProvider.initialize(this._reviewManagers.map(manager => manager.reviewModel), this._credentialStore); this._disposables.push(this._prsTreeDataProvider); } }), @@ -69,6 +73,20 @@ export class ReviewsManager { } public addReviewManager(reviewManager: ReviewManager) { + // Try to insert in workspace folder order + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + const index = workspaceFolders.findIndex( + folder => folder.uri.toString() === reviewManager.repository.rootUri.toString(), + ); + if (index > -1) { + const arrayEnd = this._reviewManagers.slice(index, this._reviewManagers.length); + this._reviewManagers = this._reviewManagers.slice(0, index); + this._reviewManagers.push(reviewManager); + this._reviewManagers.push(...arrayEnd); + return; + } + } this._reviewManagers.push(reviewManager); } @@ -76,7 +94,7 @@ export class ReviewsManager { const reviewManagerIndex = this._reviewManagers.findIndex( manager => manager.repository.rootUri.toString() === repo.rootUri.toString(), ); - if (reviewManagerIndex) { + if (reviewManagerIndex >= 0) { const manager = this._reviewManagers[reviewManagerIndex]; this._reviewManagers.splice(reviewManagerIndex); manager.dispose(); diff --git a/src/view/treeNodes/categoryNode.ts b/src/view/treeNodes/categoryNode.ts index 863eb09746..fc585e1a56 100644 --- a/src/view/treeNodes/categoryNode.ts +++ b/src/view/treeNodes/categoryNode.ts @@ -5,26 +5,39 @@ import * as vscode from 'vscode'; import { AuthenticationError } from '../../common/authentication'; -import { PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { PR_SETTINGS_NAMESPACE, QUERIES } from '../../common/settingKeys'; import { ITelemetry } from '../../common/telemetry'; import { formatError } from '../../common/utils'; -import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { FolderRepositoryManager, ItemsResponseResult } from '../../github/folderRepositoryManager'; import { PRType } from '../../github/interface'; +import { NotificationProvider } from '../../github/notifications'; import { PullRequestModel } from '../../github/pullRequestModel'; +import { PrsTreeModel } from '../prsTreeModel'; import { PRNode } from './pullRequestNode'; import { TreeNode, TreeNodeParent } from './treeNode'; -const QUERIES_CONFIGURATION: string = 'queries'; - export enum PRCategoryActionType { Empty, More, TryOtherRemotes, Login, + LoginEnterprise, NoRemotes, NoMatchingRemotes, ConfigureRemotes, - Initializing, +} + +interface QueryInspect { + key: string; + defaultValue?: { label: string; query: string }[]; + globalValue?: { label: string; query: string }[]; + workspaceValue?: { label: string; query: string }[]; + workspaceFolderValue?: { label: string; query: string }[]; + defaultLanguageValue?: { label: string; query: string }[]; + globalLanguageValue?: { label: string; query: string }[]; + workspaceLanguageValue?: { label: string; query: string }[]; + workspaceFolderLanguageValue?: { label: string; query: string }[]; + languageIds?: string[] } export class PRCategoryActionNode extends TreeNode implements vscode.TreeItem { @@ -40,26 +53,34 @@ export class PRCategoryActionNode extends TreeNode implements vscode.TreeItem { this.collapsibleState = vscode.TreeItemCollapsibleState.None; switch (type) { case PRCategoryActionType.Empty: - this.label = '0 pull requests in this category'; + this.label = vscode.l10n.t('0 pull requests in this category'); break; case PRCategoryActionType.More: - this.label = 'Load more'; + this.label = vscode.l10n.t('Load more'); this.command = { - title: 'Load more', + title: vscode.l10n.t('Load more'), command: 'pr.loadMore', arguments: [node], }; break; case PRCategoryActionType.TryOtherRemotes: - this.label = 'Continue fetching from other remotes'; + this.label = vscode.l10n.t('Continue fetching from other remotes'); this.command = { - title: 'Load more', + title: vscode.l10n.t('Load more'), command: 'pr.loadMore', arguments: [node], }; break; case PRCategoryActionType.Login: - this.label = 'Sign in'; + this.label = vscode.l10n.t('Sign in'); + this.command = { + title: vscode.l10n.t('Sign in'), + command: 'pr.signinAndRefreshList', + arguments: [], + }; + break; + case PRCategoryActionType.LoginEnterprise: + this.label = vscode.l10n.t('Sign in with GitHub Enterprise...'); this.command = { title: 'Sign in', command: 'pr.signinAndRefreshList', @@ -67,22 +88,19 @@ export class PRCategoryActionNode extends TreeNode implements vscode.TreeItem { }; break; case PRCategoryActionType.NoRemotes: - this.label = 'No GitHub repositories found.'; + this.label = vscode.l10n.t('No GitHub repositories found.'); break; case PRCategoryActionType.NoMatchingRemotes: - this.label = 'No remotes match the current setting.'; + this.label = vscode.l10n.t('No remotes match the current setting.'); break; case PRCategoryActionType.ConfigureRemotes: - this.label = 'Configure remotes...'; + this.label = vscode.l10n.t('Configure remotes...'); this.command = { - title: 'Configure remotes', + title: vscode.l10n.t('Configure remotes'), command: 'pr.configureRemotes', arguments: [], }; break; - case PRCategoryActionType.Initializing: - this.label = 'Loading...'; - break; default: break; } @@ -99,57 +117,120 @@ interface PageInformation { } export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { + protected children: (PRNode | PRCategoryActionNode)[] | undefined = undefined; public collapsibleState: vscode.TreeItemCollapsibleState; public prs: PullRequestModel[]; public fetchNextPage: boolean = false; public repositoryPageInformation: Map = new Map(); public contextValue: string; + public readonly id: string = ''; + private _firstLoad: boolean = true; constructor( public parent: TreeNodeParent, private _folderRepoManager: FolderRepositoryManager, private _telemetry: ITelemetry, - private _type: PRType, + public readonly type: PRType, + private _notificationProvider: NotificationProvider, + expandedQueries: Set, + private _prsTreeModel: PrsTreeModel, _categoryLabel?: string, private _categoryQuery?: string, ) { super(); this.prs = []; - this.collapsibleState = - this._type === PRType.All - ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.Collapsed; - switch (_type) { + switch (this.type) { case PRType.All: - this.label = 'All Open'; + this.label = vscode.l10n.t('All Open'); break; case PRType.Query: this.label = _categoryLabel!; break; case PRType.LocalPullRequest: - this.label = 'Local Pull Request Branches'; + this.label = vscode.l10n.t('Local Pull Request Branches'); break; default: + this.label = ''; break; } + this.id = parent instanceof TreeNode ? `${parent.id ?? parent.label}/${this.label}` : this.label; + + if ((expandedQueries.size === 0) && (this.type === PRType.All)) { + this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + } else { + this.collapsibleState = + expandedQueries.has(this.id) + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed; + } + if (this._categoryQuery) { this.contextValue = 'query'; } } - async editQuery() { - const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); - const inspect = config.inspect<{ label: string; query: string }[]>(QUERIES_CONFIGURATION); + private async addNewQuery(config: vscode.WorkspaceConfiguration, inspect: QueryInspect | undefined, startingValue: string) { + const inputBox = vscode.window.createInputBox(); + inputBox.title = vscode.l10n.t('Enter the title of the new query'); + inputBox.placeholder = vscode.l10n.t('Title'); + inputBox.step = 1; + inputBox.totalSteps = 2; + inputBox.show(); + let title: string | undefined; + inputBox.onDidAccept(async () => { + inputBox.validationMessage = ''; + if (inputBox.step === 1) { + if (!inputBox.value) { + inputBox.validationMessage = vscode.l10n.t('Title is required'); + return; + } + + title = inputBox.value; + inputBox.value = startingValue; + inputBox.title = vscode.l10n.t('Enter the GitHub search query'); + inputBox.step++; + } else { + if (!inputBox.value) { + inputBox.validationMessage = vscode.l10n.t('Query is required'); + return; + } + inputBox.busy = true; + if (inputBox.value && title) { + if (inspect?.workspaceValue) { + inspect.workspaceValue.push({ label: title, query: inputBox.value }); + await config.update(QUERIES, inspect.workspaceValue, vscode.ConfigurationTarget.Workspace); + } else { + const value = config.get<{ label: string; query: string }[]>(QUERIES); + value?.push({ label: title, query: inputBox.value }); + await config.update(QUERIES, value, vscode.ConfigurationTarget.Global); + } + } + inputBox.dispose(); + } + }); + inputBox.onDidHide(() => inputBox.dispose()); + } + + private updateQuery(queries: { label: string; query: string }[], queryToUpdate: { label: string; query: string }) { + for (const query of queries) { + if (query.label === queryToUpdate.label) { + query.query = queryToUpdate.query; + return; + } + } + } + + private async openSettings(config: vscode.WorkspaceConfiguration, inspect: QueryInspect | undefined) { let command: string; if (inspect?.workspaceValue) { command = 'workbench.action.openWorkspaceSettingsFile'; } else { - const value = config.get<{ label: string; query: string }[]>(QUERIES_CONFIGURATION); + const value = config.get<{ label: string; query: string }[]>(QUERIES); if (inspect?.defaultValue && JSON.stringify(inspect?.defaultValue) === JSON.stringify(value)) { - config.update(QUERIES_CONFIGURATION, inspect.defaultValue, vscode.ConfigurationTarget.Global); + await config.update(QUERIES, inspect.defaultValue, vscode.ConfigurationTarget.Global); } command = 'workbench.action.openSettingsJson'; } @@ -166,78 +247,114 @@ export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { } } + public async expandPullRequest(pullRequest: PullRequestModel, retry: boolean = true): Promise { + if (!this.children && retry) { + await this.getChildren(); + retry = false; + } + if (this.children) { + for (const child of this.children) { + if (child instanceof PRNode) { + if (child.pullRequestModel.equals(pullRequest)) { + this.reveal(child, { expand: true, select: true }); + return true; + } + } + } + // If we didn't find the PR, we might need to re-run the query + if (retry) { + await this.getChildren(); + return await this.expandPullRequest(pullRequest, false); + } + } + return false; + } + + async editQuery() { + const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + const inspect = config.inspect<{ label: string; query: string }[]>(QUERIES); + + const inputBox = vscode.window.createQuickPick(); + inputBox.title = vscode.l10n.t('Edit Pull Request Query "{0}"', this.label ?? ''); + inputBox.value = this._categoryQuery ?? ''; + inputBox.items = [{ 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 }]; + inputBox.activeItems = []; + inputBox.selectedItems = []; + inputBox.onDidAccept(async () => { + inputBox.busy = true; + if (inputBox.selectedItems[0] === inputBox.items[0]) { + const newQuery = inputBox.value; + if (newQuery !== this._categoryQuery && this.label) { + if (inspect?.workspaceValue) { + this.updateQuery(inspect.workspaceValue, { label: this.label, query: newQuery }); + await config.update(QUERIES, inspect.workspaceValue, vscode.ConfigurationTarget.Workspace); + } else { + const value = config.get<{ label: string; query: string }[]>(QUERIES) ?? inspect!.defaultValue!; + this.updateQuery(value, { label: this.label, query: newQuery }); + await config.update(QUERIES, value, vscode.ConfigurationTarget.Global); + } + } + } else if (inputBox.selectedItems[0] === inputBox.items[1]) { + this.addNewQuery(config, inspect, inputBox.value); + } else if (inputBox.selectedItems[0] === inputBox.items[2]) { + this.openSettings(config, inspect); + } + inputBox.dispose(); + }); + inputBox.onDidHide(() => inputBox.dispose()); + inputBox.show(); + } + async getChildren(): Promise { - if (this.childrenDisposables && this.childrenDisposables.length) { - this.childrenDisposables.forEach(dp => dp.dispose()); - this.childrenDisposables = []; + await super.getChildren(); + if (this._firstLoad) { + this._firstLoad = false; + this.doGetChildren().then(() => this.refresh(this)); + return []; } + return this.doGetChildren(); + } + private async doGetChildren(): Promise { let hasMorePages = false; let hasUnsearchedRepositories = false; let needLogin = false; - if (this._type === PRType.LocalPullRequest) { + if (this.type === PRType.LocalPullRequest) { try { - this.prs = await this._folderRepoManager.getLocalPullRequests(); - /* __GDPR__ - "pr.expand.local" : {} - */ - this._telemetry.sendTelemetryEvent('pr.expand.local'); + this.prs = (await this._prsTreeModel.getLocalPullRequests(this._folderRepoManager)).items; } catch (e) { - vscode.window.showErrorMessage(`Fetching local pull requests failed: ${formatError(e)}`); + vscode.window.showErrorMessage(vscode.l10n.t('Fetching local pull requests failed: {0}', formatError(e))); needLogin = e instanceof AuthenticationError; } } else { - if (!this.fetchNextPage) { - try { - const response = await this._folderRepoManager.getPullRequests( - this._type, - { fetchNextPage: false }, - this._categoryQuery, - ); - this.prs = response.items; - hasMorePages = response.hasMorePages; - hasUnsearchedRepositories = response.hasUnsearchedRepositories; - - switch (this._type) { - case PRType.All: - /* __GDPR__ - "pr.expand.all" : {} - */ - this._telemetry.sendTelemetryEvent('pr.expand.all'); - break; - case PRType.Query: - /* __GDPR__ - "pr.expand.query" : {} - */ - this._telemetry.sendTelemetryEvent('pr.expand.query'); - break; - } - } catch (e) { - vscode.window.showErrorMessage(`Fetching pull requests failed: ${formatError(e)}`); - needLogin = e instanceof AuthenticationError; + try { + let response: ItemsResponseResult; + switch (this.type) { + case PRType.All: + response = await this._prsTreeModel.getAllPullRequests(this._folderRepoManager, this.fetchNextPage); + break; + case PRType.Query: + response = await this._prsTreeModel.getPullRequestsForQuery(this._folderRepoManager, this.fetchNextPage, this._categoryQuery!); + break; } - } else { - try { - const response = await this._folderRepoManager.getPullRequests( - this._type, - { fetchNextPage: true }, - this._categoryQuery, - ); + if (!this.fetchNextPage) { + this.prs = response.items; + } else { this.prs = this.prs.concat(response.items); - hasMorePages = response.hasMorePages; - hasUnsearchedRepositories = response.hasUnsearchedRepositories; - } catch (e) { - vscode.window.showErrorMessage(`Fetching pull requests failed: ${formatError(e)}`); - needLogin = e instanceof AuthenticationError; } - + hasMorePages = response.hasMorePages; + hasUnsearchedRepositories = response.hasUnsearchedRepositories; + } catch (e) { + vscode.window.showErrorMessage(vscode.l10n.t('Fetching pull requests failed: {0}', formatError(e))); + needLogin = e instanceof AuthenticationError; + } finally { this.fetchNextPage = false; } } if (this.prs && this.prs.length) { - const nodes: TreeNode[] = this.prs.map( - prItem => new PRNode(this, this._folderRepoManager, prItem, this._type === PRType.LocalPullRequest), + const nodes: (PRNode | PRCategoryActionNode)[] = this.prs.map( + prItem => new PRNode(this, this._folderRepoManager, prItem, this.type === PRType.LocalPullRequest, this._notificationProvider), ); if (hasMorePages) { nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.More, this)); @@ -245,13 +362,13 @@ export class CategoryTreeNode extends TreeNode implements vscode.TreeItem { nodes.push(new PRCategoryActionNode(this, PRCategoryActionType.TryOtherRemotes, this)); } - this.childrenDisposables = nodes; + this.children = nodes; return nodes; } else { const category = needLogin ? PRCategoryActionType.Login : PRCategoryActionType.Empty; const result = [new PRCategoryActionNode(this, category)]; - this.childrenDisposables = result; + this.children = result; return result; } } diff --git a/src/view/treeNodes/commitNode.ts b/src/view/treeNodes/commitNode.ts index 2d53670ab3..cf4d106f6b 100644 --- a/src/view/treeNodes/commitNode.ts +++ b/src/view/treeNodes/commitNode.ts @@ -6,14 +6,16 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { getGitChangeType } from '../../common/diffHunk'; -import { FILE_LIST_LAYOUT } from '../../common/settingKeys'; -import { toReviewUri } from '../../common/uri'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { DataUri, toReviewUri } from '../../common/uri'; import { OctokitCommon } from '../../github/common'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../../github/folderRepositoryManager'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { IAccount } from '../../github/interface'; import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; +import { GitFileChangeModel } from '../fileChangeModel'; import { DirectoryTreeNode } from './directoryTreeNode'; import { GitFileChangeNode } from './fileChangeNode'; -import { TreeNode, TreeNodeParent } from './treeNode'; +import { LabelOnlyNode, TreeNode, TreeNodeParent } from './treeNode'; export class CommitNode extends TreeNode implements vscode.TreeItem { public sha: string; @@ -32,33 +34,31 @@ export class CommitNode extends TreeNode implements vscode.TreeItem { this.label = commit.commit.message; this.sha = commit.sha; this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; - let userIconUri: vscode.Uri | undefined; - try { - if (commit.author && commit.author.avatar_url) { - userIconUri = vscode.Uri.parse(`${commit.author.avatar_url}&s=${64}`); - } - } catch (_) { - // no-op - } - - this.iconPath = userIconUri; this.contextValue = 'commit'; } - getTreeItem(): vscode.TreeItem { + async getTreeItem(): Promise { + if (this.commit.author) { + const author: IAccount = { id: this.commit.author.node_id, login: this.commit.author.login, url: this.commit.author.url, avatarUrl: this.commit.author.avatar_url }; + this.iconPath = (await DataUri.avatarCirclesAsImageDataUris(this.pullRequestManager.context, [author], 16, 16))[0]; + } return this; } async getChildren(): Promise { + super.getChildren(); const fileChanges = (await this.pullRequest.getCommitChangedFiles(this.commit)) ?? []; + if (fileChanges.length === 0) { + return [new LabelOnlyNode('No changed files')]; + } + const fileChangeNodes = fileChanges.map(change => { const fileName = change.filename!; const uri = vscode.Uri.parse(path.posix.join(`commit~${this.commit.sha.substr(0, 8)}`, fileName)); - const fileChangeNode = new GitFileChangeNode( - this, + const changeModel = new GitFileChangeModel( this.pullRequestManager, - this.pullRequest as (PullRequestModel & IResolvedPullRequestModel), + this.pullRequest, { status: getGitChangeType(change.status!), fileName, @@ -82,7 +82,12 @@ export class CommitNode extends TreeNode implements vscode.TreeItem { { base: true }, this.pullRequestManager.repository.rootUri, ), - this.commit.sha, + this.commit.sha); + const fileChangeNode = new GitFileChangeNode( + this, + this.pullRequestManager, + this.pullRequest as (PullRequestModel & IResolvedPullRequestModel), + changeModel, this.isCurrent ); @@ -92,7 +97,7 @@ export class CommitNode extends TreeNode implements vscode.TreeItem { }); let result: TreeNode[] = []; - const layout = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); if (layout === 'tree') { // tree view const dirNode = new DirectoryTreeNode(this, ''); @@ -108,6 +113,7 @@ export class CommitNode extends TreeNode implements vscode.TreeItem { // flat view result = fileChangeNodes; } - return Promise.resolve(result); + this.children = result; + return result; } } diff --git a/src/view/treeNodes/commitsCategoryNode.ts b/src/view/treeNodes/commitsCategoryNode.ts index c6146263ae..eec88ccdde 100644 --- a/src/view/treeNodes/commitsCategoryNode.ts +++ b/src/view/treeNodes/commitsCategoryNode.ts @@ -11,7 +11,7 @@ import { CommitNode } from './commitNode'; import { TreeNode, TreeNodeParent } from './treeNode'; export class CommitsNode extends TreeNode implements vscode.TreeItem { - public label: string = 'Commits'; + public label: string = vscode.l10n.t('Commits'); public collapsibleState: vscode.TreeItemCollapsibleState; private _folderRepoManager: FolderRepositoryManager; private _pr: PullRequestModel; @@ -43,16 +43,17 @@ export class CommitsNode extends TreeNode implements vscode.TreeItem { } async getChildren(): Promise { + super.getChildren(); try { Logger.appendLine(`Getting children for Commits node`, PR_TREE); const commits = await this._pr.getCommits(); - const commitNodes = commits.map( - (commit, index) => new CommitNode(this, this._folderRepoManager, this._pr, commit, index === commits.length - 1), + this.children = commits.map( + (commit, index) => new CommitNode(this, this._folderRepoManager, this._pr, commit, (index === commits.length - 1) && (this._folderRepoManager.repository.state.HEAD?.commit === commit.sha)), ); Logger.appendLine(`Got all children for Commits node`, PR_TREE); - return Promise.resolve(commitNodes); + return this.children; } catch (e) { - return Promise.resolve([]); + return []; } } } diff --git a/src/view/treeNodes/descriptionNode.ts b/src/view/treeNodes/descriptionNode.ts index ff008ea628..736a6349fe 100644 --- a/src/view/treeNodes/descriptionNode.ts +++ b/src/view/treeNodes/descriptionNode.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { Repository } from '../../api/api'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { PullRequestModel } from '../../github/pullRequestModel'; import { TreeNode, TreeNodeParent } from './treeNode'; @@ -11,31 +13,37 @@ export class DescriptionNode extends TreeNode implements vscode.TreeItem { public command?: vscode.Command; public contextValue?: string; public tooltip: string; + public iconPath: vscode.ThemeIcon | vscode.Uri | undefined; constructor( public parent: TreeNodeParent, public label: string, - public iconPath: - | string - | vscode.Uri - | { light: string | vscode.Uri; dark: string | vscode.Uri } - | vscode.ThemeIcon, public pullRequestModel: PullRequestModel, + public readonly repository: Repository, + private readonly folderRepositoryManager: FolderRepositoryManager ) { super(); this.command = { - title: 'View Pull Request Description', + title: vscode.l10n.t('View Pull Request Description'), command: 'pr.openDescription', arguments: [this], }; - - this.contextValue = 'description'; - this.tooltip = `Description of pull request #${pullRequestModel.number}`; - this.accessibilityInformation = { label: `Pull request page of pull request number ${pullRequestModel.number}`, role: 'button' }; + this.iconPath = new vscode.ThemeIcon('git-pull-request'); + this.tooltip = vscode.l10n.t('Description of pull request #{0}', pullRequestModel.number); + this.accessibilityInformation = { label: vscode.l10n.t('Pull request page of pull request number {0}', pullRequestModel.number), role: 'button' }; } - getTreeItem(): vscode.TreeItem { + async getTreeItem(): Promise { + this.updateContextValue(); return this; } + + protected updateContextValue(): void { + const currentBranchIsForThisPR = this.pullRequestModel.equals(this.folderRepositoryManager.activePullRequest); + this.contextValue = 'description' + + (currentBranchIsForThisPR ? ':active' : ':nonactive') + + (this.pullRequestModel.hasChangesSinceLastReview ? ':hasChangesSinceReview' : '') + + (this.pullRequestModel.showChangesSinceReview ? ':showingChangesSinceReview' : ':showingAllChanges'); + } } diff --git a/src/view/treeNodes/directoryTreeNode.ts b/src/view/treeNodes/directoryTreeNode.ts index 68d6058330..d0134cabc0 100644 --- a/src/view/treeNodes/directoryTreeNode.ts +++ b/src/view/treeNodes/directoryTreeNode.ts @@ -11,6 +11,7 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { public collapsibleState: vscode.TreeItemCollapsibleState; public children: (RemoteFileChangeNode | InMemFileChangeNode | GitFileChangeNode | DirectoryTreeNode)[] = []; private pathToChild: Map = new Map(); + public checkboxState?: { state: vscode.TreeItemCheckboxState, tooltip: string, accessibilityInformation: vscode.AccessibilityInformation }; constructor(public parent: TreeNodeParent, public label: string) { super(); @@ -58,6 +59,7 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { this.label = this.label.substr(1); } this.children = child.children; + this.children.forEach(child => { child.parent = this; }); } private sort(): void { @@ -87,7 +89,7 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { } public addFile(file: GitFileChangeNode | RemoteFileChangeNode | InMemFileChangeNode): void { - const paths = file.fileName.split('/'); + const paths = file.changeModel.fileName.split('/'); this.addPathRecc(paths, file); } @@ -97,6 +99,7 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { } if (paths.length === 1) { + file.parent = this; this.children.push(file); return; } @@ -114,7 +117,27 @@ export class DirectoryTreeNode extends TreeNode implements vscode.TreeItem { node.addPathRecc(tail, file); } + public allChildrenViewed(): boolean { + for (const child of this.children) { + if (child instanceof DirectoryTreeNode) { + if (!child.allChildrenViewed()) { + return false; + } + } else if (child.checkboxState.state !== vscode.TreeItemCheckboxState.Checked) { + return false; + } + } + return true; + } + + private setCheckboxState(isChecked: boolean) { + this.checkboxState = isChecked ? + { state: vscode.TreeItemCheckboxState.Checked, tooltip: vscode.l10n.t('Mark all files unviewed'), accessibilityInformation: { label: vscode.l10n.t('Mark all files in folder {0} as unviewed', this.label) } } : + { state: vscode.TreeItemCheckboxState.Unchecked, tooltip: vscode.l10n.t('Mark all files viewed'), accessibilityInformation: { label: vscode.l10n.t('Mark all files in folder {0} as viewed', this.label) } }; + } + getTreeItem(): vscode.TreeItem { + this.setCheckboxState(this.allChildrenViewed()); return this; } } diff --git a/src/view/treeNodes/fileChangeNode.ts b/src/view/treeNodes/fileChangeNode.ts index 0b50b0f945..1b2b8ab4c3 100644 --- a/src/view/treeNodes/fileChangeNode.ts +++ b/src/view/treeNodes/fileChangeNode.ts @@ -6,22 +6,22 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { IComment, ViewedState } from '../../common/comment'; -import { DiffHunk, parsePatch } from '../../common/diffHunk'; -import { GitChangeType, InMemFileChange, SimpleFileChange } from '../../common/file'; -import Logger from '../../common/logger'; -import { FILE_LIST_LAYOUT } from '../../common/settingKeys'; -import { asImageDataURI, EMPTY_IMAGE_URI, fromReviewUri, ReviewUriParams, Schemes, toResourceUri } from '../../common/uri'; +import { GitChangeType, InMemFileChange } from '../../common/file'; +import { FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { asTempStorageURI, EMPTY_IMAGE_URI, fromReviewUri, ReviewUriParams, Schemes, toResourceUri } from '../../common/uri'; import { groupBy } from '../../common/utils'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../../github/folderRepositoryManager'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; +import { FileChangeModel, GitFileChangeModel, InMemFileChangeModel, RemoteFileChangeModel } from '../fileChangeModel'; import { DecorationProvider } from '../treeDecorationProvider'; import { TreeNode, TreeNodeParent } from './treeNode'; -export function openFileCommand(uri: vscode.Uri): vscode.Command { +export function openFileCommand(uri: vscode.Uri, inputOpts: vscode.TextDocumentShowOptions = {}): vscode.Command { const activeTextEditor = vscode.window.activeTextEditor; - const opts: vscode.TextDocumentShowOptions = { - preserveFocus: true, - viewColumn: vscode.ViewColumn.Active, + const opts = { + ...inputOpts, ...{ + viewColumn: vscode.ViewColumn.Active, + } }; // Check if active text editor has same path as other editor. we cannot compare via @@ -43,8 +43,8 @@ async function openDiffCommand( opts: vscode.TextDocumentShowOptions | undefined, status: GitChangeType, ): Promise { - let parentURI = (await asImageDataURI(parentFilePath, folderManager.repository)) || parentFilePath; - let headURI = (await asImageDataURI(filePath, folderManager.repository)) || filePath; + let parentURI = (await asTempStorageURI(parentFilePath, folderManager.repository)) || parentFilePath; + let headURI = (await asTempStorageURI(filePath, folderManager.repository)) || filePath; if (parentURI.scheme === 'data' || headURI.scheme === 'data') { if (status === GitChangeType.ADD) { parentURI = EMPTY_IMAGE_URI; @@ -62,81 +62,6 @@ async function openDiffCommand( }; } -/** - * File change node whose content can not be resolved locally and we direct users to GitHub. - */ -export class RemoteFileChangeNode extends TreeNode implements vscode.TreeItem { - public description: string; - public iconPath?: - | string - | vscode.Uri - | { light: string | vscode.Uri; dark: string | vscode.Uri } - | vscode.ThemeIcon; - public command: vscode.Command; - public fileChangeResourceUri: vscode.Uri; - public contextValue: string; - public childrenDisposables: vscode.Disposable[] = []; - private _viewed: ViewedState; - - constructor( - public readonly parent: TreeNodeParent, - public readonly pullRequest: PullRequestModel, - public readonly status: GitChangeType, - public readonly fileName: string, - public readonly previousFileName: string | undefined, - public readonly blobUrl: string, - public readonly filePath: vscode.Uri, - public readonly parentFilePath: vscode.Uri, - ) { - super(); - const viewed = this.pullRequest.fileChangeViewedState[fileName] ?? ViewedState.UNVIEWED; - this.contextValue = `${Schemes.FileChange}:${GitChangeType[status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' - }`; - this.label = path.basename(fileName); - this.description = vscode.workspace.asRelativePath(path.dirname(fileName), false); - if (this.description === '.') { - this.description = ''; - } - this.iconPath = vscode.ThemeIcon.File; - this.fileChangeResourceUri = toResourceUri(vscode.Uri.parse(this.blobUrl), pullRequest.number, fileName, status); - this.updateViewed(viewed); - this.command = { - command: 'pr.openFileOnGitHub', - title: 'Open File on GitHub', - arguments: [this], - }; - - this.childrenDisposables.push( - this.pullRequest.onDidChangeFileViewedState(e => { - const matchingChange = e.changed.find(viewStateChange => viewStateChange.fileName === this.fileName); - if (matchingChange) { - this.updateViewed(matchingChange.viewed); - this.refresh(this); - } - }), - ); - this.accessibilityInformation = { label: `View diffs and comments for file ${this.label}`, role: 'link' }; - } - - get resourceUri(): vscode.Uri { - return this.filePath.with({ query: this.fileChangeResourceUri.query }); - } - - updateViewed(viewed: ViewedState) { - if (this._viewed === viewed) { - return; - } - - this._viewed = viewed; - this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' - }`; - } - - getTreeItem(): vscode.TreeItem { - return this; - } -} - /** * File change node whose content is stored in memory and resolved when being revealed. */ @@ -147,73 +72,60 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem { | { light: string | vscode.Uri; dark: string | vscode.Uri } | vscode.ThemeIcon; public fileChangeResourceUri: vscode.Uri; - public parentSha: string; public contextValue: string; public command: vscode.Command; public opts: vscode.TextDocumentShowOptions; + public checkboxState: { state: vscode.TreeItemCheckboxState; tooltip?: string; accessibilityInformation: vscode.AccessibilityInformation }; + public childrenDisposables: vscode.Disposable[] = []; - private _viewed: ViewedState; get status(): GitChangeType { - return this.change.status; + return this.changeModel.status; } get fileName(): string { - return this.change.fileName; + return this.changeModel.fileName; } get blobUrl(): string | undefined { - return this.change.blobUrl; + return this.changeModel.blobUrl; } - async diffHunks(): Promise { - let diffHunks: DiffHunk[] = []; + get sha(): string | undefined { + return this.changeModel.sha; + } - if (this.change instanceof InMemFileChange) { - diffHunks = this.change.diffHunks; - } else if (this.status !== GitChangeType.RENAME) { - try { - const commit = this.sha ?? this.pullRequest.head.sha; - const patch = await this.pullRequestManager.repository.diffBetween(this.pullRequest.base.sha, commit, this.change.fileName); - diffHunks = parsePatch(patch); - } catch (e) { - Logger.appendLine(`Failed to parse patch for outdated comments: ${e}`); - } - } - return diffHunks; + get tooltip(): string { + return this.resourceUri.fsPath; } constructor( - public readonly parent: TreeNodeParent, + public parent: TreeNodeParent, protected readonly pullRequestManager: FolderRepositoryManager, public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, - protected readonly change: SimpleFileChange, - public readonly filePath: vscode.Uri, - public readonly parentFilePath: vscode.Uri, - public readonly sha?: string, + public readonly changeModel: FileChangeModel ) { super(); - const viewed = this.pullRequest.fileChangeViewedState[this.fileName] ?? ViewedState.UNVIEWED; - this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' + const viewed = this.pullRequest.fileChangeViewedState[this.changeModel.fileName] ?? ViewedState.UNVIEWED; + this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.changeModel.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' }`; - this.label = path.basename(this.fileName); + this.label = path.basename(this.changeModel.fileName); this.iconPath = vscode.ThemeIcon.File; - this.opts = { - preserveFocus: true, - }; + this.opts = {}; this.updateShowOptions(); this.fileChangeResourceUri = toResourceUri( - vscode.Uri.file(this.fileName), + vscode.Uri.file(this.changeModel.fileName), this.pullRequest.number, - this.fileName, - this.status, + this.changeModel.fileName, + this.changeModel.status, + this.changeModel.change instanceof InMemFileChange ? this.changeModel.change.previousFileName : undefined ); this.updateViewed(viewed); this.childrenDisposables.push( this.pullRequest.onDidChangeReviewThreads(e => { - if ([...e.added, ...e.removed].some(thread => thread.path === this.fileName)) { + if ([...e.added, ...e.removed].some(thread => thread.path === this.changeModel.fileName)) { this.updateShowOptions(); } }), @@ -221,7 +133,7 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem { this.childrenDisposables.push( this.pullRequest.onDidChangeFileViewedState(e => { - const matchingChange = e.changed.find(viewStateChange => viewStateChange.fileName === this.fileName); + const matchingChange = e.changed.find(viewStateChange => viewStateChange.fileName === this.changeModel.fileName); if (matchingChange) { this.updateViewed(matchingChange.viewed); this.refresh(this); @@ -229,16 +141,15 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem { }), ); - this.accessibilityInformation = { label: `View diffs and comments for file ${this.label}`, role: 'link' }; } get resourceUri(): vscode.Uri { - return this.filePath.with({ query: this.fileChangeResourceUri.query }); + return this.changeModel.filePath.with({ query: this.fileChangeResourceUri.query }); } get description(): string | true { - const layout = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); if (layout === 'flat') { return true; } else { @@ -247,30 +158,46 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem { } updateViewed(viewed: ViewedState) { - if (this._viewed === viewed) { - return; - } - - this._viewed = viewed; - this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' + this.changeModel.updateViewed(viewed); + this.contextValue = `${Schemes.FileChange}:${GitChangeType[this.changeModel.status]}:${viewed === ViewedState.VIEWED ? 'viewed' : 'unviewed' }`; + this.checkboxState = viewed === ViewedState.VIEWED ? + { state: vscode.TreeItemCheckboxState.Checked, tooltip: vscode.l10n.t('Mark file as unviewed'), accessibilityInformation: { label: vscode.l10n.t('Mark file {0} as unviewed', this.label ?? '') } } : + { state: vscode.TreeItemCheckboxState.Unchecked, tooltip: vscode.l10n.t('Mark file as viewed'), accessibilityInformation: { label: vscode.l10n.t('Mark file {0} as viewed', this.label ?? '') } }; + } + + public async markFileAsViewed(fromCheckboxChanged: boolean = true) { + await this.pullRequest.markFiles([this.fileName], !fromCheckboxChanged, 'viewed'); + this.pullRequestManager.setFileViewedContext(); + } + + public async unmarkFileAsViewed(fromCheckboxChanged: boolean = true) { + await this.pullRequest.markFiles([this.fileName], !fromCheckboxChanged, 'unviewed'); + this.pullRequestManager.setFileViewedContext(); + } + + updateFromCheckboxChanged(newState: vscode.TreeItemCheckboxState) { + const viewed = newState === vscode.TreeItemCheckboxState.Checked ? ViewedState.VIEWED : ViewedState.UNVIEWED; + this.updateViewed(viewed); } updateShowOptions() { const reviewThreads = this.pullRequest.reviewThreadsCache; const reviewThreadsByFile = groupBy(reviewThreads, thread => thread.path); - const reviewThreadsForNode = (reviewThreadsByFile[this.fileName] || []).filter(thread => !thread.isOutdated); + const reviewThreadsForNode = (reviewThreadsByFile[this.changeModel.fileName] || []).filter(thread => !thread.isOutdated); DecorationProvider.updateFileComments( this.fileChangeResourceUri, this.pullRequest.number, - this.fileName, + this.changeModel.fileName, reviewThreadsForNode.length > 0, ); if (reviewThreadsForNode.length) { reviewThreadsForNode.sort((a, b) => a.endLine - b.endLine); - this.opts.selection = new vscode.Range(reviewThreadsForNode[0].startLine, 0, reviewThreadsForNode[0].endLine, 0); + const startLine = reviewThreadsForNode[0].startLine ?? reviewThreadsForNode[0].originalStartLine; + const endLine = reviewThreadsForNode[0].endLine ?? reviewThreadsForNode[0].originalEndLine; + this.opts.selection = new vscode.Range(startLine, 0, endLine, 0); } else { delete this.opts.selection; } @@ -281,21 +208,57 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem { } openFileCommand(): vscode.Command { - return openFileCommand(this.filePath); + return openFileCommand(this.changeModel.filePath); } async openDiff(folderManager: FolderRepositoryManager, opts?: vscode.TextDocumentShowOptions): Promise { const command = await openDiffCommand( folderManager, - this.parentFilePath, - this.filePath, + this.changeModel.parentFilePath, + this.changeModel.filePath, { ...this.opts, ...opts, }, - this.status, + this.changeModel.status, ); - vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); + return vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); + } +} + +/** + * File change node whose content can not be resolved locally and we direct users to GitHub. + */ +export class RemoteFileChangeNode extends FileChangeNode implements vscode.TreeItem { + get description(): string { + let description = vscode.workspace.asRelativePath(path.dirname(this.changeModel.fileName), false); + if (description === '.') { + description = ''; + } + return description; + } + + constructor( + public parent: TreeNodeParent, + folderRepositoryManager: FolderRepositoryManager, + pullRequest: PullRequestModel & IResolvedPullRequestModel, + changeModel: RemoteFileChangeModel + ) { + super(parent, folderRepositoryManager, pullRequest, changeModel); + this.fileChangeResourceUri = toResourceUri(vscode.Uri.parse(changeModel.blobUrl), changeModel.pullRequest.number, changeModel.fileName, changeModel.status, changeModel.previousFileName); + this.command = { + command: 'pr.openFileOnGitHub', + title: 'Open File on GitHub', + arguments: [this], + }; + } + + async openDiff(): Promise { + return vscode.commands.executeCommand(this.command.command); + } + + openFileCommand(): vscode.Command { + return this.command; } } @@ -305,21 +268,15 @@ export class FileChangeNode extends TreeNode implements vscode.TreeItem { export class InMemFileChangeNode extends FileChangeNode implements vscode.TreeItem { constructor( private readonly folderRepositoryManager: FolderRepositoryManager, - public readonly parent: TreeNodeParent, + public parent: TreeNodeParent, public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, - change: SimpleFileChange, - public readonly previousFileName: string | undefined, - public readonly filePath: vscode.Uri, - public readonly parentFilePath: vscode.Uri, - public isPartial: boolean, - public readonly patch: string, - public readonly sha?: string, + public readonly changeModel: InMemFileChangeModel ) { - super(parent, folderRepositoryManager, pullRequest, change, filePath, parentFilePath, sha); + super(parent, folderRepositoryManager, pullRequest, changeModel); } get comments(): IComment[] { - return this.pullRequest.comments.filter(comment => (comment.path === this.change.fileName) && (comment.position !== null)); + return this.pullRequest.comments.filter(comment => (comment.path === this.changeModel.fileName) && (comment.position !== null)); } getTreeItem(): vscode.TreeItem { @@ -327,13 +284,17 @@ export class InMemFileChangeNode extends FileChangeNode implements vscode.TreeIt } async resolve(): Promise { - this.command = await openDiffCommand( - this.folderRepositoryManager, - this.parentFilePath, - this.filePath, - undefined, - this.status, - ); + if (this.status === GitChangeType.ADD) { + this.command = openFileCommand(this.changeModel.filePath); + } else { + this.command = await openDiffCommand( + this.folderRepositoryManager, + this.changeModel.parentFilePath, + this.changeModel.filePath, + undefined, + this.changeModel.status, + ); + } } } @@ -342,17 +303,14 @@ export class InMemFileChangeNode extends FileChangeNode implements vscode.TreeIt */ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem { constructor( - public readonly parent: TreeNodeParent, + public parent: TreeNodeParent, pullRequestManager: FolderRepositoryManager, public readonly pullRequest: PullRequestModel & IResolvedPullRequestModel, - change: SimpleFileChange, - public readonly filePath: vscode.Uri, - public readonly parentFilePath: vscode.Uri, - public readonly sha?: string, + public readonly changeModel: GitFileChangeModel, private isCurrent?: boolean, private _comments?: IComment[] ) { - super(parent, pullRequestManager, pullRequest, change, filePath, parentFilePath, sha); + super(parent, pullRequestManager, pullRequest, changeModel); } get comments(): IComment[] { @@ -362,9 +320,9 @@ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem // if there's a commit sha, then the comment must belong to the commit. return this.pullRequest.comments.filter(comment => { if (!this.sha || this.sha === this.pullRequest.head.sha) { - return comment.position && (comment.path === this.change.fileName); + return comment.position && (comment.path === this.changeModel.fileName); } else { - return (comment.path === this.change.fileName) && (comment.originalCommitId === this.sha); + return (comment.path === this.changeModel.fileName) && (comment.originalCommitId === this.sha); } }); } @@ -377,7 +335,7 @@ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem private async alternateCommand(): Promise { if (this.status === GitChangeType.DELETE || this.status === GitChangeType.ADD) { // create an empty `review` uri without any path/commit info. - const emptyFileUri = this.parentFilePath.with({ + const emptyFileUri = this.changeModel.parentFilePath.with({ query: JSON.stringify({ path: null, commit: null, @@ -388,14 +346,14 @@ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem command: 'vscode.diff', arguments: this.status === GitChangeType.DELETE - ? [this.parentFilePath, emptyFileUri, `${this.fileName}`, { preserveFocus: true }] - : [emptyFileUri, this.parentFilePath, `${this.fileName}`, { preserveFocus: true }], + ? [this.changeModel.parentFilePath, emptyFileUri, `${this.fileName}`, {}] + : [emptyFileUri, this.changeModel.parentFilePath, `${this.fileName}`, {}], title: 'Open Diff', }; } // Show the file change in a diff view. - const { path: filePath, ref, commit, rootPath } = fromReviewUri(this.filePath.query); + const { path: filePath, ref, commit, rootPath } = fromReviewUri(this.changeModel.filePath.query); const previousCommit = `${commit}^`; const query: ReviewUriParams = { path: filePath, @@ -405,17 +363,15 @@ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem isOutdated: true, rootPath, }; - const previousFileUri = this.filePath.with({ query: JSON.stringify(query) }); - let currentFilePath = this.filePath; + const previousFileUri = this.changeModel.filePath.with({ query: JSON.stringify(query) }); + let currentFilePath = this.changeModel.filePath; // If the commit is the most recent/current commit, then we just use the current file for the right. // This is so that comments display properly. if (this.isCurrent) { currentFilePath = this.pullRequestManager.repository.rootUri.with({ path: path.posix.join(query.rootPath, query.path) }); } - const options: vscode.TextDocumentShowOptions = { - preserveFocus: true, - }; + const options: vscode.TextDocumentShowOptions = {}; const reviewThreads = this.pullRequest.reviewThreadsCache; const reviewThreadsByFile = groupBy(reviewThreads, t => t.path); @@ -443,12 +399,12 @@ export class GitFileChangeNode extends FileChangeNode implements vscode.TreeItem if (this._useViewChangesCommand) { this.command = await this.alternateCommand(); } else { - const openDiff = vscode.workspace.getConfiguration().get('git.openDiffOnClick', true); - if (openDiff) { + const openDiff = vscode.workspace.getConfiguration(GIT, this.pullRequestManager.repository.rootUri).get(OPEN_DIFF_ON_CLICK, true); + if (openDiff && this.status !== GitChangeType.ADD) { this.command = await openDiffCommand( this.pullRequestManager, - this.parentFilePath, - this.filePath, + this.changeModel.parentFilePath, + this.changeModel.filePath, this.opts, this.status, ); @@ -476,41 +432,43 @@ export class GitHubFileChangeNode extends TreeNode implements vscode.TreeItem { public readonly status: GitChangeType, public readonly baseBranch: string, public readonly headBranch: string, + public readonly isLocal: boolean ) { super(); + const scheme = isLocal ? Schemes.GitPr : Schemes.GithubPr; this.label = fileName; this.iconPath = vscode.ThemeIcon.File; this.fileChangeResourceUri = vscode.Uri.file(fileName).with({ - scheme: Schemes.GithubPr, + scheme, query: JSON.stringify({ status, fileName }), }); let parentURI = vscode.Uri.file(fileName).with({ - scheme: Schemes.GithubPr, + scheme, query: JSON.stringify({ fileName, branch: baseBranch }), }); let headURI = vscode.Uri.file(fileName).with({ - scheme: Schemes.GithubPr, + scheme, query: JSON.stringify({ fileName, branch: headBranch }), }); switch (status) { case GitChangeType.ADD: parentURI = vscode.Uri.file(fileName).with({ - scheme: Schemes.GithubPr, + scheme, query: JSON.stringify({ fileName, branch: baseBranch, isEmpty: true }), }); break; case GitChangeType.RENAME: parentURI = vscode.Uri.file(previousFileName!).with({ - scheme: Schemes.GithubPr, + scheme, query: JSON.stringify({ fileName: previousFileName, branch: baseBranch, isEmpty: true }), }); break; case GitChangeType.DELETE: headURI = vscode.Uri.file(fileName).with({ - scheme: Schemes.GithubPr, + scheme, query: JSON.stringify({ fileName, branch: headBranch, isEmpty: true }), }); break; diff --git a/src/view/treeNodes/filesCategoryNode.ts b/src/view/treeNodes/filesCategoryNode.ts index d807f6dd99..625bca40d9 100644 --- a/src/view/treeNodes/filesCategoryNode.ts +++ b/src/view/treeNodes/filesCategoryNode.ts @@ -5,13 +5,14 @@ import * as vscode from 'vscode'; import Logger, { PR_TREE } from '../../common/logger'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; import { PullRequestModel } from '../../github/pullRequestModel'; import { ReviewModel } from '../reviewModel'; import { DirectoryTreeNode } from './directoryTreeNode'; -import { TreeNode, TreeNodeParent } from './treeNode'; +import { LabelOnlyNode, TreeNode, TreeNodeParent } from './treeNode'; export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { - public label: string = 'Files'; + public label: string = vscode.l10n.t('Files'); public collapsibleState: vscode.TreeItemCollapsibleState; private directories: TreeNode[] = []; @@ -42,8 +43,10 @@ export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { } async getChildren(): Promise { + super.getChildren(); + Logger.appendLine(`Getting children for Files node`, PR_TREE); - if (this._reviewModel.localFileChanges.length === 0) { + if (!this._reviewModel.hasLocalFileChanges) { // Provide loading feedback until we get the files. return new Promise(resolve => { const promiseResolver = this._reviewModel.onDidChangeLocalFileChanges(() => { @@ -53,8 +56,12 @@ export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { }); } + if (this._reviewModel.localFileChanges.length === 0) { + return [new LabelOnlyNode(vscode.l10n.t('No changed files'))]; + } + let nodes: TreeNode[]; - const layout = vscode.workspace.getConfiguration('githubPullRequests').get('fileListLayout'); + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); const dirNode = new DirectoryTreeNode(this, ''); this._reviewModel.localFileChanges.forEach(f => dirNode.addFile(f)); @@ -72,6 +79,7 @@ export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { nodes = this._reviewModel.localFileChanges; } Logger.appendLine(`Got all children for Files node`, PR_TREE); - return Promise.resolve(nodes); + this.children = nodes; + return nodes; } } diff --git a/src/view/treeNodes/pullRequestNode.ts b/src/view/treeNodes/pullRequestNode.ts index 5f5ef23cf7..0365c8bd92 100644 --- a/src/view/treeNodes/pullRequestNode.ts +++ b/src/view/treeNodes/pullRequestNode.ts @@ -4,21 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { Repository } from '../../api/api'; import { getCommentingRanges } from '../../common/commentingRanges'; -import { DiffChangeType, getModifiedContentFromDiffHunk } from '../../common/diffHunk'; -import { GitChangeType, SlimFileChange } from '../../common/file'; +import { InMemFileChange, SlimFileChange } from '../../common/file'; import Logger from '../../common/logger'; -import { FILE_LIST_LAYOUT } from '../../common/settingKeys'; -import { fromPRUri, resolvePath, Schemes, toPRUri, toReviewUri } from '../../common/uri'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../../github/folderRepositoryManager'; +import { FILE_LIST_LAYOUT, PR_SETTINGS_NAMESPACE, SHOW_PULL_REQUEST_NUMBER_IN_TREE } from '../../common/settingKeys'; +import { createPRNodeUri, DataUri, fromPRUri, Schemes } from '../../common/uri'; +import { dispose } from '../../common/utils'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { NotificationProvider } from '../../github/notifications'; import { IResolvedPullRequestModel, PullRequestModel } from '../../github/pullRequestModel'; -import { getInMemPRFileSystemProvider } from '../inMemPRContentProvider'; +import { InMemFileChangeModel, RemoteFileChangeModel } from '../fileChangeModel'; +import { getInMemPRFileSystemProvider, provideDocumentContentForChangeModel } from '../inMemPRContentProvider'; import { DescriptionNode } from './descriptionNode'; import { DirectoryTreeNode } from './directoryTreeNode'; import { InMemFileChangeNode, RemoteFileChangeNode } from './fileChangeNode'; import { TreeNode, TreeNodeParent } from './treeNode'; -export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { +export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 { static ID = 'PRNode'; private _fileChanges: (RemoteFileChangeNode | InMemFileChangeNode)[] | undefined; @@ -37,56 +40,62 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { this._command = newCommand; } + public get repository(): Repository { + return this._folderReposManager.repository; + } + constructor( public parent: TreeNodeParent, private _folderReposManager: FolderRepositoryManager, public pullRequestModel: PullRequestModel, private _isLocal: boolean, + private _notificationProvider: NotificationProvider ) { super(); + this.registerSinceReviewChange(); + this.registerConfigurationChange(); + this._disposables.push(this.pullRequestModel.onDidInvalidate(() => this.refresh(this))); + this._disposables.push(this._folderReposManager.onDidChangeActivePullRequest(e => { + if (e.new === this.pullRequestModel.number || e.old === this.pullRequestModel.number) { + this.refresh(this); + } + })); } // #region Tree async getChildren(): Promise { + super.getChildren(); Logger.debug(`Fetch children of PRNode #${this.pullRequestModel.number}`, PRNode.ID); - this.pullRequestModel.onDidInvalidate(() => this.refresh(this)); try { - if (this.childrenDisposables && this.childrenDisposables.length) { - this.childrenDisposables.forEach(dp => dp.dispose()); - this.childrenDisposables = []; - } - const descriptionNode = new DescriptionNode( this, - 'Description', - new vscode.ThemeIcon('git-pull-request'), + vscode.l10n.t('Description'), this.pullRequestModel, + this.repository, + this._folderReposManager ); if (!this.pullRequestModel.isResolved()) { return [descriptionNode]; } - await this.pullRequestModel.initializeReviewThreadCache(); - await this.pullRequestModel.initializePullRequestFileViewState(); - this._fileChanges = await this.resolveFileChanges(); + [, this._fileChanges, ,] = await Promise.all([ + this.pullRequestModel.initializePullRequestFileViewState(), + this.resolveFileChangeNodes(), + (!this._commentController) ? this.resolvePRCommentController() : new Promise(resolve => resolve()), + this.pullRequestModel.validateDraftMode() + ]); if (!this._inMemPRContentProvider) { - this._inMemPRContentProvider = getInMemPRFileSystemProvider().registerTextDocumentContentProvider( + this._inMemPRContentProvider = getInMemPRFileSystemProvider()?.registerTextDocumentContentProvider( this.pullRequestModel.number, this.provideDocumentContent.bind(this), ); } - if (!this._commentController) { - await this.resolvePRCommentController(); - } - - await this.pullRequestModel.validateDraftMode(); - const result: TreeNode[] = [descriptionNode]; - const layout = vscode.workspace.getConfiguration(SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); + const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); if (layout === 'tree') { // tree view const dirNode = new DirectoryTreeNode(this, ''); @@ -103,14 +112,75 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { result.push(...this._fileChanges); } - this.childrenDisposables = result; + if (this.pullRequestModel.showChangesSinceReview !== undefined) { + this.reopenNewPrDiffs(this.pullRequestModel); + } + + this.children = result; + + // Kick off review thread initialization but don't await it. + // Events will be fired later that will cause the tree to update when this is ready. + this.pullRequestModel.initializeReviewThreadCache(); + return result; } catch (e) { - Logger.appendLine(e); + Logger.error(e); return []; } } + protected registerSinceReviewChange() { + this._disposables.push( + this.pullRequestModel.onDidChangeChangesSinceReview(_ => { + this.refresh(this); + }) + ); + } + + protected registerConfigurationChange() { + this._disposables.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${SHOW_PULL_REQUEST_NUMBER_IN_TREE}`)) { + this.refresh(); + } + }) + ); + } + + public async reopenNewPrDiffs(pullRequest: PullRequestModel) { + let hasOpenDiff: boolean = false; + vscode.window.tabGroups.all.map(tabGroup => { + tabGroup.tabs.map(tab => { + if ( + tab.input instanceof vscode.TabInputTextDiff && + tab.input.original.scheme === Schemes.Pr && + tab.input.modified.scheme === Schemes.Pr && + this._fileChanges + ) { + for (const localChange of this._fileChanges) { + + const originalParams = fromPRUri(tab.input.original); + const modifiedParams = fromPRUri(tab.input.modified); + const newLocalChangeParams = fromPRUri(localChange.changeModel.filePath); + if ( + originalParams?.prNumber === pullRequest.number && + modifiedParams?.prNumber === pullRequest.number && + localChange.fileName === modifiedParams.fileName && + newLocalChangeParams?.headCommit !== modifiedParams.headCommit + ) { + hasOpenDiff = true; + vscode.window.tabGroups.close(tab).then(_ => localChange.openDiff(this._folderReposManager, { preview: tab.isPreview })); + break; + } + } + } + }); + }); + if (pullRequest.showChangesSinceReview && !hasOpenDiff && this._fileChanges && this._fileChanges.length && !pullRequest.isActive) { + this._fileChanges[0].openDiff(this._folderReposManager, { preview: true }); + } + } + private async resolvePRCommentController(): Promise { // If the current branch is this PR's branch, then we can rely on the review comment controller instead. if (this.pullRequestModel.equals(this._folderReposManager.activePullRequest)) { @@ -152,147 +222,108 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { ); } - private async getFileChanges(): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { - if (!this._fileChanges) { - this._fileChanges = await this.resolveFileChanges(); + public async getFileChanges(noCache: boolean | void): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { + if (!this._fileChanges || noCache) { + this._fileChanges = await this.resolveFileChangeNodes(); } return this._fileChanges; } - private async resolveFileChanges(): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { + private async resolveFileChangeNodes(): Promise<(RemoteFileChangeNode | InMemFileChangeNode)[]> { if (!this.pullRequestModel.isResolved()) { return []; } // If this PR is the the current PR, then we should be careful to use // URIs that will cause the review comment controller to be used. + const rawChanges: (SlimFileChange | InMemFileChange)[] = []; const isCurrentPR = this.pullRequestModel.equals(this._folderReposManager.activePullRequest); + if (isCurrentPR && (this._folderReposManager.activePullRequest !== undefined) && (this._folderReposManager.activePullRequest.fileChanges.size > 0)) { + this.pullRequestModel = this._folderReposManager.activePullRequest; + rawChanges.push(...this._folderReposManager.activePullRequest.fileChanges.values()); + } else { + rawChanges.push(...await this.pullRequestModel.getFileChangesInfo()); + } - const rawChanges = await this.pullRequestModel.getFileChangesInfo(this._folderReposManager.repository); - - // Merge base is set as part of getPullRequestFileChangesInfo + // Merge base is set as part of getFileChangesInfo const mergeBase = this.pullRequestModel.mergeBase; if (!mergeBase) { return []; } return rawChanges.map(change => { - const headCommit = this.pullRequestModel.head!.sha; - const parentFileName = change.status === GitChangeType.RENAME ? change.previousFileName! : change.fileName; if (change instanceof SlimFileChange) { + const changeModel = new RemoteFileChangeModel(this._folderReposManager, change, this.pullRequestModel); return new RemoteFileChangeNode( this, - this.pullRequestModel, - change.status, - change.fileName, - change.previousFileName, - change.blobUrl, - toPRUri( - vscode.Uri.file( - resolvePath(this._folderReposManager.repository.rootUri, change.fileName), - ), - this.pullRequestModel, - change.baseCommit, - headCommit, - change.fileName, - false, - change.status, - ), - toPRUri( - vscode.Uri.file( - resolvePath(this._folderReposManager.repository.rootUri, parentFileName), - ), - this.pullRequestModel, - change.baseCommit, - headCommit, - change.fileName, - true, - change.status, - ), + this._folderReposManager, + this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), + changeModel ); } - const filePath = vscode.Uri.file(resolvePath(this._folderReposManager.repository.rootUri, change.fileName)); - const parentPath = vscode.Uri.file(resolvePath(this._folderReposManager.repository.rootUri, parentFileName)); + const changeModel = new InMemFileChangeModel(this._folderReposManager, + this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), + change, isCurrentPR, mergeBase); const changedItem = new InMemFileChangeNode( this._folderReposManager, this, this.pullRequestModel as (PullRequestModel & IResolvedPullRequestModel), - change, - change.previousFileName, - isCurrentPR ? ((change.status === GitChangeType.DELETE) - ? toReviewUri(filePath, undefined, undefined, '', false, { base: false }, this._folderReposManager.repository.rootUri) - : filePath) - : toPRUri( - filePath, - this.pullRequestModel, - change.baseCommit, - headCommit, - change.fileName, - false, - change.status, - ), - isCurrentPR ? (toReviewUri( - parentPath, - change.status === GitChangeType.RENAME ? change.previousFileName : change.fileName, - undefined, - change.status === GitChangeType.ADD ? '' : mergeBase, - false, - { base: true }, - this._folderReposManager.repository.rootUri, - )) : toPRUri( - parentPath, - this.pullRequestModel, - change.baseCommit, - headCommit, - change.fileName, - true, - change.status, - ), - change.isPartial, - change.patch + changeModel ); return changedItem; }); } - getTreeItem(): vscode.TreeItem { + async getTreeItem(): Promise { const currentBranchIsForThisPR = this.pullRequestModel.equals(this._folderReposManager.activePullRequest); const { title, number, author, isDraft, html_url } = this.pullRequestModel; const { login } = author; - const labelPrefix = currentBranchIsForThisPR ? '✓ ' : ''; - const tooltipPrefix = currentBranchIsForThisPR ? 'Current Branch * ' : ''; + const hasNotification = this._notificationProvider.hasNotification(this.pullRequestModel); + const formattedPRNumber = number.toString(); - const label = `${labelPrefix}#${formattedPRNumber}: ${isDraft ? '[DRAFT] ' : ''}${title}`; + let labelPrefix = currentBranchIsForThisPR ? '✓ ' : ''; + let tooltipPrefix = currentBranchIsForThisPR ? 'Current Branch * ' : ''; + + if ( + vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(SHOW_PULL_REQUEST_NUMBER_IN_TREE, false) + ) { + labelPrefix += `#${formattedPRNumber}: `; + tooltipPrefix += `#${formattedPRNumber}: `; + } + + const label = `${labelPrefix}${isDraft ? '[DRAFT] ' : ''}${title}`; const tooltip = `${tooltipPrefix}${title} by @${login}`; const description = `by @${login}`; return { label, - id: `${this.parent instanceof TreeNode ? this.parent.label : ''}${html_url}`, // unique id stable across checkout status + id: `${this.parent instanceof TreeNode ? (this.parent.id ?? this.parent.label) : ''}${html_url}${this._isLocal ? this.pullRequestModel.localBranchName : ''}`, // unique id stable across checkout status tooltip, description, collapsibleState: 1, contextValue: - 'pullrequest' + (this._isLocal ? ':local' : '') + (currentBranchIsForThisPR ? ':active' : ':nonactive'), - iconPath: this.pullRequestModel.userAvatarUri - ? this.pullRequestModel.userAvatarUri - : new vscode.ThemeIcon('github'), + 'pullrequest' + + (this._isLocal ? ':local' : '') + + (currentBranchIsForThisPR ? ':active' : ':nonactive') + + (hasNotification ? ':notification' : ''), + iconPath: (await DataUri.avatarCirclesAsImageDataUris(this._folderReposManager.context, [this.pullRequestModel.author], 16, 16))[0] + ?? new vscode.ThemeIcon('github'), accessibilityInformation: { label: `${isDraft ? 'Draft ' : ''}Pull request number ${formattedPRNumber}: ${title} by ${login}` - } + }, + resourceUri: createPRNodeUri(this.pullRequestModel), }; } - async provideCommentingRanges( - document: vscode.TextDocument, - _token: vscode.CancellationToken, - ): Promise { + async provideCommentingRanges(document: vscode.TextDocument, _token: vscode.CancellationToken): Promise { if (document.uri.scheme === Schemes.Pr) { const params = fromPRUri(document.uri); @@ -300,13 +331,13 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { return undefined; } - const fileChange = (await this.getFileChanges()).find(change => change.fileName === params.fileName); + const fileChange = (await this.getFileChanges()).find(change => change.changeModel.fileName === params.fileName); if (!fileChange || fileChange instanceof RemoteFileChangeNode) { return undefined; } - return getCommentingRanges(await fileChange.diffHunks(), params.isBase); + return { ranges: getCommentingRanges(await fileChange.changeModel.diffHunks(), params.isBase, PRNode.ID), fileComments: true }; } return undefined; @@ -320,112 +351,15 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { } const fileChange = (await this.getFileChanges()).find( - contentChange => contentChange.fileName === params.fileName, - ); - if (!fileChange) { - Logger.appendLine(`PR> can not find content for document ${uri.toString()}`); - return ''; - } + contentChange => contentChange.changeModel.fileName === params.fileName, + )?.changeModel; - if ( - (params.isBase && fileChange.status === GitChangeType.ADD) || - (!params.isBase && fileChange.status === GitChangeType.DELETE) - ) { + if (!fileChange) { + Logger.appendLine(`Can not find content for document ${uri.toString()}`, 'PR'); return ''; } - if (fileChange instanceof RemoteFileChangeNode || fileChange.isPartial) { - try { - if (params.isBase) { - return this.pullRequestModel.getFile( - fileChange.previousFileName || fileChange.fileName, - params.baseCommit, - ); - } else { - return this.pullRequestModel.getFile(fileChange.fileName, params.headCommit); - } - } catch (e) { - Logger.appendLine(`PR> Fetching file content failed: ${e}`); - vscode.window - .showWarningMessage( - 'Opening this file locally failed. Would you like to view it on GitHub?', - 'Open on GitHub', - ) - .then(result => { - if ((result === 'Open on GitHub') && fileChange.blobUrl) { - vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(fileChange.blobUrl)); - } - }); - return ''; - } - } - - if (fileChange instanceof InMemFileChangeNode) { - const readContentFromDiffHunk = - fileChange.status === GitChangeType.ADD || fileChange.status === GitChangeType.DELETE; - - if (readContentFromDiffHunk) { - if (params.isBase) { - // left - const left: string[] = []; - const diffHunks = await fileChange.diffHunks(); - for (let i = 0; i < diffHunks.length; i++) { - for (let j = 0; j < diffHunks[i].diffLines.length; j++) { - const diffLine = diffHunks[i].diffLines[j]; - if (diffLine.type === DiffChangeType.Add) { - // nothing - } else if (diffLine.type === DiffChangeType.Delete) { - left.push(diffLine.text); - } else if (diffLine.type === DiffChangeType.Control) { - // nothing - } else { - left.push(diffLine.text); - } - } - } - - return left.join('\n'); - } else { - const right: string[] = []; - const diffHunks = await fileChange.diffHunks(); - for (let i = 0; i < diffHunks.length; i++) { - for (let j = 0; j < diffHunks[i].diffLines.length; j++) { - const diffLine = diffHunks[i].diffLines[j]; - if (diffLine.type === DiffChangeType.Add) { - right.push(diffLine.text); - } else if (diffLine.type === DiffChangeType.Delete) { - // nothing - } else if (diffLine.type === DiffChangeType.Control) { - // nothing - } else { - right.push(diffLine.text); - } - } - } - - return right.join('\n'); - } - } else { - const originalFileName = - fileChange.status === GitChangeType.RENAME ? fileChange.previousFileName : fileChange.fileName; - const originalFilePath = vscode.Uri.joinPath( - this._folderReposManager.repository.rootUri, - originalFileName!, - ); - const originalContent = await this._folderReposManager.repository.show( - params.baseCommit, - originalFilePath.fsPath, - ); - - if (params.isBase) { - return originalContent; - } else { - return getModifiedContentFromDiffHunk(originalContent, fileChange.patch); - } - } - } - - return ''; + return provideDocumentContentForChangeModel(this._folderReposManager, this.pullRequestModel, params, fileChange); } dispose(): void { @@ -437,7 +371,7 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider { this._commentController = undefined; - this._disposables.forEach(d => d.dispose()); + dispose(this._disposables); this._disposables = []; } } diff --git a/src/view/treeNodes/repositoryChangesNode.ts b/src/view/treeNodes/repositoryChangesNode.ts index 6f270f6a99..d5f497c985 100644 --- a/src/view/treeNodes/repositoryChangesNode.ts +++ b/src/view/treeNodes/repositoryChangesNode.ts @@ -5,8 +5,11 @@ import * as vscode from 'vscode'; import Logger, { PR_TREE } from '../../common/logger'; +import { AUTO_REVEAL, EXPLORER } from '../../common/settingKeys'; +import { DataUri, Schemes } from '../../common/uri'; import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { PullRequestModel } from '../../github/pullRequestModel'; +import { ProgressHelper } from '../progress'; import { ReviewModel } from '../reviewModel'; import { CommitsNode } from './commitsCategoryNode'; import { DescriptionNode } from './descriptionNode'; @@ -16,6 +19,7 @@ import { BaseTreeNode, TreeNode } from './treeNode'; export class RepositoryChangesNode extends DescriptionNode implements vscode.TreeItem { private _filesCategoryNode?: FilesCategoryNode; private _commitsCategoryNode?: CommitsNode; + public description?: string; readonly collapsibleState = vscode.TreeItemCollapsibleState.Expanded; private _disposables: vscode.Disposable[] = []; @@ -25,14 +29,23 @@ export class RepositoryChangesNode extends DescriptionNode implements vscode.Tre private _pullRequest: PullRequestModel, private _pullRequestManager: FolderRepositoryManager, private _reviewModel: ReviewModel, + private _progress: ProgressHelper ) { - super(parent, _pullRequest.title, _pullRequest.userAvatarUri!, _pullRequest); + super(parent, _pullRequest.title, _pullRequest, _pullRequestManager.repository, _pullRequestManager); // Cause tree values to be filled this.getTreeItem(); this._disposables.push( vscode.window.onDidChangeActiveTextEditor(e => { - if (vscode.workspace.getConfiguration('explorer').get('autoReveal')) { + if (vscode.workspace.getConfiguration(EXPLORER).get(AUTO_REVEAL)) { + const tabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input; + if (tabInput instanceof vscode.TabInputTextDiff) { + if ((tabInput.original.scheme === Schemes.Review) + && (tabInput.modified.scheme !== Schemes.Review) + && (tabInput.original.path.startsWith('/commit'))) { + return; + } + } const activeEditorUri = e?.document.uri.toString(); this.revealActiveEditorInTree(activeEditorUri); } @@ -53,7 +66,7 @@ export class RepositoryChangesNode extends DescriptionNode implements vscode.Tre private revealActiveEditorInTree(activeEditorUri: string | undefined): void { if (this.parent.view.visible && activeEditorUri) { - const matchingFile = this._reviewModel.localFileChanges.find(change => change.filePath.toString() === activeEditorUri); + const matchingFile = this._reviewModel.localFileChanges.find(change => change.changeModel.filePath.toString() === activeEditorUri); if (matchingFile) { this.reveal(matchingFile, { select: true }); } @@ -61,6 +74,7 @@ export class RepositoryChangesNode extends DescriptionNode implements vscode.Tre } async getChildren(): Promise { + await this._progress.progress; if (!this._filesCategoryNode || !this._commitsCategoryNode) { Logger.appendLine(`Creating file and commit nodes for PR #${this.pullRequestModel.number}`, PR_TREE); this._filesCategoryNode = new FilesCategoryNode(this.parent, this._reviewModel, this._pullRequest); @@ -70,11 +84,29 @@ export class RepositoryChangesNode extends DescriptionNode implements vscode.Tre this._pullRequest, ); } - return [this._filesCategoryNode, this._commitsCategoryNode]; + this.children = [this._filesCategoryNode, this._commitsCategoryNode]; + return this.children; } - getTreeItem(): vscode.TreeItem { + async getTreeItem(): Promise { this.label = this._pullRequest.title; + this.iconPath = (await DataUri.avatarCirclesAsImageDataUris(this._pullRequestManager.context, [this._pullRequest.author], 16, 16))[0]; + this.description = undefined; + if (this.parent.children?.length && this.parent.children.length > 1) { + const allSameOwner = this.parent.children.every(child => { + return child instanceof RepositoryChangesNode && child.pullRequestModel.remote.owner === this.pullRequestModel.remote.owner; + }); + if (allSameOwner) { + this.description = this._pullRequest.remote.repositoryName; + } else { + this.description = `${this._pullRequest.remote.owner}/${this._pullRequest.remote.repositoryName}`; + } + if (this.label.length > 35) { + this.tooltip = this.label; + this.label = `${this.label.substring(0, 35)}...`; + } + } + this.updateContextValue(); return this; } diff --git a/src/view/treeNodes/treeNode.ts b/src/view/treeNodes/treeNode.ts index 64386c2d30..9ded97902d 100644 --- a/src/view/treeNodes/treeNode.ts +++ b/src/view/treeNodes/treeNode.ts @@ -5,23 +5,29 @@ import * as vscode from 'vscode'; import Logger from '../../common/logger'; +import { dispose } from '../../common/utils'; export interface BaseTreeNode { reveal(element: TreeNode, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }): Thenable; refresh(treeNode?: TreeNode): void; + children: TreeNode[] | undefined; view: vscode.TreeView; } export type TreeNodeParent = TreeNode | BaseTreeNode; +export const EXPANDED_QUERIES_STATE = 'expandedQueries'; + export abstract class TreeNode implements vscode.Disposable { + protected children: TreeNode[] | undefined; childrenDisposables: vscode.Disposable[]; parent: TreeNodeParent; label?: string; accessibilityInformation?: vscode.AccessibilityInformation; + id?: string; - constructor() {} - abstract getTreeItem(): vscode.TreeItem; + constructor() { } + abstract getTreeItem(): vscode.TreeItem | Promise; getParent(): TreeNode | undefined { if (this.parent instanceof TreeNode) { return this.parent; @@ -35,7 +41,7 @@ export abstract class TreeNode implements vscode.Disposable { try { await this.parent.reveal(treeNode || this, options); } catch (e) { - Logger.appendLine(e, 'TreeNode'); + Logger.error(e, 'TreeNode'); } } @@ -43,14 +49,39 @@ export abstract class TreeNode implements vscode.Disposable { return this.parent.refresh(treeNode); } + async cachedChildren(): Promise { + if (this.children && this.children.length) { + return this.children; + } + return this.getChildren(); + } + async getChildren(): Promise { + if (this.children && this.children.length) { + dispose(this.children); + this.children = []; + } return []; } + updateFromCheckboxChanged(_newState: vscode.TreeItemCheckboxState): void { } + dispose(): void { if (this.childrenDisposables) { - this.childrenDisposables.forEach(dispose => dispose.dispose()); + dispose(this.childrenDisposables); this.childrenDisposables = []; } } } + +export class LabelOnlyNode extends TreeNode { + public readonly label: string = ''; + constructor(label: string) { + super(); + this.label = label; + } + getTreeItem(): vscode.TreeItem { + return new vscode.TreeItem(this.label); + } + +} \ No newline at end of file diff --git a/src/view/treeNodes/treeUtils.ts b/src/view/treeNodes/treeUtils.ts new file mode 100644 index 0000000000..aac0523229 --- /dev/null +++ b/src/view/treeNodes/treeUtils.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { FileChangeNode } from './fileChangeNode'; +import { TreeNode } from './treeNode'; + +export namespace TreeUtils { + export function processCheckboxUpdates(checkboxUpdates: vscode.TreeCheckboxChangeEvent) { + const checkedNodes: FileChangeNode[] = []; + const uncheckedNodes: FileChangeNode[] = []; + + checkboxUpdates.items.forEach(checkboxUpdate => { + const node = checkboxUpdate[0]; + const newState = checkboxUpdate[1]; + + if (node instanceof FileChangeNode) { + if (newState == vscode.TreeItemCheckboxState.Checked) { + checkedNodes.push(node); + } else { + uncheckedNodes.push(node); + } + } + + node.updateFromCheckboxChanged(newState); + }); + + if (checkedNodes.length > 0) { + const prModel = checkedNodes[0].pullRequest; + const filenames = checkedNodes.map(n => n.fileName); + prModel.markFiles(filenames, true, 'viewed'); + } + if (uncheckedNodes.length > 0) { + const prModel = uncheckedNodes[0].pullRequest; + const filenames = uncheckedNodes.map(n => n.fileName); + prModel.markFiles(filenames, true, 'unviewed'); + } + } +} \ No newline at end of file diff --git a/src/view/treeNodes/workspaceFolderNode.ts b/src/view/treeNodes/workspaceFolderNode.ts index 4dd8716935..2ceed1cacb 100644 --- a/src/view/treeNodes/workspaceFolderNode.ts +++ b/src/view/treeNodes/workspaceFolderNode.ts @@ -5,40 +5,58 @@ import * as path from 'path'; import * as vscode from 'vscode'; +import { PR_SETTINGS_NAMESPACE, QUERIES } from '../../common/settingKeys'; import { ITelemetry } from '../../common/telemetry'; -import { FolderRepositoryManager, SETTINGS_NAMESPACE } from '../../github/folderRepositoryManager'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; import { PRType } from '../../github/interface'; +import { NotificationProvider } from '../../github/notifications'; +import { PullRequestModel } from '../../github/pullRequestModel'; +import { PrsTreeModel } from '../prsTreeModel'; import { CategoryTreeNode } from './categoryNode'; -import { TreeNode, TreeNodeParent } from './treeNode'; +import { EXPANDED_QUERIES_STATE, TreeNode, TreeNodeParent } from './treeNode'; export interface IQueryInfo { label: string; query: string; } -export const QUERIES_SETTING = 'queries'; - export class WorkspaceFolderNode extends TreeNode implements vscode.TreeItem { + protected children: CategoryTreeNode[] | undefined = undefined; public collapsibleState: vscode.TreeItemCollapsibleState; public iconPath?: { light: string | vscode.Uri; dark: string | vscode.Uri }; constructor( parent: TreeNodeParent, uri: vscode.Uri, - private folderManager: FolderRepositoryManager, + public readonly folderManager: FolderRepositoryManager, private telemetry: ITelemetry, + private notificationProvider: NotificationProvider, + private context: vscode.ExtensionContext, + private readonly _prsTreeModel: PrsTreeModel ) { super(); this.parent = parent; this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; this.label = path.basename(uri.fsPath); + this.id = folderManager.repository.rootUri.toString(); + } + + public async expandPullRequest(pullRequest: PullRequestModel): Promise { + if (this.children) { + for (const child of this.children) { + if (child.type === PRType.All) { + return child.expandPullRequest(pullRequest); + } + } + } + return false; } private static getQueries(folderManager: FolderRepositoryManager): IQueryInfo[] { return ( vscode.workspace - .getConfiguration(SETTINGS_NAMESPACE, folderManager.repository.rootUri) - .get(QUERIES_SETTING) || [] + .getConfiguration(PR_SETTINGS_NAMESPACE, folderManager.repository.rootUri) + .get(QUERIES) || [] ); } @@ -47,22 +65,29 @@ export class WorkspaceFolderNode extends TreeNode implements vscode.TreeItem { } async getChildren(): Promise { - return WorkspaceFolderNode.getCategoryTreeNodes(this.folderManager, this.telemetry, this); + super.getChildren(); + this.children = WorkspaceFolderNode.getCategoryTreeNodes(this.folderManager, this.telemetry, this, this.notificationProvider, this.context, this._prsTreeModel); + return this.children; } public static getCategoryTreeNodes( folderManager: FolderRepositoryManager, telemetry: ITelemetry, parent: TreeNodeParent, + notificationProvider: NotificationProvider, + context: vscode.ExtensionContext, + prsTreeModel: PrsTreeModel ) { + const expandedQueries = new Set(context.workspaceState.get(EXPANDED_QUERIES_STATE, [] as string[])); + const queryCategories = WorkspaceFolderNode.getQueries(folderManager).map( queryInfo => - new CategoryTreeNode(parent, folderManager, telemetry, PRType.Query, queryInfo.label, queryInfo.query), + new CategoryTreeNode(parent, folderManager, telemetry, PRType.Query, notificationProvider, expandedQueries, prsTreeModel, queryInfo.label, queryInfo.query), ); return [ - new CategoryTreeNode(parent, folderManager, telemetry, PRType.LocalPullRequest), + new CategoryTreeNode(parent, folderManager, telemetry, PRType.LocalPullRequest, notificationProvider, expandedQueries, prsTreeModel), ...queryCategories, - new CategoryTreeNode(parent, folderManager, telemetry, PRType.All), + new CategoryTreeNode(parent, folderManager, telemetry, PRType.All, notificationProvider, expandedQueries, prsTreeModel), ]; } } diff --git a/src/view/webviewViewCoordinator.ts b/src/view/webviewViewCoordinator.ts new file mode 100644 index 0000000000..f72786c81f --- /dev/null +++ b/src/view/webviewViewCoordinator.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { dispose } from '../common/utils'; +import { PullRequestViewProvider } from '../github/activityBarViewProvider'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { ReviewManager } from './reviewManager'; + +export class WebviewViewCoordinator implements vscode.Disposable { + private _webviewViewProvider?: PullRequestViewProvider; + private _pullRequestModel: Map = new Map(); + private _disposables: vscode.Disposable[] = []; + + constructor(private _context: vscode.ExtensionContext) { } + + dispose() { + dispose(this._disposables); + this._disposables = []; + this._webviewViewProvider?.dispose(); + this._webviewViewProvider = undefined; + } + + private create(pullRequestModel: PullRequestModel, folderRepositoryManager: FolderRepositoryManager, reviewManager: ReviewManager) { + this._webviewViewProvider = new PullRequestViewProvider(this._context.extensionUri, folderRepositoryManager, reviewManager, pullRequestModel); + this._disposables.push( + vscode.window.registerWebviewViewProvider( + this._webviewViewProvider.viewType, + this._webviewViewProvider, + ), + ); + this._disposables.push( + vscode.commands.registerCommand('pr.refreshActivePullRequest', _ => { + this._webviewViewProvider?.refresh(); + }), + ); + } + + public setPullRequest(pullRequestModel: PullRequestModel, folderRepositoryManager: FolderRepositoryManager, reviewManager: ReviewManager, replace?: PullRequestModel) { + if (replace) { + this._pullRequestModel.delete(replace); + } + this._pullRequestModel.set(pullRequestModel, { folderRepositoryManager, reviewManager }); + this.updatePullRequest(); + } + + private updatePullRequest() { + const pullRequestModel = Array.from(this._pullRequestModel.keys())[0]; + if (!pullRequestModel) { + this.dispose(); + return; + } + const { folderRepositoryManager, reviewManager } = this._pullRequestModel.get(pullRequestModel)!; + if (!this._webviewViewProvider) { + this.create(pullRequestModel, folderRepositoryManager, reviewManager); + } else { + this._webviewViewProvider.updatePullRequest(pullRequestModel); + } + } + + public removePullRequest(pullReqestModel: PullRequestModel) { + const oldHead = Array.from(this._pullRequestModel.keys())[0]; + this._pullRequestModel.delete(pullReqestModel); + const newHead = Array.from(this._pullRequestModel.keys())[0]; + if (newHead !== oldHead) { + this.updatePullRequest(); + } + } + + public show(pullReqestModel: PullRequestModel) { + if (this._webviewViewProvider && (this._pullRequestModel.size > 0) && (Array.from(this._pullRequestModel.keys())[0] === pullReqestModel)) { + this._webviewViewProvider.show(); + } + } +} \ No newline at end of file diff --git a/tsfmt.json b/tsfmt.json new file mode 100644 index 0000000000..17c26bb085 --- /dev/null +++ b/tsfmt.json @@ -0,0 +1,17 @@ +{ + "tabSize": 4, + "indentSize": 4, + "convertTabsToSpaces": false, + "insertSpaceAfterCommaDelimiter": true, + "insertSpaceAfterSemicolonInForStatements": true, + "insertSpaceBeforeAndAfterBinaryOperators": true, + "insertSpaceAfterKeywordsInControlFlowStatements": true, + "insertSpaceAfterFunctionKeywordForAnonymousFunctions": true, + "insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false, + "insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": false, + "insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": false, + "insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": true, + "insertSpaceBeforeFunctionParenthesis": false, + "placeOpenBraceOnNewLineForFunctions": false, + "placeOpenBraceOnNewLineForControlBlocks": false +} diff --git a/webpack.config.js b/webpack.config.js index 537bd7c213..680bd441aa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -53,6 +53,9 @@ async function getWebviewConfig(mode, env, entry) { * @type WebpackConfig['plugins'] | any */ const plugins = [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1 + }), new ForkTsCheckerPlugin({ async: false, eslint: { @@ -159,6 +162,9 @@ async function getExtensionConfig(target, mode, env) { * @type WebpackConfig['plugins'] | any */ const plugins = [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1 + }), new ForkTsCheckerPlugin({ async: false, eslint: { @@ -270,7 +276,7 @@ async function getExtensionConfig(target, mode, env) { // // // // We should either fix or remove that package, then remove this rule, // // which introduces nonstandard behavior for mjs files, which are - // // terrible. This is all terrible. Everything is terrible.👇🏾 + // // terrible. This is all terrible. Everything is terrible. // { // test: /\.mjs$/, // include: /node_modules/, @@ -339,6 +345,8 @@ async function getExtensionConfig(target, mode, env) { // 'encoding': 'encoding', 'applicationinsights-native-metrics': 'applicationinsights-native-metrics', '@opentelemetry/tracing': '@opentelemetry/tracing', + '@opentelemetry/instrumentation': '@opentelemetry/instrumentation', + '@azure/opentelemetry-instrumentation-azure-sdk': '@azure/opentelemetry-instrumentation-azure-sdk', 'fs': 'fs', }, plugins: plugins, @@ -374,7 +382,7 @@ module.exports = getWebviewConfig(mode, env, { 'webview-pr-description': './webviews/editorWebview/index.ts', 'webview-open-pr-view': './webviews/activityBarView/index.ts', - 'webview-create-pr-view': './webviews/createPullRequestView/index.ts', + 'webview-create-pr-view-new': './webviews/createPullRequestViewNew/index.ts', }), ]); }; diff --git a/webviews/activityBarView/app.tsx b/webviews/activityBarView/app.tsx index 36b8800106..5d31ff9574 100644 --- a/webviews/activityBarView/app.tsx +++ b/webviews/activityBarView/app.tsx @@ -5,7 +5,7 @@ import React, { useContext, useEffect, useState } from 'react'; import { render } from 'react-dom'; -import { PullRequest } from '../common/cache'; +import { PullRequest } from '../../src/github/views'; import PullRequestContext from '../common/context'; import { Overview } from './overview'; diff --git a/webviews/activityBarView/exit.tsx b/webviews/activityBarView/exit.tsx index ec6328d9d3..9a4b7e4b15 100644 --- a/webviews/activityBarView/exit.tsx +++ b/webviews/activityBarView/exit.tsx @@ -5,19 +5,19 @@ import React, { useContext, useState } from 'react'; import { GithubItemStateEnum } from '../../src/github/interface'; -import { PullRequest } from '../common/cache'; +import { PullRequest } from '../../src/github/views'; import PullRequestContext from '../common/context'; -const ExitButton = ({ isBusy, onClick }: { isBusy: boolean, onClick: () => Promise }) => { +const ExitButton = ({ repositoryDefaultBranch, isBusy, onClick }: { repositoryDefaultBranch: string, isBusy: boolean, onClick: () => Promise }) => { return (); }; -const ExitLink = ({ onClick }: { onClick: () => Promise }) => { +const ExitLink = ({ repositoryDefaultBranch, onClick }: { repositoryDefaultBranch: string, onClick: () => Promise }) => { return ( - Exit review mode without deleting branch + Checkout '{repositoryDefaultBranch}' without deleting branch ); }; @@ -39,8 +39,8 @@ export const ExitSection = ({ pr }: { pr: PullRequest }) => {
{ pr.state === GithubItemStateEnum.Open ? - - : + + : }
); diff --git a/webviews/activityBarView/index.css b/webviews/activityBarView/index.css index 3aaf7bbb3b..071ba0c5bc 100644 --- a/webviews/activityBarView/index.css +++ b/webviews/activityBarView/index.css @@ -10,6 +10,7 @@ body { textarea { min-height: 80px; max-height: 500px; + border-radius: 2px; } .form-actions { @@ -22,30 +23,46 @@ textarea { width: 16px; } +.reviewer-icons .icon svg { + margin-right: 0px; +} + +.reviewer-icons { + margin-left: 20px; +} + #status-checks { - padding-top: 20px; - padding-bottom: 10px; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 0; +} + +.comment-form { + padding-bottom: 16px; } .status-section { - display: flex; - padding: 10px 0; + padding-bottom: 16px; } -.select-container { +.ready-for-review-container { display: flex; flex-direction: column; - width: 100%; - min-width: 155px; + gap: 12px; + padding-bottom: 16px; } -.ready-for-review-button { - width: 100%; +.ready-for-review-heading, +.status-section p { + line-height: 1.5em; + margin: 0; } -.ready-for-review-container, -.select-control { - padding-bottom: 10px; +.select-container { + display: flex; + flex-direction: column; + width: 100%; } .select-control, @@ -89,6 +106,7 @@ input[type='submit']:focus { } .select-control button, +.branch-status-container button, input[type='submit'] { min-height: 31px; white-space: normal; @@ -115,6 +133,7 @@ button .icon svg { background-color: var(--vscode-dropdown-background); color: var(--vscode-dropdown-foreground); text-align: start; + white-space: break-spaces; } .options-select button:hover, @@ -153,4 +172,46 @@ button .icon svg { #status-checks .branch-status-icon { display: flex; padding-top: 1px; +} + +.comment-button { + display: flex; + flex-grow: 1; + min-width: 0; +} + +button.split-left { + border-radius: 2px 0 0 2px; + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + + +.split { + width: 1px; height: 100%; + background-color: var(--vscode-button-background); + opacity: 0.5; +} + +button.split-right { + border-radius: 0 2px 2px 0; + cursor: pointer; + width: 24px; height: 28px; + position: relative; +} + +button.split-right:disabled { + cursor: default; +} + +button.split-right .icon { + pointer-events: none; + position: absolute; + top: 6px; right: 4px; +} + +button.split-right .icon svg path { + fill: unset; } \ No newline at end of file diff --git a/webviews/activityBarView/overview.tsx b/webviews/activityBarView/overview.tsx index 3b0e3afb4c..f427a792ff 100644 --- a/webviews/activityBarView/overview.tsx +++ b/webviews/activityBarView/overview.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import React from 'react'; -import { PullRequest } from '../common/cache'; +import { PullRequest } from '../../src/github/views'; import { AddCommentSimple } from '../components/comment'; import { StatusChecksSection } from '../components/merge'; diff --git a/webviews/common/cache.ts b/webviews/common/cache.ts index 8e07fa6cfb..4ab516e9e0 100644 --- a/webviews/common/cache.ts +++ b/webviews/common/cache.ts @@ -3,71 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { TimelineEvent } from '../../src/common/timelineEvent'; -import { - GithubItemStateEnum, - IAccount, - ILabel, - IMilestone, - MergeMethod, - MergeMethodsAvailability, - PullRequestChecks, - PullRequestMergeability, - ReviewState, -} from '../../src/github/interface'; +import { PullRequest } from '../../src/github/views'; import { vscode } from './message'; -export enum ReviewType { - Comment = 'comment', - Approve = 'approve', - RequestChanges = 'requestChanges', -} - -export interface PullRequest { - number: number; - title: string; - url: string; - createdAt: string; - body: string; - bodyHTML?: string; - author: IAccount; - state: GithubItemStateEnum; - events: TimelineEvent[]; - isCurrentlyCheckedOut: boolean; - isRemoteBaseDeleted?: boolean; - base: string; - isRemoteHeadDeleted?: boolean; - isLocalHeadDeleted?: boolean; - head: string; - labels: ILabel[]; - assignees: IAccount[]; - commitsCount: number; - milestone: IMilestone; - repositoryDefaultBranch: any; - /** - * User can edit PR title and description (author or user with push access) - */ - canEdit: boolean; - /** - * Users with push access to repo have rights to merge/close PRs, - * edit title/description, assign reviewers/labels etc. - */ - hasWritePermission: boolean; - pendingCommentText?: string; - pendingCommentDrafts?: { [key: string]: string }; - pendingReviewType?: ReviewType; - status: PullRequestChecks; - mergeable: PullRequestMergeability; - defaultMergeMethod: MergeMethod; - mergeMethodsAvailability: MergeMethodsAvailability; - reviewers: ReviewState[]; - isDraft?: boolean; - isIssue: boolean; - isAuthor?: boolean; - continueOnGitHub: boolean; - currentUserReviewState: string; -} - export function getState(): PullRequest { return vscode.getState(); } diff --git a/webviews/common/common.css b/webviews/common/common.css index d47b67061c..317649306c 100644 --- a/webviews/common/common.css +++ b/webviews/common/common.css @@ -10,12 +10,12 @@ body a { body a:hover { text-decoration: underline; } + button, input[type='submit'] { - background-color: var(--vscode-button-background); color: var(--vscode-button-foreground); font-family: var(--vscode-font-family); - border-radius: 0px; + border-radius: 2px; border: 1px solid transparent; outline: none; padding: 4px 12px; @@ -25,12 +25,31 @@ input[type='submit'] { user-select: none; } +button:not(.icon-button), +input[type='submit'] { + background-color: var(--vscode-button-background); +} + +input.select-left { + border-radius: 2px 0 0 2px; +} + +button.select-right { + border-radius: 0 2px 2px 0; +} + button:focus, input[type='submit']:focus { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; } +button:focus-within, +input[type='submit']:focus-within { + border: 1px solid transparent; + outline: 1px solid transparent; +} + button:hover:enabled, button:focus:enabled, input[type='submit']:focus:enabled, @@ -60,6 +79,7 @@ input[type='text'] { background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); font-family: var(--vscode-font-family); + border-radius: 2px; } textarea::placeholder, @@ -71,7 +91,7 @@ select { display: block; box-sizing: border-box; padding: 4px 8px; - border-radius: 0; + border-radius: 2px; font-size: 13px; border: 1px solid var(--vscode-dropdown-border); background-color: var(--vscode-dropdown-background); @@ -112,8 +132,8 @@ body .hidden { body img.avatar, body span.avatar-icon svg { - width: 24px; - height: 24px; + width: 20px; + height: 20px; border-radius: 50%; } @@ -125,6 +145,37 @@ body img.avatar { flex-shrink: 0; } +.icon-button { + display: flex; + padding: 2px; + background: transparent; + border-radius: 4px; + line-height: 0; +} + +.icon-button:hover, +.section .icon-button:hover, +.section .icon-button:focus { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.icon-button:focus, +.section .icon-button:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: unset; +} + +.label .icon-button:hover, +.label .icon-button:focus { + background-color: transparent; +} + +.section-item { + display: flex; + align-items: center; + justify-content: space-between; +} + .section-item .avatar-link { margin-right: 8px; } @@ -138,35 +189,98 @@ body img.avatar { flex-shrink: 0; } -.section-item { - margin-bottom: 8px; +.section-item img.avatar { + width: 20px; + height: 20px; +} + +.section-icon { display: flex; align-items: center; + justify-content: center; + padding: 3px; } -.section-item img.avatar { - width: 18px; - height: 18px; +.section-icon.changes svg path { + fill: var(--vscode-list-errorForeground); +} + +.section-icon.commented svg path, +.section-icon.requested svg path { + fill: var(--vscode-list-warningForeground); +} + +.section-icon.approved svg path { + fill: var(--vscode-issues-open); +} + +.reviewer-icons { + display: flex; + gap: 4px; } .push-right { margin-left: auto; } +.avatar-with-author { + display: flex; + align-items: center; +} + .author-link { - font-weight: bolder; + font-weight: 600; color: var(--vscode-editor-foreground); } -.section-item { - margin-right: 8px; +.automerge-section { + display: flex; +} + +#status-checks .automerge-section { + align-items: center; + padding: 16px; + background: var(--vscode-editorHoverWidget-background); + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +} + +.automerge-section .merge-select-container { + margin-left: 8px; +} + +.automerge-checkbox-wrapper, +.automerge-checkbox-label { + display: flex; + align-items: center; + margin-right: 4px; +} + +.automerge-checkbox-label { + min-width: 80px; +} + +.merge-queue-title .merge-queue-pending { + color: var(--vscode-list-warningForeground); +} + +.merge-queue-title .merge-queue-blocked { + color: var(--vscode-list-errorForeground); +} + +.merge-queue-title { + font-weight: bold; + font-size: larger; } /** Theming */ +.vscode-high-contrast button:not(.secondary):not(.icon-button) { + background: var(--vscode-button-background); +} + .vscode-high-contrast button { outline: none; - background: var(--vscode-button-background); border: 1px solid var(--vscode-contrastBorder); } @@ -187,3 +301,69 @@ body img.avatar { ::-webkit-scrollbar-corner { display: none; } + +.labels-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.label { + display: flex; + justify-content: normal; + padding: 0 8px; + border-radius: 20px; + border-style: solid; + border-width: 1px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 11px; + line-height: 18px; + font-weight: 600; +} + +/* split button */ + +.primary-split-button { + display: flex; + flex-grow: 1; + min-width: 0; +} + +button.split-left { + border-radius: 2px 0 0 2px; + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.split { + width: 1px; + height: 100%; + background-color: var(--vscode-button-background); + opacity: 0.5; +} + +button.split-right { + border-radius: 0 2px 2px 0; + cursor: pointer; + width: 24px; + height: 28px; + position: relative; +} + +button.split-right:disabled { + cursor: default; +} + +button.split-right .icon { + pointer-events: none; + position: absolute; + top: 6px; + right: 4px; +} + +button.split-right .icon svg path { + fill: unset; +} \ No newline at end of file diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index 2de8bb82f8..e08909f6d9 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -5,23 +5,27 @@ import { createContext } from 'react'; import { IComment } from '../../src/common/comment'; -import { EventType, isReviewEvent, ReviewEvent } from '../../src/common/timelineEvent'; -import { MergeMethod, ReviewState } from '../../src/github/interface'; -import { getState, PullRequest, setState, updateState } from './cache'; +import { EventType, ReviewEvent, TimelineEvent } from '../../src/common/timelineEvent'; +import { IProjectItem, MergeMethod, ReviewState } from '../../src/github/interface'; +import { ProjectItemsReply, PullRequest } from '../../src/github/views'; +import { getState, setState, updateState } from './cache'; import { getMessageHandler, MessageHandler } from './message'; export class PRContext { constructor( public pr: PullRequest = getState(), public onchange: ((ctx: PullRequest) => void) | null = null, - private _handler: MessageHandler = null, + private _handler: MessageHandler | null = null, ) { if (!_handler) { this._handler = getMessageHandler(this.handleMessage); } } - public setTitle = (title: string) => this.postMessage({ command: 'pr.edit-title', args: { text: title } }); + public setTitle = async (title: string) => { + const result = await this.postMessage({ command: 'pr.edit-title', args: { text: title } }); + this.updatePR({ titleHTML: result.titleHTML }); + }; public setDescription = (description: string) => this.postMessage({ command: 'pr.edit-description', args: { text: description } }); @@ -30,6 +34,8 @@ export class PRContext { public copyPrLink = () => this.postMessage({ command: 'pr.copy-prlink' }); + public copyVscodeDevLink = () => this.postMessage({ command: 'pr.copy-vscodedevlink' }); + public exitReviewMode = async () => { if (!this.pr) { return; @@ -40,11 +46,13 @@ export class PRContext { }); }; + public gotoChangesSinceReview = () => this.postMessage({ command: 'pr.gotoChangesSinceReview' }); + public refresh = () => this.postMessage({ command: 'pr.refresh' }); public checkMergeability = () => this.postMessage({ command: 'pr.checkMergeability' }); - public merge = (args: { title: string; description: string; method: MergeMethod }) => + public merge = (args: { title: string | undefined; description: string | undefined; method: MergeMethod }) => this.postMessage({ command: 'pr.merge', args }); public openOnGitHub = () => this.postMessage({ command: 'pr.openOnGitHub' }); @@ -63,11 +71,15 @@ export class PRContext { }); }; - public addReviewers = () => this.postMessage({ command: 'pr.add-reviewers' }); + public addReviewers = () => this.postMessage({ command: 'pr.change-reviewers' }); + public changeProjects = (): Promise => this.postMessage({ command: 'pr.change-projects' }); + public removeProject = (project: IProjectItem) => this.postMessage({ command: 'pr.remove-project', args: project }); public addMilestone = () => this.postMessage({ command: 'pr.add-milestone' }); public removeMilestone = () => this.postMessage({ command: 'pr.remove-milestone' }); - public addAssignees = () => this.postMessage({ command: 'pr.add-assignees' }); + public addAssignees = () => this.postMessage({ command: 'pr.change-assignees' }); + public addAssigneeYourself = () => this.postMessage({ command: 'pr.add-assignee-yourself' }); public addLabels = () => this.postMessage({ command: 'pr.add-labels' }); + public create = () => this.postMessage({ command: 'pr.open-create' }); public deleteComment = async (args: { id: number; pullRequestReviewId?: number }) => { await this.postMessage({ command: 'pr.delete-comment', args }); @@ -126,18 +138,6 @@ export class PRContext { } }; - public removeReviewer = async (login: string) => { - await this.postMessage({ command: 'pr.remove-reviewer', args: login }); - const reviewers = this.pr.reviewers.filter(r => r.reviewer.login !== login); - this.updatePR({ reviewers }); - }; - - public removeAssignee = async (login: string) => { - await this.postMessage({ command: 'pr.remove-assignee', args: login }); - const assignees = this.pr.assignees.filter(a => a.login !== login); - this.updatePR({ assignees }); - }; - public removeLabel = async (label: string) => { await this.postMessage({ command: 'pr.remove-label', args: label }); const labels = this.pr.labels.filter(r => r.name !== label); @@ -148,22 +148,74 @@ export class PRContext { this.postMessage({ command: 'pr.apply-patch', args: { comment } }); }; - private appendReview({ review, reviewers }: {review: ReviewEvent, reviewers: ReviewState[]}) { + private appendReview({ review, reviewers }: { review?: ReviewEvent, reviewers?: ReviewState[] }) { const state = this.pr; - const events = state.events.filter(e => !isReviewEvent(e) || e.state.toLowerCase() !== 'pending'); + state.busy = false; + if (!review || !reviewers) { + this.updatePR(state); + return; + } + const events = state.events.filter(e => e.event !== EventType.Reviewed || e.state.toLowerCase() !== 'pending'); events.forEach(event => { - if (isReviewEvent(event)) { + if (event.event === EventType.Reviewed) { event.comments.forEach(c => (c.isDraft = false)); } }); state.reviewers = reviewers; - state.events = [...state.events.filter(e => (isReviewEvent(e) ? e.state !== 'PENDING' : e)), review]; + state.events = [...state.events.filter(e => (e.event === EventType.Reviewed ? e.state !== 'PENDING' : e)), review]; state.currentUserReviewState = review.state; this.updatePR(state); } + public reRequestReview = async (reviewerId: string) => { + const { reviewers } = await this.postMessage({ command: 'pr.re-request-review', args: reviewerId }); + const state = this.pr; + state.reviewers = reviewers; + this.updatePR(state); + } + + public async updateAutoMerge({ autoMerge, autoMergeMethod }: { autoMerge?: boolean, autoMergeMethod?: MergeMethod }) { + const response: { autoMerge: boolean, autoMergeMethod?: MergeMethod } = await this.postMessage({ command: 'pr.update-automerge', args: { autoMerge, autoMergeMethod } }); + const state = this.pr; + state.autoMerge = response.autoMerge; + state.autoMergeMethod = response.autoMergeMethod; + this.updatePR(state); + } + + public dequeue = async () => { + const isDequeued = await this.postMessage({ command: 'pr.dequeue' }); + const state = this.pr; + if (isDequeued) { + state.mergeQueueEntry = undefined; + } + this.updatePR(state); + } + + public enqueue = async () => { + const result = await this.postMessage({ command: 'pr.enqueue' }); + const state = this.pr; + if (result.mergeQueueEntry) { + state.mergeQueueEntry = result.mergeQueueEntry; + } + this.updatePR(state); + } + public openDiff = (comment: IComment) => this.postMessage({ command: 'pr.open-diff', args: { comment } }); + public toggleResolveComment = (threadId: string, thread: IComment[], newResolved: boolean) => { + this.postMessage({ + command: 'pr.resolve-comment-thread', + args: { threadId: threadId, toResolve: newResolved, thread } + }).then((timelineEvents: TimelineEvent[] | undefined) => { + if (timelineEvents) { + this.updatePR({ events: timelineEvents }); + } + else { + this.refresh(); + } + }); + }; + setPR = (pr: PullRequest) => { this.pr = pr; setState(this.pr); @@ -183,7 +235,7 @@ export class PRContext { }; postMessage(message: any) { - return this._handler.postMessage(message); + return (this._handler?.postMessage(message) ?? Promise.resolve(undefined)); } handleMessage = (message: any) => { @@ -215,6 +267,10 @@ export class PRContext { pendingReview.scrollIntoView(); } return; + case 'pr.submitting-review': + return this.updatePR({ busy: true, lastReviewType: message.lastReviewType }); + case 'pr.append-review': + return this.appendReview(message); } }; diff --git a/webviews/common/createContext.ts b/webviews/common/createContext.ts deleted file mode 100644 index bd24608e49..0000000000 --- a/webviews/common/createContext.ts +++ /dev/null @@ -1,212 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { createContext } from 'react'; -import { CreateParams, ScrollPosition } from '../../common/views'; -import { getMessageHandler, MessageHandler, vscode } from './message'; - -const defaultCreateParams: CreateParams = { - availableBaseRemotes: [], - availableCompareRemotes: [], - branchesForRemote: [], - branchesForCompare: [], - validate: false, - showTitleValidationError: false, - isDraft: false, -}; - -export class CreatePRContext { - constructor( - public createParams: CreateParams = { ...defaultCreateParams, ...vscode.getState() }, - public onchange: ((ctx: CreateParams) => void) | null = null, - private _handler: MessageHandler = null, - ) { - if (!_handler) { - this._handler = getMessageHandler(this.handleMessage); - } - } - - public cancelCreate = (): Promise => { - vscode.setState(defaultCreateParams); - return this.postMessage({ command: 'pr.cancelCreate' }); - }; - - public updateState = (params: Partial): void => { - this.createParams = { ...this.createParams, ...params }; - vscode.setState(this.createParams); - if (this.onchange) { - this.onchange(this.createParams); - } - }; - - public changeBaseRemote = async (owner: string, repositoryName: string): Promise => { - const response = await this.postMessage({ - command: 'pr.changeBaseRemote', - args: { - owner, - repositoryName, - }, - }); - - this.updateState({ - baseRemote: { owner, repositoryName }, - branchesForRemote: response.branches, - baseBranch: response.defaultBranch, - }); - }; - - public changeBaseBranch = async (branch: string): Promise => { - const response: { title?: string, description?: string } = await this.postMessage({ - command: 'pr.changeBaseBranch', - args: branch - }); - - const pendingTitle = (!this.createParams.pendingTitle || (this.createParams.pendingTitle === this.createParams.defaultTitle)) - ? response.title : this.createParams.pendingTitle; - const pendingDescription = (!this.createParams.pendingDescription || (this.createParams.pendingDescription === this.createParams.defaultDescription)) - ? response.description : this.createParams.pendingDescription; - - this.updateState({ - pendingTitle, - pendingDescription - }); - }; - - public changeCompareRemote = async (owner: string, repositoryName: string): Promise => { - const response = await this.postMessage({ - command: 'pr.changeCompareRemote', - args: { - owner, - repositoryName, - }, - }); - - this.updateState({ - compareRemote: { owner, repositoryName }, - branchesForCompare: response.branches, - compareBranch: response.defaultBranch, - }); - }; - - public changeCompareBranch = async (branch: string): Promise => { - return this.postMessage({ command: 'pr.changeCompareBranch', args: branch }); - }; - - public validate = (): boolean => { - let isValid = true; - if (!this.createParams.pendingTitle) { - this.updateState({ showTitleValidationError: true }); - isValid = false; - } - - this.updateState({ validate: true, createError: undefined }); - - return isValid; - }; - - public submit = async (): Promise => { - try { - await this.postMessage({ - command: 'pr.create', - args: { - title: this.createParams.pendingTitle, - body: this.createParams.pendingDescription, - owner: this.createParams.baseRemote.owner, - repo: this.createParams.baseRemote.repositoryName, - base: this.createParams.baseBranch, - compareBranch: this.createParams.compareBranch, - compareOwner: this.createParams.compareRemote.owner, - compareRepo: this.createParams.compareRemote.repositoryName, - draft: this.createParams.isDraft, - }, - }); - vscode.setState(defaultCreateParams); - } catch (e) { - this.updateState({ createError: (typeof e === 'string') ? e : (e.message ? e.message : 'An unknown error occurred.') }); - } - }; - - postMessage = (message: any): Promise => { - return this._handler.postMessage(message); - }; - - handleMessage = async (message: {command: string, params?: CreateParams, scrollPosition?: ScrollPosition}): Promise => { - switch (message.command) { - case 'pr.initialize': - if (!message.params) { - return; - } - if (this.createParams.pendingTitle === undefined) { - message.params.pendingTitle = message.params.defaultTitle; - } - - if (this.createParams.pendingDescription === undefined) { - message.params.pendingDescription = message.params.defaultDescription; - } - - if (this.createParams.baseRemote === undefined) { - message.params.baseRemote = message.params.defaultBaseRemote; - } else { - // Notify the extension of the stored selected remote state - await this.changeBaseRemote( - this.createParams.baseRemote.owner, - this.createParams.baseRemote.repositoryName, - ); - } - - if (this.createParams.baseBranch === undefined) { - message.params.baseBranch = message.params.defaultBaseBranch; - } else { - // Notify the extension of the stored base branch state - await this.changeBaseBranch(this.createParams.baseBranch); - } - - if (this.createParams.compareRemote === undefined) { - message.params.compareRemote = message.params.defaultCompareRemote; - } else { - // Notify the extension of the stored base branch state This is where master is getting set. - await this.changeCompareRemote( - this.createParams.compareRemote.owner, - this.createParams.compareRemote.repositoryName - ); - } - - if (this.createParams.compareBranch === undefined) { - message.params.compareBranch = message.params.defaultCompareBranch; - } else { - // Notify the extension of the stored compare branch state - await this.changeCompareBranch(this.createParams.compareBranch); - } - - this.updateState(message.params); - return; - - case 'reset': - if (!message.params) { - return; - } - message.params.pendingTitle = message.params.defaultTitle; - message.params.pendingDescription = message.params.defaultDescription; - message.params.baseRemote = message.params.defaultBaseRemote; - message.params.baseBranch = message.params.defaultBaseBranch; - message.params.compareBranch = message.params.defaultCompareBranch; - message.params.compareRemote = message.params.defaultCompareRemote; - this.updateState(message.params); - return; - - case 'set-scroll': - if (!message.scrollPosition) { - return; - } - window.scrollTo(message.scrollPosition.x, message.scrollPosition.y); - return; - } - }; - - public static instance = new CreatePRContext(); -} - -const PullRequestContext = createContext(CreatePRContext.instance); -export default PullRequestContext; diff --git a/webviews/common/createContextNew.ts b/webviews/common/createContextNew.ts new file mode 100644 index 0000000000..b505c569b2 --- /dev/null +++ b/webviews/common/createContextNew.ts @@ -0,0 +1,333 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContext } from 'react'; +import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, RemoteInfo, ScrollPosition, TitleAndDescriptionArgs, TitleAndDescriptionResult } from '../../common/views'; +import { getMessageHandler, MessageHandler, vscode } from './message'; + +const defaultCreateParams: CreateParamsNew = { + defaultBaseRemote: undefined, + defaultBaseBranch: undefined, + defaultCompareRemote: undefined, + defaultCompareBranch: undefined, + validate: false, + showTitleValidationError: false, + labels: [], + isDraftDefault: false, + autoMergeDefault: false, + assignees: [], + reviewers: [], + milestone: undefined, + defaultTitle: undefined, + pendingTitle: undefined, + defaultDescription: undefined, + pendingDescription: undefined, + creating: false, + generateTitleAndDescriptionTitle: undefined, + initializeWithGeneratedTitleAndDescription: false, + baseHasMergeQueue: false +}; + +export class CreatePRContextNew { + public createParams: CreateParamsNew; + private _titleStack: string[] = []; + private _descriptionStack: string[] = []; + + constructor( + public onchange: ((ctx: CreateParamsNew) => void) | null = null, + private _handler: MessageHandler | null = null, + ) { + this.createParams = vscode.getState() ?? defaultCreateParams; + if (!_handler) { + this._handler = getMessageHandler(this.handleMessage); + } + } + + get initialized(): boolean { + if (this.createParams.defaultBaseRemote !== undefined + || this.createParams.defaultBaseBranch !== undefined + || this.createParams.defaultCompareRemote !== undefined + || this.createParams.defaultCompareBranch !== undefined + || this.createParams.validate + || this.createParams.showTitleValidationError) { + return true; + } + + return false; + } + + private _requestedInitialize = false; + public initialize = async (): Promise => { + if (!this._requestedInitialize) { + this._requestedInitialize = true; + this.postMessage({ command: 'pr.requestInitialize' }); + } + }; + + public cancelCreate = (): Promise => { + const args = this.copyParams(); + vscode.setState(defaultCreateParams); + return this.postMessage({ command: 'pr.cancelCreate', args }); + }; + + public updateState = (params: Partial, reset: boolean = false): void => { + this.createParams = reset ? { ...defaultCreateParams, ...params } : { ...this.createParams, ...params }; + vscode.setState(this.createParams); + if (this.onchange) { + this.onchange(this.createParams); + } + }; + + public changeBaseRemoteAndBranch = async (currentRemote?: RemoteInfo, currentBranch?: string): Promise => { + const args: ChooseRemoteAndBranchArgs = { + currentRemote, + currentBranch + }; + const response: ChooseBaseRemoteAndBranchResult = await this.postMessage({ + command: 'pr.changeBaseRemoteAndBranch', + args + }); + + const updateValues: Partial = { + baseRemote: response.baseRemote, + baseBranch: response.baseBranch, + createError: '' + }; + if ((this.createParams.baseRemote?.owner !== response.baseRemote.owner) || (this.createParams.baseRemote.repositoryName !== response.baseRemote.repositoryName)) { + updateValues.defaultMergeMethod = response.defaultMergeMethod; + updateValues.allowAutoMerge = response.allowAutoMerge; + updateValues.mergeMethodsAvailability = response.mergeMethodsAvailability; + updateValues.autoMergeDefault = response.autoMergeDefault; + updateValues.baseHasMergeQueue = response.baseHasMergeQueue; + if (!this.createParams.allowAutoMerge && updateValues.allowAutoMerge) { + updateValues.autoMerge = this.createParams.isDraft ? false : updateValues.autoMergeDefault; + } + updateValues.defaultTitle = response.defaultTitle; + if ((this.createParams.pendingTitle === undefined) || (this.createParams.pendingTitle === this.createParams.defaultTitle)) { + updateValues.pendingTitle = response.defaultTitle; + } + updateValues.defaultDescription = response.defaultDescription; + if ((this.createParams.pendingDescription === undefined) || (this.createParams.pendingDescription === this.createParams.defaultDescription)) { + updateValues.pendingDescription = response.defaultDescription; + } + } + + this.updateState(updateValues); + }; + + public changeMergeRemoteAndBranch = async (currentRemote?: RemoteInfo, currentBranch?: string): Promise => { + const args: ChooseRemoteAndBranchArgs = { + currentRemote, + currentBranch + }; + const response: ChooseCompareRemoteAndBranchResult = await this.postMessage({ + command: 'pr.changeCompareRemoteAndBranch', + args + }); + + const updateValues: Partial = { + compareRemote: response.compareRemote, + compareBranch: response.compareBranch, + createError: '' + }; + + this.updateState(updateValues); + }; + + public generateTitle = async (useCopilot: boolean): Promise => { + const args: TitleAndDescriptionArgs = { + useCopilot + }; + const response: TitleAndDescriptionResult = await this.postMessage({ + command: 'pr.generateTitleAndDescription', + args + }); + const updateValues: { pendingTitle?: string, pendingDescription?: string } = {}; + if (response.title) { + updateValues.pendingTitle = response.title; + } + if (response.description) { + updateValues.pendingDescription = response.description; + } + if (updateValues.pendingTitle && this.createParams.pendingTitle && this.createParams.pendingTitle !== updateValues.pendingTitle) { + this._titleStack.push(this.createParams.pendingTitle); + } + if (updateValues.pendingDescription && this.createParams.pendingDescription && this.createParams.pendingDescription !== updateValues.pendingDescription) { + this._descriptionStack.push(this.createParams.pendingDescription); + } + this.updateState(updateValues); + }; + + public cancelGenerateTitle = async (): Promise => { + return this.postMessage({ + command: 'pr.cancelGenerateTitleAndDescription' + }); + }; + + public popTitle = (): void => { + if (this._titleStack.length > 0) { + this.updateState({ pendingTitle: this._titleStack.pop() }); + } + }; + + public popDescription = (): void => { + if (this._descriptionStack.length > 0) { + this.updateState({ pendingDescription: this._descriptionStack.pop() }); + } + } + + public validate = (): boolean => { + let isValid = true; + if (!this.createParams.pendingTitle) { + this.updateState({ showTitleValidationError: true }); + isValid = false; + } + + this.updateState({ validate: true, createError: undefined }); + + return isValid; + }; + + private copyParams(): CreatePullRequestNew { + return { + title: this.createParams.pendingTitle!, + body: this.createParams.pendingDescription!, + owner: this.createParams.baseRemote!.owner, + repo: this.createParams.baseRemote!.repositoryName, + base: this.createParams.baseBranch!, + compareBranch: this.createParams.compareBranch!, + compareOwner: this.createParams.compareRemote!.owner, + compareRepo: this.createParams.compareRemote!.repositoryName, + draft: !!this.createParams.isDraft, + autoMerge: !!this.createParams.autoMerge, + autoMergeMethod: this.createParams.autoMergeMethod, + labels: this.createParams.labels ?? [], + assignees: this.createParams.assignees ?? [], + reviewers: this.createParams.reviewers ?? [], + milestone: this.createParams.milestone + }; + } + + public submit = async (): Promise => { + try { + this.updateState({ creating: false }); + const args: CreatePullRequestNew = this.copyParams(); + vscode.setState(defaultCreateParams); + await this.postMessage({ + command: 'pr.create', + args, + }); + } catch (e) { + this.updateState({ createError: (typeof e === 'string') ? e : (e.message ? e.message : 'An unknown error occurred.') }); + } + }; + + postMessage = async (message: any): Promise => { + return this._handler?.postMessage(message); + }; + + handleMessage = async (message: { command: string, params?: Partial, scrollPosition?: ScrollPosition }): Promise => { + switch (message.command) { + case 'pr.initialize': + if (!message.params) { + return; + } + if (this.createParams.pendingTitle === undefined) { + message.params.pendingTitle = message.params.defaultTitle; + } + + if (this.createParams.pendingDescription === undefined) { + message.params.pendingDescription = message.params.defaultDescription; + } + + if (this.createParams.baseRemote === undefined) { + message.params.baseRemote = message.params.defaultBaseRemote; + } + + if (this.createParams.baseBranch === undefined) { + message.params.baseBranch = message.params.defaultBaseBranch; + } + + if (this.createParams.compareRemote === undefined) { + message.params.compareRemote = message.params.defaultCompareRemote; + } + + if (this.createParams.compareBranch === undefined) { + message.params.compareBranch = message.params.defaultCompareBranch; + } + + if (this.createParams.isDraft === undefined) { + message.params.isDraft = message.params.isDraftDefault; + } else { + message.params.isDraft = this.createParams.isDraft; + } + + if (this.createParams.autoMerge === undefined) { + message.params.autoMerge = message.params.autoMergeDefault; + message.params.autoMergeMethod = message.params.defaultMergeMethod; + message.params.isDraft = false; + } else { + message.params.autoMerge = this.createParams.autoMerge; + message.params.autoMergeMethod = this.createParams.autoMergeMethod; + } + + this.updateState(message.params); + return; + + case 'reset': + if (!message.params) { + this.updateState(defaultCreateParams, true); + return; + } + message.params.pendingTitle = message.params.defaultTitle ?? this.createParams.pendingTitle; + message.params.pendingDescription = message.params.defaultDescription ?? this.createParams.pendingDescription; + message.params.baseRemote = message.params.defaultBaseRemote ?? this.createParams.baseRemote; + message.params.baseBranch = message.params.defaultBaseBranch ?? this.createParams.baseBranch; + message.params.compareBranch = message.params.defaultCompareBranch ?? this.createParams.compareBranch; + message.params.compareRemote = message.params.defaultCompareRemote ?? this.createParams.compareRemote; + message.params.autoMerge = (message.params.autoMergeDefault !== undefined ? message.params.autoMergeDefault : this.createParams.autoMerge); + message.params.autoMergeMethod = (message.params.defaultMergeMethod !== undefined ? message.params.defaultMergeMethod : this.createParams.autoMergeMethod); + message.params.isDraft = (message.params.isDraftDefault !== undefined ? message.params.isDraftDefault : this.createParams.isDraft); + if (message.params.autoMergeDefault) { + message.params.isDraft = false; + } + this.updateState(message.params); + return; + + case 'set-scroll': + if (!message.scrollPosition) { + return; + } + window.scrollTo(message.scrollPosition.x, message.scrollPosition.y); + return; + + case 'set-labels': + case 'set-assignees': + case 'set-reviewers': + if (!message.params) { + return; + } + this.updateState(message.params); + return; + case 'set-milestone': + if (!message.params) { + return; + } + this.updateState(Object.keys(message.params).length === 0 ? { milestone: undefined } : message.params); + return; + case 'create': + if (!message.params) { + return; + } + this.updateState(message.params); + return; + } + }; + + public static instance = new CreatePRContextNew(); +} + +const PullRequestContextNew = createContext(CreatePRContextNew.instance); +export default PullRequestContextNew; diff --git a/webviews/common/label.tsx b/webviews/common/label.tsx new file mode 100644 index 0000000000..9c1b442e76 --- /dev/null +++ b/webviews/common/label.tsx @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import React, { ReactNode } from 'react'; +import { gitHubLabelColor } from '../../src/common/utils'; +import { ILabel } from '../../src/github/interface'; + +export interface LabelProps { + label: ILabel & { canDelete: boolean; isDarkTheme: boolean }; +} + +export function Label(label: ILabel & { canDelete: boolean; isDarkTheme: boolean; children?: ReactNode}) { + const { name, canDelete, color } = label; + const labelColor = gitHubLabelColor(color, label.isDarkTheme, false); + return ( +
+ {name}{label.children} +
+ ); +} + +export function LabelCreate(label: ILabel & { canDelete: boolean; isDarkTheme: boolean; children?: ReactNode}) { + const { name, color } = label; + const labelColor = gitHubLabelColor(color, label.isDarkTheme, false); + return ( +
  • + {name}{label.children}
  • + ); +} diff --git a/webviews/common/message.ts b/webviews/common/message.ts index bc15a80e35..5c5640ea34 100644 --- a/webviews/common/message.ts +++ b/webviews/common/message.ts @@ -26,7 +26,7 @@ export class MessageHandler { this._commandHandler = commandHandler; this.lastSentReq = 0; this.pendingReplies = Object.create(null); - window.addEventListener('message', this.handleMessage.bind(this)); + window.addEventListener('message', this.handleMessage.bind(this) as (this: Window, ev: MessageEvent) => any); } public registerCommandHandler(commandHandler: (message: any) => void) { diff --git a/webviews/components/automergeSelect.tsx b/webviews/components/automergeSelect.tsx new file mode 100644 index 0000000000..f50ecc69d5 --- /dev/null +++ b/webviews/components/automergeSelect.tsx @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as React from 'react'; +import { MergeMethod, MergeMethodsAvailability, MergeQueueEntry, MergeQueueState } from '../../src/github/interface'; +import PullRequestContext from '../common/context'; +import { MergeSelect } from './merge'; + +const AutoMergeLabel = ({ busy, baseHasMergeQueue }: { busy: boolean, baseHasMergeQueue: boolean }) => { + if (busy) { + return ; + } else { + return ; + } +}; + +export const AutoMerge = ({ + updateState, + baseHasMergeQueue, + allowAutoMerge, + defaultMergeMethod, + mergeMethodsAvailability, + autoMerge, + isDraft, +}: { + updateState: (params: Partial<{ autoMerge: boolean; autoMergeMethod: MergeMethod }>) => Promise; + baseHasMergeQueue: boolean; + allowAutoMerge?: boolean; + defaultMergeMethod?: MergeMethod; + mergeMethodsAvailability?: MergeMethodsAvailability; + autoMerge?: boolean; + isDraft?: boolean; +}) => { + if ((!allowAutoMerge && !autoMerge) || !mergeMethodsAvailability || !defaultMergeMethod) { + return null; + } + const select: React.MutableRefObject = React.useRef() as React.MutableRefObject; + + const [isBusy, setBusy] = React.useState(false); + const selectedMethod = (): MergeMethod => { + const value: string = select.current?.value ?? 'merge'; + return value as MergeMethod; + }; + + return ( +
    +
    + { + setBusy(true); + await updateState({ autoMerge: !autoMerge, autoMergeMethod: selectedMethod() }); + setBusy(false); + }} + > +
    + + {baseHasMergeQueue ? null : +
    + { + setBusy(true); + await updateState({ autoMergeMethod: selectedMethod() }); + setBusy(false); + }} + disabled={isBusy} + /> +
    + } +
    + ); +}; + +export const QueuedToMerge = ({ mergeQueueEntry }: { mergeQueueEntry: MergeQueueEntry }) => { + const ctx = React.useContext(PullRequestContext); + let message; + let title; + switch (mergeQueueEntry.state) { + case (MergeQueueState.Mergeable): // TODO @alexr00 What does "Mergeable" mean in the context of a merge queue? + case (MergeQueueState.AwaitingChecks): + case (MergeQueueState.Queued): { + title = Queued to merge...; + if (mergeQueueEntry.position === 1) { + message = This pull request is at the head of the merge queue.; + } else { + message = This pull request is in the merge queue.; + } + break; + } + case (MergeQueueState.Locked): { + title = Merging is blocked; + message = The base branch does not allow updates; + break; + } + case (MergeQueueState.Unmergeable): { + title = Merging is blocked; + message = There are conflicts with the base branch.; + break; + } + } + return
    +
    +
    +
    {title}
    + {message} +
    +
    + +
    +
    ; +}; diff --git a/webviews/components/comment.tsx b/webviews/components/comment.tsx index f0d1c3bf6b..cd9af6a8bd 100644 --- a/webviews/components/comment.tsx +++ b/webviews/components/comment.tsx @@ -5,24 +5,41 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { IComment } from '../../src/common/comment'; +import { CommentEvent, ReviewEvent } from '../../src/common/timelineEvent'; import { GithubItemStateEnum } from '../../src/github/interface'; -import { PullRequest, ReviewType } from '../common/cache'; +import { PullRequest, ReviewType } from '../../src/github/views'; import PullRequestContext from '../common/context'; import emitter from '../common/events'; import { useStateProp } from '../common/hooks'; -import { Dropdown } from './dropdown'; +import { ContextDropdown } from './contextDropdown'; import { commentIcon, deleteIcon, editIcon } from './icon'; import { nbsp, Spaced } from './space'; import { Timestamp } from './timestamp'; import { AuthorLink, Avatar } from './user'; -export type Props = Partial & { +export type Props = { headerInEditMode?: boolean; isPRDescription?: boolean; + children?: any; + comment: IComment | ReviewEvent | PullRequest | CommentEvent; + allowEmpty?: boolean; }; -export function CommentView(comment: Props) { - const { id, pullRequestReviewId, canEdit, canDelete, bodyHTML, body, isPRDescription } = comment; +const association = ({ authorAssociation }: ReviewEvent, format = (assoc: string) => `(${assoc.toLowerCase()})`) => + authorAssociation.toLowerCase() === 'user' + ? format('you') + : authorAssociation && authorAssociation !== 'NONE' + ? format(authorAssociation) + : null; + +export function CommentView(commentProps: Props) { + const { isPRDescription, children, comment, headerInEditMode } = commentProps; + const { bodyHTML, body } = comment; + const id = ('id' in comment) ? comment.id : -1; + const canEdit = ('canEdit' in comment) ? comment.canEdit : false; + const canDelete = ('canDelete' in comment) ? comment.canDelete : false; + + const pullRequestReviewId = (comment as IComment).pullRequestReviewId; const [bodyMd, setBodyMd] = useStateProp(body); const [bodyHTMLState, setBodyHtml] = useStateProp(bodyHTML); const { deleteComment, editComment, setDescription, pr } = useContext(PullRequestContext); @@ -31,9 +48,10 @@ export function CommentView(comment: Props) { const [showActionBar, setShowActionBar] = useState(false); if (inEditMode) { - return React.cloneElement(comment.headerInEditMode ? : <>, {}, [ + return React.cloneElement(headerInEditMode ? : <>, {}, [ { if (pr.pendingCommentDrafts) { @@ -62,49 +80,85 @@ export function CommentView(comment: Props) { for={comment} onMouseEnter={() => setShowActionBar(true)} onMouseLeave={() => setShowActionBar(false)} + onFocus={() => setShowActionBar(true)} > - {showActionBar ? ( -
    - + {canEdit ? ( + - {canEdit ? ( - - ) : null} - {canDelete ? ( - - ) : null} -
    - ) : null} - + ) : null} + {canDelete ? ( + + ) : null} + + + {children}
    ); } type CommentBoxProps = { - for: Partial; + for: IComment | ReviewEvent | PullRequest | CommentEvent; header?: React.ReactChild; + onFocus?: any; onMouseEnter?: any; onMouseLeave?: any; children?: any; }; -function CommentBox({ for: comment, onMouseEnter, onMouseLeave, children }: CommentBoxProps) { - const { user, author, createdAt, htmlUrl, isDraft } = comment; +function isReviewEvent(comment: IComment | ReviewEvent | PullRequest | CommentEvent): comment is ReviewEvent { + return (comment as ReviewEvent).authorAssociation !== undefined; +} + +const DESCRIPTORS = { + PENDING: 'will review', + COMMENTED: 'reviewed', + CHANGES_REQUESTED: 'requested changes', + APPROVED: 'approved', +}; + +const reviewDescriptor = (state: string) => DESCRIPTORS[state] || 'reviewed'; + +function CommentBox({ for: comment, onFocus, onMouseEnter, onMouseLeave, children }: CommentBoxProps) { + const htmlUrl = ('htmlUrl' in comment) ? comment.htmlUrl : (comment as PullRequest).url; + const isDraft = (comment as IComment).isDraft ?? (isReviewEvent(comment) && (comment.state.toLocaleUpperCase() === 'PENDING')); + const author = ('user' in comment) ? comment.user! : (comment as PullRequest).author!; + const createdAt = ('createdAt' in comment) ? comment.createdAt : (comment as ReviewEvent).submittedAt; + return ( -
    +
    - - + + + {isReviewEvent(comment) ? association(comment) : null} + + {createdAt ? ( <> - commented{nbsp} + {isReviewEvent(comment) ? reviewDescriptor(comment.state) : 'commented'} + {nbsp} ) : ( @@ -150,7 +204,7 @@ function EditComment({ id, body, onCancel, onSave }: EditCommentProps) { }, [draftComment]); const submit = useCallback(async () => { - const { markdown, submitButton }: FormInputSet = form.current; + const { markdown, submitButton }: FormInputSet = form.current!; submitButton.disabled = true; try { await onSave(markdown.value); @@ -186,13 +240,13 @@ function EditComment({ id, body, onCancel, onSave }: EditCommentProps) { ); return ( -
    + } onSubmit={onSubmit}> +
    - - +
    - ) : ( -

    - {currentTitle} #{number} -

    ); - return ( + const displayTitle = (
    -
    - {editableTitle} -
    - {/* - For whatever reason, triple click on a block element in MacOS will select everything in that element, *and* every `user-select: false` block adjacent to that element. - Add an empty selectable div here to block triple click on title from selecting the following buttons. Issue #628. - */} -
    - {canEdit && !inEditMode ? ( -
    - { - - } - { - - } -
    - ) : ( -
    - )} +

    + + {' '} + + #{number} + +

    +
    + ); + + const editableTitle = inEditMode ? titleForm : displayTitle; + return editableTitle; +} + +function ButtonGroup({ isCurrentlyCheckedOut, canEdit, isIssue, repositoryDefaultBranch, setEditMode }) { + const { refresh, copyPrLink, copyVscodeDevLink } = useContext(PullRequestContext); + + return ( +
    + + + {canEdit && ( + <> + + + + + )} +
    + ); +} + +function Subtitle({ state, isDraft, isIssue, author, base, head }) { + const { text, color, icon } = getStatus(state, isDraft); + + return ( +
    +
    + {isIssue ? null : icon} + {text}
    -
    - - +
    + {!isIssue ? : null} + {!isIssue ? ( +
    + {getActionText(state)} into{' '} + {base} from {head} +
    + ) : null}
    ); } -const CheckoutButtons = ({ isCurrentlyCheckedOut, isIssue }) => { +const CheckoutButtons = ({ isCurrentlyCheckedOut, isIssue, repositoryDefaultBranch }) => { const { exitReviewMode, checkout } = useContext(PullRequestContext); const [isBusy, setBusy] = useState(false); @@ -140,17 +170,29 @@ const CheckoutButtons = ({ isCurrentlyCheckedOut, isIssue }) => { if (isCurrentlyCheckedOut) { return ( <> - - ); } else if (!isIssue) { return ( - ); @@ -161,11 +203,11 @@ const CheckoutButtons = ({ isCurrentlyCheckedOut, isIssue }) => { export function getStatus(state: GithubItemStateEnum, isDraft: boolean) { if (state === GithubItemStateEnum.Merged) { - return 'Merged'; + return { text: 'Merged', color: 'merged', icon: mergeIcon }; } else if (state === GithubItemStateEnum.Open) { - return isDraft ? 'Draft' : 'Open'; + return isDraft ? { text: 'Draft', color: 'draft', icon: prDraftIcon } : { text: 'Open', color: 'open', icon: prOpenIcon }; } else { - return 'Closed'; + return { text: 'Closed', color: 'closed', icon: prClosedIcon }; } } diff --git a/webviews/components/icon.tsx b/webviews/components/icon.tsx index 6740058a62..3952ef223d 100644 --- a/webviews/components/icon.tsx +++ b/webviews/components/icon.tsx @@ -16,14 +16,29 @@ export const alertIcon = ; export const skipIcon = ; export const chevronIcon = ; +export const chevronDownIcon = ; export const commentIcon = ; export const commitIcon = ; export const copyIcon = ; export const deleteIcon = ; export const mergeIcon = ; +export const mergeMethodIcon = ; +export const prClosedIcon = ; +export const prOpenIcon = ; +export const prDraftIcon = ; export const editIcon = ; export const plusIcon = ; export const pendingIcon = ; -export const diffIcon = ; -export const repoIcon = ; -export const gitCompareIcon = ; +export const requestChanges = ; +export const settingsIcon = ; +export const closeIcon = ; +export const syncIcon = ; +export const prBaseIcon = ; +export const prMergeIcon = ; +export const gearIcon = ; +export const assigneeIcon = ; +export const reviewerIcon = ; +export const labelIcon = ; +export const milestoneIcon = ; +export const sparkleIcon = ; +export const stopIcon = ; diff --git a/webviews/components/merge.tsx b/webviews/components/merge.tsx index 5f6a0c88f1..6db36dd83c 100644 --- a/webviews/components/merge.tsx +++ b/webviews/components/merge.tsx @@ -3,39 +3,58 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import React, { useCallback, useContext, useEffect, useReducer, useRef, useState } from 'react'; +import React, { + ChangeEventHandler, + useCallback, + useContext, + useEffect, + useReducer, + useRef, + useState, +} from 'react'; import { groupBy } from '../../src/common/utils'; -import { GithubItemStateEnum, MergeMethod, PullRequestMergeability } from '../../src/github/interface'; -import { PullRequest } from '../common/cache'; +import { + CheckState, + GithubItemStateEnum, + MergeMethod, + PullRequestCheckStatus, + PullRequestMergeability, + PullRequestReviewRequirement, + reviewerId, +} from '../../src/github/interface'; +import { PullRequest } from '../../src/github/views'; import PullRequestContext from '../common/context'; import { Reviewer } from '../components/reviewer'; +import { AutoMerge, QueuedToMerge } from './automergeSelect'; import { Dropdown } from './dropdown'; -import { alertIcon, checkIcon, deleteIcon, mergeIcon, pendingIcon, skipIcon } from './icon'; +import { alertIcon, checkIcon, closeIcon, mergeIcon, pendingIcon, requestChanges, skipIcon } from './icon'; import { nbsp } from './space'; import { Avatar } from './user'; -const PRStatusMessage = ({ pr, isSimple }: { pr: PullRequest, isSimple: boolean }) => { +const PRStatusMessage = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { return pr.state === GithubItemStateEnum.Merged ? ( -
    {isSimple ? mergeIcon : null}
    {'Pull request successfully merged.'}
    +
    +
    {isSimple ? mergeIcon : null}
    {' '} + {'Pull request successfully merged.'} +
    ) : pr.state === GithubItemStateEnum.Closed ? (
    {'This pull request is closed.'}
    ) : null; }; const DeleteOption = ({ pr }: { pr: PullRequest }) => { - return pr.state === GithubItemStateEnum.Open ? null - : (); + return pr.state === GithubItemStateEnum.Open ? null : ; }; const StatusChecks = ({ pr }: { pr: PullRequest }) => { const { state, status } = pr; const [showDetails, toggleDetails] = useReducer( show => !show, - status.statuses.some(s => s.state === 'failure'), + status?.statuses.some(s => s.state === CheckState.Failure) ?? false, ) as [boolean, () => void]; useEffect(() => { - if (status.statuses.some(s => s.state === 'failure')) { + if (status?.statuses.some(s => s.state === CheckState.Failure) ?? false) { if (!showDetails) { toggleDetails(); } @@ -44,17 +63,21 @@ const StatusChecks = ({ pr }: { pr: PullRequest }) => { toggleDetails(); } } - }, status.statuses); + }, status?.statuses); - return ((state === GithubItemStateEnum.Open) && status.statuses.length) ? ( + return state === GithubItemStateEnum.Open && status?.statuses.length ? ( <>
    {showDetails ? : null}
    @@ -62,17 +85,36 @@ const StatusChecks = ({ pr }: { pr: PullRequest }) => { ) : null; }; -const InlineReviewers = ({ pr, isSimple }: { pr: PullRequest, isSimple: boolean }) => { - return (isSimple && (pr.state === GithubItemStateEnum.Open)) - ? pr.reviewers - ? - <> { - pr.reviewers.map(state => ( - - )) - } - : null - : null; +const RequiredReviewers = ({ pr }: { pr: PullRequest }) => { + const { state, reviewRequirement } = pr; + if (!reviewRequirement || state !== GithubItemStateEnum.Open) { + return null; + } + return ( + <> +
    +
    + +

    + {getRequiredReviewSummary(reviewRequirement)} +

    +
    +
    + + ); +}; + +const InlineReviewers = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { + return isSimple && pr.state === GithubItemStateEnum.Open ? ( + pr.reviewers ? ( +
    + {' '} + {pr.reviewers.map(state => ( + + ))} +
    + ) : null + ) : null; }; export const StatusChecksSection = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { @@ -85,6 +127,7 @@ export const StatusChecksSection = ({ pr, isSimple }: { pr: PullRequest; isSimpl { <> + @@ -96,12 +139,14 @@ export const StatusChecksSection = ({ pr, isSimple }: { pr: PullRequest; isSimpl }; export const MergeStatusAndActions = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean }) => { - if (isSimple && (pr.state !== GithubItemStateEnum.Open)) { - const string = (pr.state === GithubItemStateEnum.Merged) ? 'Pull Request Merged' : 'Pull Request Closed'; + const { create, checkMergeability } = useContext(PullRequestContext); + + if (isSimple && pr.state !== GithubItemStateEnum.Open) { + const string = 'Create New Pull Request...'; return (
    -
    @@ -114,49 +159,56 @@ export const MergeStatusAndActions = ({ pr, isSimple }: { pr: PullRequest; isSim const { mergeable: _mergeable } = pr; const [mergeable, setMergeability] = useState(_mergeable); - if (_mergeable !== mergeable) { + if ((_mergeable !== mergeable) && (_mergeable !== PullRequestMergeability.Unknown)) { setMergeability(_mergeable); } - const { checkMergeability } = useContext(PullRequestContext); useEffect(() => { const handle = setInterval(async () => { if (mergeable === PullRequestMergeability.Unknown) { - setMergeability(await checkMergeability()); + const newMergeability = await checkMergeability(); + setMergeability(newMergeability); } }, 3000); return () => clearInterval(handle); - }); + }, [mergeable]); return ( - +
    - +
    ); }; export default StatusChecksSection; export const MergeStatus = ({ mergeable, isSimple }: { mergeable: PullRequestMergeability; isSimple: boolean }) => { + let icon: JSX.Element | null = pendingIcon; + let summary: string = 'Checking if this branch can be merged...'; + if (mergeable === PullRequestMergeability.Mergeable) { + icon = checkIcon; + summary = 'This branch has no conflicts with the base branch.'; + } else if (mergeable === PullRequestMergeability.Conflict) { + icon = closeIcon; + summary = 'This branch has conflicts that must be resolved.'; + } else if (mergeable === PullRequestMergeability.NotMergeable) { + icon = closeIcon; + summary = 'Branch protection policy must be fulfilled before merging.'; + } else if (mergeable === PullRequestMergeability.Behind) { + icon = alertIcon; + summary = 'This branch is out-of-date with the base branch.'; + } + + if (isSimple) { + icon = null; + } return (
    - {isSimple - ? null - : mergeable === PullRequestMergeability.Mergeable - ? checkIcon - : (mergeable === PullRequestMergeability.NotMergeable || mergeable === PullRequestMergeability.Conflict) - ? deleteIcon - : pendingIcon} -
    - {(mergeable) === PullRequestMergeability.Mergeable - ? 'This branch has no conflicts with the base branch.' - : mergeable === PullRequestMergeability.Conflict - ? 'This branch has conflicts that must be resolved.' - : mergeable === PullRequestMergeability.NotMergeable - ? 'Branch protection policy must be fulfilled before merging.' - : 'Checking if this branch can be merged...'} -
    + {icon} +

    + {summary} +

    ); }; @@ -177,29 +229,40 @@ export const ReadyForReview = ({ isSimple }: { isSimple: boolean }) => { return (
    -
    - +
    +
    {isSimple ? null : alertIcon}
    +
    +
    This pull request is still a work in progress.
    +
    Draft pull requests cannot be merged.
    +
    +
    +
    +
    - {isSimple ? '' :
    {alertIcon}
    } -
    This pull request is still a work in progress.
    - Draft pull requests cannot be merged.
    ); }; export const Merge = (pr: PullRequest) => { + const ctx = useContext(PullRequestContext); const select = useRef(); const [selectedMethod, selectMethod] = useState(null); + if (pr.mergeQueueMethod) { + return
    +
    + +
    +
    ; + } + if (selectedMethod) { return selectMethod(null)} />; } return ( -
    - +
    + {nbsp}using method{nbsp}
    @@ -216,8 +279,22 @@ export const PrActions = ({ pr, isSimple }: { pr: PullRequest; isSimple: boolean return canEdit ? : null; } - if (mergeable === PullRequestMergeability.Mergeable && hasWritePermission) { + if (mergeable === PullRequestMergeability.Mergeable && hasWritePermission && !pr.mergeQueueEntry) { return isSimple ? : ; + } else if (hasWritePermission && !pr.mergeQueueEntry) { + const ctx = useContext(PullRequestContext); + return ( + ) => { + return ctx.updateAutoMerge(params); + }} + {...pr} + baseHasMergeQueue={!!pr.mergeQueueMethod} + defaultMergeMethod={pr.autoMergeMethod ?? pr.defaultMergeMethod} + /> + ); + } else if (pr.mergeQueueEntry) { + return ; } return null; @@ -292,32 +369,30 @@ function ConfirmMerge({ pr, method, cancel }: { pr: PullRequest; method: MergeMe return (
    -
    { - event.preventDefault(); - - try { - setBusy(true); - const { title, description }: any = event.target; - const { state } = await merge({ - title: title.value, - description: description.value, - method, - }); - updatePR({ state }); - } finally { - setBusy(false); - } - }} - > - -
    {isAuthor ? null : ( - )} {isAuthor ? null : ( - )} - +
    -
    + ); } -const CommentEventView = (event: CommentEvent) => ; +const CommentEventView = (event: CommentEvent) => ; const MergedEventView = (event: MergedEvent) => (
    diff --git a/webviews/components/user.tsx b/webviews/components/user.tsx index 1ac5a516f5..1cb4e8c1ce 100644 --- a/webviews/components/user.tsx +++ b/webviews/components/user.tsx @@ -4,20 +4,30 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; -import { PullRequest } from '../common/cache'; +import { IAccount, IActor, ITeam, reviewerLabel } from '../../src/github/interface'; import { Icon } from './icon'; -export const Avatar = ({ for: author }: { for: Partial }) => ( - +const InnerAvatar = ({ for: author }: { for: Partial }) => ( + <> {author.avatarUrl ? ( - + ) : ( )} - + ); -export const AuthorLink = ({ for: author, text = author.login }: { for: PullRequest['author']; text?: string }) => ( +export const Avatar = ({ for: author, link = true }: { for: Partial, link?: boolean }) => { + if (link) { + return + + ; + } else { + return ; + } +}; + +export const AuthorLink = ({ for: author, text = reviewerLabel(author) }: { for: IActor | ITeam; text?: string }) => ( {text} diff --git a/webviews/createPullRequestView/app.tsx b/webviews/createPullRequestView/app.tsx deleted file mode 100644 index 2462b79775..0000000000 --- a/webviews/createPullRequestView/app.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import React, { useContext, useEffect, useRef, useState } from 'react'; -import { render } from 'react-dom'; -import { CreateParams, RemoteInfo } from '../../common/views'; -import PullRequestContext from '../common/createContext'; -import { ErrorBoundary } from '../common/errorBoundary'; -import { gitCompareIcon, repoIcon } from '../components/icon'; - -export const RemoteSelect = ({ onChange, defaultOption, repos }: - { onChange: (owner: string, repositoryName: string) => Promise, defaultOption: string | undefined, repos: RemoteInfo[] }) => { - let caseCorrectedDefaultOption: string | undefined; - const options = repos.map(param => { - const value = param.owner + '/' + param.repositoryName; - const label = `${param.owner}/${param.repositoryName}`; - if (label.toLowerCase() === defaultOption) { - caseCorrectedDefaultOption = label; - } - return ; - }); - - return -
    - {repoIcon} -
    -
    ; -}; - -export const BranchSelect = ({ onChange, defaultOption, branches }: - { onChange: (branch: string) => void, defaultOption: string | undefined, branches: string[] }) => { - return -
    - {gitCompareIcon} -
    -
    ; -}; - -export function main() { - render( - - {(params: CreateParams) => { - const ctx = useContext(PullRequestContext); - const [isBusy, setBusy] = useState(false); - - const titleInput = useRef(); - - function updateBaseBranch(branch: string): void { - ctx.changeBaseBranch(branch); - ctx.updateState({ baseBranch: branch }); - } - - function updateCompareBranch(branch: string): void { - ctx.changeCompareBranch(branch); - ctx.updateState({ compareBranch: branch }); - } - - function updateTitle(title: string): void { - if (params.validate) { - ctx.updateState({ pendingTitle: title, showTitleValidationError: !title }); - } else { - ctx.updateState({ pendingTitle: title }); - } - } - - async function create(): Promise { - setBusy(true); - const hasValidTitle = ctx.validate(); - if (!hasValidTitle) { - titleInput.current.focus(); - } else { - await ctx.submit(); - } - setBusy(false); - } - - return
    -
    - Merge changes from - - - -
    - -
    - into - - - -
    - -
    - - updateTitle(e.currentTarget.value)}> - -
    A title is required.
    -
    - -
    - - -
    - -
    - - {params.createError} - -
    -
    - ctx.updateState({ isDraft: !params.isDraft })} - > - -
    -
    - - -
    -
    ; - }} -
    , - document.getElementById('app'), - ); -} - -export function Root({ children }) { - const ctx = useContext(PullRequestContext); - const [pr, setPR] = useState(ctx.createParams); - useEffect(() => { - ctx.onchange = setPR; - setPR(ctx.createParams); - }, []); - ctx.postMessage({ command: 'ready' }); - return pr ? children(pr) :
    Loading...
    ; -} diff --git a/webviews/createPullRequestView/index.css b/webviews/createPullRequestView/index.css deleted file mode 100644 index a6f3674a51..0000000000 --- a/webviews/createPullRequestView/index.css +++ /dev/null @@ -1,87 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -input, -textarea { - padding: 5px !important; - margin-top: 5px; -} - -select { - width: 100%; -} - -textarea { - height: 100px; - /* 16px = 10px top and bottom padding + 6px of "padding" around the font */ - min-height: calc(var(--vscode-font-size) + 16px); -} - -.actions { - margin-top: 10px; - display: flex; - justify-content: space-between; -} - -svg, -svg path { - fill: var(--vscode-foreground); -} - -.icon svg { - width: 16px; - height: 16px; -} - -.wrapper { - margin-top: 10px; -} - -.flex { - display: flex; - align-items: center; -} - -.wrapper span { - display: flex; - padding: 5px; -} - -body { - padding: 5px 20px; -} - -.validation-error { - padding: 5px; - border: 1px solid var(--vscode-inputValidation-errorBorder); - background-color: var(--vscode-inputValidation-errorBackground); -} - -.below-input-error { - border-top: none !important; -} - -.input-error { - border: 1px solid var(--vscode-inputValidation-errorBorder) !important; -} - -.input-label { - text-transform: uppercase; - font-size: 11px; -} - -.selector-group { - padding: 5px; - border: 1px solid var(--vscode-panel-border); - margin-bottom: 10px; -} - -.selector-group .wrapper:last-of-type { - margin-bottom: 5px; -} - -.selector-group .wrapper { - padding-right: 5px; -} \ No newline at end of file diff --git a/webviews/createPullRequestViewNew/app.tsx b/webviews/createPullRequestViewNew/app.tsx new file mode 100644 index 0000000000..1a718425bd --- /dev/null +++ b/webviews/createPullRequestViewNew/app.tsx @@ -0,0 +1,365 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { render } from 'react-dom'; +import { CreateParamsNew, RemoteInfo } from '../../common/views'; +import { compareIgnoreCase } from '../../src/common/utils'; +import { isTeam, MergeMethod } from '../../src/github/interface'; +import PullRequestContextNew from '../common/createContextNew'; +import { ErrorBoundary } from '../common/errorBoundary'; +import { LabelCreate } from '../common/label'; +import { ContextDropdown } from '../components/contextDropdown'; +import { assigneeIcon, labelIcon, milestoneIcon, prBaseIcon, prMergeIcon, reviewerIcon, sparkleIcon, stopIcon } from '../components/icon'; +import { Avatar } from '../components/user'; + +type CreateMethod = 'create-draft' | 'create' | 'create-automerge-squash' | 'create-automerge-rebase' | 'create-automerge-merge'; + +export const ChooseRemoteAndBranch = ({ onClick, defaultRemote, defaultBranch, isBase, remoteCount = 0, disabled }: + { onClick: (remote?: RemoteInfo, branch?: string) => Promise, defaultRemote: RemoteInfo | undefined, defaultBranch: string | undefined, isBase: boolean, remoteCount: number | undefined, disabled: boolean }) => { + + const defaultsLabel = (defaultRemote && defaultBranch) ? `${remoteCount > 1 ? `${defaultRemote.owner}/` : ''}${defaultBranch}` : '\u2014'; + const title = isBase ? 'Base branch: ' + defaultsLabel : 'Branch to merge: ' + defaultsLabel; + + return +
    + +
    +
    ; +}; + +export function main() { + render( + + {(params: CreateParamsNew) => { + const ctx = useContext(PullRequestContextNew); + const [isBusy, setBusy] = useState(params.creating); + const [isGeneratingTitle, setGeneratingTitle] = useState(false); + function createMethodLabel(isDraft?: boolean, autoMerge?: boolean, autoMergeMethod?: MergeMethod, baseHasMergeQueue?: boolean): { value: CreateMethod, label: string } { + let value: CreateMethod; + let label: string; + if (autoMerge && baseHasMergeQueue) { + value = 'create-automerge-merge'; + label = 'Create + Merge When Ready'; + } else if (autoMerge && autoMergeMethod) { + value = `create-automerge-${autoMergeMethod}` as CreateMethod; + const mergeMethodLabel = autoMergeMethod.charAt(0).toUpperCase() + autoMergeMethod.slice(1); + label = `Create + Auto-${mergeMethodLabel}`; + } else if (isDraft) { + value = 'create-draft'; + label = 'Create Draft'; + } else { + value = 'create'; + label = 'Create'; + } + + return {value, label}; + } + + const titleInput = useRef() as React.MutableRefObject; + + function updateTitle(title: string): void { + if (params.validate) { + ctx.updateState({ pendingTitle: title, showTitleValidationError: !title }); + } else { + ctx.updateState({ pendingTitle: title }); + } + } + + useEffect(() => { + if (ctx.initialized) { + titleInput.current?.focus(); + } + }, [ctx.initialized]); + + async function create(): Promise { + setBusy(true); + const hasValidTitle = ctx.validate(); + if (!hasValidTitle) { + titleInput.current?.focus(); + } else { + await ctx.submit(); + } + setBusy(false); + } + + let isCreateable: boolean = true; + if (ctx.createParams.baseRemote && ctx.createParams.compareRemote && ctx.createParams.baseBranch && ctx.createParams.compareBranch + && compareIgnoreCase(ctx.createParams.baseRemote?.owner, ctx.createParams.compareRemote?.owner) === 0 + && compareIgnoreCase(ctx.createParams.baseRemote?.repositoryName, ctx.createParams.compareRemote?.repositoryName) === 0 + && compareIgnoreCase(ctx.createParams.baseBranch, ctx.createParams.compareBranch) === 0) { + + isCreateable = false; + } + + const onKeyDown = useCallback((isTitle: boolean, e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + create(); + } else if ((e.metaKey || e.ctrlKey) && e.key === 'z') { + if (isTitle) { + ctx.popTitle(); + } else { + ctx.popDescription(); + } + } + }, + [create], + ); + + const onCreateButton: React.MouseEventHandler = (event) => { + const selected = (event.target as HTMLButtonElement).value as CreateMethod; + let isDraft = false; + let autoMerge = false; + let autoMergeMethod: MergeMethod | undefined; + switch (selected) { + case 'create-draft': + isDraft = true; + autoMerge = false; + break; + case 'create-automerge-squash': + isDraft = false; + autoMerge = true; + autoMergeMethod = 'squash'; + break; + case 'create-automerge-rebase': + isDraft = false; + autoMerge = true; + autoMergeMethod = 'rebase'; + break; + case 'create-automerge-merge': + isDraft = false; + autoMerge = true; + autoMergeMethod = 'merge'; + break; + } + ctx.updateState({ isDraft, autoMerge, autoMergeMethod }); + return create(); + }; + + function makeCreateMenuContext(createParams: CreateParamsNew) { + const createMenuContexts = { + 'preventDefaultContextMenuItems': true, + 'github:createPrMenu': true, + 'github:createPrMenuDraft': true + }; + if (createParams.baseHasMergeQueue) { + createMenuContexts['github:createPrMenuMergeWhenReady'] = true; + } else { + if (createParams.allowAutoMerge && createParams.mergeMethodsAvailability && createParams.mergeMethodsAvailability['merge']) { + createMenuContexts['github:createPrMenuMerge'] = true; + } + if (createParams.allowAutoMerge && createParams.mergeMethodsAvailability && createParams.mergeMethodsAvailability['squash']) { + createMenuContexts['github:createPrMenuSquash'] = true; + } + if (createParams.allowAutoMerge && createParams.mergeMethodsAvailability && createParams.mergeMethodsAvailability['rebase']) { + createMenuContexts['github:createPrMenuRebase'] = true; + } + } + const stringified = JSON.stringify(createMenuContexts); + return stringified; + } + + if (params.creating) { + create(); + } + + function activateCommand(event: MouseEvent | KeyboardEvent, command: string): void { + if (event instanceof KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + ctx.postMessage({ command: command }); + } + } else if (event instanceof MouseEvent) { + ctx.postMessage({ command: command }); + } + } + + async function generateTitle(useCopilot?: boolean) { + setGeneratingTitle(true); + await ctx.generateTitle(!!useCopilot); + setGeneratingTitle(false); + } + + if (!ctx.initialized) { + ctx.initialize(); + } + + if (ctx.createParams.initializeWithGeneratedTitleAndDescription) { + ctx.createParams.initializeWithGeneratedTitleAndDescription = false; + generateTitle(true); + } + + return
    +
    +
    +
    + +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + updateTitle(e.currentTarget.value)} + onKeyDown={(e) => onKeyDown(true, e)} + data-vscode-context='{"preventDefaultContextMenuItems": false}' + disabled={!ctx.initialized || isBusy || isGeneratingTitle}> + + {ctx.createParams.generateTitleAndDescriptionTitle ? + isGeneratingTitle ? + {stopIcon} + : generateTitle()} tabIndex={0}>{sparkleIcon} : null} +
    A title is required
    +
    + +
    + {params.assignees && (params.assignees.length > 0) ? +
    + +
      activateCommand(e.nativeEvent, 'pr.changeAssignees')} + onKeyPress={(e) => activateCommand(e.nativeEvent, 'pr.changeAssignees')} + > + {params.assignees.map(assignee => +
    • + + + {assignee.login} + +
    • )} +
    +
    + : null} + + {params.reviewers && (params.reviewers.length > 0) ? +
    + +
      activateCommand(e.nativeEvent, 'pr.changeReviewers')} + onKeyPress={(e) => activateCommand(e.nativeEvent, 'pr.changeReviewers')} + > + {params.reviewers.map(reviewer => +
    • + + + {isTeam(reviewer) ? reviewer.slug : reviewer.login} + +
    • )} +
    +
    + : null} + + {params.labels && (params.labels.length > 0) ? +
    + +
      activateCommand(e.nativeEvent, 'pr.changeLabels')} + onKeyPress={(e) => activateCommand(e.nativeEvent, 'pr.changeLabels')} + > + {params.labels.map(label => )} +
    +
    + : null} + + {params.milestone ? +
    + +
      activateCommand(e.nativeEvent, 'pr.changeMilestone')} + onKeyPress={(e) => activateCommand(e.nativeEvent, 'pr.changeMilestone')} + > +
    • + {params.milestone.title} +
    • +
    +
    + : null} +
    + +
    + +
    + +
    + + {params.createError} + +
    + +
    + + + makeCreateMenuContext(params)} + defaultAction={onCreateButton} + defaultOptionLabel={() => createMethodLabel(ctx.createParams.isDraft, ctx.createParams.autoMerge, ctx.createParams.autoMergeMethod, ctx.createParams.baseHasMergeQueue).label} + defaultOptionValue={() => createMethodLabel(ctx.createParams.isDraft, ctx.createParams.autoMerge, ctx.createParams.autoMergeMethod, ctx.createParams.baseHasMergeQueue).value} + optionsTitle='Create with Option' + disabled={isBusy || isGeneratingTitle || !isCreateable || !ctx.initialized} + /> + +
    +
    ; + }} +
    , + document.getElementById('app'), + ); +} + +export function Root({ children }) { + const ctx = useContext(PullRequestContextNew); + const [pr, setPR] = useState(ctx.createParams); + useEffect(() => { + ctx.onchange = setPR; + setPR(ctx.createParams); + }, []); + ctx.postMessage({ command: 'ready' }); + return children(pr); +} diff --git a/webviews/createPullRequestViewNew/index.css b/webviews/createPullRequestViewNew/index.css new file mode 100644 index 0000000000..329f579a50 --- /dev/null +++ b/webviews/createPullRequestViewNew/index.css @@ -0,0 +1,281 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +body { + padding: 0 20px; +} + +input:disabled, +textarea:disabled { + opacity: 0.4; +} + +.group-main { + height: 100vh; + min-width: 160px; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.icon svg { + width: 16px; + height: 16px; +} + + +/* Base + Merge Branches */ + +.group-branches { + margin-top: 20px; + margin-bottom: 10px; +} + +.base, +.merge { + display: block !important; +} + +.base .deco, +.merge .deco { + display: block; + float: left; + margin-right: 8px; + user-select: none; +} + +.base button, +.merge button { + display: block; + float: left; + width: 100%; + height: 24px; + margin-top: -4px; + padding-top: 2px; + padding-left: 8px; + text-align: left; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--vscode-input-foreground) !important; + background-color: var(--vscode-input-background) !important; +} + +.base button:active, +.base button:focus, +.merge button:active, +.merge button:focus { + color: var(--vscode-inputOption-activeForeground) !important; + background-color: var(--vscode-inputOption-activeBackground) !important; +} + +.base button:hover:not(:disabled), +.merge button:hover:not(:disabled) { + background-color: var(--vscode-inputOption-hoverBackground) !important; +} + +.base button:focus, +.merge button:focus { + border-color: var(--vscode-focusBorder) !important; +} + +.merge { + padding-left: 28px; +} + +.merge .icon svg { + margin-top: -16px; +} + +.flex { + display: flex; + align-items: center; +} + +.input-label { + display: flex; + align-items: center; + font-variant-caps: all-small-caps; + font-size: small; + margin-bottom: 14px; +} + +.input-label .icon { + display: block; + float: left; + margin-right: 6px; +} + + +/* Title, Description */ + +#title { + padding-right: 23px; +} + +.group-title { + position: relative; + display: flex; +} + +.group-title .title-action:hover { + outline-style: none; + cursor: pointer; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.group-title .title-action:focus { + outline-style: none; +} + +.group-title .title-action:focus-visible { + outline-width: 1px; + outline-style: solid; + outline-offset: -1px; + outline-color: var(--vscode-focusBorder); + background: unset; +} + +.group-title .title-action.disabled { + cursor: default; + background-color: unset; +} + +.group-title .title-action svg { + padding: 2px; +} + +.group-title .title-action.disabled svg path { + fill: var(--vscode-disabledForeground); +} + +.group-title .title-action { + position: absolute; + top: 6px; + right: 5px; + background: unset; + padding: unset; + margin: unset; + height: 20px; + margin-top: -2px; +} + +.group-description { + flex-grow: 1; + margin-top: 10px; +} + +input[type=text], +textarea { + padding: 5px; +} + +textarea { + height: 100%; + min-height: 96px; + resize: none; +} + +.validation-error { + padding: 5px; + border: 1px solid var(--vscode-inputValidation-errorBorder); + background-color: var(--vscode-inputValidation-errorBackground); +} + +.below-input-error { + border-top: none !important; +} + +.input-error { + border: 1px solid var(--vscode-inputValidation-errorBorder) !important; +} + + +/* Assignees, Reviewers, Labels, Milestone */ + +.group-additions { + display: block; +} + +.group-additions div { + display: block; + position: relative; + float: left; + width: 100%; + box-sizing: border-box; + padding: 0 1px; + border-bottom: 1px solid var(--vscode-menu-separatorBackground); +} + +.group-additions div:first-child { + margin-top: 8px; +} + +.group-additions div:last-child { + border: none; + margin-bottom: 4px; +} + +.group-additions .icon { + display: block; + position: absolute; + z-index: -1; + top: 9px; + left: 9px; + width: 16px; + height: 16px; +} + +.group-additions img.avatar, +.group-additions img.avatar-icon { + margin-right: 4px; + width: 16px; + height: 16px; +} + +.group-additions ul { + display: flex; + align-content: flex-start; + flex-wrap: wrap; + gap: 4px; + list-style-type: none; + cursor: pointer; + user-select: none; + font-size: smaller; + margin: 0; + margin-top: 0; + padding: 0; + padding-top: 8px; + padding-bottom: 8px; + padding-left: 32px; + padding-right: 8px; + border-radius: 2px; +} + +.group-additions ul:focus { + outline: var(--vscode-focusBorder) solid 1px; +} + +.group-additions ul li { + padding: 2px; +} + +.labels ul li { + border: 1px solid var(--vscode-menu-separatorBackground); + border-radius: 2px; + padding: 2px 4px; +} + + +/* Actions */ + +.group-actions { + display: flex; + gap: 8px; + padding-top: 10px; + padding-bottom: 20px; + width: 100%; +} \ No newline at end of file diff --git a/webviews/createPullRequestView/index.ts b/webviews/createPullRequestViewNew/index.ts similarity index 100% rename from webviews/createPullRequestView/index.ts rename to webviews/createPullRequestViewNew/index.ts diff --git a/webviews/editorWebview/app.tsx b/webviews/editorWebview/app.tsx index 36b8800106..4cac8e8339 100644 --- a/webviews/editorWebview/app.tsx +++ b/webviews/editorWebview/app.tsx @@ -3,9 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as debounce from 'debounce'; import React, { useContext, useEffect, useState } from 'react'; import { render } from 'react-dom'; -import { PullRequest } from '../common/cache'; +import { PullRequest } from '../../src/github/views'; import PullRequestContext from '../common/context'; import { Overview } from './overview'; @@ -20,6 +21,17 @@ export function Root({ children }) { ctx.onchange = setPR; setPR(ctx.pr); }, []); + window.onscroll = debounce(() => { + ctx.postMessage({ + command: 'scroll', + args: { + scrollPosition: { + x: window.scrollX, + y: window.scrollY + } + } + }); + }, 200); ctx.postMessage({ command: 'ready' }); ctx.postMessage({ command: 'pr.debug', args: 'initialized ' + (pr ? 'with PR' : 'without PR') }); return pr ? children(pr) :
    Loading...
    ; diff --git a/webviews/editorWebview/index.css b/webviews/editorWebview/index.css index 9b175867df..44037dc6c7 100644 --- a/webviews/editorWebview/index.css +++ b/webviews/editorWebview/index.css @@ -5,7 +5,8 @@ #app { display: grid; - grid-template-columns: 670px auto; + grid-template-columns: 1fr minmax(200px, 300px); + column-gap: 32px; } #title { @@ -17,12 +18,21 @@ #main { grid-column: 1; grid-row: 2; + display: flex; + flex-direction: column; + gap: 16px; } #sidebar { + display: flex; + flex-direction: column; + gap: 16px; grid-column: 2; grid-row: 2; - padding-left: 20px; +} + +#project a { + cursor: pointer; } a:focus, @@ -41,7 +51,7 @@ textarea:focus, display: flex; align-items: flex-start; margin: 20px 0; - padding-bottom: 10px; + padding-bottom: 24px; border-bottom: 1px solid var(--vscode-list-inactiveSelectionBackground); } @@ -63,7 +73,7 @@ textarea:focus, .comment-body code, .comment-body a, span.lineContent { - overflow-wrap: break-word; + overflow-wrap: anywhere; } #title:empty { @@ -111,22 +121,30 @@ body .comment-container.review { .review-comment-container { width: 100%; + max-width: 1000px; display: flex; flex-direction: column; position: relative; } +body #main > .comment-container > .review-comment-container > .review-comment-header:not(:nth-last-child(2)) { + border-bottom: 1px solid var(--vscode-editorHoverWidget-border); +} + body .comment-container .review-comment-header { position: relative; display: flex; width: 100%; box-sizing: border-box; - padding: 6px; - font-size: 12px; + padding: 8px 16px; color: var(--vscode-foreground); align-items: center; - background: var(--vscode-list-inactiveSelectionBackground); - border: 1px solid var(--vscode-list-inactiveSelectionBackground); + background: var(--vscode-editorWidget-background); + border-top-left-radius: 3px; + border-top-right-radius: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .description-header { @@ -159,86 +177,103 @@ body .comment-container .review-comment-header { display: flex; align-items: center; justify-content: space-between; - margin-top: 5px; - margin-left: 15px; + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-editorHoverWidget-border); +} + +.status-check-details { + display: flex; + align-items: center; + gap: 8px; } #merge-on-github { margin-top: 10px; } +.status-item { + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-editorHoverWidget-border); +} + +.status-item:first-of-type { + background: var(--vscode-editorWidget-background); + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} + .status-item, -.form-actions { +.form-actions, +.ready-for-review-text-wrapper { display: flex; + gap: 8px; + align-items: center; } -.form-actions > input[type='submit'] { - margin-left: auto; +.status-item-detail-text { + display: flex; + gap: 8px; } .status-check-detail-text { - margin-left: 0.7em; + margin-right: 8px; } -.ready-for-review-container { - border-top: 1px solid; - padding-top: 10px; +.status-section p { + margin: 0; } -.ready-for-review-button { - float: right; +.merge-queue-container, +.ready-for-review-container { + padding: 16px; + background-color: var(--vscode-editorWidget-background); + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; + display: flex; + justify-content: space-between; + align-items: center; } .ready-for-review-icon { - float: left; + width: 16px; + height: 16px; } .ready-for-review-heading { - font-size: 1.2; - font-weight: bold; + font-weight: 600; } .ready-for-review-meta { font-size: 0.9; } -#confirm-merge { - margin-left: auto; -} - -.status-section { - padding-bottom: 16px; +#status-checks { + border: 1px solid var(--vscode-editorHoverWidget-border); + border-radius: 4px; } -.status-section:last-of-type { - padding-bottom: 0px; +#status-checks .label { + display: inline-flex; + margin-right: 16px; } #status-checks a { - margin-left: 10px; cursor: pointer; } -#status-checks { - padding: 10px; - border: 1px solid var(--vscode-list-inactiveSelectionBackground); - margin-top: 20px; -} - #status-checks summary { display: flex; align-items: center; } -#status-checks svg { - margin-right: 6px; - width: 16px; +#status-checks-display-button { + margin-left: auto; } #status-checks .avatar-link svg { width: 24px; margin-right: 0px; - vertical-align: middle; + vertical-align: middle; } .status-check .avatar-link .avatar-icon { @@ -248,6 +283,9 @@ body .comment-container .review-comment-header { #status-checks .merge-select-container { display: flex; align-items: center; + background-color: var(--vscode-editorWidget-background); + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; } #status-checks .merge-select-container > * { @@ -265,22 +303,28 @@ body .comment-container .review-comment-header { #status-checks .branch-status-message { display: inline-block; line-height: 100%; - padding: 0 10px; + padding: 16px; } body .comment-container .review-comment-header > span, body .comment-container .review-comment-header > a, body .commit .commit-message > a, body .merged .merged-message > a { - margin-right: 4px; - white-space: nowrap; + margin-right: 6px; } body .comment-container .review-comment-container .pending-label, body .resolved-container .outdatedLabel { - border: 1px solid; - border-radius: 2px 2px 2px 2px; - padding: 0.1rem 0.3rem; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 11px; + font-weight: 600; + border-radius: 20px; + padding: 4px 8px; + margin-left: 6px; +} + +body .resolved-container .unresolvedLabel { font-style: italic; margin-left: 5px; } @@ -289,16 +333,21 @@ body .diff .diffPath { margin-right: 4px; } +.comment-container form, #merge-comment-form { + padding: 16px; + background-color: var(--vscode-editorWidget-background); +} + body .comment-container .comment-body, .review-body { - padding: 10px; - border: 1px solid var(--vscode-list-inactiveSelectionBackground); + padding: 16px; border-top: none; } body .comment-container .review-comment-container .review-comment-body { - padding: 0; - margin: 0 0 0 36px; + display: flex; + flex-direction: column; + gap: 16px; border: none; } @@ -306,6 +355,7 @@ body .comment-container .comment-body > p, body .comment-container .comment-body > div > p, .comment-container .review-body > p { margin-top: 0; + line-height: 1.5em; } body .comment-container .comment-body > p:last-child, @@ -317,7 +367,7 @@ body .comment-container .comment-body > div > p:last-child, body { margin: auto; width: 100%; - max-width: 925px; + max-width: 1280px; padding: 0 32px; box-sizing: border-box; } @@ -335,14 +385,12 @@ body .hidden-focusable { body button.checkedOut { color: var(--vscode-foreground); opacity: 1 !important; - border: none; background-color: transparent; } body button .icon { - width: 1em; - height: 1em; - margin-right: 6px; + width: 16px; + height: 16px; } .prIcon { @@ -352,32 +400,14 @@ body button .icon { margin-top: 18px; } -.overview-title { - display: flex; - position: relative; - flex-wrap: wrap; - justify-content: space-between; -} - .overview-title h2 { - font-size: 24px; + font-size: 32px; } .overview-title textarea { min-height: 50px; } -.overview-title .button-group { - padding-top: 2px; - display: flex; - align-self: start; -} - -.overview-title .title-and-edit { - display: flex; - flex-grow: 1; -} - .title-container { width: 100%; } @@ -385,16 +415,32 @@ body button .icon { .subtitle { display: flex; align-items: center; - margin-top: 8px; + flex-wrap: wrap; + row-gap: 12px; } .subtitle .avatar, .subtitle .avatar-icon svg { - margin-right: 8px; + margin-right: 6px; } .subtitle .author { - margin-right: 8px; + display: flex; + align-items: center; +} + +.merge-branches { + display: inline-flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.branch-tag { + padding: 2px 4px; + background: var(--vscode-editorInlayHint-background); + color: var(--vscode-editorInlayHint-foreground); + border-radius: 4px; } .subtitle .created-at { @@ -402,55 +448,90 @@ body button .icon { white-space: nowrap; } -body .overview-title .button-group button { +.button-group { display: flex; + gap: 8px; } -body .overview-title .button-group button:last-child { - margin-left: 10px; +.small-button { + display: flex; + font-size: 11px; + font-weight: 600; + padding: 0 5px; } #status { box-sizing: border-box; line-height: 18px; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - border-radius: 4px; - padding: 2px 8px; + color: var(--vscode-button-foreground); + border-radius: 18px; + padding: 4px 12px; margin-right: 10px; + font-weight: 600; + display: flex; + gap: 4px; } -.section { - padding-bottom: 20px; +#status svg path { + fill: var(--vscode-button-foreground); } -.section-header { - padding-bottom: 8px; - display: flex; +.vscode-high-contrast #status { + border: 1px solid var(--vscode-contrastBorder); + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); } -.section-header .section-title { - font-weight: bold; +.vscode-high-contrast #status svg path { + fill: var(--vscode-badge-foreground); } -.section-placeholder { - font-style: italic; +.status-badge-merged { + background-color: var(--vscode-pullRequests-merged); } -.section button { - margin-left: auto; - padding: 0; - background: transparent; +.status-badge-open { + background-color: var(--vscode-pullRequests-open); +} + +.status-badge-closed { + background-color: var(--vscode-pullRequests-closed); +} + +.status-badge-draft { + background-color: var(--vscode-pullRequests-draft); +} + +.section { + padding-bottom: 24px; + border-bottom: 1px solid var(--vscode-editorWidget-border); + display: flex; + flex-direction: column; + gap: 12px; +} + +.section:last-of-type { + padding-bottom: 0px; + border-bottom: none; +} + +.section-header { display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; } -.section .icon { - margin-right: 0; +.section-header .section-title { + font-weight: 600; +} + +.section-placeholder { + color: var(--vscode-descriptionForeground); } -.section button:hover, -.section button:focus { - background: transparent; +.assign-yourself:hover { + cursor: pointer; } .section svg { @@ -460,15 +541,6 @@ body .overview-title .button-group button:last-child { margin-right: 0; } -.label { - padding: 2px 0 2px 6px; - height: 16px; - border-radius: 2px; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - white-space: nowrap; -} - .commit svg { width: 16px; height: auto; @@ -476,9 +548,13 @@ body .overview-title .button-group button:last-child { flex-shrink: 0; } +.comment-container.commit { + border: none; + padding: 4px 16px; +} + .comment-container.commit, .comment-container.merged { - padding: 16px 0 0 12px; box-sizing: border-box; } @@ -488,7 +564,6 @@ body .overview-title .button-group button:last-child { display: flex; width: 100%; border: none; - font-size: 12px; color: var(--vscode-foreground); } @@ -501,7 +576,6 @@ body .overview-title .button-group button:last-child { .merged .merged-message { display: flex; align-items: center; - line-height: 18px; overflow: hidden; flex-grow: 1; } @@ -522,13 +596,23 @@ body .overview-title .button-group button:last-child { height: 18px; } +.message-container { + display: inline-grid; +} + .commit .commit-message .message, .merged .merged-message .message { - overflow: inherit; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sha-with-timestamp { + display: flex; + align-items: center; + gap: 8px; +} + .commit .sha { min-width: 50px; font-family: var(--vscode-editor-font-family); @@ -550,6 +634,7 @@ body .overview-title .button-group button:last-child { .details { display: flex; flex-direction: column; + gap: 12px; width: 100%; } @@ -559,36 +644,51 @@ body .overview-title .button-group button:last-child { .comment-container { position: relative; - padding-top: 20px; width: 100%; display: flex; margin: 0; align-items: center; + border-radius: 4px; + border: 1px solid var(--vscode-editorHoverWidget-border); } .comment-container[data-type='commit'] { padding: 8px 0; + border: none; } -.comment-container[data-type='commit'] + .comment-container[data-type='commit'], -.comment-container:first-of-type { +.comment-container[data-type='commit'] + .comment-container[data-type='commit'] { border-top: none; } .comment-body .review-comment { - padding: 3px; box-sizing: border-box; - border-top: 1px solid var(--vscode-list-inactiveSelectionBackground); + border-top: 1px solid var(--vscode-editorHoverWidget-border); +} + +.resolve-comment-row { + display: flex; + align-items: center; + padding: 16px; + background-color: var(--vscode-editorHoverWidget-background); + border-top: 1px solid var(--vscode-editorHoverWidget-border); + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; } .review-comment-container .review-comment .review-comment-header { + padding: 16px 16px 8px 16px; border: none; background: none; } .review-comment-container .review-comment .comment-body { border: none; - padding: 4px 12px 8px 12px; + padding: 0px 16px 8px 16px; +} + +.review-comment-container .review-comment .comment-body:last-of-type { + padding: 0px 16px 16px 16px; } .comment-body .line { @@ -612,7 +712,7 @@ body .comment-form { } #status-checks textarea { - margin: 10px 0; + margin-top: 10px; } textarea { @@ -628,8 +728,9 @@ textarea { } .editing-form .form-actions { - margin-left: auto; - padding: 5px 0; + display: flex; + gap: 8px; + justify-content: flex-end; } .comment-form .form-actions > button, @@ -638,26 +739,22 @@ textarea { margin-left: 0; } -.comment-form .form-actions > .push-right { - margin-left: auto; -} - -.comment-form .form-actions > #close { - margin-left: 0; - margin-right: auto; +.primary-split-button { + flex-grow: unset; } .form-actions { display: flex; + justify-content: flex-end; padding-top: 10px; } -.main-comment-form > .form-actions { - margin-bottom: 10px; +#rebase-actions { + flex-direction: row-reverse; } -body .comment-form .form-actions button { - margin-right: 10px; +.main-comment-form > .form-actions { + margin-bottom: 10px; } .details .comment-body { @@ -745,6 +842,28 @@ code { font-family: Menlo, Monaco, Consolas, 'Droid Sans Mono', 'Courier New', monospace, 'Droid Sans Fallback'; } +.comment-body .snippet-clipboard-content { + display: grid; +} + +.comment-body video { + width: 100%; + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 4px; +} + +.comment-body summary { + margin-bottom: 8px; +} + +.comment-body details summary::marker { + display: flex; +} + +.comment-body details summary svg { + margin-left: 8px; +} + .comment-body body.wordWrap pre { white-space: pre-wrap; } @@ -767,6 +886,11 @@ code { white-space: nowrap; } +.timestamp { + overflow: hidden; + text-overflow: ellipsis; +} + /** Theming */ .comment-body pre code { @@ -784,8 +908,9 @@ code { } .vscode-high-contrast .comment-body pre:not(.hljs), -.vscode-high-contrast .comment-body code > div { - background-color: rgb(0, 0, 0); +.vscode-high-contrast .comment-body code>div { + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); } .vscode-high-contrast .comment-body h1 { @@ -824,14 +949,18 @@ code { } .review-comment-body .diff-container { - margin-top: 10px; - border: 1px solid var(--vscode-list-inactiveSelectionBackground); + border-radius: 4px; + border: 1px solid var(--vscode-editorHoverWidget-border); } .review-comment-body .diff-container .review-comment-container .comment-container { padding-top: 0; } +.review-comment-body .diff-container .comment-container { + border: none; +} + .review-comment-body .diff-container .review-comment-container .review-comment-header .avatar-container { margin-right: 4px; } @@ -841,13 +970,19 @@ code { height: 18px; } +.review-comment-body .diff-container .diff { + border-top: 1px solid var(--vscode-editorWidget-border); + overflow: scroll; +} + .resolved-container { padding: 6px 12px; display: flex; align-items: center; justify-content: space-between; - background: var(--vscode-editorGroupHeader-tabsBackground); - line-height: 1.5; + background: var(--vscode-editorWidget-background); + border-top-left-radius: 3px; + border-top-right-radius: 3px; } .resolved-container .diffPath:hover { @@ -856,6 +991,12 @@ code { cursor: pointer; } +.diff .diffLine { + display: flex; + font-size: 12px; + line-height: 20px; +} + .win32 .diff .diffLine { font-family: Consolas, Inconsolata, 'Courier New', monospace; } @@ -890,9 +1031,8 @@ code { line-height: 20px; text-align: right; white-space: nowrap; - vertical-align: top; box-sizing: border-box; - display: inline-block; + display: block; user-select: none; font-family: var(--vscode-editor-font-family); } @@ -946,6 +1086,7 @@ code { column-gap: 20px; grid-template-columns: 50% 50%; padding: 0; + padding-bottom: 24px; } .section-content { @@ -954,7 +1095,7 @@ code { } .section-item { - margin-right: 8px; + display: flex; } body .hidden-focusable { @@ -967,22 +1108,22 @@ code { display: flex; } - .section-item.reviewer { - border-radius: 3px; - padding: 2px 6px; - } - .section-item .login { width: auto; margin-right: 4px; } + + /* Hides bottom borders on bottom two sections */ + .section:nth-last-child(-n + 2) { + border-bottom: none; + } } .icon { - width: 1em; - height: 1em; + width: 16px; + height: 16px; font-size: 16px; - margin-right: 6px; + display: flex; } .action-bar { @@ -1009,11 +1150,6 @@ code { margin-right: 4px; } -.remove-item { - height: 16px; - cursor: pointer; -} - .title-editing-form { flex-grow: 1; } @@ -1021,3 +1157,103 @@ code { .title-editing-form > .form-actions { margin-left: 0; } + +/* permalinks */ +.comment-body .Box p { + margin-block-start: 0px; + margin-block-end: 0px; +} + +.comment-body .Box { + border-radius: 4px; + border-style: solid; + border-width: 1px; + border-color: var(--vscode-editorHoverWidget-border); +} + +.comment-body .Box-header { + background-color: var(--vscode-editorWidget-background); + color: var(--vscode-disabledForeground); + border-bottom-style: solid; + border-bottom-width: 1px; + padding: 8px 16px; + border-bottom-color: var(--vscode-editorHoverWidget-border); + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} + +.comment-body .blob-num { + word-wrap: break-word; + box-sizing: border-box; + border: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + min-width: 50px; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + color: var(--vscode-editorLineNumber-foreground); + line-height: 20px; + text-align: right; + white-space: nowrap; + vertical-align: top; + cursor: pointer; + user-select: none; +} + +.comment-body .blob-num::before { + content: attr(data-line-number); +} + +.comment-body .blob-code-inner { + tab-size: 8; + border: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + line-height: 20px; + vertical-align: top; + display: table-cell; + overflow: visible; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + word-wrap: anywhere; + text-indent: 0; + white-space: pre-wrap; +} + +.comment-body .commit-tease-sha { + font-family: var(--vscode-editor-font-family); + font-size: 12px; +} + +/* Suggestion */ +.comment-body .blob-wrapper.data.file .d-table { + border-radius: 4px; + border-style: solid; + border-width: 1px; + border-collapse: unset; + border-color: var(--vscode-editorHoverWidget-border); +} + +.comment-body .js-suggested-changes-blob { + border-collapse: collapse; +} + +.blob-code-deletion, +.blob-num-deletion { + border-collapse: collapse; + background-color: var(--vscode-diffEditor-removedLineBackground); +} + +.blob-code-addition, +.blob-num-addition { + border-collapse: collapse; + background-color: var(--vscode-diffEditor-insertedLineBackground); +} + +.blob-code-marker-addition::before { + content: "+ "; +} + +.blob-code-marker-deletion::before { + content: "- "; +} \ No newline at end of file diff --git a/webviews/editorWebview/overview.tsx b/webviews/editorWebview/overview.tsx index c61402b0ff..15668e44d6 100644 --- a/webviews/editorWebview/overview.tsx +++ b/webviews/editorWebview/overview.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; -import { PullRequest } from '../common/cache'; +import { PullRequest } from '../../src/github/views'; import { AddComment, CommentView } from '../components/comment'; import { Header } from '../components/header'; @@ -22,7 +22,7 @@ export const Overview = (pr: PullRequest) => (
    - +
    diff --git a/webviews/editorWebview/test/builder/account.ts b/webviews/editorWebview/test/builder/account.ts index ce32630b5a..9846a99e0c 100644 --- a/webviews/editorWebview/test/builder/account.ts +++ b/webviews/editorWebview/test/builder/account.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { IAccount } from '../../../../src/github/interface'; import { createBuilderClass } from '../../../../src/test/builders/base'; @@ -6,4 +11,5 @@ export const AccountBuilder = createBuilderClass()({ name: { default: 'Myself' }, avatarUrl: { default: 'https://avatars3.githubusercontent.com/u/17565?v=4' }, url: { default: 'https://github.com/me' }, + id: { default: '123' } }); diff --git a/webviews/editorWebview/test/builder/pullRequest.ts b/webviews/editorWebview/test/builder/pullRequest.ts index 28c600250a..aae8212411 100644 --- a/webviews/editorWebview/test/builder/pullRequest.ts +++ b/webviews/editorWebview/test/builder/pullRequest.ts @@ -1,13 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import { GithubItemStateEnum, PullRequestMergeability } from '../../../../src/github/interface'; +import { PullRequest } from '../../../../src/github/views'; import { createBuilderClass } from '../../../../src/test/builders/base'; import { CombinedStatusBuilder } from '../../../../src/test/builders/rest/combinedStatusBuilder'; -import { PullRequest } from '../../../common/cache'; import { AccountBuilder } from './account'; export const PullRequestBuilder = createBuilderClass()({ number: { default: 1234 }, title: { default: 'the default title' }, + titleHTML: { default: 'the default title' }, url: { default: 'https://github.com/owner/name/pulls/1234' }, createdAt: { default: '2019-01-01T10:00:00Z' }, body: { default: 'the *default* body' }, @@ -26,17 +32,27 @@ export const PullRequestBuilder = createBuilderClass()({ repositoryDefaultBranch: { default: 'main' }, canEdit: { default: true }, hasWritePermission: { default: true }, - pendingCommentText: { default: null }, - pendingCommentDrafts: { default: null }, + pendingCommentText: { default: undefined }, + pendingCommentDrafts: { default: undefined }, status: { linked: CombinedStatusBuilder }, + reviewRequirement: { default: null }, mergeable: { default: PullRequestMergeability.Mergeable }, defaultMergeMethod: { default: 'merge' }, mergeMethodsAvailability: { default: { merge: true, squash: true, rebase: true } }, + allowAutoMerge: { default: false }, + mergeQueueEntry: { default: undefined }, + mergeQueueMethod: { default: undefined }, reviewers: { default: [] }, isDraft: { default: false }, isIssue: { default: false }, assignees: { default: [] }, + projectItems: { default: undefined }, milestone: { default: undefined }, continueOnGitHub: { default: false }, - currentUserReviewState: { default: 'REQUESTED' } + currentUserReviewState: { default: 'REQUESTED' }, + isDarkTheme: { default: true }, + isEnterprise: { default: false }, + hasReviewDraft: { default: false }, + busy: { default: undefined }, + lastReviewType: { default: undefined }, }); diff --git a/yarn.lock b/yarn.lock index 40935fd849..88824c7bed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,59 @@ # yarn lockfile v1 +"@azure/abort-controller@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-1.1.0.tgz#788ee78457a55af8a1ad342acb182383d2119249" + integrity sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw== + dependencies: + tslib "^2.2.0" + +"@azure/core-auth@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@azure/core-auth/-/core-auth-1.4.0.tgz#6fa9661c1705857820dbc216df5ba5665ac36a9e" + integrity sha512-HFrcTgmuSuukRf/EdPmqBrc5l6Q5Uu+2TbuhaKbgaCpP2TfAeiNaQPAadxO+CYBRHGUzIDteMAjFspFLDLnKVQ== + dependencies: + "@azure/abort-controller" "^1.0.0" + tslib "^2.2.0" + +"@azure/core-rest-pipeline@^1.10.0": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@azure/core-rest-pipeline/-/core-rest-pipeline-1.10.1.tgz#348290847ca31b9eecf9cf5de7519aaccdd30968" + integrity sha512-Kji9k6TOFRDB5ZMTw8qUf2IJ+CeJtsuMdAHox9eqpTf1cefiNMpzrfnF6sINEBZJsaVaWgQ0o48B6kcUH68niA== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-auth" "^1.4.0" + "@azure/core-tracing" "^1.0.1" + "@azure/core-util" "^1.0.0" + "@azure/logger" "^1.0.0" + form-data "^4.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.0" + tslib "^2.2.0" + uuid "^8.3.0" + +"@azure/core-tracing@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.0.1.tgz#352a38cbea438c4a83c86b314f48017d70ba9503" + integrity sha512-I5CGMoLtX+pI17ZdiFJZgxMJApsK6jjfm85hpgp3oazCdq5Wxgh4wMr7ge/TTWW1B5WBuvIOI1fMU/FrOAMKrw== + dependencies: + tslib "^2.2.0" + +"@azure/core-util@^1.0.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@azure/core-util/-/core-util-1.1.1.tgz#8f87b3dd468795df0f0849d9f096c3e7b29452c1" + integrity sha512-A4TBYVQCtHOigFb2ETiiKFDocBoI1Zk2Ui1KpI42aJSIDexF7DHQFpnjonltXAIU/ceH+1fsZAWWgvX6/AKzog== + dependencies: + "@azure/abort-controller" "^1.0.0" + tslib "^2.2.0" + +"@azure/logger@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.0.3.tgz#6e36704aa51be7d4a1bae24731ea580836293c96" + integrity sha512-aK4s3Xxjrx3daZr3VylxejK3vG5ExXck5WOHDJ8in/k9AqlfIyFMMT1uG7u8mNjX+QRILTIn0/Xgschfh/dQ9g== + dependencies: + tslib "^2.2.0" + "@babel/code-frame@7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" @@ -37,6 +90,18 @@ dependencies: regenerator-runtime "^0.13.4" +"@eslint-community/eslint-utils@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" + integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== + dependencies: + eslint-visitor-keys "^3.3.0" + +"@eslint-community/regexpp@^4.5.1": + version "4.10.0" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" + integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== + "@eslint/eslintrc@^0.4.0": version "0.4.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.0.tgz#99cc0a0584d72f1df38b900fb062ba995f395547" @@ -61,6 +126,53 @@ "@types/istanbul-reports" "^1.1.1" "@types/yargs" "^13.0.0" +"@jridgewell/gen-mapping@^0.3.0": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" + integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A== + dependencies: + "@jridgewell/set-array" "^1.0.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.9" + +"@jridgewell/resolve-uri@^3.0.3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" + integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== + +"@jridgewell/set-array@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" + integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== + +"@jridgewell/source-map@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" + integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" + +"@jridgewell/sourcemap-codec@^1.4.10": + version "1.4.14" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" + integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== + +"@jridgewell/trace-mapping@^0.3.9": + version "0.3.14" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed" + integrity sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@koa/cors@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.3.0.tgz#b4c1c7ee303b7c968c8727f2a638a74675b50bb2" + integrity sha512-lzlkqLlL5Ond8jb6JLnVVDmD2OPym0r5kvZlMgAWiS9xle+Q5ulw1T358oW+RVguxUkANquZQz82i/STIRmsqQ== + dependencies: + vary "^1.1.2" + "@koa/router@^10.1.1": version "10.1.1" resolved "https://registry.yarnpkg.com/@koa/router/-/router-10.1.1.tgz#8e5a85c9b243e0bc776802c0de564561e57a5f78" @@ -72,6 +184,77 @@ methods "^1.1.2" path-to-regexp "^6.1.0" +"@microsoft/1ds-core-js@3.2.9", "@microsoft/1ds-core-js@^3.2.8": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@microsoft/1ds-core-js/-/1ds-core-js-3.2.9.tgz#8a26935966e4871d1f1e40d992828bdd52bba84e" + integrity sha512-3pCfM2TzHn3gU9pxHztduKcVRdb/nzruvPFfHPZD0IM0mb0h6TGo2isELF3CTMahTx50RAC51ojNIw2/7VRkOg== + dependencies: + "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-shims" "^2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" + +"@microsoft/1ds-post-js@^3.2.8": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@microsoft/1ds-post-js/-/1ds-post-js-3.2.9.tgz#07030f7455cb4ac8993e9b0bfa6c78ebfe25b499" + integrity sha512-D/RtqkQ2Nr4cuoGqmhi5QTmi3cBlxehIThJ1u3BaH9H/YkLNTKEcHZRWTXy14bXheCefNHciLuadg37G2Kekcg== + dependencies: + "@microsoft/1ds-core-js" "3.2.9" + "@microsoft/applicationinsights-shims" "^2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" + +"@microsoft/applicationinsights-channel-js@2.8.10": + version "2.8.10" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-channel-js/-/applicationinsights-channel-js-2.8.10.tgz#9d7077d7daa74d02d15bc26cc5440b4b5802cf5d" + integrity sha512-jXEUw3+U6WABygDOjEIlCLsniUpPqH5d/1Rfj1MVWMW6FFZo1vvYZoziOqb+dWWn41Dn5GF4EgXnvsfdkpz29w== + dependencies: + "@microsoft/applicationinsights-common" "2.8.10" + "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-shims" "2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" + +"@microsoft/applicationinsights-common@2.8.10": + version "2.8.10" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-common/-/applicationinsights-common-2.8.10.tgz#927227db35e4448692726f68deb0f6af576b483c" + integrity sha512-wXji97I1eANL5PG8RxZ/st+HCwKgAB1uySSxEvVNj3VcOiUyTYTtBYYEK2xhjBGR49+A2/fIJQHvu1ygco2b3Q== + dependencies: + "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-shims" "2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" + +"@microsoft/applicationinsights-core-js@2.8.10": + version "2.8.10" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-core-js/-/applicationinsights-core-js-2.8.10.tgz#beb96a97a046ddb031d6adecf0d3143b635edf42" + integrity sha512-jQrufDW0+sV8fBhRvzIPNGiCC6dELH+Ug0DM5CfN9757TBqZJz8CSWyDjex39as8+jD0F/8HRU9QdmrVgq5vFg== + dependencies: + "@microsoft/applicationinsights-shims" "2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" + +"@microsoft/applicationinsights-shims@2.0.2", "@microsoft/applicationinsights-shims@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-shims/-/applicationinsights-shims-2.0.2.tgz#92b36a09375e2d9cb2b4203383b05772be837085" + integrity sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg== + +"@microsoft/applicationinsights-web-basic@^2.8.9": + version "2.8.10" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-web-basic/-/applicationinsights-web-basic-2.8.10.tgz#3bc52c2252a6dc41fa14679d941f7da0658d3676" + integrity sha512-Iay9y4eYxcX5vIrqbAuOx51hCqABopiQljGQjdxKO/aEET1nHrOxXxcrTUYGUJF/aYoR3+RxaiFcqcuujLPiOg== + dependencies: + "@microsoft/applicationinsights-channel-js" "2.8.10" + "@microsoft/applicationinsights-common" "2.8.10" + "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-shims" "2.0.2" + "@microsoft/dynamicproto-js" "^1.1.7" + +"@microsoft/applicationinsights-web-snippet@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-web-snippet/-/applicationinsights-web-snippet-1.0.1.tgz#6bb788b2902e48bf5d460c38c6bb7fedd686ddd7" + integrity sha512-2IHAOaLauc8qaAitvWS+U931T+ze+7MNWrDHY47IENP5y2UA0vqJDu67kWZDdpCN1fFC77sfgfB+HV7SrKshnQ== + +"@microsoft/dynamicproto-js@^1.1.7": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.7.tgz#ede48dd3f85af14ee369c805e5ed5b84222b9fe2" + integrity sha512-SK3D3aVt+5vOOccKPnGaJWB5gQ8FuKfjboUJHedMP7gu54HqSCXX5iFXhktGD8nfJb0Go30eDvs/UDoTnR2kOA== + "@nodelib/fs.scandir@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz#d4b3549a5db5de2683e0c1071ab4f140904bbf69" @@ -131,6 +314,11 @@ "@octokit/types" "^6.0.3" universal-user-agent "^6.0.0" +"@octokit/openapi-types@^12.11.0": + version "12.11.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-12.11.0.tgz#da5638d64f2b919bca89ce6602d059f1b52d3ef0" + integrity sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ== + "@octokit/openapi-types@^5.1.0", "@octokit/openapi-types@^5.3.2": version "5.3.2" resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-5.3.2.tgz#b8ac43c5c3d00aef61a34cf744e315110c78deb4" @@ -148,12 +336,12 @@ resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.3.tgz#70a62be213e1edc04bb8897ee48c311482f9700d" integrity sha512-4RFU4li238jMJAzLgAwkBAw+4Loile5haQMQr+uhFq27BmyJXcXSKvoQKqh0agsZEiUlW6iSv3FAgvmGkur7OQ== -"@octokit/plugin-rest-endpoint-methods@4.12.0": - version "4.12.0" - resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.12.0.tgz#1cec405cd4eaf0bdb58cb7d2a9b3d8473b3a70e8" - integrity sha512-RgnQ1aoetdOJjZYC37LV5FNlL7GY/v1CdC5dur1Zp/UiADJlbRFbAz/xLx26ovXw67dK7EUtwCghS+6QyiI9RA== +"@octokit/plugin-rest-endpoint-methods@4.12.2": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.12.2.tgz#d2bd0b794d6c11a13113db6199baf44a39b06f50" + integrity sha512-5+MmGusB7wPw7OholtcGaMyjfrsFSpFqtJW8VsrbfU/TuaiQepY4wgVkS7P3TAObX257jrTbbGo/sJLcoGf16g== dependencies: - "@octokit/types" "^6.10.0" + "@octokit/types" "^6.10.1" deprecation "^2.3.1" "@octokit/request-error@^2.0.0", "@octokit/request-error@^2.0.5": @@ -179,30 +367,71 @@ once "^1.4.0" universal-user-agent "^6.0.0" -"@octokit/rest@18.2.0": - version "18.2.0" - resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-18.2.0.tgz#b75c87870bb1f7bc9f37ae0e9acb3a411a34a25f" - integrity sha512-xsp6bIqL2sb/NmgLXTxw96caegobRw+YHnzdIi70ruquHtPPDW2cBAONhDYMUuAOeXx0JH2auOeplpk4SQJy1w== +"@octokit/rest@18.2.1": + version "18.2.1" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-18.2.1.tgz#04835fe9ab0d90ca2a93898cde2aa944c78c70bc" + integrity sha512-DdQ1vps41JSyB2axyL1mBwJiXAPibgugIQPOmt0mL/yhwheQ6iuq2aKiJWgGWa9ldMfe3v9gIFYlrFgxQ5ThGQ== dependencies: "@octokit/core" "^3.2.3" "@octokit/plugin-paginate-rest" "^2.6.2" "@octokit/plugin-request-log" "^1.0.2" - "@octokit/plugin-rest-endpoint-methods" "4.12.0" + "@octokit/plugin-rest-endpoint-methods" "4.12.2" -"@octokit/types@6.10.0": - version "6.10.0" - resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.10.0.tgz#243faa864b0955f574012d52e179de38ac9ebafe" - integrity sha512-aMDo10kglofejJ96edCBIgQLVuzMDyjxmhdgEcoUUD64PlHYSrNsAGqN0wZtoiX4/PCQ3JLA50IpkP1bcKD/cA== +"@octokit/types@6.10.1": + version "6.10.1" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.10.1.tgz#5955dc0cf344bb82a46283a0c332651f5dd9f1ad" + integrity sha512-hgNC5jxKG8/RlqxU/6GThkGrvFpz25+cPzjQjyiXTNBvhyltn2Z4GhFY25+kbtXwZ4Co4zM0goW5jak1KLp1ug== dependencies: "@octokit/openapi-types" "^5.1.0" -"@octokit/types@^6.0.3", "@octokit/types@^6.10.0", "@octokit/types@^6.11.0", "@octokit/types@^6.7.1": +"@octokit/types@^6.0.3", "@octokit/types@^6.11.0", "@octokit/types@^6.7.1": version "6.12.2" resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.12.2.tgz#5b44add079a478b8eb27d78cf384cc47e4411362" integrity sha512-kCkiN8scbCmSq+gwdJV0iLgHc0O/GTPY1/cffo9kECu1MvatLPh9E+qFhfRIktKfHEA6ZYvv6S1B4Wnv3bi3pA== dependencies: "@octokit/openapi-types" "^5.3.2" +"@octokit/types@^6.10.1": + version "6.41.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.41.0.tgz#e58ef78d78596d2fb7df9c6259802464b5f84a04" + integrity sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg== + dependencies: + "@octokit/openapi-types" "^12.11.0" + +"@opentelemetry/api@^1.0.4": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.4.0.tgz#2c91791a9ba6ca0a0f4aaac5e45d58df13639ac8" + integrity sha512-IgMK9i3sFGNUqPMbjABm0G26g0QCKCUBfglhQ7rQq6WcxbKfEHRcmwsoER4hZcuYqJgkYn2OeuoJIv7Jsftp7g== + +"@opentelemetry/core@1.9.1", "@opentelemetry/core@^1.0.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.9.1.tgz#e343337e1a7bf30e9a6aef3ef659b9b76379762a" + integrity sha512-6/qon6tw2I8ZaJnHAQUUn4BqhTbTNRS0WP8/bA0ynaX+Uzp/DDbd0NS0Cq6TMlh8+mrlsyqDE7mO50nmv2Yvlg== + dependencies: + "@opentelemetry/semantic-conventions" "1.9.1" + +"@opentelemetry/resources@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.9.1.tgz#5ad3d80ba968a3a0e56498ce4bc82a6a01f2682f" + integrity sha512-VqBGbnAfubI+l+yrtYxeLyOoL358JK57btPMJDd3TCOV3mV5TNBmzvOfmesM4NeTyXuGJByd3XvOHvFezLn3rQ== + dependencies: + "@opentelemetry/core" "1.9.1" + "@opentelemetry/semantic-conventions" "1.9.1" + +"@opentelemetry/sdk-trace-base@^1.0.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.9.1.tgz#c349491b432a7e0ae7316f0b48b2d454d79d2b84" + integrity sha512-Y9gC5M1efhDLYHeeo2MWcDDMmR40z6QpqcWnPCm4Dmh+RHAMf4dnEBBntIe1dDpor686kyU6JV1D29ih1lZpsQ== + dependencies: + "@opentelemetry/core" "1.9.1" + "@opentelemetry/resources" "1.9.1" + "@opentelemetry/semantic-conventions" "1.9.1" + +"@opentelemetry/semantic-conventions@1.9.1", "@opentelemetry/semantic-conventions@^1.0.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.9.1.tgz#ad3367684a57879392513479e0a436cb2ac46dad" + integrity sha512-oPQdbFDmZvjXk5ZDoBGXG8B4tSB/qW5vQunJWQMFUBp7Xe8O1ByPANueJ+Jzg58esEBegyyxZ7LRmfJr7kFcFg== + "@sheerun/mutationobserver-shim@^0.3.2": version "0.3.3" resolved "https://registry.yarnpkg.com/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.3.tgz#5405ee8e444ed212db44e79351f0c70a582aae25" @@ -259,10 +488,10 @@ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.15.tgz#b7a6d263c2cecf44b6de9a051cf496249b154553" integrity sha512-rYff6FI+ZTKAPkJUoyz7Udq3GaoDZnxYDEvdEdFZASiA7PoErltHezDishqQiSDWrGxvxmplH304jyzQmjp0AQ== -"@types/eslint-scope@^3.7.0": - version "3.7.0" - resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.0.tgz#4792816e31119ebd506902a482caec4951fabd86" - integrity sha512-O/ql2+rrCUe2W2rs7wMR+GqPRcgB6UiqN5RhrR5xruFlY7l9YLMn0ZkDzjoHLeiFkR8MCQZVudUuuvQ2BLC9Qw== +"@types/eslint-scope@^3.7.3": + version "3.7.4" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" + integrity sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA== dependencies: "@types/eslint" "*" "@types/estree" "*" @@ -275,15 +504,10 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*": - version "0.0.46" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.46.tgz#0fb6bfbbeabd7a30880504993369c4bf1deab1fe" - integrity sha512-laIjwTQaD+5DukBZaygQ79K1Z0jb1bPEMRrkXSLjtCcZm+abyp5YbrqpSLzD42FwWW6gK/aS4NYpJ804nG2brg== - -"@types/estree@^0.0.50": - version "0.0.50" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83" - integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw== +"@types/estree@*", "@types/estree@^0.0.51": + version "0.0.51" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" + integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== "@types/glob@7.1.3": version "7.1.3" @@ -313,11 +537,16 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-report" "*" -"@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6": +"@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5": version "7.0.7" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== +"@types/json-schema@^7.0.12": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + "@types/json-schema@^7.0.8": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" @@ -343,6 +572,11 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== +"@types/minimatch@^3.0.3": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" + integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ== + "@types/mocha@^8.2.2": version "8.2.3" resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323" @@ -389,6 +623,11 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275" integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== +"@types/semver@^7.5.0": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.5.tgz#deed5ab7019756c9c90ea86139106b0346223f35" + integrity sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg== + "@types/sinon@7.0.11": version "7.0.11" resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.0.11.tgz#6f28f005a36e779b7db0f1359b9fb9eef72aae88" @@ -401,10 +640,10 @@ dependencies: "@types/node" "*" -"@types/vscode@1.58.0": - version "1.58.0" - resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.58.0.tgz#91662aa27197c750df314e0d27de4b23512ebe59" - integrity sha512-enznxcBi4uYt20LWal09E0+VKEHZlq9PZawTu/mpWCVCFWWbiyR8HNI1ikBP1Ypqv8b3A/0md3DY1jcx+oQ8kQ== +"@types/vscode@1.79.0": + version "1.79.0" + resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.79.0.tgz#e2aed1bb3946ae2bebbc3b88020d0efe18f941a9" + integrity sha512-Tfowu2rSW8hVGbqzQLSPlOEiIOYYryTkgJ+chMecpYiJcnw9n0essvSiclnK+Qh/TcSVJHgaK4EMrQDZjZJ/Sw== "@types/webpack-env@^1.16.0": version "1.16.2" @@ -423,119 +662,139 @@ dependencies: "@types/yargs-parser" "*" -"@types/yauzl@^2.9.1": - version "2.9.2" - resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.9.2.tgz#c48e5d56aff1444409e39fa164b0b4d4552a7b7a" - integrity sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA== - dependencies: - "@types/node" "*" - "@types/zen-observable@^0.8.0": version "0.8.2" resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71" integrity sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg== -"@typescript-eslint/eslint-plugin@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.18.0.tgz#50fbce93211b5b690895d20ebec6fe8db48af1f6" - integrity sha512-Lzkc/2+7EoH7+NjIWLS2lVuKKqbEmJhtXe3rmfA8cyiKnZm3IfLf51irnBcmow8Q/AptVV0XBZmBJKuUJTe6cQ== - dependencies: - "@typescript-eslint/experimental-utils" "4.18.0" - "@typescript-eslint/scope-manager" "4.18.0" - debug "^4.1.1" - functional-red-black-tree "^1.0.1" - lodash "^4.17.15" - regexpp "^3.0.0" - semver "^7.3.2" - tsutils "^3.17.1" - -"@typescript-eslint/experimental-utils@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.18.0.tgz#ed6c955b940334132b17100d2917449b99a91314" - integrity sha512-92h723Kblt9JcT2RRY3QS2xefFKar4ZQFVs3GityOKWQYgtajxt/tuXIzL7sVCUlM1hgreiV5gkGYyBpdOwO6A== - dependencies: - "@types/json-schema" "^7.0.3" - "@typescript-eslint/scope-manager" "4.18.0" - "@typescript-eslint/types" "4.18.0" - "@typescript-eslint/typescript-estree" "4.18.0" - eslint-scope "^5.0.0" - eslint-utils "^2.0.0" - -"@typescript-eslint/parser@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.18.0.tgz#a211edb14a69fc5177054bec04c95b185b4dde21" - integrity sha512-W3z5S0ZbecwX3PhJEAnq4mnjK5JJXvXUDBYIYGoweCyWyuvAKfGHvzmpUzgB5L4cRBb+cTu9U/ro66dx7dIimA== - dependencies: - "@typescript-eslint/scope-manager" "4.18.0" - "@typescript-eslint/types" "4.18.0" - "@typescript-eslint/typescript-estree" "4.18.0" - debug "^4.1.1" +"@typescript-eslint/eslint-plugin@6.10.0": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.10.0.tgz#cfe2bd34e26d2289212946b96ab19dcad64b661a" + integrity sha512-uoLj4g2OTL8rfUQVx2AFO1hp/zja1wABJq77P6IclQs6I/m9GLrm7jCdgzZkvWdDCQf1uEvoa8s8CupsgWQgVg== + dependencies: + "@eslint-community/regexpp" "^4.5.1" + "@typescript-eslint/scope-manager" "6.10.0" + "@typescript-eslint/type-utils" "6.10.0" + "@typescript-eslint/utils" "6.10.0" + "@typescript-eslint/visitor-keys" "6.10.0" + debug "^4.3.4" + graphemer "^1.4.0" + ignore "^5.2.4" + natural-compare "^1.4.0" + semver "^7.5.4" + ts-api-utils "^1.0.1" -"@typescript-eslint/scope-manager@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.18.0.tgz#d75b55234c35d2ff6ac945758d6d9e53be84a427" - integrity sha512-olX4yN6rvHR2eyFOcb6E4vmhDPsfdMyfQ3qR+oQNkAv8emKKlfxTWUXU5Mqxs2Fwe3Pf1BoPvrwZtwngxDzYzQ== +"@typescript-eslint/parser@6.10.0": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.10.0.tgz#578af79ae7273193b0b6b61a742a2bc8e02f875a" + integrity sha512-+sZwIj+s+io9ozSxIWbNB5873OSdfeBEH/FR0re14WLI6BaKuSOnnwCJ2foUiu8uXf4dRp1UqHP0vrZ1zXGrog== dependencies: - "@typescript-eslint/types" "4.18.0" - "@typescript-eslint/visitor-keys" "4.18.0" + "@typescript-eslint/scope-manager" "6.10.0" + "@typescript-eslint/types" "6.10.0" + "@typescript-eslint/typescript-estree" "6.10.0" + "@typescript-eslint/visitor-keys" "6.10.0" + debug "^4.3.4" -"@typescript-eslint/types@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.18.0.tgz#bebe323f81f2a7e2e320fac9415e60856267584a" - integrity sha512-/BRociARpj5E+9yQ7cwCF/SNOWwXJ3qhjurMuK2hIFUbr9vTuDeu476Zpu+ptxY2kSxUHDGLLKy+qGq2sOg37A== +"@typescript-eslint/scope-manager@6.10.0": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.10.0.tgz#b0276118b13d16f72809e3cecc86a72c93708540" + integrity sha512-TN/plV7dzqqC2iPNf1KrxozDgZs53Gfgg5ZHyw8erd6jd5Ta/JIEcdCheXFt9b1NYb93a1wmIIVW/2gLkombDg== + dependencies: + "@typescript-eslint/types" "6.10.0" + "@typescript-eslint/visitor-keys" "6.10.0" -"@typescript-eslint/typescript-estree@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.18.0.tgz#756d3e61da8c16ab99185532c44872f4cd5538cb" - integrity sha512-wt4xvF6vvJI7epz+rEqxmoNQ4ZADArGQO9gDU+cM0U5fdVv7N+IAuVoVAoZSOZxzGHBfvE3XQMLdy+scsqFfeg== +"@typescript-eslint/type-utils@6.10.0": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.10.0.tgz#1007faede067c78bdbcef2e8abb31437e163e2e1" + integrity sha512-wYpPs3hgTFblMYwbYWPT3eZtaDOjbLyIYuqpwuLBBqhLiuvJ+9sEp2gNRJEtR5N/c9G1uTtQQL5AhV0fEPJYcg== dependencies: - "@typescript-eslint/types" "4.18.0" - "@typescript-eslint/visitor-keys" "4.18.0" - debug "^4.1.1" - globby "^11.0.1" - is-glob "^4.0.1" - semver "^7.3.2" - tsutils "^3.17.1" + "@typescript-eslint/typescript-estree" "6.10.0" + "@typescript-eslint/utils" "6.10.0" + debug "^4.3.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/types@6.10.0": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.10.0.tgz#f4f0a84aeb2ac546f21a66c6e0da92420e921367" + integrity sha512-36Fq1PWh9dusgo3vH7qmQAj5/AZqARky1Wi6WpINxB6SkQdY5vQoT2/7rW7uBIsPDcvvGCLi4r10p0OJ7ITAeg== -"@typescript-eslint/visitor-keys@4.18.0": - version "4.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.18.0.tgz#4e6fe2a175ee33418318a029610845a81e2ff7b6" - integrity sha512-Q9t90JCvfYaN0OfFUgaLqByOfz8yPeTAdotn/XYNm5q9eHax90gzdb+RJ6E9T5s97Kv/UHWKERTmqA0jTKAEHw== +"@typescript-eslint/typescript-estree@6.10.0": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.10.0.tgz#667381eed6f723a1a8ad7590a31f312e31e07697" + integrity sha512-ek0Eyuy6P15LJVeghbWhSrBCj/vJpPXXR+EpaRZqou7achUWL8IdYnMSC5WHAeTWswYQuP2hAZgij/bC9fanBg== + dependencies: + "@typescript-eslint/types" "6.10.0" + "@typescript-eslint/visitor-keys" "6.10.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/utils@6.10.0": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.10.0.tgz#4d76062d94413c30e402c9b0df8c14aef8d77336" + integrity sha512-v+pJ1/RcVyRc0o4wAGux9x42RHmAjIGzPRo538Z8M1tVx6HOnoQBCX/NoadHQlZeC+QO2yr4nNSFWOoraZCAyg== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@types/json-schema" "^7.0.12" + "@types/semver" "^7.5.0" + "@typescript-eslint/scope-manager" "6.10.0" + "@typescript-eslint/types" "6.10.0" + "@typescript-eslint/typescript-estree" "6.10.0" + semver "^7.5.4" + +"@typescript-eslint/visitor-keys@6.10.0": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.10.0.tgz#b9eaf855a1ac7e95633ae1073af43d451e8f84e3" + integrity sha512-xMGluxQIEtOM7bqFCo+rCMh5fqI+ZxV5RUUOa29iVPz1OgCZrtc7rFnz5cLUazlkPKYqX+75iuDq7m0HQ48nCg== dependencies: - "@typescript-eslint/types" "4.18.0" - eslint-visitor-keys "^2.0.0" + "@typescript-eslint/types" "6.10.0" + eslint-visitor-keys "^3.4.1" "@ungap/promise-all-settled@1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== -"@vscode/test-electron@^1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@vscode/test-electron/-/test-electron-1.6.1.tgz#2b282154097e250ee9b94b6a284eb5804e53a3d7" - integrity sha512-WTs+OK9YrKSVJNZ9IjytNibHSJG2YslZXuS3pw9gedF25TgYF/+FQhQYL0ZPX4uupS0SGAPKzMnhYDkjPDxowA== +"@vscode/extension-telemetry@0.7.5": + version "0.7.5" + resolved "https://registry.yarnpkg.com/@vscode/extension-telemetry/-/extension-telemetry-0.7.5.tgz#bf965731816e08c3f146f96d901ec67954fc913b" + integrity sha512-fJ5y3TcpqqkFYHneabYaoB4XAhDdVflVm+TDKshw9VOs77jkgNS4UA7LNXrWeO0eDne3Sh3JgURf+xzc1rk69w== + dependencies: + "@microsoft/1ds-core-js" "^3.2.8" + "@microsoft/1ds-post-js" "^3.2.8" + "@microsoft/applicationinsights-web-basic" "^2.8.9" + applicationinsights "2.4.1" + +"@vscode/test-electron@^2.3.8": + version "2.3.8" + resolved "https://registry.yarnpkg.com/@vscode/test-electron/-/test-electron-2.3.8.tgz#06a7c50b38cfac0ede833905e088d55c61cd12d3" + integrity sha512-b4aZZsBKtMGdDljAsOPObnAi7+VWIaYl3ylCz1jTs+oV6BZ4TNHcVNC3xUn0azPeszBmwSBDQYfFESIaUQnrOg== dependencies: http-proxy-agent "^4.0.1" https-proxy-agent "^5.0.0" - rimraf "^3.0.2" - unzipper "^0.10.11" + jszip "^3.10.1" + semver "^7.5.2" -"@vscode/test-web@^0.0.22": - version "0.0.22" - resolved "https://registry.yarnpkg.com/@vscode/test-web/-/test-web-0.0.22.tgz#8767c80e7b16e73e78cf30da93d4dff5d4db148a" - integrity sha512-sm4WYidw26eFb1AReC8w5y4aOMdBb5ma5x3ukRJcun9iUB1ajz2nM18rxiYAVimUzrIMQHr9WqC8HYBYP8aNKQ== +"@vscode/test-web@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@vscode/test-web/-/test-web-0.0.29.tgz#00f19159cf3ae70fdfae4a909df66e407c8b5e56" + integrity sha512-QJwu3F6U+IT/X6UiRVQEe1tKSB1aRVDlWi5jAfnbXaAH8Gk4NrUFLxAB33mms82XQK4PuCTXAqNd/eC8v3ZQDA== dependencies: + "@koa/cors" "^3.3.0" "@koa/router" "^10.1.1" decompress "^4.2.1" decompress-targz "^4.1.1" + get-stream "6.0.1" http-proxy-agent "^5.0.0" - https-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" koa "^2.13.4" koa-morgan "^1.0.1" koa-mount "^4.0.0" koa-static "^5.0.0" - minimist "^1.2.5" - playwright "^1.18.1" + minimist "^1.2.6" + playwright "^1.23.1" vscode-uri "^3.0.3" "@webassemblyjs/ast@1.11.1": @@ -696,10 +955,10 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== -abab@^2.0.3: - version "2.0.5" - resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" - integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== +abab@^2.0.5, abab@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" + integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== accepts@^1.3.5: version "1.3.7" @@ -737,12 +996,12 @@ acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.4.1: - version "8.7.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf" - integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ== +acorn@^8.5.0, acorn@^8.7.1: + version "8.8.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" + integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== -agent-base@6, agent-base@^6.0.2: +agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== @@ -754,7 +1013,7 @@ ajv-keywords@^3.4.1, ajv-keywords@^3.5.2: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: +ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -779,6 +1038,13 @@ ansi-colors@4.1.1, ansi-colors@^4.1.1: resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== +ansi-colors@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-1.1.0.tgz#6374b4dd5d4718ff3ce27a671a3b1cad077132a9" + integrity sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA== + dependencies: + ansi-wrap "^0.1.0" + ansi-regex@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1" @@ -808,6 +1074,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +ansi-wrap@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf" + integrity sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw== + anymatch@~3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" @@ -927,6 +1198,30 @@ apollo-utilities@1.3.4, apollo-utilities@^1.3.0, apollo-utilities@^1.3.4: ts-invariant "^0.4.0" tslib "^1.10.0" +append-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/append-buffer/-/append-buffer-1.0.2.tgz#d8220cf466081525efea50614f3de6514dfa58f1" + integrity sha512-WLbYiXzD3y/ATLZFufV/rZvWdZOs+Z/+5v1rBZ463Jn398pa6kcde27cvozYnBoxXblGZTFfoPpsaEw0orU5BA== + dependencies: + buffer-equal "^1.0.0" + +applicationinsights@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/applicationinsights/-/applicationinsights-2.4.1.tgz#4de4c4dd3c7c4a44445cfbf3d15808fc0dcc423d" + integrity sha512-0n0Ikd0gzSm460xm+M0UTWIwXrhrH/0bqfZatcJjYObWyefxfAxapGEyNnSGd1Tg90neHz+Yhf+Ff/zgvPiQYA== + dependencies: + "@azure/core-auth" "^1.4.0" + "@azure/core-rest-pipeline" "^1.10.0" + "@microsoft/applicationinsights-web-snippet" "^1.0.1" + "@opentelemetry/api" "^1.0.4" + "@opentelemetry/core" "^1.0.1" + "@opentelemetry/sdk-trace-base" "^1.0.1" + "@opentelemetry/semantic-conventions" "^1.0.1" + cls-hooked "^4.2.2" + continuation-local-storage "^3.2.1" + diagnostic-channel "1.1.0" + diagnostic-channel-publishers "1.0.5" + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -939,11 +1234,26 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA== + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q== + array-back@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.1.tgz#9b80312935a52062e1a233a9c7abeb5481b30e90" integrity sha512-Z/JnaVEXv+A9xabHzN43FiiiWEE7gPCRXMrVmRm00tWbjZRul1iHm7ECzlyNq1p4a4ATXz+G9FJ3GqGOkOV3fg== +array-differ@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-3.0.0.tgz#3cbb3d0f316810eafcc47624734237d6aee4ae6b" + integrity sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg== + array-includes@^3.1.1: version "3.1.3" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.3.tgz#c7f619b382ad2afaf5326cddfdc0afc61af7690a" @@ -969,6 +1279,11 @@ array.prototype.flat@^1.2.3: define-properties "^1.1.3" es-abstract "^1.18.0-next.1" +arrify@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" + integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== + asn1.js@^5.2.0: version "5.4.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" @@ -979,18 +1294,6 @@ asn1.js@^5.2.0: minimalistic-assert "^1.0.0" safer-buffer "^2.1.0" -asn1@~0.2.3: - version "0.2.4" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" - integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== - dependencies: - safer-buffer "~2.1.0" - -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= - assert@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/assert/-/assert-2.0.0.tgz#95fc1c616d48713510680f2eaf2d10dd22e02d32" @@ -1001,11 +1304,31 @@ assert@^2.0.0: object-is "^1.0.1" util "^0.12.0" +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw== + astral-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +async-hook-jl@^1.7.6: + version "1.7.6" + resolved "https://registry.yarnpkg.com/async-hook-jl/-/async-hook-jl-1.7.6.tgz#4fd25c2f864dbaf279c610d73bf97b1b28595e68" + integrity sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg== + dependencies: + stack-chain "^1.3.7" + +async-listener@^0.6.0: + version "0.6.10" + resolved "https://registry.yarnpkg.com/async-listener/-/async-listener-0.6.10.tgz#a7c97abe570ba602d782273c0de60a51e3e17cbc" + integrity sha512-gpuo6xOyF4D5DE5WvyqZdPA3NGhiT6Qf07l7DCB0wwDEsLvDIbCr6j9S5aj5Ch96dLace5tXVzWBZkxU/c5ohw== + dependencies: + semver "^5.3.0" + shimmer "^1.1.0" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -1021,22 +1344,14 @@ available-typed-arrays@^1.0.2: resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.4.tgz#9e0ae84ecff20caae6a94a1c3bc39b955649b7a9" integrity sha512-SA5mXJWrId1TaQjfxUYghbqQ/hYioKmLJvPJyDuYRtXXenFNMjj4hSSt1Cf1xsuXSXrtxrVC5Ot4eU6cOtBDdA== -aws-sign2@~0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" - integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= - -aws4@^1.8.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" - integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== - -axios@^0.21.1: - version "0.21.4" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" - integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== +axios@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.1.tgz#76550d644bf0a2d469a01f9244db6753208397d7" + integrity sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g== dependencies: - follow-redirects "^1.14.0" + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" balanced-match@^1.0.0: version "1.0.0" @@ -1055,23 +1370,11 @@ basic-auth@~2.0.1: dependencies: safe-buffer "5.1.2" -bcrypt-pbkdf@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= - dependencies: - tweetnacl "^0.14.3" - before-after-hook@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.0.tgz#09c40d92e936c64777aa385c4e9b904f8147eaf0" integrity sha512-jH6rKQIfroBbhEXVmI7XmXe3ix5S/PgJqpzdDPnR8JGLHWNYLsYZ6tK5iWOF/Ra3oqEX0NobXGlzbiylIzVphQ== -big-integer@^1.6.17: - version "1.6.48" - resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.48.tgz#8fd88bd1632cba4a1c8c3e3d7159f08bb95b4b9e" - integrity sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w== - big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -1082,14 +1385,6 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== -binary@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79" - integrity sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk= - dependencies: - buffers "~0.1.1" - chainsaw "~0.1.0" - bl@^1.0.0: version "1.2.3" resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7" @@ -1098,21 +1393,21 @@ bl@^1.0.0: readable-stream "^2.3.5" safe-buffer "^5.1.1" -bluebird@~3.4.1: - version "3.4.7" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" - integrity sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM= - bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: version "4.12.0" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== -bn.js@^5.0.0, bn.js@^5.1.1: +bn.js@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.0.tgz#358860674396c6997771a9d051fcc1b57d4ae002" integrity sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw== +bn.js@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" + integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -1121,7 +1416,7 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -braces@^3.0.1, braces@~3.0.2: +braces@^3.0.1, braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -1174,7 +1469,7 @@ browserify-des@^1.0.0: inherits "^2.0.1" safe-buffer "^5.1.2" -browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: +browserify-rsa@^4.0.0, browserify-rsa@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.1.0.tgz#b2fd06b5b75ae297f7ce2dc651f918f5be158c8d" integrity sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog== @@ -1183,19 +1478,19 @@ browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: randombytes "^2.0.1" browserify-sign@^4.0.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.1.tgz#eaf4add46dd54be3bb3b36c0cf15abbeba7956c3" - integrity sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg== + version "4.2.2" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.2.tgz#e78d4b69816d6e3dd1c747e64e9947f9ad79bc7e" + integrity sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg== dependencies: - bn.js "^5.1.1" - browserify-rsa "^4.0.1" + bn.js "^5.2.1" + browserify-rsa "^4.1.0" create-hash "^1.2.0" create-hmac "^1.1.7" - elliptic "^6.5.3" + elliptic "^6.5.4" inherits "^2.0.4" - parse-asn1 "^5.1.5" - readable-stream "^3.6.0" - safe-buffer "^5.2.0" + parse-asn1 "^5.1.6" + readable-stream "^3.6.2" + safe-buffer "^5.2.1" browserslist@^4.14.5: version "4.16.6" @@ -1226,20 +1521,20 @@ buffer-crc32@~0.2.3: resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= +buffer-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.0.tgz#59616b498304d556abd466966b22eeda3eca5fbe" + integrity sha512-tcBWO2Dl4e7Asr9hTGcpVrCe+F7DubpmqWCTbj4FHLmjqO2hIaC383acQubWtRJhdceqs5uBHs6Es+Sk//RKiQ== + buffer-fill@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" integrity sha1-+PeLdniYiO858gXNY39o5wISKyw= buffer-from@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" - integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== - -buffer-indexof-polyfill@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c" - integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A== + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== buffer-xor@^1.0.3: version "1.0.3" @@ -1262,11 +1557,6 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" -buffers@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb" - integrity sha1-skV5w77U1tOWru5tmorn9Ugqt7s= - cache-content-type@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c" @@ -1298,18 +1588,6 @@ caniuse-lite@^1.0.30001219: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz#bfdc5942cd3326fa51ee0b42fbef4da9d492a7fa" integrity sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A== -caseless@~0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" - integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= - -chainsaw@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98" - integrity sha1-XqtQsor+WAdNDVgpE4iCi15fvJg= - dependencies: - traverse ">=0.3.0 <0.4" - chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -1386,11 +1664,49 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +clone-buffer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" + integrity sha512-KLLTJWrvwIP+OPfMn0x2PheDEP20RPUcGXj/ERegTgdmPEZylALQldygiqrPPu8P45uNuPs7ckmReLY6v/iA5g== + +clone-stats@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680" + integrity sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag== + +clone@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== + +cloneable-readable@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec" + integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ== + dependencies: + inherits "^2.0.1" + process-nextick-args "^2.0.0" + readable-stream "^2.3.5" + +cls-hooked@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/cls-hooked/-/cls-hooked-4.2.2.tgz#ad2e9a4092680cdaffeb2d3551da0e225eae1908" + integrity sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw== + dependencies: + async-hook-jl "^1.7.6" + emitter-listener "^1.0.1" + semver "^5.4.1" + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= +cockatiel@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/cockatiel/-/cockatiel-3.1.1.tgz#82c95dcad673649c43c0a35c424c5d2ad59d4e6b" + integrity sha512-zHMqBGvkZLfMKkBMD+0U8X1nW8zYwMtymgJ8CTknWOmTDpvjEwygtFN4QR9A1iFQDwCbg8g8+B/zVBoxvj1feQ== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -1420,7 +1736,7 @@ colorette@^1.2.1, colorette@^1.2.2: resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== -combined-stream@^1.0.6, combined-stream@~1.0.6: +combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -1437,7 +1753,7 @@ command-line-usage@^6.1.0: table-layout "^1.0.1" typical "^5.2.0" -commander@^2.20.0, commander@^2.8.1: +commander@^2.19.0, commander@^2.20.0, commander@^2.8.1: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -1447,10 +1763,10 @@ commander@^6.2.0: resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== -commander@^8.2.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" - integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== +commandpost@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/commandpost/-/commandpost-1.4.0.tgz#89218012089dfc9b67a337ba162f15c88e0f1048" + integrity sha512-aE2Y4MTFJ870NuB/+2z1cXBhSBBzRydVVjzhFC4gtenEhpnj15yu0qptWGJsO9YGrcPZ3ezX8AWb1VA391MKpQ== concat-map@0.0.1: version "0.0.1" @@ -1479,6 +1795,21 @@ content-type@^1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== +continuation-local-storage@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/continuation-local-storage/-/continuation-local-storage-3.2.1.tgz#11f613f74e914fe9b34c92ad2d28fe6ae1db7ffb" + integrity sha512-jx44cconVqkCEEyLSKWwkvUXwO561jXMa3LPjTPsm5QR22PA0/mhe33FT4Xb5y74JDvt/Cq+5lm8S8rskLv9ZA== + dependencies: + async-listener "^0.6.0" + emitter-listener "^1.1.1" + +convert-source-map@^1.5.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" + integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA== + dependencies: + safe-buffer "~5.1.1" + cookies@~0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90" @@ -1487,7 +1818,7 @@ cookies@~0.8.0: depd "~2.0.0" keygrip "~1.1.0" -core-util-is@1.0.2, core-util-is@~1.0.0: +core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= @@ -1595,17 +1926,17 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== -cssom@^0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" - integrity sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw== +cssom@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" + integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== cssom@~0.3.6: version "0.3.8" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== -cssstyle@^2.2.0: +cssstyle@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== @@ -1617,27 +1948,25 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.7.tgz#2a5fb75e1015e84dd15692f71e89a1450290950b" integrity sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g== -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= - dependencies: - assert-plus "^1.0.0" - -data-urls@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" - integrity sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ== +data-urls@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" + integrity sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ== dependencies: - abab "^2.0.3" - whatwg-mimetype "^2.3.0" - whatwg-url "^8.0.0" + abab "^2.0.6" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" dayjs@1.10.4: version "1.10.4" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== +debounce@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" + integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== + debug@2.6.9, debug@^2.2.0, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -1659,22 +1988,29 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -debug@^4.3.1, debug@^4.3.2: +debug@^4.3.2: version "4.3.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== dependencies: ms "2.1.2" +debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + decamelize@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== -decimal.js@^10.2.0: - version "10.2.1" - resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.1.tgz#238ae7b0f0c793d3e3cea410108b35a2c01426a3" - integrity sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw== +decimal.js@^10.3.1: + version "10.3.1" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" + integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ== decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1: version "4.1.1" @@ -1794,6 +2130,18 @@ destroy@^1.0.4: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= +diagnostic-channel-publishers@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diagnostic-channel-publishers/-/diagnostic-channel-publishers-1.0.5.tgz#df8c317086c50f5727fdfb5d2fce214d2e4130ae" + integrity sha512-dJwUS0915pkjjimPJVDnS/QQHsH0aOYhnZsLJdnZIMOrB+csj8RnZhWTuwnm8R5v3Z7OZs+ksv5luC14DGB7eg== + +diagnostic-channel@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/diagnostic-channel/-/diagnostic-channel-1.1.0.tgz#6985e9dfedfbc072d91dc4388477e4087147756e" + integrity sha512-fwujyMe1gj6rk6dYi9hMZm0c8Mz8NDMVl2LB4iaYh3+LIAThZC8RKFGXWG0IML2OxAit/ZFRgZhMkhQ3d/bobQ== + dependencies: + semver "^5.3.0" + diff@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" @@ -1845,27 +2193,37 @@ dom-testing-library@^4.1.0: pretty-format "^24.7.0" wait-for-expect "^1.1.1" -domexception@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" - integrity sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg== +domexception@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" + integrity sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw== dependencies: - webidl-conversions "^5.0.0" + webidl-conversions "^7.0.0" -duplexer2@~0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" - integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= +duplexer@^0.1.1, duplexer@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" + integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== + +duplexify@^3.6.0: + version "3.7.1" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" + integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g== dependencies: - readable-stream "^2.0.2" + end-of-stream "^1.0.0" + inherits "^2.0.1" + readable-stream "^2.0.0" + stream-shift "^1.0.0" -ecc-jsbn@~0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= +editorconfig@^0.15.0: + version "0.15.3" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" + integrity sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g== dependencies: - jsbn "~0.1.0" - safer-buffer "^2.1.0" + commander "^2.19.0" + lru-cache "^4.1.5" + semver "^5.6.0" + sigmund "^1.0.1" ee-first@1.1.1: version "1.1.1" @@ -1877,7 +2235,7 @@ electron-to-chromium@^1.3.723: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.737.tgz#196f2e9656f4f3c31930750e1899c091b72d36b5" integrity sha512-P/B84AgUSQXaum7a8m11HUsYL8tj9h/Pt5f7Hg7Ty6bm5DxlFq+e5+ouHUoNQMsKDJ7u4yGfI8mOErCmSH9wyg== -elliptic@^6.5.3: +elliptic@^6.5.3, elliptic@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== @@ -1890,6 +2248,13 @@ elliptic@^6.5.3: minimalistic-assert "^1.0.1" minimalistic-crypto-utils "^1.0.1" +emitter-listener@^1.0.1, emitter-listener@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/emitter-listener/-/emitter-listener-1.1.2.tgz#56b140e8f6992375b3d7cb2cab1cc7432d9632e8" + integrity sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ== + dependencies: + shimmer "^1.2.0" + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -1921,10 +2286,10 @@ enhanced-resolve@^4.0.0: memory-fs "^0.5.0" tapable "^1.0.0" -enhanced-resolve@^5.8.3: - version "5.8.3" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.8.3.tgz#6d552d465cce0423f5b3d718511ea53826a7b2f0" - integrity sha512-EGAbGvH7j7Xt2nc0E7D99La1OiEs8LnyimkRgwExpUMScN6O+3x9tIWs7PLQZVNx4YD+00skHXPXi1yQHpAmZA== +enhanced-resolve@^5.10.0: + version "5.12.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634" + integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -2033,18 +2398,13 @@ escape-string-regexp@^1.0.5: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= -escape-string-regexp@^2.0.0: +escodegen@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" - integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== - -escodegen@^1.14.1: - version "1.14.3" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" - integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw== + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd" + integrity sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw== dependencies: esprima "^4.0.1" - estraverse "^4.2.0" + estraverse "^5.2.0" esutils "^2.0.2" optionator "^0.8.1" optionalDependencies: @@ -2059,11 +2419,6 @@ eslint-cli@1.1.1: debug "^2.6.8" resolve "^1.3.3" -eslint-config-prettier@8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.1.0.tgz#4ef1eaf97afe5176e6a75ddfb57c335121abc5a6" - integrity sha512-oKMhGv3ihGbCIimCAjqkdzx2Q+jthoqnXSP+d86M9tptwugycmTFdVR4IpLgq2c4SHifbwO90z2fQ8/Aio73yw== - eslint-import-resolver-node@^0.3.4: version "0.3.4" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717" @@ -2099,7 +2454,7 @@ eslint-plugin-import@2.22.1: resolve "^1.17.0" tsconfig-paths "^3.9.0" -eslint-scope@5.1.1, eslint-scope@^5.0.0, eslint-scope@^5.1.1: +eslint-scope@5.1.1, eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== @@ -2107,7 +2462,7 @@ eslint-scope@5.1.1, eslint-scope@^5.0.0, eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-utils@^2.0.0, eslint-utils@^2.1.0: +eslint-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== @@ -2124,6 +2479,11 @@ eslint-visitor-keys@^2.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8" integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + eslint@7.22.0: version "7.22.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.22.0.tgz#07ecc61052fec63661a2cab6bd507127c07adc6f" @@ -2195,7 +2555,7 @@ esrecurse@^4.3.0: dependencies: estraverse "^5.2.0" -estraverse@^4.1.1, estraverse@^4.2.0: +estraverse@^4.1.1: version "4.3.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== @@ -2210,6 +2570,19 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +event-stream@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-4.0.1.tgz#4092808ec995d0dd75ea4580c1df6a74db2cde65" + integrity sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA== + 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" + events@3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/events/-/events-3.2.0.tgz#93b87c18f8efcd4202a461aec4dfc0556b639379" @@ -2243,48 +2616,34 @@ execa@^4.1.0: signal-exit "^3.0.2" strip-final-newline "^2.0.0" -extend@~3.0.2: +extend-shallow@^3.0.2: version "3.0.2" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" - integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== - -extract-zip@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" - integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q== dependencies: - debug "^4.1.1" - get-stream "^5.1.0" - yauzl "^2.10.0" - optionalDependencies: - "@types/yauzl" "^2.9.1" - -extsprintf@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + assign-symbols "^1.0.0" + is-extendable "^1.0.1" -extsprintf@^1.2.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" - integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.1.1: - version "3.2.5" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.5.tgz#7939af2a656de79a4f1901903ee8adcaa7cb9661" - integrity sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg== +fast-glob@^3.2.9: + version "3.3.2" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== dependencies: "@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.0" + glob-parent "^5.1.2" merge2 "^1.3.0" - micromatch "^4.0.2" - picomatch "^2.2.1" + micromatch "^4.0.4" fast-json-stable-stringify@^2.0.0: version "2.1.0" @@ -2380,21 +2739,24 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== -follow-redirects@^1.14.0: - version "1.14.8" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc" - integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA== +flush-write-stream@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" + integrity sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w== + dependencies: + inherits "^2.0.3" + readable-stream "^2.3.6" + +follow-redirects@^1.15.0: + version "1.15.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" + integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== foreach@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= - fork-ts-checker-webpack-plugin@6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.1.1.tgz#c6c3b6506bfb0c7b645cd5c377e82e670d7d71c9" @@ -2413,13 +2775,13 @@ fork-ts-checker-webpack-plugin@6.1.1: semver "^7.3.2" tapable "^1.0.0" -form-data@~2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" - integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== dependencies: asynckit "^0.4.0" - combined-stream "^1.0.6" + combined-stream "^1.0.8" mime-types "^2.1.12" fresh@~0.5.2: @@ -2427,6 +2789,11 @@ fresh@~0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= +from@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" + integrity sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g== + fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" @@ -2442,6 +2809,14 @@ fs-extra@^9.0.0: jsonfile "^6.0.1" universalify "^2.0.0" +fs-mkdirp-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-mkdirp-stream/-/fs-mkdirp-stream-1.0.0.tgz#0b7815fc3201c6a69e14db98ce098c16935259eb" + integrity sha512-+vSd9frUnapVC2RZYfL3FCB2p3g4TBhaUmrsWlSudsGdnxIuUvBB2QM1VZeBtc49QFwrp+wQLrDs3+xxDgI5gQ== + dependencies: + graceful-fs "^4.1.11" + through2 "^2.0.3" + fs-monkey@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.1.tgz#4a82f36944365e619f4454d9fff106553067b781" @@ -2457,16 +2832,6 @@ fsevents@~2.3.1, fsevents@~2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== -fstream@^1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" - integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== - dependencies: - graceful-fs "^4.1.2" - inherits "~2.0.0" - mkdirp ">=0.5 0" - rimraf "2" - function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -2491,6 +2856,11 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.1" +get-stream@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + get-stream@^2.2.0: version "2.3.1" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-2.3.1.tgz#5f38f93f346009666ee0150a054167f91bdd95de" @@ -2499,27 +2869,44 @@ get-stream@^2.2.0: object-assign "^4.0.1" pinkie-promise "^2.0.0" -get-stream@^5.0.0, get-stream@^5.1.0: +get-stream@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== dependencies: pump "^3.0.0" -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= +glob-parent@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" + integrity sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA== dependencies: - assert-plus "^1.0.0" + is-glob "^3.1.0" + path-dirname "^1.0.0" -glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0, glob-parent@~5.1.2: +glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@~5.1.0, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: is-glob "^4.0.1" +glob-stream@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-6.1.0.tgz#7045c99413b3eb94888d83ab46d0b404cc7bdde4" + integrity sha512-uMbLGAP3S2aDOHUDfdoYcdIePUCfysbAd0IAoWVZbeGU/oNQ8asHVSshLDJUPWxfzj8zsCG7/XeHPHTtow0nsw== + 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" + glob-to-regexp@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" @@ -2549,6 +2936,18 @@ glob@7.1.7: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.1.1: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + 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" + globals@^12.1.0: version "12.4.0" resolved "https://registry.yarnpkg.com/globals/-/globals-12.4.0.tgz#a18813576a41b00a24a97e7f815918c2e19925f8" @@ -2563,27 +2962,27 @@ globals@^13.6.0: dependencies: type-fest "^0.20.2" -globby@^11.0.1: - version "11.0.2" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.2.tgz#1af538b766a3b540ebfb58a32b2e2d5897321d83" - integrity sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og== +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== dependencies: array-union "^2.1.0" dir-glob "^3.0.1" - fast-glob "^3.1.1" - ignore "^5.1.4" - merge2 "^1.3.0" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" slash "^3.0.0" -graceful-fs@^4.1.10, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4: - version "4.2.6" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" - integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== +graceful-fs@^4.0.0, graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9: + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== -graceful-fs@^4.2.9: - version "4.2.9" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" - integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== graphql-tag@2.11.0, graphql-tag@^2.4.2: version "2.11.0" @@ -2600,18 +2999,15 @@ growl@1.10.5: resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== -har-schema@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= - -har-validator@~5.1.3: - version "5.1.5" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" - integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== +gulp-filter@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/gulp-filter/-/gulp-filter-7.0.0.tgz#e0712f3e57b5d647f802a1880255cafb54abf158" + integrity sha512-ZGWtJo0j1mHfP77tVuhyqem4MRA5NfNRjoVe6VAkLGeQQ/QGo2VsFwp7zfPTGDsd1rwzBmoDHhxpE6f5B3Zuaw== dependencies: - ajv "^6.12.3" - har-schema "^2.0.0" + multimatch "^5.0.0" + plugin-error "^1.0.1" + streamfilter "^3.0.0" + to-absolute-glob "^2.0.2" has-bigints@^1.0.0: version "1.0.1" @@ -2676,12 +3072,12 @@ hosted-git-info@^2.1.4: resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== -html-encoding-sniffer@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" - integrity sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ== +html-encoding-sniffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" + integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA== dependencies: - whatwg-encoding "^1.0.5" + whatwg-encoding "^2.0.0" http-assert@^1.3.0: version "1.4.1" @@ -2741,15 +3137,6 @@ http-proxy-agent@^5.0.0: agent-base "6" debug "4" -http-signature@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= - dependencies: - assert-plus "^1.0.0" - jsprim "^1.2.2" - sshpk "^1.7.0" - https-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" @@ -2758,17 +3145,30 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + human-signals@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== -iconv-lite@0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== +husky@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.1.tgz#511cb3e57de3e3190514ae49ed50f6bc3f50b3e9" + integrity sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw== + +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== dependencies: - safer-buffer ">= 2.1.2 < 3" + safer-buffer ">= 2.1.2 < 3.0.0" icss-utils@^5.0.0, icss-utils@^5.1.0: version "5.1.0" @@ -2785,10 +3185,15 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -ignore@^5.1.4: - version "5.1.8" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" - integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== +ignore@^5.2.0, ignore@^5.2.4: + version "5.2.4" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" + integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== + +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: version "3.3.0" @@ -2824,7 +3229,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3, inherits@~2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -2839,15 +3244,13 @@ interpret@^2.2.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== -ip-regex@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" - integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= - -ip@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" - integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= +is-absolute@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576" + integrity sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA== + dependencies: + is-relative "^1.0.0" + is-windows "^1.0.1" is-arguments@^1.0.4: version "1.1.0" @@ -2880,7 +3283,7 @@ is-boolean-object@^1.1.0: dependencies: call-bind "^1.0.0" -is-buffer@~1.1.6: +is-buffer@^1.1.5, is-buffer@~1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== @@ -2902,7 +3305,14 @@ is-date-object@^1.0.1: resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== -is-extglob@^2.1.1: +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + +is-extglob@^2.1.0, is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= @@ -2922,6 +3332,13 @@ is-generator-function@^1.0.7: resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.9.tgz#e5f82c2323673e7fcad3d12858c83c4039f6399c" integrity sha512-ZJ34p1uvIfptHCN7sFTjGibB9/oBg17sHqzDLfuwhvmN/qLVvIQXRQ8licZQ35WJ8KuEQt/etnnzQFI9C9Ue/A== +is-glob@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" + integrity sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw== + dependencies: + is-extglob "^2.1.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" @@ -2929,6 +3346,13 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + is-nan@^1.2.1: version "1.3.2" resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" @@ -2942,6 +3366,11 @@ is-natural-number@^4.0.1: resolved "https://registry.yarnpkg.com/is-natural-number/-/is-natural-number-4.0.1.tgz#ab9d76e1db4ced51e35de0c72ebecf09f734cde8" integrity sha1-q5124dtM7VHjXeDHLr7PCfc0zeg= +is-negated-glob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2" + integrity sha512-czXVVn/QEmgvej1f50BZ648vUI+em0xqMq2Sn+QncCLN4zj1UAxlT+kw/6ggQTOaZPd1HqKQGEqbpQVtJucWug== + is-negative-zero@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" @@ -2962,15 +3391,22 @@ is-plain-obj@^2.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== +is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + is-plain-object@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== -is-potential-custom-element-name@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397" - integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c= +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== is-regex@^1.1.2: version "1.1.2" @@ -2980,6 +3416,13 @@ is-regex@^1.1.2: call-bind "^1.0.2" has-symbols "^1.0.1" +is-relative@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d" + integrity sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA== + dependencies: + is-unc-path "^1.0.0" + is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -3013,16 +3456,33 @@ is-typed-array@^1.1.3: foreach "^2.0.5" has-symbols "^1.0.1" -is-typedarray@~1.0.0: +is-unc-path@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-1.0.0.tgz#d731e8898ed090a12c352ad2eaed5095ad322c9d" + integrity sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ== + dependencies: + unc-path-regex "^0.1.2" is-unicode-supported@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== +is-utf8@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" + integrity sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q== + +is-valid-glob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-1.0.0.tgz#29bf3eff701be2d4d315dbacc39bc39fe8f601aa" + integrity sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA== + +is-windows@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + isarray@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" @@ -3038,10 +3498,10 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= -isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= +isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== jest-worker@^26.6.2: version "26.6.2" @@ -3066,11 +3526,6 @@ joycon@^2.2.5: resolved "https://registry.yarnpkg.com/joycon/-/joycon-2.2.5.tgz#8d4cf4cbb2544d7b7583c216fcdfec19f6be1615" integrity sha512-YqvUxoOcVPnCp0VU1/56f+iKSdvIRJYPznH22BdXV3xMk75SFXhWeJkZ8C9XxUWt1b5x2X1SxuFygW1U0FmkEQ== -jpeg-js@^0.4.2: - version "0.4.3" - resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.3.tgz#6158e09f1983ad773813704be80680550eff977b" - integrity sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q== - "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3091,54 +3546,45 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= - jsdom-global@3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/jsdom-global/-/jsdom-global-3.0.2.tgz#6bd299c13b0c4626b2da2c0393cd4385d606acb9" integrity sha1-a9KZwTsMRiay2iwDk81DhdYGrLk= -jsdom@16.4.0: - version "16.4.0" - resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.4.0.tgz#36005bde2d136f73eee1a830c6d45e55408edddb" - integrity sha512-lYMm3wYdgPhrl7pDcRmvzPhhrGVBeVhPIqeHjzeiHN3DFmD1RBpbExbi8vU7BJdH8VAZYovR8DMt0PNNDM7k8w== +jsdom@19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-19.0.0.tgz#93e67c149fe26816d38a849ea30ac93677e16b6a" + integrity sha512-RYAyjCbxy/vri/CfnjUWJQQtZ3LKlLnDqj+9XLNnJPgEGeirZs3hllKR20re8LUZ6o1b1X4Jat+Qd26zmP41+A== dependencies: - abab "^2.0.3" - acorn "^7.1.1" + abab "^2.0.5" + acorn "^8.5.0" acorn-globals "^6.0.0" - cssom "^0.4.4" - cssstyle "^2.2.0" - data-urls "^2.0.0" - decimal.js "^10.2.0" - domexception "^2.0.1" - escodegen "^1.14.1" - html-encoding-sniffer "^2.0.1" - is-potential-custom-element-name "^1.0.0" + cssom "^0.5.0" + cssstyle "^2.3.0" + data-urls "^3.0.1" + decimal.js "^10.3.1" + domexception "^4.0.0" + escodegen "^2.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.0" + is-potential-custom-element-name "^1.0.1" nwsapi "^2.2.0" - parse5 "5.1.1" - request "^2.88.2" - request-promise-native "^1.0.8" - saxes "^5.0.0" + parse5 "6.0.1" + saxes "^5.0.1" symbol-tree "^3.2.4" - tough-cookie "^3.0.1" + tough-cookie "^4.0.0" w3c-hr-time "^1.0.2" - w3c-xmlserializer "^2.0.0" - webidl-conversions "^6.1.0" - whatwg-encoding "^1.0.5" - whatwg-mimetype "^2.3.0" - whatwg-url "^8.0.0" - ws "^7.2.3" - xml-name-validator "^3.0.0" - -json-parse-better-errors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" - integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== - -json-parse-even-better-errors@^2.3.0: + w3c-xmlserializer "^3.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^10.0.0" + ws "^8.2.3" + xml-name-validator "^4.0.0" + +json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== @@ -3153,27 +3599,15 @@ json-schema-traverse@^1.0.0: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== -json-schema@0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" - integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= - json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= -json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= - -json5@2.2.0, json5@^2.1.2, json5@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" - integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA== - dependencies: - minimist "^1.2.5" +json5@2.2.2, json5@^2.1.2, json5@^2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.2.tgz#64471c5bdcc564c18f7c1d4df2e2297f2457c5ab" + integrity sha512-46Tk9JiOL2z7ytNQWFLpj99RZkVgeHf87yGQKsIkaPz1qSH9UczKH1rO7K3wgRselo0tYMUNfecYpm/p1vC7tQ== json5@^1.0.1: version "1.0.1" @@ -3191,15 +3625,15 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" -jsprim@^1.2.2: - version "1.4.1" - resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" - integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== dependencies: - assert-plus "1.0.0" - extsprintf "1.3.0" - json-schema "0.2.3" - verror "1.10.0" + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" just-extend@^4.0.2: version "4.1.1" @@ -3287,6 +3721,20 @@ koa@^2.13.4: type-is "^1.6.16" vary "^1.1.2" +lazystream@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.1.tgz#494c831062f1f9408251ec44db1cba29242a2638" + integrity sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw== + dependencies: + readable-stream "^2.0.5" + +lead@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lead/-/lead-1.0.0.tgz#6f14f99a37be3a9dd784f5495690e5903466ee42" + integrity sha512-IpSVCk9AYvLHo5ctcIXxOBpMWUe+4TKN3VPWAKUbJikkmsGp0VrSM8IttVc32D6J4WUsiPE6aEFRNmIoF/gdow== + dependencies: + flush-write-stream "^1.0.2" + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -3308,16 +3756,18 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= -listenercount@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937" - integrity sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc= - load-json-file@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" @@ -3334,9 +3784,9 @@ loader-runner@^4.2.0: integrity sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw== loader-utils@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" - integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== + version "1.4.2" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.2.tgz#29a957f3a63973883eb684f10ffd3d151fec01a3" + integrity sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg== dependencies: big.js "^5.2.2" emojis-list "^3.0.0" @@ -3378,12 +3828,7 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= -lodash.sortby@^4.7.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" - integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= - -lodash@^4.16.4, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21: +lodash@^4.16.4, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -3410,6 +3855,14 @@ lru-cache@6.0.0, lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" + integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + make-dir@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" @@ -3417,6 +3870,11 @@ make-dir@^1.0.0: dependencies: pify "^3.0.0" +map-stream@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.0.7.tgz#8a1f07896d82b10926bd3744a2420009f88974a8" + integrity sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ== + marked@^4.0.10: version "4.0.10" resolved "https://registry.yarnpkg.com/marked/-/marked-4.0.10.tgz#423e295385cc0c3a70fa495e0df68b007b879423" @@ -3472,7 +3930,7 @@ merge-stream@^2.0.0: resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== -merge2@^1.3.0: +merge2@^1.3.0, merge2@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== @@ -3482,7 +3940,7 @@ methods@^1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= -micromatch@^4.0.0, micromatch@^4.0.2: +micromatch@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== @@ -3490,6 +3948,14 @@ micromatch@^4.0.0, micromatch@^4.0.2: braces "^3.0.1" picomatch "^2.0.5" +micromatch@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + miller-rabin@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" @@ -3498,35 +3964,18 @@ miller-rabin@^4.0.0: bn.js "^4.0.0" brorand "^1.0.1" -mime-db@1.46.0: - version "1.46.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.46.0.tgz#6267748a7f799594de3cbc8cde91def349661cee" - integrity sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ== - mime-db@1.48.0: version "1.48.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" integrity sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ== -mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.19: - version "2.1.29" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.29.tgz#1d4ab77da64b91f5f72489df29236563754bb1b2" - integrity sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ== - dependencies: - mime-db "1.46.0" - -mime-types@^2.1.18, mime-types@~2.1.24: +mime-types@^2.1.12, mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.24: version "2.1.31" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.31.tgz#a00d76b74317c61f9c2db2218b8e9f8e9c5c9e6b" integrity sha512-XGZnNzm3QvgKxa8dpzyhFTHmpP3l5YNusmne07VUOXxou9CqUqYa/HBy124RqtVh/O2pECas/MOcsDgpilPOPg== dependencies: mime-db "1.48.0" -mime@^2.4.6: - version "2.5.2" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" - integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== - mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -3549,17 +3998,24 @@ minimatch@3.0.4, minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" +minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: - version "1.2.6" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" - integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== + version "1.2.7" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" + integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== mkdirp@1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -"mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@~0.5.1: +mkdirp@^0.5.1, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -3642,11 +4098,27 @@ ms@2.1.3, ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nanoid@3.1.23, nanoid@^3.1.23: +multimatch@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-5.0.0.tgz#932b800963cea7a31a033328fa1e0c3a1874dbe6" + integrity sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA== + dependencies: + "@types/minimatch" "^3.0.3" + array-differ "^3.0.0" + array-union "^2.1.0" + arrify "^2.0.1" + minimatch "^3.0.4" + +nanoid@3.1.23: version "3.1.23" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81" integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw== +nanoid@^3.3.6: + version "3.3.6" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" + integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -3695,11 +4167,25 @@ normalize-package-data@^2.3.2: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" +normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w== + dependencies: + remove-trailing-separator "^1.0.1" + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +now-and-later@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/now-and-later/-/now-and-later-2.0.1.tgz#8e579c8685764a7cc02cb680380e94f43ccb1f7c" + integrity sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ== + dependencies: + once "^1.3.2" + npm-run-path@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -3712,11 +4198,6 @@ nwsapi@^2.2.0: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== -oauth-sign@~0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" - integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== - object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -3740,7 +4221,7 @@ object-keys@^1.0.12, object-keys@^1.1.1: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.2: +object.assign@^4.0.4, object.assign@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== @@ -3772,7 +4253,7 @@ on-headers@~1.0.2: resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== -once@^1.3.0, once@^1.3.1, once@^1.4.0: +once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= @@ -3822,11 +4303,25 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" +ordered-read-streams@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz#77c0cb37c41525d64166d990ffad7ec6a0e1363e" + integrity sha512-Z87aSjx3r5c0ZB7bcJqIgIRX5bxR7A4aSzvIbaxd0oTkWBCOoKfuGHiKj60CHVUgg1Phm5yMZzBdt8XqRs73Mw== + dependencies: + readable-stream "^2.0.1" + os-browserify@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= +p-all@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-all/-/p-all-1.0.0.tgz#93bdf53a55a23821fdfa98b4174a99bf7f31df8d" + integrity sha512-OtbznqfGjQT+i89LK9C9YPh1G8d6n8xgsJ8yRVXrx6PRXrlOthNJhP+dHxrPopty8fugYb1DodpwrzP7z0Mtvw== + dependencies: + p-map "^1.0.0" + p-limit@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" @@ -3869,6 +4364,11 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-map@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" + integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA== + p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -3879,6 +4379,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -3886,7 +4391,7 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-asn1@^5.0.0, parse-asn1@^5.1.5: +parse-asn1@^5.0.0, parse-asn1@^5.1.6: version "5.1.6" resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.6.tgz#385080a3ec13cb62a62d39409cb3e88844cdaed4" integrity sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw== @@ -3914,10 +4419,10 @@ parse-json@^5.0.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse5@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" - integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== +parse5@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" + integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== parseurl@^1.3.2: version "1.3.3" @@ -3929,6 +4434,11 @@ path-browserify@1.0.1: resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== +path-dirname@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" + integrity sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q== + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -3978,6 +4488,13 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pause-stream@^0.0.11: + version "0.0.11" + resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" + integrity sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A== + dependencies: + through "~2.3" + pbkdf2@^3.0.3: version "3.1.2" resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075" @@ -3994,16 +4511,21 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1: version "2.2.2" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + pify@^2.0.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -4040,39 +4562,27 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -playwright-core@=1.18.1: - version "1.18.1" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.18.1.tgz#a5cf3f212d10692382e2acd1f7bc8c9ff9bbb849" - integrity sha512-NALGl8R1GHzGLlhUApmpmfh6M1rrrPcDTygWvhTbprxwGB9qd/j9DRwyn4HTQcUB6o0/VOpo46fH9ez3+D/Rog== - dependencies: - commander "^8.2.0" - debug "^4.1.1" - extract-zip "^2.0.1" - https-proxy-agent "^5.0.0" - jpeg-js "^0.4.2" - mime "^2.4.6" - pngjs "^5.0.0" - progress "^2.0.3" - proper-lockfile "^4.1.1" - proxy-from-env "^1.1.0" - rimraf "^3.0.2" - socks-proxy-agent "^6.1.0" - stack-utils "^2.0.3" - ws "^7.4.6" - yauzl "^2.10.0" - yazl "^2.5.1" +playwright-core@1.24.2: + version "1.24.2" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.24.2.tgz#47bc5adf3dcfcc297a5a7a332449c9009987db26" + integrity sha512-zfAoDoPY/0sDLsgSgLZwWmSCevIg1ym7CppBwllguVBNiHeixZkc1AdMuYUPZC6AdEYc4CxWEyLMBTw2YcmRrA== -playwright@^1.18.1: - version "1.18.1" - resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.18.1.tgz#45c2ca6ee25c44e336985de9b51955727b5f17cf" - integrity sha512-8EaX9EtbtAoMq5tnzIsoA3b/V86V/6Mq2skuOU4qEw+5OVxs1lwesDwmjy/RVU1Qfx5UuwSQzhp45wyH22oa+A== +playwright@^1.23.1: + version "1.24.2" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.24.2.tgz#51e60f128b386023e5ee83deca23453aaf73ba6d" + integrity sha512-iMWDLgaFRT+7dXsNeYwgl8nhLHsUrzFyaRVC+ftr++P1dVs70mPrFKBZrGp1fOKigHV9d1syC03IpPbqLKlPsg== dependencies: - playwright-core "=1.18.1" + playwright-core "1.24.2" -pngjs@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" - integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== +plugin-error@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-1.0.1.tgz#77016bd8919d0ac377fdcdd0322328953ca5781c" + integrity sha512-L1zP0dk7vGweZME2i+EeakvUNqSrdiI3F91TwEoYiGrAfUXmVv6fJIq4g82PAXxNsWOp0J7ZqQy/3Szz0ajTxA== + dependencies: + ansi-colors "^1.0.1" + arr-diff "^4.0.0" + arr-union "^3.1.0" + extend-shallow "^3.0.2" postcss-modules-extract-imports@^3.0.0: version "3.0.0" @@ -4118,13 +4628,13 @@ postcss-value-parser@^4.1.0: integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== postcss@^8.2.8: - version "8.2.15" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.15.tgz#9e66ccf07292817d226fc315cbbf9bc148fbca65" - integrity sha512-2zO3b26eJD/8rb106Qu2o7Qgg52ND5HPjcyQiK2B98O388h43A448LCslC0dI2P97wCAQRJsFvwTRcXxTKds+Q== + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== dependencies: - colorette "^1.2.2" - nanoid "^3.1.23" - source-map "^0.6.1" + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" prelude-ls@^1.2.1: version "1.2.1" @@ -4136,11 +4646,6 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= -prettier@2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" - integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== - pretty-format@^24.7.0: version "24.9.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" @@ -4151,7 +4656,7 @@ pretty-format@^24.7.0: ansi-styles "^3.2.0" react-is "^16.8.4" -process-nextick-args@~2.0.0: +process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== @@ -4161,7 +4666,7 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= -progress@^2.0.0, progress@^2.0.3: +progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== @@ -4175,15 +4680,6 @@ prop-types@^15.6.2: object-assign "^4.1.1" react-is "^16.8.1" -proper-lockfile@^4.1.1: - version "4.1.2" - resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" - integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== - dependencies: - graceful-fs "^4.2.4" - retry "^0.12.0" - signal-exit "^3.0.2" - proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -4194,7 +4690,12 @@ prr@~1.0.1: resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY= -psl@^1.1.28: +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ== + +psl@^1.1.33: version "1.8.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== @@ -4211,6 +4712,14 @@ public-encrypt@^4.0.0: randombytes "^2.0.1" safe-buffer "^5.1.2" +pump@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" + integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" @@ -4219,15 +4728,24 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" +pumpify@^1.3.5: + version "1.5.1" + resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" + integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ== + dependencies: + duplexify "^3.6.0" + inherits "^2.0.3" + pump "^2.0.0" + punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -qs@~6.5.2: - version "6.5.2" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" - integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== queue-microtask@^1.2.2: version "1.2.2" @@ -4306,7 +4824,7 @@ read-pkg@^2.0.0: normalize-package-data "^2.3.2" path-type "^2.0.0" -readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@~2.3.6: +readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -4319,7 +4837,7 @@ readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.3.0, readable string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.5.0, readable-stream@^3.6.0: +readable-stream@^3.0.6, readable-stream@^3.5.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -4328,6 +4846,15 @@ readable-stream@^3.5.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@~3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" @@ -4359,52 +4886,37 @@ regenerator-runtime@^0.13.4: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== -regexpp@^3.0.0, regexpp@^3.1.0: +regexpp@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2" integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q== -request-promise-core@1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.4.tgz#3eedd4223208d419867b78ce815167d10593a22f" - integrity sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw== +remove-bom-buffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz#c2bf1e377520d324f623892e33c10cac2c252b53" + integrity sha512-8v2rWhaakv18qcvNeli2mZ/TMTL2nEyAKRvzo1WtnZBl15SHyEhrCu2/xKlJyUFKHiHgfXIyuY6g2dObJJycXQ== dependencies: - lodash "^4.17.19" + is-buffer "^1.1.5" + is-utf8 "^0.2.1" -request-promise-native@^1.0.8: - version "1.0.9" - resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.9.tgz#e407120526a5efdc9a39b28a5679bf47b9d9dc28" - integrity sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g== - dependencies: - request-promise-core "1.1.4" - stealthy-require "^1.1.1" - tough-cookie "^2.3.3" - -request@^2.88.2: - version "2.88.2" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" - integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.8.0" - caseless "~0.12.0" - combined-stream "~1.0.6" - extend "~3.0.2" - forever-agent "~0.6.1" - form-data "~2.3.2" - har-validator "~5.1.3" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.19" - oauth-sign "~0.9.0" - performance-now "^2.1.0" - qs "~6.5.2" - safe-buffer "^5.1.2" - tough-cookie "~2.5.0" - tunnel-agent "^0.6.0" - uuid "^3.3.2" +remove-bom-stream@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/remove-bom-stream/-/remove-bom-stream-1.2.0.tgz#05f1a593f16e42e1fb90ebf59de8e569525f9523" + integrity sha512-wigO8/O08XHb8YPzpDDT+QmRANfW6vLqxfaXm1YXhnFf3AkSLyjfG3GEFg4McZkmgL7KvCj5u2KczkvSP6NfHA== + dependencies: + remove-bom-buffer "^3.0.0" + safe-buffer "^5.1.0" + through2 "^2.0.3" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw== + +replace-ext@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a" + integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw== require-directory@^2.1.1: version "2.1.1" @@ -4416,6 +4928,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" @@ -4433,6 +4950,13 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== +resolve-options@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/resolve-options/-/resolve-options-1.1.0.tgz#32bb9e39c06d67338dc9378c0d6d6074566ad131" + integrity sha512-NYDgziiroVeDC29xq7bp/CacZERYsA9bXYd1ZmcJlF3BcrZv5pTb4NG7SjdyKDnXZ84aC4vo2u6sNKIA1LCu/A== + dependencies: + value-or-function "^3.0.0" + resolve-path@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7" @@ -4449,23 +4973,11 @@ resolve@^1.10.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.3.3, resolve@^1.9. is-core-module "^2.2.0" path-parse "^1.0.6" -retry@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" - integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= - reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== -rimraf@2: - version "2.7.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== - dependencies: - glob "^7.1.3" - rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" @@ -4500,17 +5012,17 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -saxes@^5.0.0: +saxes@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" integrity sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw== @@ -4534,16 +5046,7 @@ schema-utils@2.7.0: ajv "^6.12.2" ajv-keywords "^3.4.1" -schema-utils@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.0.0.tgz#67502f6aa2b66a2d4032b4279a2944978a0913ef" - integrity sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA== - dependencies: - "@types/json-schema" "^7.0.6" - ajv "^6.12.5" - ajv-keywords "^3.5.2" - -schema-utils@^3.1.0, schema-utils@^3.1.1: +schema-utils@^3.0.0, schema-utils@^3.1.0, schema-utils@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.1.tgz#bc74c4b6b6995c1d88f76a8b77bea7219e0c8281" integrity sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw== @@ -4559,15 +5062,15 @@ seek-bzip@^1.0.5: dependencies: commander "^2.8.1" -"semver@2 || 3 || 4 || 5": - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.6.0: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: - version "7.3.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97" - integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw== +semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.5.2, semver@^7.5.4: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== dependencies: lru-cache "^6.0.0" @@ -4585,10 +5088,10 @@ serialize-javascript@^5.0.1: dependencies: randombytes "^2.1.0" -setimmediate@^1.0.4, setimmediate@~1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== setprototypeof@1.1.0: version "1.1.0" @@ -4625,6 +5128,16 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +shimmer@^1.1.0, shimmer@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/shimmer/-/shimmer-1.2.1.tgz#610859f7de327b587efebf501fb43117f9aff337" + integrity sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw== + +sigmund@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" + integrity sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g== + signal-exit@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" @@ -4662,34 +5175,17 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" -smart-buffer@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" - integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== - -socks-proxy-agent@^6.1.0: - version "6.1.1" - resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-6.1.1.tgz#e664e8f1aaf4e1fb3df945f09e3d94f911137f87" - integrity sha512-t8J0kG3csjA4g6FTbsMOWws+7R7vuRC8aQ/wy3/1OWmsgwA68zs/+cExQ0koSitUDXqhufF/YJr9wtNMZHw5Ew== - dependencies: - agent-base "^6.0.2" - debug "^4.3.1" - socks "^2.6.1" - -socks@^2.6.1: - version "2.6.2" - resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.2.tgz#ec042d7960073d40d94268ff3bb727dc685f111a" - integrity sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA== - dependencies: - ip "^1.1.5" - smart-buffer "^4.2.0" - source-list-map@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== -source-map-support@0.5.19, source-map-support@~0.5.19: +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + +source-map-support@0.5.19: version "0.5.19" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== @@ -4710,11 +5206,6 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@~0.7.2: - version "0.7.3" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" - integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== - spdx-correct@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" @@ -4741,48 +5232,33 @@ spdx-license-ids@^3.0.0: resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65" integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ== +split@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9" + integrity sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg== + dependencies: + through "2" + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -ssh-config@^2.0.0-alpha.3: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ssh-config/-/ssh-config-2.0.0.tgz#84ed978ca0e56939a44e851117a6c5181256df94" - integrity sha512-yjsjUa33gtB0UoQj0FKqfEMf+KABs/l21Lf/vslT7hkbnK05d5Dm8vApg/dHAn4WMq/pwLjP/N3ep408WspSBQ== - -sshpk@^1.7.0: - version "1.16.1" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" - integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - bcrypt-pbkdf "^1.0.0" - dashdash "^1.12.0" - ecc-jsbn "~0.1.1" - getpass "^0.1.1" - jsbn "~0.1.0" - safer-buffer "^2.0.2" - tweetnacl "~0.14.0" - -stack-utils@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.3.tgz#cd5f030126ff116b78ccb3c027fe302713b61277" - integrity sha512-gL//fkxfWUsIlFL2Tl42Cl6+HFALEaB1FU76I/Fy+oZjRreP7OPMXFlGbxM7NQsI0ZpUfw76sHnv0WNYuTb7Iw== - dependencies: - escape-string-regexp "^2.0.0" +ssh-config@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ssh-config/-/ssh-config-4.1.1.tgz#de8ab080c97234873291488b36090cdc2d075b06" + integrity sha512-p9t6ZX2yg3vzrUh81arLOOh73arUdm8aZ8YG1Rxve0T+1PVv6v7DhXQ/uPQ0lJaubEvGXJz7eGj7Z850443Ohg== + +stack-chain@^1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-1.3.7.tgz#d192c9ff4ea6a22c94c4dd459171e3f00cea1285" + integrity sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug== "statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= -stealthy-require@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" - integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= - stream-browserify@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" @@ -4791,6 +5267,26 @@ stream-browserify@^3.0.0: inherits "~2.0.4" readable-stream "^3.5.0" +stream-combiner@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.2.2.tgz#aec8cbac177b56b6f4fa479ced8c1912cee52858" + integrity sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ== + dependencies: + duplexer "~0.1.1" + through "~2.3.4" + +stream-shift@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" + integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== + +streamfilter@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/streamfilter/-/streamfilter-3.0.0.tgz#8c61b08179a6c336c6efccc5df30861b7a9675e7" + integrity sha512-kvKNfXCmUyC8lAXSSHCIXBUlo/lhsLcCU/OmzACZYpRUdtKIH68xYhm/+HI15jFJYtNJGYtCgn2wmIiExY1VwA== + dependencies: + readable-stream "^3.0.6" + "string-width@^1.0.2 || 2": version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -4965,12 +5461,12 @@ tar-stream@^1.5.2: to-buffer "^1.1.1" xtend "^4.0.0" -tas-client@0.1.16: - version "0.1.16" - resolved "https://registry.yarnpkg.com/tas-client/-/tas-client-0.1.16.tgz#9ef07229f9f65e593bba226100e8ecff6d970ad2" - integrity sha512-ZMGg7dGXiYVJHYusDpUb/Ilg+iPNYZdKJSIA2ADn0f2RovHWM0TpNVe2YHPEc0hdFMsUwWKS5pCRzLnlUqcqGg== +tas-client@0.1.73: + version "0.1.73" + resolved "https://registry.yarnpkg.com/tas-client/-/tas-client-0.1.73.tgz#2dacf68547a37989ef1554c6510dc108a1ea7a71" + integrity sha512-UDdUF9kV2hYdlv+7AgqP2kXarVSUhjK7tg1BUflIRGEgND0/QoNpN64rcEuhEcM8AIbW65yrCopJWqRhLZ3m8w== dependencies: - axios "^0.21.1" + axios "^1.6.1" temp@0.9.4: version "0.9.4" @@ -5003,22 +5499,14 @@ terser-webpack-plugin@^5.1.3: source-map "^0.6.1" terser "^5.7.2" -terser@^5.5.1: - version "5.6.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.6.1.tgz#a48eeac5300c0a09b36854bf90d9c26fb201973c" - integrity sha512-yv9YLFQQ+3ZqgWCUk+pvNJwgUTdlIxUk1WTN+RnaFJe2L7ipG2csPT0ra2XRm7Cs8cxN7QXmK1rFzEwYEQkzXw== - dependencies: - commander "^2.20.0" - source-map "~0.7.2" - source-map-support "~0.5.19" - -terser@^5.7.2: - version "5.10.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.10.0.tgz#b86390809c0389105eb0a0b62397563096ddafcc" - integrity sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA== +terser@^5.5.1, terser@^5.7.2: + version "5.14.2" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.14.2.tgz#9ac9f22b06994d736174f4091aa368db896f1c10" + integrity sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA== dependencies: + "@jridgewell/source-map" "^0.3.2" + acorn "^8.5.0" commander "^2.20.0" - source-map "~0.7.2" source-map-support "~0.5.20" text-table@^0.2.0: @@ -5026,7 +5514,23 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= -through@^2.3.8: +through2-filter@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254" + integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA== + dependencies: + through2 "~2.0.0" + xtend "~4.0.0" + +through2@^2.0.0, through2@^2.0.3, through2@~2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" + integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== + dependencies: + readable-stream "~2.3.6" + xtend "~4.0.1" + +through@2, through@^2.3.8, through@~2.3, through@~2.3.4: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= @@ -5038,6 +5542,14 @@ timers-browserify@^2.0.12: dependencies: setimmediate "^1.0.4" +to-absolute-glob@^2.0.0, to-absolute-glob@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1865f43d9e74b0822db9f145b78cff7d0f7c849b" + integrity sha512-rtwLUQEwT8ZeKQbyFJyomBRYXyE16U5VKuy0ftxLMK/PZb2fkOsg5r9kHdauuVDbsNdIBoC/HCthpidamQFXYA== + dependencies: + is-absolute "^1.0.0" + is-negated-glob "^1.0.0" + to-buffer@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" @@ -5050,32 +5562,32 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +to-through@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-through/-/to-through-2.0.0.tgz#fc92adaba072647bc0b67d6b03664aa195093af6" + integrity sha512-+QIz37Ly7acM4EMdw2PRN389OneM5+d844tirkGp4dPKzI5OE72V9OsbFp+CIYJDahZ41ZV05hNtcPAQUAm9/Q== + dependencies: + through2 "^2.0.3" + toidentifier@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== -tough-cookie@^2.3.3, tough-cookie@~2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" - integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== - dependencies: - psl "^1.1.28" - punycode "^2.1.1" - -tough-cookie@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-3.0.1.tgz#9df4f57e739c26930a018184887f4adb7dca73b2" - integrity sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg== +tough-cookie@^4.0.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" + integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== dependencies: - ip-regex "^2.1.0" - psl "^1.1.28" + psl "^1.1.33" punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" -tr46@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.0.2.tgz#03273586def1595ae08fedb38d7733cee91d2479" - integrity sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg== +tr46@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" + integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA== dependencies: punycode "^2.1.1" @@ -5084,10 +5596,10 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= -"traverse@>=0.3.0 <0.4": - version "0.3.9" - resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" - integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk= +ts-api-utils@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.3.tgz#f12c1c781d04427313dbac808f453f050e54a331" + integrity sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg== ts-invariant@^0.4.0: version "0.4.4" @@ -5117,45 +5629,31 @@ tsconfig-paths@^3.9.0: minimist "^1.2.0" strip-bom "^3.0.0" -tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: +tslib@^1.10.0, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.2.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" + integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== + tsscmp@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== -tsutils@^3.17.1: - version "3.21.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" - integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== - dependencies: - tslib "^1.8.1" - tty@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tty/-/tty-1.0.1.tgz#e4409ac98b0dd1c50b59ff38e86eac3f0764ee45" integrity sha1-5ECayYsN0cULWf846G6sPwdk7kU= -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" - integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= - dependencies: - safe-buffer "^5.0.1" - tunnel@0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= - type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -5198,10 +5696,18 @@ type-is@^1.6.16: media-typer "0.3.0" mime-types "~2.1.24" -typescript@4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.3.tgz#39062d8019912d43726298f09493d598048c1ce3" - integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== +typescript-formatter@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/typescript-formatter/-/typescript-formatter-7.2.2.tgz#a147181839b7bb09c2377b072f20f6336547c00a" + integrity sha512-V7vfI9XArVhriOTYHPzMU2WUnm5IMdu9X/CPxs8mIMGxmTBFpDABlbkBka64PZJ9/xgQeRpK8KzzAG4MPzxBDQ== + dependencies: + commandpost "^1.0.0" + editorconfig "^0.15.0" + +typescript@4.5.5: + version "4.5.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" + integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== typical@^5.2.0: version "5.2.0" @@ -5226,37 +5732,39 @@ unbzip2-stream@^1.0.9: buffer "^5.2.1" through "^2.3.8" +unc-path-regex@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" + integrity sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg== + uniq@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= +unique-stream@^2.0.2: + version "2.3.1" + resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac" + integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A== + dependencies: + json-stable-stringify-without-jsonify "^1.0.1" + through2-filter "^3.0.0" + universal-user-agent@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + universalify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== -unzipper@^0.10.11: - version "0.10.11" - resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e" - integrity sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw== - dependencies: - big-integer "^1.6.17" - binary "~0.3.0" - bluebird "~3.4.1" - buffer-indexof-polyfill "~1.0.0" - duplexer2 "~0.1.4" - fstream "^1.0.12" - graceful-fs "^4.2.2" - listenercount "~1.0.1" - readable-stream "~2.3.6" - setimmediate "~1.0.4" - uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" @@ -5264,6 +5772,19 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +url-search-params-polyfill@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/url-search-params-polyfill/-/url-search-params-polyfill-8.1.1.tgz#9e69e4dba300a71ae7ad3cead62c7717fd99329f" + integrity sha512-KmkCs6SjE6t4ihrfW9JelAPQIIIFbJweaaSLTh/4AO+c58JlDcb+GbdPt8yr5lRcFg4rPswRFRRhBGpWwh0K/Q== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -5281,16 +5802,11 @@ util@^0.12.0: safe-buffer "^5.1.2" which-typed-array "^1.1.2" -uuid@8.3.2: +uuid@8.3.2, uuid@^8.3.0: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@^3.3.2: - version "3.4.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" - integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== - v8-compile-cache@^2.0.3, v8-compile-cache@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" @@ -5304,31 +5820,70 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +value-or-function@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-3.0.0.tgz#1c243a50b595c1be54a754bfece8563b9ff8d813" + integrity sha512-jdBB2FrWvQC/pnPtIqcLsMaQgjhdb6B7tk1MMyTKapox+tQZbdRP4uLxu/JY0t7fbfDCUMnuelzEYv5GsxHhdg== + vary@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= -verror@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" - integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= +vinyl-fs@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-3.0.3.tgz#c85849405f67428feabbbd5c5dbdd64f47d31bc7" + integrity sha512-vIu34EkyNyJxmP0jscNzWBSygh7VWhqun6RmqVfXePrOwi9lhvRs//dOaGOTRUQr4tx7/zd26Tk5WeSVZitgng== + 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" + +vinyl-sourcemap@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/vinyl-sourcemap/-/vinyl-sourcemap-1.1.0.tgz#92a800593a38703a8cdb11d8b300ad4be63b3e16" + integrity sha512-NiibMgt6VJGJmyw7vtzhctDcfKch4e4n9TBeoWlirb7FMg9/1Ov9k+A5ZRAtywBpRPiyECvQRQllYM8dECegVA== dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" + 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" -vscode-extension-telemetry@0.4.5: - version "0.4.5" - resolved "https://registry.yarnpkg.com/vscode-extension-telemetry/-/vscode-extension-telemetry-0.4.5.tgz#1957d5a8b0cd6ad9a79d4f260fe037fbf98732bb" - integrity sha512-YhPiPcelqM5xyYWmD46jIcsxLYWkPZhAxlBkzqmpa218fMtTT17ERdOZVCXcs1S5AjvDHlq43yCgi8TaVQjjEg== +vinyl@^2.0.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.1.tgz#23cfb8bbab5ece3803aa2c0a1eb28af7cbba1974" + integrity sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw== + 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" -vscode-tas-client@^0.1.17: - version "0.1.17" - resolved "https://registry.yarnpkg.com/vscode-tas-client/-/vscode-tas-client-0.1.17.tgz#3a8613776149f4c571b6eb4a61def307a32997d9" - integrity sha512-5uqMeg7sjsu1/QkmuRtBOXtZnnrCXAMEihbOSxan3bk2NdA/nZvhfhfLh8gd9FlBBL56QH69I8Zn25B2yGPRng== +vscode-tas-client@^0.1.75: + version "0.1.75" + resolved "https://registry.yarnpkg.com/vscode-tas-client/-/vscode-tas-client-0.1.75.tgz#771780a9a178163028299f52d41973300060dd38" + integrity sha512-/+ALFWPI4U3obeRvLFSt39guT7P9bZQrkmcLoiS+2HtzJ/7iPKNt5Sj+XTiitGlPYVFGFc0plxX8AAp6Uxs0xQ== dependencies: - tas-client "0.1.16" + tas-client "0.1.73" vscode-uri@^3.0.3: version "3.0.3" @@ -5347,22 +5902,22 @@ w3c-hr-time@^1.0.2: dependencies: browser-process-hrtime "^1.0.0" -w3c-xmlserializer@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz#3e7104a05b75146cc60f564380b7f683acf1020a" - integrity sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA== +w3c-xmlserializer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz#06cdc3eefb7e4d0b20a560a5a3aeb0d2d9a65923" + integrity sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg== dependencies: - xml-name-validator "^3.0.0" + xml-name-validator "^4.0.0" wait-for-expect@^1.1.1: version "1.3.0" resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-1.3.0.tgz#65241ce355425f907f5d127bdb5e72c412ff830c" integrity sha512-8fJU7jiA96HfGPt+P/UilelSAZfhMBJ52YhKzlmZQvKEZU2EcD1GQ0yqGB6liLdHjYtYAoGVigYwdxr5rktvzA== -watchpack@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25" - integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA== +watchpack@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -5372,15 +5927,10 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= -webidl-conversions@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" - integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== - -webidl-conversions@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" - integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== webpack-cli@4.2.0: version "4.2.0" @@ -5421,47 +5971,63 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@5.68.0: - version "5.68.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.68.0.tgz#a653a58ed44280062e47257f260117e4be90d560" - integrity sha512-zUcqaUO0772UuuW2bzaES2Zjlm/y3kRBQDVFVCge+s2Y8mwuUTdperGaAv65/NtRL/1zanpSJOq/MD8u61vo6g== +webpack@5.76.0: + version "5.76.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c" + integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA== dependencies: - "@types/eslint-scope" "^3.7.0" - "@types/estree" "^0.0.50" + "@types/eslint-scope" "^3.7.3" + "@types/estree" "^0.0.51" "@webassemblyjs/ast" "1.11.1" "@webassemblyjs/wasm-edit" "1.11.1" "@webassemblyjs/wasm-parser" "1.11.1" - acorn "^8.4.1" + acorn "^8.7.1" acorn-import-assertions "^1.7.6" browserslist "^4.14.5" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.8.3" + enhanced-resolve "^5.10.0" es-module-lexer "^0.9.0" eslint-scope "5.1.1" events "^3.2.0" glob-to-regexp "^0.4.1" graceful-fs "^4.2.9" - json-parse-better-errors "^1.0.2" + json-parse-even-better-errors "^2.3.1" loader-runner "^4.2.0" mime-types "^2.1.27" neo-async "^2.6.2" schema-utils "^3.1.0" tapable "^2.1.1" terser-webpack-plugin "^5.1.3" - watchpack "^2.3.1" + watchpack "^2.4.0" webpack-sources "^3.2.3" -whatwg-encoding@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" - integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== +whatwg-encoding@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" + integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== dependencies: - iconv-lite "0.4.24" + iconv-lite "0.6.3" -whatwg-mimetype@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" - integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + +whatwg-url@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-10.0.0.tgz#37264f720b575b4a311bd4094ed8c760caaa05da" + integrity sha512-CLxxCmdUby142H5FZzn4D8ikO1cmypvXVQktsgosNy4a4BHrDHeciBBGZhb0bNoR5/MltoCatso+vFjjGx8t0w== + dependencies: + tr46 "^3.0.0" + webidl-conversions "^7.0.0" + +whatwg-url@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018" + integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ== + dependencies: + tr46 "^3.0.0" + webidl-conversions "^7.0.0" whatwg-url@^5.0.0: version "5.0.0" @@ -5471,15 +6037,6 @@ whatwg-url@^5.0.0: tr46 "~0.0.3" webidl-conversions "^3.0.0" -whatwg-url@^8.0.0: - version "8.4.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.4.0.tgz#50fb9615b05469591d2b2bd6dfaed2942ed72837" - integrity sha512-vwTUFf6V4zhcPkWp/4CQPr1TW9Ml6SF4lVyaIMBdJw5i6qUUJ1QWM4Z6YYVkfka0OUIzVo/0aNtGVGk256IKWw== - dependencies: - lodash.sortby "^4.7.0" - tr46 "^2.0.2" - webidl-conversions "^6.1.0" - which-boxed-primitive@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" @@ -5519,9 +6076,9 @@ wide-align@1.1.3: string-width "^1.0.2 || 2" word-wrap@^1.2.3, word-wrap@~1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + version "1.2.4" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" + integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== wordwrapjs@^4.0.0: version "4.0.1" @@ -5550,20 +6107,15 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= -ws@^7.2.3: - version "7.4.6" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" - integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== - -ws@^7.4.6: - version "7.5.3" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.3.tgz#160835b63c7d97bfab418fc1b8a9fced2ac01a74" - integrity sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg== +ws@^8.2.3: + version "8.7.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.7.0.tgz#eaf9d874b433aa00c0e0d8752532444875db3957" + integrity sha512-c2gsP0PRwcLFzUiA8Mkr37/MI7ilIlHQxaEAtd0uNMbVMoy8puJyafRlm0bV9MbGSabUPeLrRRaqIBcFcA2Pqg== -xml-name-validator@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" - integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== +xml-name-validator@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" + integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== xml@^1.0.0: version "1.0.1" @@ -5575,7 +6127,7 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xtend@^4.0.0: +xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== @@ -5585,6 +6137,11 @@ y18n@^5.0.5: resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + integrity sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A== + yallist@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" @@ -5628,7 +6185,7 @@ yargs@16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yauzl@^2.10.0, yauzl@^2.4.2: +yauzl@^2.4.2: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk= @@ -5636,13 +6193,6 @@ yauzl@^2.10.0, yauzl@^2.4.2: buffer-crc32 "~0.2.3" fd-slicer "~1.1.0" -yazl@^2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/yazl/-/yazl-2.5.1.tgz#a3d65d3dd659a5b0937850e8609f22fffa2b5c35" - integrity sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw== - dependencies: - buffer-crc32 "~0.2.3" - ylru@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"