From d5a6d39f56da790bd7a123fb3a5fb625760dc88a Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Tue, 15 Oct 2024 18:43:26 +0200 Subject: [PATCH] Add chat participant, tools, and notifications view (#6280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * adding code * renaming ghpr to github * Add "issue" tool, still needs error handling * renaming the particpant * wiring the tool and the chat participant * Scaffold notifications view * Remove slash commands * using single quotes instead * cleaning up the code * tweaking the prompt in order to prompt githubpr to use the workspace tool to fix issues if so instructed * adding a command which sends a request to fix the issue given a github url * removing the isSticky attribute in the package.json * Resolve Issue/PullRequest * Use timestamp for cache invalidation * Add commands in issues view * filing in the TODO, removing old command * Polish chat when clauses and menus * Add stub action to prioritize notifications * Update participant name * Choose first repo if none provided in issue tool * Basic priority computation. Lots of work left * Saving out changes * Increase notification page limit * add fix issue tool * adding back semi-colon * renaming the types after the proposed API has been updated * processing notifications in batches * adding comments into the prompt for the notifications as well as reaction, formatting the prompt * add labels to the prompt * adding the issue reactions into the prompt * Add search tools (#6273) * WIP * Get display tool called, even though it's with the wrong args * Merge branch 'dev/chat' into alexr00/chatSearchTable * Refactor for last tool result * Add a 'text/display' type * adding prompt * Choose issue columns based on llm * adding an icon to open the issue or PR to the side in the notifications view * refactoring the notifications provider, breaking down the logic into smaller pieces * fetching labels and reaction count from the issue model gql call, not the rest calls * specifying we have only the reaction count not all reactions * Improve search prompts add logging improve search tools descriptions * Label validation in search tool * tweaking the prompt so that prioritization is correct * Let the user specify columns to include and make sure labels show * Tweak some prompts * specifying that we only add the last 5 comments so the notification does not take up too much token space * Put all chat behind a setting * use the model when it is available, fall back on simple sorting if the LLM prioritization fails * adding notificationsview experimental setting * only showing the priority when it is defined * Add open on github.com to search query * Improve query display * updating the tools API and our code * Finish updates for breaking API chages * adding tree item which adds more notifications * add code to load more notification and hide `load more notification` when can not load more * Get "similar issues" search working add a prompt specifically for free form keywords in the syntax tool limit keywords on only one, and soft limit to lables to only one * add different sorting methods * assigning sorting method * adding utils file * small refactoring * setting title if titleHTML is undefined * showing the notifications corresponding to closed issues or merged PRs at the end * add code * removing the sorting and adding a boolean which determines that we are in dev mode * adding the reasoning of priority assignment * adding commands for notifications view * only offer summarization option for issues * using small letter for 'by' * using correct regex for priority reasoning extraction * Get @me working better and better handling of free form query * 💄 - types - match existing parse pattern - cleanup --------- Co-authored-by: Aiday Marlen Kyzy Co-authored-by: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> --- build/hygiene.js | 2 + package.json | 418 ++++++++++++++++- package.nls.json | 11 + src/@types/vscode.proposed.lmTools.d.ts | 332 +++++++++++++ src/commands.ts | 39 +- src/common/executeCommands.ts | 2 + src/common/settingKeys.ts | 3 + src/extension.ts | 19 +- src/github/common.ts | 1 + src/github/graphql.ts | 11 +- src/github/interface.ts | 38 +- src/github/issueModel.ts | 18 - src/github/issueOverview.ts | 8 +- src/github/notifications.ts | 9 +- src/github/pullRequestOverview.ts | 2 +- src/github/queries.gql | 6 + src/github/queriesExtra.gql | 6 + src/github/queriesLimited.gql | 6 + src/github/queriesShared.gql | 37 ++ src/github/quickPicks.ts | 2 +- src/github/repositoriesManager.ts | 29 +- src/github/utils.ts | 57 ++- src/issues/issueFeatureRegistrar.ts | 25 + src/issues/util.ts | 11 +- src/lm/displayIssuesTool.ts | 142 ++++++ src/lm/participants.ts | 196 ++++++++ src/lm/searchTools.ts | 439 ++++++++++++++++++ src/lm/tools/issueTool.ts | 61 +++ src/lm/tools/suggestFixTool.ts | 74 +++ src/lm/tools/tools.ts | 33 ++ src/lm/tools/toolsUtils.ts | 42 ++ src/notifications/notificationTreeItem.ts | 33 ++ .../notificationsFeatureRegistar.ts | 108 +++++ src/notifications/notificationsProvider.ts | 339 ++++++++++++++ src/notifications/notificationsView.ts | 111 +++++ .../builders/graphql/pullRequestBuilder.ts | 4 +- yarn.lock | 5 + 37 files changed, 2598 insertions(+), 81 deletions(-) create mode 100644 src/@types/vscode.proposed.lmTools.d.ts create mode 100644 src/lm/displayIssuesTool.ts create mode 100644 src/lm/participants.ts create mode 100644 src/lm/searchTools.ts create mode 100644 src/lm/tools/issueTool.ts create mode 100644 src/lm/tools/suggestFixTool.ts create mode 100644 src/lm/tools/tools.ts create mode 100644 src/lm/tools/toolsUtils.ts create mode 100644 src/notifications/notificationTreeItem.ts create mode 100644 src/notifications/notificationsFeatureRegistar.ts create mode 100644 src/notifications/notificationsProvider.ts create mode 100644 src/notifications/notificationsView.ts diff --git a/build/hygiene.js b/build/hygiene.js index 63c49b674a..9cd9eb298a 100644 --- a/build/hygiene.js +++ b/build/hygiene.js @@ -63,6 +63,8 @@ function hygiene(some) { // good indent } else if (/^[\t]* \*/.test(line)) { // block comment using an extra space + } else if (/^[\s]*- /.test(line)) { + // multiline string using extra space } else { console.error( file.relative + '(' + (i + 1) + ',1): Bad whitespace indentation' diff --git a/package.json b/package.json index c95b893311..e5d44e389a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "contribShareMenu", "diffCommand", "fileComments", + "lmTools", "quickDiffProvider", "shareProvider", "tokenInformation", @@ -38,7 +39,9 @@ "vscode": "^1.94.0" }, "categories": [ - "Other" + "Other", + "AI", + "Chat" ], "extensionDependencies": [ "vscode.github-authentication" @@ -60,6 +63,15 @@ "virtualWorkspaces": true }, "contributes": { + "chatParticipants": [ + { + "id": "githubpr", + "name": "githubpr", + "fullName": "GitHub", + "description": "Chat participant for GitHub Pull Requests extension", + "when": "config.githubPullRequests.experimental.chat" + } + ], "configuration": { "type": "object", "title": "GitHub Pull Requests", @@ -100,7 +112,7 @@ "description": "%githubPullRequests.pullRequestDescription.description%" }, "githubPullRequests.defaultCreateOption": { - "type":"string", + "type": "string", "enum": [ "lastUsed", "create", @@ -673,6 +685,13 @@ "icon": "$(issues)", "accessibilityHelpContent": "%view.pr.github.accessibilityHelpContent%" }, + { + "id": "notifications:github", + "name": "%view.notifications.github.name%", + "when": "ReposManagerStateContext != NeedsAuthentication && !github:resolvingConflicts && config.githubPullRequests.experimental.notificationsView", + "icon": "$(bell)", + "accessibilityHelpContent": "%view.pr.github.accessibilityHelpContent%" + }, { "id": "github:conflictResolution", "name": "%view.github.conflictResolution.name%", @@ -1452,6 +1471,51 @@ "title": "%command.issues.openIssuesWebsite.title%", "category": "%command.issues.category%", "icon": "$(globe)" + }, + { + "command": "issue.chatSummarizeIssue", + "title": "%command.issue.chatSummarizeIssue.title%", + "category": "%command.issues.category%", + "icon": "$(copilot)" + }, + { + "command": "issue.chatSuggestFix", + "title": "%command.issue.chatSuggestFix.title%", + "category": "%command.issues.category%", + "icon": "$(sparkle)" + }, + { + "command": "notifications.refresh", + "title": "%command.notifications.refresh.title%", + "category": "%command.notifications.category%", + "icon": "$(refresh)" + }, + { + "command": "notifications.loadMore", + "title": "%command.notifications.loadMore.title%", + "category": "%command.notifications.category%" + }, + { + "command": "notifications.sortByTimestamp", + "title": "%command.notifications.sortByTimestamp.title%", + "category": "%command.notifications.category%" + }, + { + "command": "notifications.sortByPriority", + "title": "%command.notifications.sortByPriority.title%", + "category": "%command.notifications.category%" + }, + { + "command": "notification.openOnGitHub", + "title": "%command.notifications.openOnGitHub.title%", + "category": "%command.notifications.category%", + "icon": "$(globe)" + }, + { + "command": "notification.chatSummarizeNotification", + "title": "%command.notification.chatSummarizeNotification.title%", + "category": "%command.notifications.category%", + "icon": "$(copilot)" } ], "viewsWelcome": [ @@ -2071,6 +2135,38 @@ { "command": "issues.openIssuesWebsite", "when": "github:hasGitHubRemotes" + }, + { + "command": "issue.chatSummarizeIssue", + "when": "false" + }, + { + "command": "issue.chatSuggestFix", + "when": "false" + }, + { + "command": "notifications.sortByTimestamp", + "when": "false" + }, + { + "command": "notifications.sortByPriority", + "when": "false" + }, + { + "command": "notifications.loadMore", + "when": "false" + }, + { + "command": "notifications.refresh", + "when": "false" + }, + { + "command": "notification.openOnGitHub", + "when": "false" + }, + { + "command": "notification.chatSummarizeNotification", + "when": "false" } ], "view/title": [ @@ -2178,6 +2274,21 @@ "command": "pr.refreshComments", "when": "view == workbench.panel.comments", "group": "navigation" + }, + { + "command": "notifications.sortByTimestamp", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == notifications:github", + "group": "sortNotifications@1" + }, + { + "command": "notifications.sortByPriority", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == notifications:github", + "group": "sortNotifications@2" + }, + { + "command": "notifications.refresh", + "when": "gitHubOpenRepositoryCount != 0 && github:initialized && view == notifications:github", + "group": "navigation@1" } ], "view/item/context": [ @@ -2206,6 +2317,16 @@ "group": "inline@1", "when": "view =~ /(pr|prStatus):github/ && viewItem =~ /description:(active|nonactive):hasChangesSinceReview:showingChangesSinceReview/" }, + { + "command": "notification.chatSummarizeNotification", + "group": "inline@1", + "when": "view == notifications:github && viewItem == 'Issue'" + }, + { + "command": "notification.openOnGitHub", + "group": "inline@2", + "when": "view == notifications:github && (viewItem == 'Issue' || viewItem == 'PullRequest')" + }, { "command": "pr.openDescriptionToTheSide", "group": "inline@2", @@ -2215,7 +2336,7 @@ "command": "pr.openPullRequestOnGitHub", "group": "inline@3", "when": "view == prStatus:github && viewItem =~ /description/ && github:activePRCount >= 2" - }, + }, { "command": "pr.exit", "when": "view == pr:github && viewItem =~ /pullrequest(:local)?:active|description:active/", @@ -2296,9 +2417,19 @@ }, { "command": "issue.openIssue", - "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && !github.copilot-chat.activated", "group": "inline@2" }, + { + "command": "issue.chatSummarizeIssue", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && github.copilot-chat.activated", + "group": "inline@0" + }, + { + "command": "issue.chatSuggestFix", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && github.copilot-chat.activated", + "group": "inline@1" + }, { "command": "issue.openIssue", "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", @@ -2312,22 +2443,22 @@ { "command": "issue.startWorking", "when": "view == issues:github && viewItem =~ /^(link)?issue/ && config.githubIssues.useBranchForIssues != on", - "group": "inline@1" + "group": "inline@2" }, { "command": "issue.startWorkingBranchDescriptiveTitle", "when": "view == issues:github && viewItem =~ /^(link)?issue/ && config.githubIssues.useBranchForIssues == on", - "group": "inline@1" + "group": "inline@2" }, { "command": "issue.startWorking", "when": "view == issues:github && viewItem =~ /^(link)?continueissue/ && config.githubIssues.useBranchForIssues != on", - "group": "inline@1" + "group": "inline@2" }, { "command": "issue.startWorkingBranchDescriptiveTitle", "when": "view == issues:github && viewItem =~ /^(link)?continueissue/ && config.githubIssues.useBranchForIssues == on", - "group": "inline@1" + "group": "inline@2" }, { "command": "issue.startWorking", @@ -2360,15 +2491,25 @@ "when": "view == issues:github && viewItem =~ /^(link)?currentissue/ && config.githubIssues.useBranchForIssues == on", "group": "inline@1" }, + { + "command": "issue.chatSummarizeIssue", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && github.copilot-chat.activated", + "group": "issues_1@0" + }, + { + "command": "issue.chatSuggestFix", + "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/ && github.copilot-chat.activated", + "group": "issues_1@1" + }, { "command": "issue.copyIssueNumber", "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", - "group": "issues_1@1" + "group": "issues_2@1" }, { "command": "issue.copyIssueUrl", "when": "view == issues:github && viewItem =~ /^(link)?(current|continue)?issue/", - "group": "issues_1@2" + "group": "issues_2@2" }, { "command": "issue.editQuery", @@ -2939,6 +3080,260 @@ "stripPathStartingSeparator": true } } + ], + "languageModelTools": [ + { + "name": "github-pull-request_issue", + "tags": [ + "github", + "issues" + ], + "toolReferenceName": "issue", + "displayName": "Get GitHub Issue", + "modelDescription": "A GitHub issue, which includes the title, body, and comments.", + "icon": "$(info)", + "canBeReferencedInPrompt": true, + "parametersSchema": { + "type": "object", + "properties": { + "repo": { + "type": "object", + "description": "The repository to get the issue from.", + "properties": { + "owner": { + "type": "string", + "description": "The owner of the repository to get the issue from." + }, + "name": { + "type": "string", + "description": "The name of the repository to get the issue from." + } + }, + "required": [ + "owner", + "name" + ] + }, + "issueNumber": { + "type": "number", + "description": "The number of the issue to get." + } + }, + "required": [ + "issueNumber" + ] + }, + "supportedContentTypes": [ + "text/plain" + ], + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_suggest-fix", + "tags": [ + "github", + "issues" + ], + "toolReferenceName": "suggest-fix", + "displayName": "Suggest a Fix for a GitHub Issue", + "modelDescription": "Summarize and suggest a fix for a GitHub issue.", + "icon": "$(info)", + "canBeReferencedInPrompt": true, + "parametersSchema": { + "type": "object", + "properties": { + "repo": { + "type": "object", + "description": "The repository to get the issue from.", + "properties": { + "owner": { + "type": "string", + "description": "The owner of the repository to get the issue from." + }, + "name": { + "type": "string", + "description": "The name of the repository to get the issue from." + } + }, + "required": [ + "owner", + "name" + ] + }, + "issueNumber": { + "type": "number", + "description": "The number of the issue to get." + } + }, + "required": [ + "issueNumber", + "repo" + ] + }, + "supportedContentTypes": [ + "text/plain" + ], + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_formSearchQuery", + "tags": [ + "github", + "issues", + "search", + "query", + "natural language" + ], + "toolReferenceName": "searchSyntax", + "displayName": "Convert natural language to a GitHub search query", + "modelDescription": "Converts natural language to a GitHub search query. Should ALWAYS be called before doing a search.", + "icon": "$(search)", + "canBeReferencedInPrompt": true, + "parametersSchema": { + "type": "object", + "properties": { + "repo": { + "type": "object", + "description": "The repository to get the issue from.", + "properties": { + "owner": { + "type": "string", + "description": "The owner of the repository to get the issue from." + }, + "name": { + "type": "string", + "description": "The name of the repository to get the issue from." + } + }, + "required": [ + "owner", + "name" + ] + }, + "naturalLanguageString": { + "type": "string", + "description": "A plain text description of what the search should be." + } + }, + "required": [ + "naturalLanguageString" + ] + }, + "supportedContentTypes": [ + "text/plain", + "text/display" + ], + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_doSearch", + "tags": [ + "github", + "issues", + "search" + ], + "toolReferenceName": "doSearch", + "displayName": "Execute a GitHub search", + "modelDescription": "Execute a GitHub search given a well formed GitHub search query. Call github-pull-request_formSearchQuery first to get good search syntax and pass the exact result in as the 'query'.", + "icon": "$(search)", + "canBeReferencedInPrompt": true, + "parametersSchema": { + "type": "object", + "properties": { + "repo": { + "type": "object", + "description": "The repository to get the issue from.", + "properties": { + "owner": { + "type": "string", + "description": "The owner of the repository to get the issue from." + }, + "name": { + "type": "string", + "description": "The name of the repository to get the issue from." + } + }, + "required": [ + "owner", + "name" + ] + }, + "query": { + "type": "string", + "description": "A well formed GitHub search query using proper GitHub search syntax." + } + }, + "required": [ + "query" + ] + }, + "supportedContentTypes": [ + "text/plain", + "text/json" + ], + "when": "config.githubPullRequests.experimental.chat" + }, + { + "name": "github-pull-request_renderIssues", + "tags": [ + "github", + "issues", + "render", + "display" + ], + "toolReferenceName": "renderIssues", + "displayName": "Render issue items in a markdown table.", + "modelDescription": "Render issue items from an issue search in a markdown table.", + "icon": "$(paintcan)", + "canBeReferencedInPrompt": false, + "parametersSchema": { + "type": "object", + "properties": { + "arrayOfIssues": { + "oneOf": [ + { + "type": "array", + "description": "An array of GitHub Issues.", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The title of the issue." + }, + "number": { + "type": "number", + "description": "The number of the issue." + }, + "body": { + "type": "string", + "description": "The body of the issue." + }, + "url": { + "type": "string", + "description": "The URL of the issue." + } + } + } + }, + { + "type": "string", + "description": "A Json stringified array of GitHub Issues." + } + ], + "description": "An array or stringified array of GitHub Issues." + } + }, + "required": [ + "issues" + ] + }, + "supportedContentTypes": [ + "text/plain", + "text/markdown" + ], + "when": "config.githubPullRequests.experimental.chat" + } ] }, "scripts": { @@ -3035,6 +3430,7 @@ "@octokit/rest": "18.2.1", "@octokit/types": "6.10.1", "@vscode/extension-telemetry": "0.7.5", + "@vscode/prompt-tsx": "^0.2.11-alpha", "apollo-boost": "^0.4.9", "apollo-link-context": "1.0.20", "cockatiel": "^3.1.1", @@ -3055,4 +3451,4 @@ "vsls": "^0.3.967" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/package.nls.json b/package.nls.json index 10982feba5..14d6c97fc2 100644 --- a/package.nls.json +++ b/package.nls.json @@ -150,6 +150,7 @@ ] }, "view.issues.github.name": "Issues", + "view.notifications.github.name": "Notifications", "view.github.conflictResolution.name": "Conflict Resolution", "view.github.create.pull.request.name": "Create", "view.github.compare.changes.name": "Files Changed", @@ -277,6 +278,16 @@ "command.issue.signinAndRefreshList.title": "Sign in and Refresh", "command.issue.goToLinkedCode.title": "Go to Linked Code", "command.issues.openIssuesWebsite.title": "Open on GitHub", + "command.issue.chatSummarizeIssue.title": "Summarize With Copilot", + "command.issue.chatSuggestFix.title": "Fix With Copilot", + "command.notifications.category": "GitHub Notifications", + "command.notifications.refresh.title": "Refresh", + "command.notifications.pri.title": "Prioritize", + "command.notifications.loadMore.title": "Load More Notifications", + "command.notifications.sortByTimestamp.title": "Sort by Timestamp", + "command.notifications.sortByPriority.title": "Sort by Priority using Copilot", + "command.notifications.openOnGitHub.title": "Open on GitHub", + "command.notification.chatSummarizeNotification.title": "Summarize With Copilot", "welcome.github.login.contents": { "message": "You have not yet signed in with GitHub\n[Sign in](command:pr.signin)", "comment" : [ diff --git a/src/@types/vscode.proposed.lmTools.d.ts b/src/@types/vscode.proposed.lmTools.d.ts new file mode 100644 index 0000000000..72b8c6c79a --- /dev/null +++ b/src/@types/vscode.proposed.lmTools.d.ts @@ -0,0 +1,332 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 9 +// https://github.com/microsoft/vscode/issues/213274 + +declare module 'vscode' { + + // TODO@API capabilities + + /** + * A tool that is available to the language model via {@link LanguageModelChatRequestOptions}. + */ + export interface LanguageModelChatTool { + /** + * The name of the tool. + */ + name: string; + + /** + * The description of the tool. + */ + description: string; + + /** + * A JSON schema for the parameters this tool accepts. + */ + parametersSchema?: object; + } + + export interface LanguageModelChatRequestOptions { + // TODO@API this will be a heterogeneous array of different types of tools + /** + * An optional list of tools that are available to the language model. + */ + tools?: LanguageModelChatTool[]; + + /** + * Force a specific tool to be used. + */ + toolChoice?: string; + } + + /** + * A language model response part indicating a tool call, returned from a {@link LanguageModelChatResponse}, and also can be + * included as a content part on a {@link LanguageModelChatMessage}, to represent a previous tool call in a + * chat request. + */ + export class LanguageModelToolCallPart { + /** + * The name of the tool to call. + */ + name: string; + + /** + * The ID of the tool call. This is a unique identifier for the tool call within the chat request. + */ + toolCallId: string; + + /** + * The parameters with which to call the tool. + */ + parameters: object; + + constructor(name: string, toolCallId: string, parameters: object); + } + + /** + * A language model response part containing a piece of text, returned from a {@link LanguageModelChatResponse}. + */ + export class LanguageModelTextPart { + /** + * The text content of the part. + */ + value: string; + + constructor(value: string); + } + + export interface LanguageModelChatResponse { + /** + * A stream of parts that make up the response. Could be extended with more types in the future. + * TODO@API add "| unknown"? + */ + stream: AsyncIterable; + } + + /** + * The result of a tool call. Can only be included in the content of a User message. + */ + export class LanguageModelToolResultPart { + /** + * The ID of the tool call. + */ + toolCallId: string; + + /** + * The content of the tool result. + */ + content: string; + + constructor(toolCallId: string, content: string); + } + + export interface LanguageModelChatMessage { + /** + * A heterogeneous array of other things that a message can contain as content. Some parts may be message-type specific + * for some models. + */ + content2: (string | LanguageModelToolResultPart | LanguageModelToolCallPart)[]; + } + + // Tool registration/invoking between extensions + + /** + * A result returned from a tool invocation. + */ + // TODO@API should we align this with NotebookCellOutput and NotebookCellOutputItem + export interface LanguageModelToolResult { + /** + * The result can contain arbitrary representations of the content. A tool user can set + * {@link LanguageModelToolInvocationOptions.requested} to request particular types, and a tool implementation should only + * compute the types that were requested. `text/plain` is recommended to be supported by all tools, which would indicate + * any text-based content. Another example might be a `PromptElementJSON` from `@vscode/prompt-tsx`, using the + * `contentType` exported by that library. + */ + [contentType: string]: any; + } + + export namespace lm { + /** + * Register a LanguageModelTool. The tool must also be registered in the package.json `languageModelTools` contribution + * point. A registered tool is available in the {@link lm.tools} list for any extension to see. But in order for it to + * be seen by a language model, it must be passed in the list of available tools in {@link LanguageModelChatRequestOptions.tools}. + */ + export function registerTool(name: string, tool: LanguageModelTool): Disposable; + + /** + * A list of all available tools. + */ + export const tools: ReadonlyArray; + + /** + * Invoke a tool with the given parameters. + */ + export function invokeTool(id: string, options: LanguageModelToolInvocationOptions, token: CancellationToken): Thenable; + } + + /** + * A token that can be passed to {@link lm.invokeTool} when invoking a tool inside the context of handling a chat request. + */ + export type ChatParticipantToolToken = unknown; + + /** + * Options provided for tool invocation. + */ + export interface LanguageModelToolInvocationOptions { + /** + * When this tool is being invoked within the context of a chat request, this token should be passed from + * {@link ChatRequest.toolInvocationToken}. In that case, a progress bar will be automatically shown for the tool + * invocation in the chat response view, and if the tool requires user confirmation, it will show up inline in the chat + * view. If the tool is being invoked outside of a chat request, `undefined` should be passed instead. + * + * If a tool invokes another tool during its invocation, it can pass along the `toolInvocationToken` that it received. + */ + toolInvocationToken: ChatParticipantToolToken | undefined; + + /** + * The parameters with which to invoke the tool. The parameters must match the schema defined in + * {@link LanguageModelToolDescription.parametersSchema} + */ + parameters: T; + + /** + * A tool user can request that particular content types be returned from the tool, depending on what the tool user + * supports. All tools are recommended to support `text/plain`. See {@link LanguageModelToolResult}. + */ + requestedContentTypes: string[]; + + /** + * Options to hint at how many tokens the tool should return in its response. + */ + tokenOptions?: { + /** + * If known, the maximum number of tokens the tool should emit in its result. + */ + tokenBudget: number; + + /** + * Count the number of tokens in a message using the model specific tokenizer-logic. + * @param text A string. + * @param token Optional cancellation token. See {@link CancellationTokenSource} for how to create one. + * @returns A thenable that resolves to the number of tokens. + */ + countTokens(text: string, token?: CancellationToken): Thenable; + }; + } + + /** + * A description of an available tool. + */ + export interface LanguageModelToolDescription { + /** + * A unique name for the tool. + */ + readonly name: string; + + /** + * A description of this tool that may be passed to a language model. + */ + readonly description: string; + + /** + * A JSON schema for the parameters this tool accepts. + */ + readonly parametersSchema?: object; + + /** + * The list of content types that the tool has declared support for. See {@link LanguageModelToolResult}. + */ + readonly supportedContentTypes: string[]; + + /** + * A set of tags, declared by the tool, that roughly describe the tool's capabilities. A tool user may use these to filter + * the set of tools to just ones that are relevant for the task at hand. + */ + readonly tags: string[]; + } + + /** + * When this is returned in {@link PreparedToolInvocation}, the user will be asked to confirm before running the tool. These + * messages will be shown with buttons that say "Continue" and "Cancel". + */ + export interface LanguageModelToolConfirmationMessages { + /** + * The title of the confirmation message. + */ + title: string; + + /** + * The body of the confirmation message. + */ + message: string | MarkdownString; + } + + /** + * Options for {@link LanguageModelTool.prepareToolInvocation}. + */ + export interface LanguageModelToolInvocationPrepareOptions { + /** + * The parameters that the tool is being invoked with. + */ + parameters: T; + } + + /** + * A tool that can be invoked by a call to a {@link LanguageModelChat}. + */ + export interface LanguageModelTool { + /** + * Invoke the tool with the given parameters and return a result. + */ + invoke(options: LanguageModelToolInvocationOptions, token: CancellationToken): ProviderResult; + + /** + * Called once before a tool is invoked. May be implemented to signal that a tool needs user confirmation before running, + * and to customize the progress message that appears while the tool is running. + */ + prepareToolInvocation?(options: LanguageModelToolInvocationPrepareOptions, token: CancellationToken): ProviderResult; + } + + /** + * The result of a call to {@link LanguageModelTool.prepareToolInvocation}. + */ + export interface PreparedToolInvocation { + /** + * A customized progress message to show while the tool runs. + */ + invocationMessage?: string; + + /** + * The presence of this property indicates that the user should be asked to confirm before running the tool. + */ + confirmationMessages?: LanguageModelToolConfirmationMessages; + } + + /** + * A reference to a tool attached to a user's request. + */ + export interface ChatLanguageModelToolReference { + /** + * The tool's ID. Refers to a tool listed in {@link lm.tools}. + */ + readonly id: string; + + /** + * The start and end index of the reference in the {@link ChatRequest.prompt prompt}. When undefined, the reference was + * not part of the prompt text. + * + * *Note* that the indices take the leading `#`-character into account which means they can be used to modify the prompt + * as-is. + */ + readonly range?: [start: number, end: number]; + } + + export interface ChatRequest { + /** + * The list of tools that the user attached to their request. + * + * *Note* that if tools are referenced in the text of the prompt, using `#`, the prompt contains references as authored + * and it is up to the participant to further modify the prompt, for instance by inlining reference values or + * creating links to headings which contain the resolved values. References are sorted in reverse by their range in the + * prompt. That means the last reference in the prompt is the first in this list. This simplifies string-manipulation of + * the prompt. + */ + readonly toolReferences: readonly ChatLanguageModelToolReference[]; + + /** + * A token that can be passed to {@link lm.invokeTool} when invoking a tool inside the context of handling a chat request. + */ + readonly toolInvocationToken: ChatParticipantToolToken; + } + + export interface ChatRequestTurn { + /** + * The list of tools were attached to this request. + */ + readonly toolReferences?: readonly ChatLanguageModelToolReference[]; + } +} diff --git a/src/commands.ts b/src/commands.ts index 61247c8ef8..01234ebe84 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -18,13 +18,16 @@ import { formatError } from './common/utils'; import { EXTENSION_ID } from './constants'; import { FolderRepositoryManager } from './github/folderRepositoryManager'; import { GitHubRepository } from './github/githubRepository'; -import { PullRequest } from './github/interface'; +import { Issue, PullRequest } from './github/interface'; +import { IssueModel } from './github/issueModel'; +import { IssueOverviewPanel } from './github/issueOverview'; import { NotificationProvider } from './github/notifications'; import { GHPRComment, GHPRCommentThread, TemporaryComment } from './github/prComment'; import { PullRequestModel } from './github/pullRequestModel'; import { PullRequestOverviewPanel } from './github/pullRequestOverview'; import { RepositoriesManager } from './github/repositoriesManager'; import { getIssuesUrl, getPullsUrl, isInCodespaces, vscodeDevPrLink } from './github/utils'; +import { NotificationTreeItem } from './notifications/notificationTreeItem'; import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; import { ReviewCommentController } from './view/reviewCommentController'; import { ReviewManager } from './view/reviewManager'; @@ -45,7 +48,9 @@ import { RepositoryChangesNode } from './view/treeNodes/repositoryChangesNode'; const _onDidUpdatePR = new vscode.EventEmitter(); export const onDidUpdatePR: vscode.Event = _onDidUpdatePR.event; -function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode | PullRequestModel): PullRequestModel { +function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode): PullRequestModel; +function ensurePR>(folderRepoManager: FolderRepositoryManager, pr?: T): T; +function ensurePR>(folderRepoManager: FolderRepositoryManager, pr?: PRNode | T): T { // If the command is called from the command palette, no arguments are passed. if (!pr) { if (!folderRepoManager.activePullRequest) { @@ -53,15 +58,15 @@ function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode | Pull throw new Error('Unable to find current pull request.'); } - return folderRepoManager.activePullRequest; + return folderRepoManager.activePullRequest as unknown as T; } else { - return pr instanceof PRNode ? pr.pullRequestModel : pr; + return (pr instanceof PRNode ? pr.pullRequestModel : pr) as T; } } export async function openDescription( telemetry: ITelemetry, - pullRequestModel: PullRequestModel, + pullRequestModel: IssueModel, descriptionNode: DescriptionNode | undefined, folderManager: FolderRepositoryManager, revealNode: boolean, @@ -73,7 +78,11 @@ export async function openDescription( descriptionNode?.reveal(descriptionNode, { select: true, focus: true }); } // Create and show a new webview - await PullRequestOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, pullRequest, undefined, preserveFocus); + if (pullRequest instanceof PullRequestModel) { + await PullRequestOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, pullRequest, undefined, preserveFocus); + } else { + await IssueOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, pullRequest); + } if (notificationProvider?.hasNotification(pullRequest)) { notificationProvider.markPrNotificationsAsRead(pullRequest); @@ -105,9 +114,11 @@ async function chooseItem( return (await vscode.window.showQuickPick(items, options))?.itemValue; } -export async function openPullRequestOnGitHub(e: PRNode | DescriptionNode | PullRequestModel, telemetry: ITelemetry) { +export async function openPullRequestOnGitHub(e: PRNode | DescriptionNode | PullRequestModel | NotificationTreeItem, telemetry: ITelemetry) { if (e instanceof PRNode || e instanceof DescriptionNode) { vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.pullRequestModel.html_url)); + } else if (e instanceof NotificationTreeItem) { + vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.model.html_url)); } else { vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(e.html_url)); } @@ -149,6 +160,16 @@ export function registerCommands( }, ), ); + context.subscriptions.push( + vscode.commands.registerCommand( + 'notification.openOnGitHub', + async (e: NotificationTreeItem | undefined) => { + if (e) { + openPullRequestOnGitHub(e, telemetry); + } + }, + ), + ); context.subscriptions.push( vscode.commands.registerCommand( @@ -773,8 +794,8 @@ export function registerCommands( context.subscriptions.push( vscode.commands.registerCommand( 'pr.openDescription', - async (argument: DescriptionNode | PullRequestModel | undefined) => { - let pullRequestModel: PullRequestModel | undefined; + async (argument: DescriptionNode | IssueModel | undefined) => { + let pullRequestModel: IssueModel | undefined; if (!argument) { const activePullRequests: PullRequestModel[] = reposManager.folderManagers .map(manager => manager.activePullRequest!) diff --git a/src/common/executeCommands.ts b/src/common/executeCommands.ts index 1b936e5638..28fdf474c0 100644 --- a/src/common/executeCommands.ts +++ b/src/common/executeCommands.ts @@ -21,6 +21,8 @@ export namespace contexts { } export namespace commands { + export const OPEN_CHAT = 'workbench.action.chat.open'; + export function executeCommand(command: string, arg1?: any, arg2?: any) { return vscode.commands.executeCommand(command, arg1, arg2); } diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts index 843c19ed76..893d5629e5 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -51,6 +51,9 @@ export const CREATE_ISSUE_TRIGGERS = 'createIssueTriggers'; export const DEFAULT = 'default'; export const IGNORE_MILESTONES = 'ignoreMilestones'; export const ALLOW_FETCH = 'allowFetch'; +export const EXPERIMENTAL_CHAT = 'experimental.chat'; +export const EXPERIMENTAL_NOTIFICATIONS = 'experimental.notificationsView'; +export const EXPERIMENTAL_NOTIFICATIONS_PAGE_SIZE = 'experimental.notificationsViewPageSize'; // git export const GIT = 'git'; diff --git a/src/extension.ts b/src/extension.ts index 1ac467f03e..7bcb90734a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,7 +15,7 @@ import Logger from './common/logger'; import * as PersistentState from './common/persistentState'; import { parseRepositoryRemotes } from './common/remote'; import { Resource } from './common/resources'; -import { BRANCH_PUBLISH, FILE_LIST_LAYOUT, GIT, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; +import { BRANCH_PUBLISH, EXPERIMENTAL_CHAT, EXPERIMENTAL_NOTIFICATIONS, 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'; @@ -27,6 +27,9 @@ import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitP import { GitHubContactServiceProvider } from './gitProviders/GitHubContactServiceProvider'; import { GitLensIntegration } from './integrations/gitlens/gitlensImpl'; import { IssueFeatureRegistrar } from './issues/issueFeatureRegistrar'; +import { ChatParticipant, ChatParticipantState } from './lm/participants'; +import { registerTools } from './lm/tools/tools'; +import { NotificationsFeatureRegister } from './notifications/notificationsFeatureRegistar'; import { CommentDecorationProvider } from './view/commentDecorationProvider'; import { CompareChanges } from './view/compareChangesTreeDataProvider'; import { CreatePullRequestHelper } from './view/createPullRequestHelper'; @@ -219,11 +222,25 @@ async function init( context.subscriptions.push(issuesFeatures); await issuesFeatures.initialize(); + const notificationsViewEnabled = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(EXPERIMENTAL_NOTIFICATIONS, false); + if (notificationsViewEnabled) { + const notificationsFeatures = new NotificationsFeatureRegister(credentialStore, reposManager, telemetry); + context.subscriptions.push(notificationsFeatures); + } + context.subscriptions.push(new GitLensIntegration()); await vscode.commands.executeCommand('setContext', 'github:initialized', true); registerPostCommitCommandsProvider(reposManager, git); + + const chatEnabled = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(EXPERIMENTAL_CHAT, false); + if (chatEnabled) { + const chatParticipantState = new ChatParticipantState(); + context.subscriptions.push(new ChatParticipant(context, chatParticipantState)); + registerTools(context, reposManager, chatParticipantState); + } + // Make sure any compare changes tabs, which come from the create flow, are closed. CompareChanges.closeTabs(); /* __GDPR__ diff --git a/src/github/common.ts b/src/github/common.ts index 9a6a2fffe0..4047a24ab8 100644 --- a/src/github/common.ts +++ b/src/github/common.ts @@ -47,6 +47,7 @@ export namespace OctokitCommon { 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 Notification = Endpoints['GET /notifications']['response']['data'][0]; } export type Schema = { [key: string]: any, definitions: any[]; }; diff --git a/src/github/graphql.ts b/src/github/graphql.ts index 8dbe9f3e75..98c4bcc570 100644 --- a/src/github/graphql.ts +++ b/src/github/graphql.ts @@ -52,6 +52,9 @@ export interface AbbreviatedIssueComment { }; body: string; databaseId: number; + reactions: { + totalCount: number; + }; } export interface IssueComment extends AbbreviatedIssueComment { @@ -593,8 +596,9 @@ export interface PullRequest { }; }[]; }; - comments?: { - nodes: AbbreviatedIssueComment[]; + comments: { + nodes?: AbbreviatedIssueComment[]; + totalCount: number; }; createdAt: string; updatedAt: string; @@ -650,6 +654,9 @@ export interface PullRequest { }; url: string; }; + reactions: { + totalCount: number; + } } export enum DefaultCommitTitle { diff --git a/src/github/interface.ts b/src/github/interface.ts index e2017eaa89..20932c55f0 100644 --- a/src/github/interface.ts +++ b/src/github/interface.ts @@ -143,7 +143,14 @@ export interface IGitHubRef { export interface ILabel { name: string; color: string; - description?: string; + description?: string | null; +} + +export interface IIssueComment { + author: IAccount; + body: string; + databaseId: number; + reactionCount: number; } export interface Issue { @@ -166,11 +173,9 @@ export interface Issue { repositoryOwner?: string; repositoryName?: string; repositoryUrl?: string; - comments?: { - author: IAccount; - body: string; - databaseId: number; - }[]; + comments?: IIssueComment[]; + commentCount: number; + reactionCount: number; } export interface PullRequest extends Issue { @@ -195,6 +200,27 @@ export interface PullRequest extends Issue { hasComments?: boolean; } +export enum NotificationSubjectType { + Issue = 'Issue', + PullRequest = 'PullRequest' +} + +export interface Notification { + owner: string; + name: string; + key: string; + id: string; + subject: { + title: string; + type: NotificationSubjectType; + url: string; + }; + reason: string; + unread: boolean; + updatedAd: Date; + lastReadAt: Date | undefined; +} + export interface IRawFileChange { sha: string; filename: string; diff --git a/src/github/issueModel.ts b/src/github/issueModel.ts index 11a9c19bdc..0881fc5050 100644 --- a/src/github/issueModel.ts +++ b/src/github/issueModel.ts @@ -9,7 +9,6 @@ import Logger from '../common/logger'; import { Remote } from '../common/remote'; import { TimelineEvent } from '../common/timelineEvent'; import { formatError } from '../common/utils'; -import { OctokitCommon } from './common'; import { GitHubRepository } from './githubRepository'; import { AddIssueCommentResponse, @@ -186,21 +185,6 @@ export class IssueModel { return this.githubRepository.isCurrentUser(username); } - async getIssueComments(): Promise { - Logger.debug(`Fetch issue comments of PR #${this.number} - enter`, IssueModel.ID); - const { octokit, remote } = await this.githubRepository.ensure(); - - const promise = await octokit.call(octokit.api.issues.listComments, { - owner: remote.owner, - repo: remote.repositoryName, - issue_number: this.number, - per_page: 100, - }); - Logger.debug(`Fetch issue comments of PR #${this.number} - done`, IssueModel.ID); - - return promise.data; - } - async createIssueComment(text: string): Promise { const { mutate, schema } = await this.githubRepository.ensure(); const { data } = await mutate({ @@ -356,6 +340,4 @@ export class IssueModel { return []; } } - - } diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 46d3796fdb..6083d1874c 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -146,7 +146,7 @@ export class IssueOverviewPanel extends W } this._item = issue as TItem; - this.setPanelTitle(`Pull Request #${issueModel.number.toString()}`); + this.setPanelTitle(`Issue #${issueModel.number.toString()}`); Logger.debug('pr.initialize', IssueOverviewPanel.ID); this._postMessage({ @@ -154,6 +154,7 @@ export class IssueOverviewPanel extends W pullrequest: { number: this._item.number, title: this._item.title, + titleHTML: this._item.titleHTML, url: this._item.html_url, createdAt: this._item.createdAt, body: this._item.body, @@ -188,7 +189,7 @@ export class IssueOverviewPanel extends W scrollPosition: this._scrollPosition, }); - this._panel.webview.html = this.getHtmlForWebview(issueModel.number.toString()); + this._panel.webview.html = this.getHtmlForWebview(); return this.updateIssue(issueModel); } @@ -391,7 +392,7 @@ export class IssueOverviewPanel extends W } } - protected getHtmlForWebview(number: string) { + protected getHtmlForWebview() { const nonce = getNonce(); const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-pr-description.js'); @@ -403,7 +404,6 @@ export class IssueOverviewPanel extends W - Pull Request #${number}
diff --git a/src/github/notifications.ts b/src/github/notifications.ts index 6d4122c0c4..1e2f5ef51b 100644 --- a/src/github/notifications.ts +++ b/src/github/notifications.ts @@ -16,6 +16,7 @@ import { TreeNode } from '../view/treeNodes/treeNode'; import { CredentialStore, GitHub } from './credentials'; import { GitHubRepository } from './githubRepository'; import { PullRequestState } from './graphql'; +import { IssueModel } from './issueModel'; import { PullRequestModel } from './pullRequestModel'; import { RepositoriesManager } from './repositoriesManager'; import { hasEnterpriseUri } from './utils'; @@ -133,7 +134,7 @@ export class NotificationProvider implements vscode.Disposable { ); } - private getPrIdentifier(pullRequest: PullRequestModel | OctokitResponse['data']): string { + private getPrIdentifier(pullRequest: IssueModel | OctokitResponse['data']): string { if (pullRequest instanceof PullRequestModel) { return `${pullRequest.remote.url}:${pullRequest.number}`; } @@ -144,8 +145,8 @@ export class NotificationProvider implements vscode.Disposable { /* 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 ? + public hasNotification(pullRequest: IssueModel | string): boolean { + const identifier = pullRequest instanceof IssueModel ? this.getPrIdentifier(pullRequest) : pullRequest; const prNotifications = this._notifications.get(identifier); @@ -265,7 +266,7 @@ export class NotificationProvider implements vscode.Disposable { }); } - public async markPrNotificationsAsRead(pullRequestModel: PullRequestModel) { + public async markPrNotificationsAsRead(pullRequestModel: IssueModel) { const identifier = this.getPrIdentifier(pullRequestModel); const prNotifications = this._notifications.get(identifier); if (prNotifications && prNotifications.length) { diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index b2b5514740..bf0b281633 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -338,7 +338,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel this.updatePullRequest(pullRequestModel)); diff --git a/src/github/queries.gql b/src/github/queries.gql index 3bf340a417..b8f0bec5ba 100644 --- a/src/github/queries.gql +++ b/src/github/queries.gql @@ -117,6 +117,12 @@ fragment PullRequestFragment on PullRequest { url } } + reactions(first: 1) { + totalCount + } + comments(first: 1) { + totalCount + } } query PullRequest($owner: String!, $name: String!, $number: Int!) { diff --git a/src/github/queriesExtra.gql b/src/github/queriesExtra.gql index 3a2e31bb96..e59003effe 100644 --- a/src/github/queriesExtra.gql +++ b/src/github/queriesExtra.gql @@ -126,6 +126,12 @@ fragment PullRequestFragment on PullRequest { url } } + reactions(first: 1) { + totalCount + } + comments(first: 1) { + totalCount + } } query PullRequest($owner: String!, $name: String!, $number: Int!) { diff --git a/src/github/queriesLimited.gql b/src/github/queriesLimited.gql index 4a833e7de1..1fca7b66c4 100644 --- a/src/github/queriesLimited.gql +++ b/src/github/queriesLimited.gql @@ -108,6 +108,12 @@ fragment PullRequestFragment on PullRequest { url } } + reactions(first: 1) { + totalCount + } + comments(first: 1) { + totalCount + } } query PullRequest($owner: String!, $name: String!, $number: Int!) { diff --git a/src/github/queriesShared.gql b/src/github/queriesShared.gql index e1af9c27bd..87d27f7b16 100644 --- a/src/github/queriesShared.gql +++ b/src/github/queriesShared.gql @@ -585,6 +585,15 @@ query Issue($owner: String!, $name: String!, $number: Int!) { } createdAt updatedAt + assignees(first: 10) { + nodes { + avatarUrl + email + login + url + id + } + } labels(first: 50) { nodes { name @@ -593,6 +602,12 @@ query Issue($owner: String!, $name: String!, $number: Int!) { } id databaseId + reactions(first: 100) { + totalCount + } + comments(first: 1) { + totalCount + } } } rateLimit { @@ -649,7 +664,23 @@ query IssueWithComments($owner: String!, $name: String!, $number: Int!) { } body databaseId + reactions(first: 100) { + totalCount + } } + totalCount + } + assignees(first: 10) { + nodes { + avatarUrl + email + login + url + id + } + } + reactions(first: 100) { + totalCount } } } @@ -1025,6 +1056,12 @@ query Issues($query: String!) { } url } + reactions(first: 1) { + totalCount + } + comments(first: 1) { + totalCount + } } } } diff --git a/src/github/quickPicks.ts b/src/github/quickPicks.ts index fd09fa25c1..fe622ae177 100644 --- a/src/github/quickPicks.ts +++ b/src/github/quickPicks.ts @@ -385,7 +385,7 @@ export async function getLabelOptions( const labelPicks = newLabels.map(label => { return { label: label.name, - description: label.description, + description: label.description ?? undefined, picked: labels.some(existingLabel => existingLabel.name === label.name), iconPath: DataUri.asImageDataURI(Buffer.from(` diff --git a/src/github/repositoriesManager.ts b/src/github/repositoriesManager.ts index b3755b00ad..4e107a85c3 100644 --- a/src/github/repositoriesManager.ts +++ b/src/github/repositoriesManager.ts @@ -122,19 +122,7 @@ export class RepositoriesManager implements vscode.Disposable { if (issueModel === undefined) { return undefined; } - const issueRemoteUrl = `${issueModel.remote.owner.toLowerCase()}/${issueModel.remote.repositoryName.toLowerCase()}`; - for (const folderManager of this._folderManagers) { - if ( - folderManager.gitHubRepositories - .map(repo => - `${repo.remote.owner.toLowerCase()}/${repo.remote.repositoryName.toLowerCase()}` - ) - .includes(issueRemoteUrl) - ) { - return folderManager; - } - } - return undefined; + return this.getManagerForRepository(issueModel.remote.owner, issueModel.remote.repositoryName); } getManagerForFile(uri: vscode.Uri): FolderRepositoryManager | undefined { @@ -159,6 +147,21 @@ export class RepositoriesManager implements vscode.Disposable { return undefined; } + getManagerForRepository(owner: string, repo: string) { + const issueRemoteUrl = `${owner.toLowerCase()}/${repo.toLowerCase()}`; + for (const folderManager of this._folderManagers) { + if ( + folderManager.gitHubRepositories + .map(repo => + `${repo.remote.owner.toLowerCase()}/${repo.remote.repositoryName.toLowerCase()}` + ) + .includes(issueRemoteUrl) + ) { + return folderManager; + } + } + } + get state() { return this._state; } diff --git a/src/github/utils.ts b/src/github/utils.ts index f6cb9a7165..49160b17f1 100644 --- a/src/github/utils.ts +++ b/src/github/utils.ts @@ -18,7 +18,7 @@ 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 { gitHubLabelColor, uniqBy } from '../common/utils'; import { OctokitCommon } from './common'; import { FolderRepositoryManager, PullRequestDefaults } from './folderRepositoryManager'; import { GitHubRepository, ViewerPermission } from './githubRepository'; @@ -27,6 +27,7 @@ import { IAccount, IActor, IGitHubRef, + IIssueComment, ILabel, IMilestone, IProjectItem, @@ -36,6 +37,8 @@ import { MergeMethod, MergeQueueEntry, MergeQueueState, + Notification, + NotificationSubjectType, PullRequest, PullRequestMergeability, reviewerId, @@ -340,6 +343,8 @@ export function convertRESTPullRequestToRawPullRequest( suggestedReviewers: [], // suggested reviewers only available through GraphQL API projectItems: [], // projects only available through GraphQL API commits: [], // commits only available through GraphQL API + reactionCount: 0, // reaction count only available through GraphQL API + commentCount: 0 // comment count only available through GraphQL API }; // mergeable is not included in the list response, will need to fetch later @@ -369,6 +374,7 @@ export function convertRESTIssueToRawPullRequest( labels, node_id, id, + comments } = pullRequest; const item: Issue = { @@ -390,6 +396,8 @@ export function convertRESTIssueToRawPullRequest( typeof l === 'string' ? { name: l, color: '' } : { name: l.name ?? '', color: l.color ?? '', description: l.description ?? undefined }, ), projectItems: [], // projects only available through GraphQL API + reactionCount: 0, // reaction count only available through GraphQL API + commentCount: comments }; return item; @@ -726,6 +734,8 @@ export function parseGraphQLPullRequest( milestone: parseMilestone(graphQLPullRequest.milestone), assignees: graphQLPullRequest.assignees?.nodes.map(assignee => parseAuthor(assignee, githubRepository)), commits: parseCommits(graphQLPullRequest.commits.nodes), + reactionCount: graphQLPullRequest.reactions.totalCount, + commentCount: graphQLPullRequest.comments.totalCount, }; pr.mergeCommitMeta = parseCommitMeta(graphQLPullRequest.baseRepository.mergeCommitTitle, graphQLPullRequest.baseRepository.mergeCommitMessage, pr); pr.squashCommitMeta = parseCommitMeta(graphQLPullRequest.baseRepository.squashMergeCommitTitle, graphQLPullRequest.baseRepository.squashMergeCommitMessage, pr); @@ -797,12 +807,14 @@ function parseComments(comments: GraphQL.AbbreviatedIssueComment[] | undefined, author: IAccount; body: string; databaseId: number; + reactionCount: number; }[] = []; for (const comment of comments) { parsedComments.push({ author: parseAuthor(comment.author, githubRepository), body: comment.body, databaseId: comment.databaseId, + reactionCount: comment.reactions.totalCount }); } @@ -830,6 +842,18 @@ export function parseGraphQLIssue(issue: GraphQL.PullRequest, githubRepository: repositoryOwner: issue.repository?.owner.login ?? githubRepository.remote.owner, repositoryUrl: issue.repository?.url ?? githubRepository.remote.url, projectItems: parseProjectItems(issue.projectItems?.nodes), + comments: issue.comments.nodes?.map(comment => parseIssueComment(comment)), + reactionCount: issue.reactions.totalCount, + commentCount: issue.comments.totalCount + }; +} + +function parseIssueComment(comment: GraphQL.AbbreviatedIssueComment): IIssueComment { + return { + author: comment.author, + body: comment.body, + databaseId: comment.databaseId, + reactionCount: comment.reactions.totalCount }; } @@ -1227,6 +1251,31 @@ export function parseReviewers( return reviewers; } +export function parseNotification(notification: OctokitCommon.Notification): Notification | undefined { + if (!notification.subject.url) { + return undefined; + } + const owner = notification.repository.owner.login; + const name = notification.repository.name; + const id = notification.subject.url.split('/').pop(); + + return { + owner, + name, + key: `${owner}/${name}#${id}`, + id: id!, + subject: { + title: notification.subject.title, + type: notification.subject.type as NotificationSubjectType, + url: notification.subject.url + }, + lastReadAt: new Date(notification.last_read_at), + reason: notification.reason, + unread: notification.unread, + updatedAd: new Date(notification.updated_at), + }; +} + export function insertNewCommitsSinceReview( timelineEvents: Common.TimelineEvent[], latestReviewCommitOid: string | undefined, @@ -1411,3 +1460,9 @@ 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}`; } + +export function makeLabel(label: ILabel): string { + const isDarkTheme = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark; + const labelColor = gitHubLabelColor(label.color, isDarkTheme, true); + return `  ${label.name.trim()}  `; +} diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index ff76a43223..27dc199048 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { GitApiImpl } from '../api/api1'; +import { commands } from '../common/executeCommands'; import Logger from '../common/logger'; import { CREATE_INSERT_FORMAT, @@ -484,6 +485,30 @@ export class IssueFeatureRegistrar implements vscode.Disposable { return openCodeLink(issueModel, this.manager); }), ); + this.context.subscriptions.push( + vscode.commands.registerCommand('issue.chatSummarizeIssue', (issue: any) => { + if (!(issue instanceof IssueModel)) { + return; + } + /* __GDPR__ + "issue.chatSummarizeIssue" : {} + */ + this.telemetry.sendTelemetryEvent('issue.chatSummarizeIssue'); + commands.executeCommand(commands.OPEN_CHAT, vscode.l10n.t('@githubpr Summarize issue {0}/{1}#{2}', issue.remote.owner, issue.remote.repositoryName, issue.number)); + }), + ); + this.context.subscriptions.push( + vscode.commands.registerCommand('issue.chatSuggestFix', (issue: any) => { + if (!(issue instanceof IssueModel)) { + return; + } + /* __GDPR__ + "issue.chatSuggestFix" : {} + */ + this.telemetry.sendTelemetryEvent('issue.chatSuggestFix'); + commands.executeCommand(commands.OPEN_CHAT, vscode.l10n.t('@githubpr Find a fix for issue {0}/{1}#{2}', issue.remote.owner, issue.remote.repositoryName, issue.number)); + }), + ); this._stateManager.tryInitializeAndWait().then(() => { this.registerCompletionProviders(); diff --git a/src/issues/util.ts b/src/issues/util.ts index 11878eaa80..2f7f654917 100644 --- a/src/issues/util.ts +++ b/src/issues/util.ts @@ -8,7 +8,6 @@ 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'; @@ -19,7 +18,7 @@ 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 { getEnterpriseUri, getIssueNumberLabelFromParsed, getRepositoryForFile, ISSUE_OR_URL_EXPRESSION, makeLabel, ParsedIssue, parseIssueExpressionOutput } from '../github/utils'; import { ReviewManager } from '../view/reviewManager'; import { CODE_PERMALINK, findCodeLinkLocally } from './issueLinkLookup'; import { StateManager } from './stateManager'; @@ -120,12 +119,6 @@ export function userMarkdown(origin: PullRequestDefaults, user: User): vscode.Ma 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, @@ -238,7 +231,7 @@ export async function issueMarkdown( if (issue.item.labels.length > 0) { issue.item.labels.forEach(label => { markdown.appendMarkdown( - `[${makeLabel(label.color, label.name)}](https://github.com/${ownerName}/labels/${encodeURIComponent( + `[${makeLabel(label)}](https://github.com/${ownerName}/labels/${encodeURIComponent( label.name, )}) `, ); diff --git a/src/lm/displayIssuesTool.ts b/src/lm/displayIssuesTool.ts new file mode 100644 index 0000000000..47b038f9a6 --- /dev/null +++ b/src/lm/displayIssuesTool.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import Logger from '../common/logger'; +import { Issue } from '../github/interface'; +import { ChatParticipantState } from './participants'; +import { SearchToolResult } from './searchTools'; +import { concatAsyncIterable, ToolBase } from './tools/toolsUtils'; + +export type DisplayIssuesParameters = SearchToolResult; + +type IssueColumn = keyof Issue; + +const LLM_FIND_IMPORTANT_COLUMNS_INSTRUCTIONS = `Instructions: +You are an expert on GitHub issues. You can help the user identify the most important columns for rendering issues based on a query for issues. Include a column related to the sort value, if given. Output a newline separated list of columns only, max 4 columns. List the columns in the order they should be displayed. Don't change the casing. Here are the possible columns: +`; + +export class DisplayIssuesTool extends ToolBase { + static ID = 'DisplayIssuesTool'; + constructor(chatParticipantState: ChatParticipantState) { + super(chatParticipantState); + } + + private assistantPrompt(issues: Issue[]): string { + const possibleColumns = Object.keys(issues[0]); + return `${LLM_FIND_IMPORTANT_COLUMNS_INSTRUCTIONS}\n${possibleColumns.map(column => `- ${column}`).join('\n')}\nHere's the data you have about the issues:\n`; + } + + private postProcess(output: string, issues: Issue[]): IssueColumn[] { + const lines = output.split('\n'); + const possibleColumns = Object.keys(issues[0]); + const finalColumns: IssueColumn[] = []; + for (const line of lines) { + if (line === '') { + continue; + } + if (!possibleColumns.includes(line)) { + // Check if the llm decided to use formatting, even though we asked it not to + const splitOnSpace = line.split(' '); + if (splitOnSpace.length > 1) { + const testColumn = splitOnSpace[splitOnSpace.length - 1]; + if (possibleColumns.includes(testColumn)) { + finalColumns.push(testColumn as IssueColumn); + } + } + } else { + finalColumns.push(line as IssueColumn); + } + } + const indexOfId = finalColumns.indexOf('id'); + if (indexOfId !== -1) { + finalColumns[indexOfId] = 'number'; + } + return finalColumns; + } + + private async getImportantColumns(issueItemsInfo: string, issues: Issue[], token: vscode.CancellationToken): Promise { + // Try to get the llm to tell us which columns are important based on information it has about the issues + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + const model = models[0]; + const chatOptions: vscode.LanguageModelChatRequestOptions = { + justification: 'Answering user questions pertaining to GitHub.' + }; + const messages = [vscode.LanguageModelChatMessage.Assistant(this.assistantPrompt(issues))]; + messages.push(vscode.LanguageModelChatMessage.User(issueItemsInfo)); + const response = await model.sendRequest(messages, chatOptions, token); + const result = this.postProcess(await concatAsyncIterable(response.text), issues); + if (result.length === 0) { + return ['number', 'title', 'state']; + } + + return result; + } + + private issueToRow(issue: Issue, importantColumns: IssueColumn[]): string { + return `| ${importantColumns.map(column => { + switch (column) { + case 'number': + return `[${issue[column]}](${issue.url})`; + case 'labels': + return issue[column].map((label) => label.name).join(', '); + default: + return issue[column]; + } + }).join(' | ')} |`; + } + + async invoke(_options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): Promise { + // The llm won't actually pass the output of the search tool to this tool, so we need to get the issues from the last message + let issueItems: Issue[] = []; // = (typeof options.parameters.arrayOfIssues === 'string') ? JSON.parse(options.parameters.arrayOfIssues) : options.parameters.arrayOfIssues; + let issueItemsInfo: string = this.chatParticipantState.firstUserMessage ?? ''; + const lastMessage = this.chatParticipantState.lastToolResult; + if (lastMessage) { + try { + for (const part of lastMessage) { + if (part instanceof vscode.LanguageModelToolResultPart) { + const issues = JSON.parse(part.content) as SearchToolResult; + if (Array.isArray(issues.arrayOfIssues)) { + issueItems = issues.arrayOfIssues; + } + } else if (typeof part === 'string') { + issueItemsInfo += part; + } + } + } catch { + // ignore, the data doesn't exist + } + } + if (issueItems.length === 0) { + return { + 'text/plain': 'No issues found. Please try another query.', + 'text/markdown': 'No issues found. Please try another query.' + }; + } + Logger.debug(`Displaying ${issueItems.length} issues, first issue ${issueItems[0].number}`, DisplayIssuesTool.ID); + const importantColumns = await this.getImportantColumns(issueItemsInfo, issueItems, token); + + const titleRow = `| ${importantColumns.join(' | ')} |`; + Logger.debug(`Columns ${titleRow} issues`, DisplayIssuesTool.ID); + const separatorRow = `| ${importantColumns.map(() => '---').join(' | ')} |\n`; + const issues = new vscode.MarkdownString(titleRow); + issues.appendMarkdown('\n'); + issues.appendMarkdown(separatorRow); + issues.appendMarkdown(issueItems.slice(0, 10).map(issue => { + return this.issueToRow(issue, importantColumns); + }).join('\n')); + + return { + 'text/plain': `Here is a markdown table of the first 10 issues.`, + 'text/markdown': issues.value, + 'text/display': `Here's a markdown table of the first 10 issues.\n\n${issues.value}\n\n` + }; + } + +} \ No newline at end of file diff --git a/src/lm/participants.ts b/src/lm/participants.ts new file mode 100644 index 0000000000..0722b795f4 --- /dev/null +++ b/src/lm/participants.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; +import * as vscode from 'vscode'; +import { dispose } from '../common/utils'; +import { IToolCall } from './tools/toolsUtils'; + +const llmInstructions = `Instructions: +- The user will ask a question related to GitHub, and it may require lots of research to answer correctly. There is a selection of tools that let you perform actions or retrieve helpful context to answer the user's question. +- If you aren't sure which tool is relevant, you can call multiple tools. You can call tools repeatedly to take actions or gather as much context as needed until you have completed the task fully. Don't give up unless you are sure the request cannot be fulfilled with the tools you have. +- Don't ask the user for confirmation to use tools, just use them. +- When talking about issues, be as concise as possible while still conveying all the information you need to. Avoid mentioning the following: + - The fact that there are no comments. + - Any info that seems like template info.`; + +export class ChatParticipantState { + private _messages: vscode.LanguageModelChatMessage[] = []; + + get lastToolResult(): (string | vscode.LanguageModelToolResultPart | vscode.LanguageModelToolCallPart)[] { + for (let i = this._messages.length - 1; i >= 0; i--) { + const message = this._messages[i]; + for (const part of message.content2) { + if (part instanceof vscode.LanguageModelToolResultPart) { + return message.content2; + } + } + } + return []; + } + + get firstUserMessage(): string | undefined { + for (let i = 0; i < this._messages.length; i++) { + const message = this._messages[i]; + if (message.role === vscode.LanguageModelChatMessageRole.User && message.content) { + return message.content; + } + } + } + + get messages(): vscode.LanguageModelChatMessage[] { + return this._messages; + } + + addMessage(message: vscode.LanguageModelChatMessage): void { + this._messages.push(message); + } + + reset(): void { + this._messages = []; + } + + constructor() { + + } +} + +export class ChatParticipant implements vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + + constructor(context: vscode.ExtensionContext, private readonly state: ChatParticipantState) { + const ghprChatParticipant = vscode.chat.createChatParticipant('githubpr', ( + request: vscode.ChatRequest, + context: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken + ) => this.handleParticipantRequest(request, context, stream, token)); + ghprChatParticipant.iconPath = vscode.Uri.joinPath(context.extensionUri, 'resources/icons/github_logo.png'); + this.disposables.push(ghprChatParticipant); + } + + dispose() { + dispose(this.disposables); + } + + async handleParticipantRequest( + request: vscode.ChatRequest, + context: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken + ): Promise { + this.state.reset(); + + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + const model = models[0]; + const allTools = vscode.lm.tools.map((tool): vscode.LanguageModelChatTool => { + return { + name: tool.name, + description: tool.description, + parametersSchema: tool.parametersSchema ?? {} + }; + }); + + this.state.addMessage(vscode.LanguageModelChatMessage.Assistant(llmInstructions)); + this.state.addMessage(vscode.LanguageModelChatMessage.User(request.prompt)); + const toolReferences = [...request.toolReferences]; + const options: vscode.LanguageModelChatRequestOptions = { + justification: 'Answering user questions pertaining to GitHub.' + }; + + const runWithFunctions = async (): Promise => { + + const requestedTool = toolReferences.shift(); + if (requestedTool) { + options.toolChoice = requestedTool.id; + options.tools = allTools.filter(tool => tool.name === requestedTool.id); + } else { + options.toolChoice = undefined; + options.tools = allTools; + } + + const toolCalls: IToolCall[] = []; + const response = await model.sendRequest(this.state.messages, options, token); + + for await (const part of response.stream) { + + if (part instanceof vscode.LanguageModelTextPart) { + stream.markdown(part.value); + } else if (part instanceof vscode.LanguageModelToolCallPart) { + + const tool = vscode.lm.tools.find(tool => tool.name === part.name); + if (!tool) { + throw new Error('Got invalid tool choice: ' + part.name); + } + + let parameters: any; + try { + parameters = part.parameters; + } catch (err) { + throw new Error(`Got invalid tool use parameters: "${JSON.stringify(part.parameters)}". (${(err as Error).message})`); + } + + const invocationOptions = { parameters, toolInvocationToken: request.toolInvocationToken, requestedContentTypes: ['text/plain', 'text/markdown', 'text/json', 'text/display'] }; + toolCalls.push({ + call: part, + result: vscode.lm.invokeTool(tool.name, invocationOptions, token), + tool + }); + } + } + + if (toolCalls.length) { + const assistantMsg = vscode.LanguageModelChatMessage.Assistant(''); + assistantMsg.content2 = toolCalls.map(toolCall => new vscode.LanguageModelToolCallPart(toolCall.tool.name, toolCall.call.toolCallId, toolCall.call.parameters)); + this.state.addMessage(assistantMsg); + + let hasJson = false; + let display: string | undefined; + for (const toolCall of toolCalls) { + let toolCallResult = (await toolCall.result); + + const plainText = toolCallResult['text/plain']; + const markdown = toolCallResult['text/markdown']; + const json = toolCallResult['text/json']; + display = toolCallResult['text/display']; // our own fake type that we use to indicate something that should be streamed to the user + if (display) { + stream.markdown(display); + } + + const content: (string | vscode.LanguageModelToolResultPart | vscode.LanguageModelToolCallPart)[] = []; + let isOnlyPlaintext = true; + if (json !== undefined) { + content.push(new vscode.LanguageModelToolResultPart(toolCall.call.toolCallId, JSON.stringify(json))); + isOnlyPlaintext = false; + hasJson = true; + + } else if (markdown !== undefined) { + content.push(new vscode.LanguageModelToolResultPart(toolCall.call.toolCallId, markdown)); + isOnlyPlaintext = false; + } + if (plainText !== undefined) { + if (isOnlyPlaintext) { + content.push(new vscode.LanguageModelToolResultPart(toolCall.call.toolCallId, plainText)); + } else { + content.push(plainText); + } + } + const message = vscode.LanguageModelChatMessage.User(''); + message.content2 = content; + this.state.addMessage(message); + } + + this.state.addMessage(vscode.LanguageModelChatMessage.User(`Above is the result of calling the functions ${toolCalls.map(call => call.tool.name).join(', ')}.${hasJson ? ' The JSON is also included and should be passed to the next tool.' : ''} ${display ? 'The user can see the result of the tool call and doesn\'t need you to show it.' : 'The user cannot see the result of the tool call, so you should show it to them in an appropriate way.'}`)); + return runWithFunctions(); + } + }; + await runWithFunctions(); + } + +} + diff --git a/src/lm/searchTools.ts b/src/lm/searchTools.ts new file mode 100644 index 0000000000..fb02ad91a8 --- /dev/null +++ b/src/lm/searchTools.ts @@ -0,0 +1,439 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import Logger from '../common/logger'; +import { FolderRepositoryManager } from '../github/folderRepositoryManager'; +import { ILabel, Issue } from '../github/interface'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { ChatParticipantState } from './participants'; +import { concatAsyncIterable, ToolBase } from './tools/toolsUtils'; + +interface ConvertToQuerySyntaxParameters { + naturalLanguageString: string; + repo?: { + owner?: string; + name?: string; + }; +} + +interface ConvertToQuerySyntaxResult { + query: string; + repo?: { + owner?: string; + name?: string; + }; +} + +enum ValidatableProperty { + is = 'is', + type = 'type', + state = 'state', + in = 'in', + linked = 'linked', + status = 'status', + draft = 'draft', + review = 'review', + no = 'no', +} + +const MATCH_UNQUOTED_SPACES = /(?!\B"[^"]*)\s+(?![^"]*"\B)/; + +export class ConvertToSearchSyntaxTool extends ToolBase { + static ID = 'ConvertToSearchSyntaxTool'; + constructor(private readonly repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { + super(chatParticipantState); + } + + private async fullQueryAssistantPrompt(folderRepoManager: FolderRepositoryManager): Promise { + const remote = folderRepoManager.activePullRequest?.remote ?? folderRepoManager.activeIssue?.remote ?? (await folderRepoManager.getPullRequestDefaultRepo()).remote; + + return `Instructions: +You are an expert on GitHub issue search syntax. GitHub issues are always software engineering related. You can help the user convert a natural language query to a query that can be used to search GitHub issues. Here are some rules to follow: +- Always try to include "repo:" or "org:" in your response. +- "repo" is often formated as "owner/name". If needed, the current repo is ${remote.owner}/${remote.repositoryName}. +- Ignore display information. +- Respond with only the query. +- Always include a "sort:" parameter. +- Always include a property with the @me value if the query includes "me" or "my". +- Here are some examples of valid queries: + - repo:microsoft/vscode is:issue state:open sort:updated-asc + - mentions:@me org:microsoft is:issue state:open sort:updated + - assignee:@me milestone:"October 2024" is:open is:issue sort:reactions + - comments:>5 org:contoso is:issue state:closed mentions:@me label:bug + - interactions:>5 repo:contoso/cli is:issue state:open +- Go through each word of the natural language query and try to match it to a syntax component. +- As a reminder, here are the components of the query syntax: + Filters: +| Property | Possible Values | Value Description | +|-----------|-----------------|-------------------| +| is | issue, pr, draft, public, private, locked, unlocked | | +| assignee | | A GitHub user name or @me | +| author | | A GitHub user name or @me | +| mentions | | A GitHub user name or @me | +| team | | A GitHub user name | +| commenter | | A GitHub user name or @me | +| involves | | A GitHub user name or @me | +| label | | A GitHub issue/pr label | +| type | pr, issue | | +| state | open, closed, merged | | +| in | title, body, comments | | +| user | | A GitHub user name or @me | +| org | | A GitHub org, without the repo name | +| repo | | A GitHub repo, without the org name | +| linked | pr, issue | | +| milestone | | A GitHub milestone | +| project | | A GitHub project | +| status | success, failure, pending | | +| head | | A git commit sha or branch name | +| base | | A git commit sha or branch name | +| comments | | A number | +| interactions | | A number | +| reactions | | A number | +| draft | true, false | | +| review | none, required, approved, changes_requested | | +| reviewed-by | | A GitHub user name or @me | +| review-requested | | A GitHub user name or @me | +| user-review-requested | | A GitHub user name or @me | +| team-review-requested | | A GitHub user name | +| created | | A date, with an optional < > | +| updated | | A date, with an optional < > | +| closed | | A date, with an optional < > | +| no | label, milestone, assignee, project | | +| sort | updated, updated-asc, interactions, interactions-asc, author-date, author-date-asc, committer-date, committer-date-asc, reactions, reactions-asc, reactions-(+1, -1, smile, tada, heart) | | + + Logical Operators: + - - + + Special Values: + - @me +`; + } + + private async labelsAssistantPrompt(folderRepoManager: FolderRepositoryManager, labels: ILabel[]): Promise { + // It seems that AND and OR aren't supported in GraphQL, so we can't use them in the query + // Here's the prompt in case we switch to REST: + //- Use as many labels as you think fit the query. If one label fits, then there are probably more that fit. + // - Respond with a list of labels in github search syntax, separated by AND or OR. Examples: "label:bug OR label:polish", "label:accessibility AND label:editor-accessibility" + return `Instructions: +You are an expert on choosing search keywords based on a natural language search query. Here are some rules to follow: +- Choose labels based on what the user wants to search for, not based on the actual words in the query. +- Labels will be and-ed together, so don't pick a bunch of super specific labels. +- Try to pick just one label. +- Respond with a space-separated list of labels: Examples: 'bug polish', 'accessibility "feature accessibility"' +- Only choose labels that you're sure are relevant. Having no labels is preferable than lables that aren't relevant. +- Respond with labels chosen from these options: +${labels.map(label => label.name).filter(label => !label.includes('required') && !label.includes('search') && !label.includes('question') && !label.includes('find')).join(', ')} +`; + } + + private freeFormAssistantPrompt(): string { + return `Instructions: +You are getting ready to make a GitHub search query. Given a natural language query, you should find any key words that might be good for searching: +- Only include a max of 1 key word that is relevant to the search query. +- Don't refer to issue numbers. +- Don't refer to product names. +- Don't include any key words that might be related to sorting. +- Respond with only your chosen key word. +- It's better to return no keywords than to return irrelevant keywords. +`; + } + + private freeFormUserPrompt(originalUserPrompt: string): string { + return `The best search keywords in "${originalUserPrompt}" are:`; + } + + private labelsUserPrompt(originalUserPrompt: string): string { + return `The following labels are most appropriate for "${originalUserPrompt}":`; + } + + private fullQueryUserPrompt(originalUserPrompt: string): string { + originalUserPrompt = originalUserPrompt.replace(/\b(me|my)\b/, (value) => value.toUpperCase()); + const date = new Date(); + return `Pretend today's date is ${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}, but only include it if needed. How should this be converted to a GitHub issue search query? ${originalUserPrompt}`; + } + + private validateSpecificQueryPart(property: ValidatableProperty | string, value: string): boolean { + switch (property) { + case ValidatableProperty.is: + return value === 'issue' || value === 'pr' || value === 'draft' || value === 'public' || value === 'private' || value === 'locked' || value === 'unlocked'; + case ValidatableProperty.type: + return value === 'pr' || value === 'issue'; + case ValidatableProperty.state: + return value === 'open' || value === 'closed' || value === 'merged'; + case ValidatableProperty.in: + return value === 'title' || value === 'body' || value === 'comments'; + case ValidatableProperty.linked: + return value === 'pr' || value === 'issue'; + case ValidatableProperty.status: + return value === 'success' || value === 'failure' || value === 'pending'; + case ValidatableProperty.draft: + return value === 'true' || value === 'false'; + case ValidatableProperty.review: + return value === 'none' || value === 'required' || value === 'approved' || value === 'changes_requested'; + case ValidatableProperty.no: + return value === 'label' || value === 'milestone' || value === 'assignee' || value === 'project'; + default: + return true; + } + } + + private validateLabelsList(labelsList: string, allLabels: ILabel[]): string[] { + // I wrote everything for AND and OR, but it isn't supported with GraphQL. + // Leaving it in for now in case we switch to REST. + const isAndOrOr = (labelOrOperator: string) => { + return labelOrOperator === 'AND' || labelOrOperator === 'OR'; + }; + + const labelsAndOperators = labelsList.split(MATCH_UNQUOTED_SPACES).map(label => label.trim()); + let goodLabels: string[] = []; + for (let labelOrOperator of labelsAndOperators) { + if (isAndOrOr(labelOrOperator)) { + if (goodLabels.length === 0) { + continue; + } else if (goodLabels.length > 0 && isAndOrOr(goodLabels[goodLabels.length - 1])) { + goodLabels[goodLabels.length - 1] = labelOrOperator; + } else { + goodLabels.push(labelOrOperator); + } + continue; + } + // Make sure it does start with `label:` + const labelPrefixRegex = /^label:(?!\B"[^"]*)\s+(?![^"]*"\B)/; + const labelPrefixMatch = labelOrOperator.match(labelPrefixRegex); + let label = labelOrOperator; + if (labelPrefixMatch) { + label = labelPrefixMatch[1]; + } + if (allLabels.find(l => l.name === label)) { + goodLabels.push(label); + } + } + if (goodLabels.length > 0 && isAndOrOr(goodLabels[goodLabels.length - 1])) { + goodLabels = goodLabels.slice(0, goodLabels.length - 1); + } + return goodLabels; + } + + private validateFreeForm(baseQuery: string, labels: string[], freeForm: string) { + // Currently, we only allow the free form to return one keyword + freeForm = freeForm.trim(); + if (baseQuery.includes(freeForm)) { + return ''; + } + if (labels.includes(freeForm)) { + return ''; + } + if (labels.some(label => freeForm.includes(label))) { + return ''; + } + return freeForm; + } + + private validateQuery(query: string, labelsList: string, allLabels: ILabel[], freeForm: string) { + let reformedQuery = ''; + const queryParts = query.split(MATCH_UNQUOTED_SPACES); + // Only keep property:value pairs and '-', no reform allowed here. + for (const part of queryParts) { + if (part.startsWith('label:')) { + continue; + } + const propAndVal = part.split(':'); + if (propAndVal.length === 2) { + const label = propAndVal[0]; + const value = propAndVal[1]; + if (!label.match(/^[a-zA-Z]+$/)) { + continue; + } + if (!this.validateSpecificQueryPart(label, value)) { + continue; + } + } else if (!part.startsWith('-')) { + continue; + } + reformedQuery = `${reformedQuery} ${part}`; + } + + const validLabels = this.validateLabelsList(labelsList, allLabels); + const validFreeForm = this.validateFreeForm(reformedQuery, validLabels, freeForm); + + reformedQuery = `${reformedQuery} ${validLabels.map(label => `label:${label}`).join(' ')} ${validFreeForm}`; + return reformedQuery.trim(); + } + + private postProcess(queryPart: string, freeForm: string, labelsList: string, allLabels: ILabel[]): ConvertToQuerySyntaxResult | undefined { + const query = this.findQuery(queryPart); + if (!query) { + return; + } + const fixedLabels = this.validateQuery(query, labelsList, allLabels, freeForm); + const fixedRepo = this.fixRepo(fixedLabels); + return fixedRepo; + } + + private fixRepo(query: string): ConvertToQuerySyntaxResult { + const repoRegex = /repo:([^ ]+)/; + const orgRegex = /org:([^ ]+)/; + const repoMatch = query.match(repoRegex); + const orgMatch = query.match(orgRegex); + let newQuery = query.trim(); + let owner: string | undefined; + let name: string | undefined; + if (repoMatch) { + const originalRepo = repoMatch[1]; + if (originalRepo.includes('/')) { + const ownerAndRepo = originalRepo.split('/'); + owner = ownerAndRepo[0]; + name = ownerAndRepo[1]; + } + + if (orgMatch && originalRepo.includes('/')) { + // remove the org match + newQuery = query.replace(orgRegex, ''); + } else if (orgMatch) { + // We need to add the org into the repo + newQuery = query.replace(repoRegex, `repo:${orgMatch[1]}/${originalRepo}`); + owner = orgMatch[1]; + name = originalRepo; + } + } + return { + query: newQuery, + repo: owner && name ? { owner, name } : undefined + }; + } + + private findQuery(result: string): string | undefined { + // if there's a code block, then that's all we take + if (result.includes('```')) { + const start = result.indexOf('```'); + const end = result.indexOf('```', start + 3); + return result.substring(start + 3, end); + } + // if it's only one line, we take that + const lines = result.split('\n'); + if (lines.length <= 1) { + return lines.length === 0 ? result : lines[0]; + } + // if there are multiple lines, we take the first line that has a colon + for (const line of lines) { + if (line.includes(':')) { + return line; + } + } + } + + private async generateLabelQuery(folderManager: FolderRepositoryManager, labels: ILabel[], chatOptions: vscode.LanguageModelChatRequestOptions, model: vscode.LanguageModelChat, naturalLanguageString: string, token: vscode.CancellationToken): Promise { + const messages = [vscode.LanguageModelChatMessage.Assistant(await this.labelsAssistantPrompt(folderManager, labels))]; + messages.push(vscode.LanguageModelChatMessage.User(this.labelsUserPrompt(naturalLanguageString))); + const response = await model.sendRequest(messages, chatOptions, token); + return concatAsyncIterable(response.text); + } + + private async generateFreeFormQuery(folderManager: FolderRepositoryManager, chatOptions: vscode.LanguageModelChatRequestOptions, model: vscode.LanguageModelChat, naturalLanguageString: string, token: vscode.CancellationToken): Promise { + const messages = [vscode.LanguageModelChatMessage.Assistant(this.freeFormAssistantPrompt())]; + messages.push(vscode.LanguageModelChatMessage.User(this.freeFormUserPrompt(naturalLanguageString))); + const response = await model.sendRequest(messages, chatOptions, token); + return concatAsyncIterable(response.text); + } + + private async generateQuery(folderManager: FolderRepositoryManager, chatOptions: vscode.LanguageModelChatRequestOptions, model: vscode.LanguageModelChat, naturalLanguageString: string, token: vscode.CancellationToken): Promise { + const messages = [vscode.LanguageModelChatMessage.Assistant(await this.fullQueryAssistantPrompt(folderManager))]; + messages.push(vscode.LanguageModelChatMessage.User(this.fullQueryUserPrompt(naturalLanguageString))); + const response = await model.sendRequest(messages, chatOptions, token); + return concatAsyncIterable(response.text); + } + + private toGitHubUrl(query: string) { + return `https://github.com/issues/?q=${encodeURIComponent(query)}`; + } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): Promise { + let owner: string | undefined; + let name: string | undefined; + let folderManager: FolderRepositoryManager | undefined; + const firstUserMessage = `${this.chatParticipantState.firstUserMessage}, ${options.parameters.naturalLanguageString}`; + // The llm likes to make up an owner and name if it isn't provided one, and they tend to include 'owner' and 'name' respectively + if (options.parameters.repo && options.parameters.repo.owner && options.parameters.repo.name && !options.parameters.repo.owner.includes('owner') && !options.parameters.repo.name.includes('name')) { + owner = options.parameters.repo.owner; + name = options.parameters.repo.name; + folderManager = this.repositoriesManager.getManagerForRepository(options.parameters.repo.owner, options.parameters.repo.name); + } else if (this.repositoriesManager.folderManagers.length > 0) { + folderManager = this.repositoriesManager.folderManagers[0]; + owner = folderManager.gitHubRepositories[0].remote.owner; + name = folderManager.gitHubRepositories[0].remote.repositoryName; + } + if (!folderManager || !owner || !name) { + throw new Error(`No folder manager found for ${owner}/${name}. Make sure to have a repository open.`); + } + + const labels = await folderManager.getLabels(undefined, { owner, repo: name }); + + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + const model = models[0]; + const chatOptions: vscode.LanguageModelChatRequestOptions = { + justification: 'Answering user questions pertaining to GitHub.' + }; + const [query, freeForm, labelsList] = await Promise.all([this.generateQuery(folderManager, chatOptions, model, firstUserMessage, token), this.generateFreeFormQuery(folderManager, chatOptions, model, firstUserMessage, token), this.generateLabelQuery(folderManager, labels, chatOptions, model, firstUserMessage, token)]); + + const result = this.postProcess(query, freeForm, labelsList, labels); + if (!result) { + throw new Error('Unable to form a query.'); + } + Logger.debug(`Query \`${result.query}\``, ConvertToSearchSyntaxTool.ID); + return { + 'text/plain': result.query, + 'text/display': `Query \`${result.query}\`. [Open on GitHub.com](${this.toGitHubUrl(result.query)})\n\n`, + }; + } +} + +type SearchToolParameters = ConvertToQuerySyntaxResult; + +export interface SearchToolResult { + arrayOfIssues: Issue[]; +} + +export class SearchTool implements vscode.LanguageModelTool { + static ID = 'SearchTool'; + constructor(private readonly repositoriesManager: RepositoriesManager) { } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + const parameterQuery = options.parameters.query; + Logger.debug(`Searching with query \`${parameterQuery}\``, SearchTool.ID); + let owner: string | undefined; + let name: string | undefined; + let folderManager: FolderRepositoryManager | undefined; + // The llm likes to make up an owner and name if it isn't provided one, and they tend to include 'owner' and 'name' respectively + if (options.parameters.repo && options.parameters.repo.owner && options.parameters.repo.name && !options.parameters.repo.owner.includes('owner') && !options.parameters.repo.name.includes('name')) { + owner = options.parameters.repo.owner; + name = options.parameters.repo.name; + folderManager = this.repositoriesManager.getManagerForRepository(options.parameters.repo.owner, options.parameters.repo.name); + } else if (this.repositoriesManager.folderManagers.length > 0) { + folderManager = this.repositoriesManager.folderManagers[0]; + owner = folderManager.gitHubRepositories[0].remote.owner; + name = folderManager.gitHubRepositories[0].remote.repositoryName; + } + if (!folderManager || !owner || !name) { + throw new Error(`No folder manager found for ${owner}/${name}. Make sure to have the repository open.`); + } + const searchResult = await folderManager.getIssues(parameterQuery); + if (!searchResult) { + throw new Error(`No issues found for ${parameterQuery}. Make sure the query is valid.`); + } + const result: SearchToolResult = { + arrayOfIssues: searchResult.items.map(i => i.item) + }; + Logger.debug(`Found ${result.arrayOfIssues.length} issues, first issue ${result.arrayOfIssues[0]?.number}.`, SearchTool.ID); + return { + 'text/plain': `Here are the issues I found for the query ${parameterQuery} in json format. You can pass these to a tool that can display them.`, + 'text/json': result + }; + } +} \ No newline at end of file diff --git a/src/lm/tools/issueTool.ts b/src/lm/tools/issueTool.ts new file mode 100644 index 0000000000..66641fd123 --- /dev/null +++ b/src/lm/tools/issueTool.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { RepositoriesManager } from '../../github/repositoriesManager'; + +interface IssueToolParameters { + issueNumber: number; + repo?: { + owner: string; + name: string; + }; +} + +interface IssueResult { + title: string; + body: string; + comments: { + body: string; + }[]; +} + +export class IssueTool implements vscode.LanguageModelTool { + constructor(private readonly repositoriesManager: RepositoriesManager) { } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, _token: vscode.CancellationToken): Promise { + let owner: string | undefined; + let name: string | undefined; + let folderManager: FolderRepositoryManager | undefined; + // The llm likes to make up an owner and name if it isn't provided one, and they tend to include 'owner' and 'name' respectively + if (options.parameters.repo && !options.parameters.repo.owner.includes('owner') && !options.parameters.repo.name.includes('name')) { + owner = options.parameters.repo.owner; + name = options.parameters.repo.name; + folderManager = this.repositoriesManager.getManagerForRepository(options.parameters.repo.owner, options.parameters.repo.name); + } else if (this.repositoriesManager.folderManagers.length > 0) { + folderManager = this.repositoriesManager.folderManagers[0]; + owner = folderManager.gitHubRepositories[0].remote.owner; + name = folderManager.gitHubRepositories[0].remote.repositoryName; + } + if (!folderManager || !owner || !name) { + throw new Error(`No folder manager found for ${owner}/${name}. Make sure to have the repository open.`); + } + const issue = await folderManager.resolveIssue(owner, name, options.parameters.issueNumber, true); + if (!issue) { + throw new Error(`No issue found for ${owner}/${name}/${options.parameters.issueNumber}. Make sure the issue exists.`); + } + const result: IssueResult = { + title: issue.title, + body: issue.body, + comments: issue.item.comments?.map(c => ({ body: c.body })) ?? [] + }; + return { + 'text/plain': JSON.stringify(result) + }; + } + +} \ No newline at end of file diff --git a/src/lm/tools/suggestFixTool.ts b/src/lm/tools/suggestFixTool.ts new file mode 100644 index 0000000000..5e54157636 --- /dev/null +++ b/src/lm/tools/suggestFixTool.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. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { IssueResult, IssueToolParameters } from './toolsUtils'; + +export class SuggestFixTool implements vscode.LanguageModelTool { + constructor(private readonly repositoriesManager: RepositoriesManager) { } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): Promise { + const folderManager = this.repositoriesManager.getManagerForRepository(options.parameters.repo.owner, options.parameters.repo.name); + if (!folderManager) { + throw new Error(`No folder manager found for ${options.parameters.repo.owner}/${options.parameters.repo.name}. Make sure to have the repository open.`); + } + const issue = await folderManager.resolveIssue(options.parameters.repo.owner, options.parameters.repo.name, options.parameters.issueNumber, true); + if (!issue) { + throw new Error(`No issue found for ${options.parameters.repo.owner}/${options.parameters.repo.name}/${options.parameters.issueNumber}. Make sure the issue exists.`); + } + + const result: IssueResult = { + title: issue.title, + body: issue.body, + comments: issue.item.comments?.map(c => ({ body: c.body })) ?? [] + }; + + const messages: vscode.LanguageModelChatMessage[] = []; + messages.push(vscode.LanguageModelChatMessage.Assistant(`You are a world-class developer who is capable of solving very difficult bugs and issues.`)); + messages.push(vscode.LanguageModelChatMessage.Assistant(`The user will give you an issue title, body and a list of comments from GitHub. The user wants you to suggest a fix.`)); + messages.push(vscode.LanguageModelChatMessage.Assistant(`Analyze the issue content, the workspace context below and using all this information suggest a fix.`)); + messages.push(vscode.LanguageModelChatMessage.Assistant(`Where possible output code-blocks and reference real files in the workspace with the fix.`)); + messages.push(vscode.LanguageModelChatMessage.User(`The issue content is as follows: `)); + messages.push(vscode.LanguageModelChatMessage.User(`Issue Title: ${result.title}`)); + messages.push(vscode.LanguageModelChatMessage.User(`Issue Body: ${result.body}`)); + result.comments.forEach((comment, index) => { + messages.push(vscode.LanguageModelChatMessage.User(`Comment ${index}: ${comment.body}`)); + }); + + const copilotCodebaseResult = await vscode.lm.invokeTool('copilot_codebase', { + toolInvocationToken: undefined, + requestedContentTypes: ['text/plain'], + parameters: { + query: result.title + } + }, token); + + const plainTextResult = copilotCodebaseResult['text/plain']; + if (plainTextResult !== undefined) { + messages.push(vscode.LanguageModelChatMessage.User(`Below is some potential relevant workspace context to the issue. The user cannot see this result, so you should explain it to the user if referencing it in your answer.`)); + const toolMessage = vscode.LanguageModelChatMessage.User(''); + toolMessage.content2 = [plainTextResult]; + messages.push(toolMessage); + } + + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + const model = models[0]; + const response = await model.sendRequest(messages, {}, token); + + let responseResult = ''; + for await (const chunk of response.text) { + responseResult += chunk; + } + return { + 'text/plain': responseResult + }; + } + +} \ No newline at end of file diff --git a/src/lm/tools/tools.ts b/src/lm/tools/tools.ts new file mode 100644 index 0000000000..4ab83ccf53 --- /dev/null +++ b/src/lm/tools/tools.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { DisplayIssuesTool } from '../displayIssuesTool'; +import { ChatParticipantState } from '../participants'; +import { ConvertToSearchSyntaxTool, SearchTool } from '../searchTools'; +import { IssueTool } from './issueTool'; +import { SuggestFixTool } from './suggestFixTool'; + +export function registerTools(context: vscode.ExtensionContext, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { + registerIssueTool(context, repositoriesManager); + registerSuggestFixTool(context, repositoriesManager); + registerSearchTools(context, repositoriesManager, chatParticipantState); +} + +function registerIssueTool(context: vscode.ExtensionContext, repositoriesManager: RepositoriesManager) { + context.subscriptions.push(vscode.lm.registerTool('github-pull-request_issue', new IssueTool(repositoriesManager))); +} + +function registerSuggestFixTool(context: vscode.ExtensionContext, repositoriesManager: RepositoriesManager) { + context.subscriptions.push(vscode.lm.registerTool('github-pull-request_suggest-fix', new SuggestFixTool(repositoriesManager))); +} + +function registerSearchTools(context: vscode.ExtensionContext, repositoriesManager: RepositoriesManager, chatParticipantState: ChatParticipantState) { + context.subscriptions.push(vscode.lm.registerTool('github-pull-request_formSearchQuery', new ConvertToSearchSyntaxTool(repositoriesManager, chatParticipantState))); + context.subscriptions.push(vscode.lm.registerTool('github-pull-request_doSearch', new SearchTool(repositoriesManager))); + context.subscriptions.push(vscode.lm.registerTool('github-pull-request_renderIssues', new DisplayIssuesTool(chatParticipantState))); +} \ No newline at end of file diff --git a/src/lm/tools/toolsUtils.ts b/src/lm/tools/toolsUtils.ts new file mode 100644 index 0000000000..1c04885d9a --- /dev/null +++ b/src/lm/tools/toolsUtils.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ChatParticipantState } from '../participants'; + +export interface IToolCall { + tool: vscode.LanguageModelToolDescription; + call: vscode.LanguageModelToolCallPart; + result: Thenable; +} + +export interface IssueToolParameters { + issueNumber: number; + repo: { + owner: string; + name: string; + }; +} + +export interface IssueResult { + title: string; + body: string; + comments: { + body: string; + }[]; +} + +export abstract class ToolBase implements vscode.LanguageModelTool { + constructor(protected readonly chatParticipantState: ChatParticipantState) { } + abstract invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): vscode.ProviderResult; +} + +export async function concatAsyncIterable(asyncIterable: AsyncIterable): Promise { + let result = ''; + for await (const chunk of asyncIterable) { + result += chunk; + } + return result; +} \ No newline at end of file diff --git a/src/notifications/notificationTreeItem.ts b/src/notifications/notificationTreeItem.ts new file mode 100644 index 0000000000..139d05bf6b --- /dev/null +++ b/src/notifications/notificationTreeItem.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Notification } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; + +export interface NotificationsPaginationRange { + startPage: number; + endPage: number; +} + +export enum NotificationsSortMethod { + Timestamp = 'Timestamp', + Priority = 'Priority' +} + +export type NotificationTreeDataItem = NotificationTreeItem | LoadMoreNotificationsTreeItem; + +export class LoadMoreNotificationsTreeItem { } + +export class NotificationTreeItem { + + public priority: string | undefined; + + public priorityReasoning: string | undefined; + + constructor( + public readonly notification: Notification, + readonly model: IssueModel + ) { } +} \ No newline at end of file diff --git a/src/notifications/notificationsFeatureRegistar.ts b/src/notifications/notificationsFeatureRegistar.ts new file mode 100644 index 0000000000..f7d6270b0d --- /dev/null +++ b/src/notifications/notificationsFeatureRegistar.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { commands } from '../common/executeCommands'; +import { ITelemetry } from '../common/telemetry'; +import { dispose } from '../common/utils'; +import { CredentialStore } from '../github/credentials'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { NotificationsProvider } from './notificationsProvider'; +import { NotificationsTreeData } from './notificationsView'; +import { NotificationTreeItem } from './notificationTreeItem'; + +export class NotificationsFeatureRegister implements vscode.Disposable { + + private readonly _disposables: vscode.Disposable[] = []; + + constructor( + credentialStore: CredentialStore, + private readonly _repositoriesManager: RepositoriesManager, + private readonly _telemetry: ITelemetry + ) { + const notificationsProvider = new NotificationsProvider(credentialStore, this._repositoriesManager); + + // View + const dataProvider = new NotificationsTreeData(notificationsProvider); + this._disposables.push(dataProvider); + const view = vscode.window.createTreeView('notifications:github', { + treeDataProvider: dataProvider + }); + this._disposables.push(view); + + // Commands + this._disposables.push( + vscode.commands.registerCommand( + 'notifications.sortByTimestamp', + async () => { + /* __GDPR__ + "notifications.sortByTimestamp" : {} + */ + this._telemetry.sendTelemetryEvent('notifications.sortByTimestamp'); + return dataProvider.sortByTimestamp(); + }, + this, + ), + ); + this._disposables.push( + vscode.commands.registerCommand( + 'notifications.sortByPriority', + async () => { + /* __GDPR__ + "notifications.sortByTimestamp" : {} + */ + this._telemetry.sendTelemetryEvent('notifications.sortByTimestamp'); + return dataProvider.sortByPriority(); + }, + this, + ), + ); + this._disposables.push( + vscode.commands.registerCommand( + 'notifications.refresh', + () => { + /* __GDPR__ + "notifications.refresh" : {} + */ + this._telemetry.sendTelemetryEvent('notifications.refresh'); + notificationsProvider.clearCache(); + return dataProvider.refresh(); + }, + this, + ), + ); + this._disposables.push( + vscode.commands.registerCommand('notifications.loadMore', () => { + /* __GDPR__ + "notifications.loadMore" : {} + */ + this._telemetry.sendTelemetryEvent('notifications.loadMore'); + dataProvider.loadMore(); + }) + ); + this._disposables.push( + vscode.commands.registerCommand('notification.chatSummarizeNotification', (notification: any) => { + if (!(notification instanceof NotificationTreeItem)) { + return; + } + /* __GDPR__ + "notification.chatSummarizeNotification" : {} + */ + this._telemetry.sendTelemetryEvent('notification.chatSummarizeNotification'); + vscode.commands.executeCommand(commands.OPEN_CHAT, vscode.l10n.t('@githubpr Summarize notification {0}/{1}#{2}', notification.model.remote.owner, notification.model.remote.repositoryName, notification.model.number)); + }) + ); + + // Events + this._repositoriesManager.onDidLoadAnyRepositories(() => { + notificationsProvider.clearCache(); + dataProvider.refresh(); + }); + } + + dispose() { + dispose(this._disposables); + } +} diff --git a/src/notifications/notificationsProvider.ts b/src/notifications/notificationsProvider.ts new file mode 100644 index 0000000000..aadf6011cc --- /dev/null +++ b/src/notifications/notificationsProvider.ts @@ -0,0 +1,339 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AuthProvider } from '../common/authentication'; +import { EXPERIMENTAL_NOTIFICATIONS_PAGE_SIZE, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { OctokitCommon } from '../github/common'; +import { CredentialStore, GitHub } from '../github/credentials'; +import { Issue, Notification, NotificationSubjectType } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { hasEnterpriseUri, parseNotification } from '../github/utils'; +import { concatAsyncIterable } from '../lm/tools/toolsUtils'; +import { NotificationsPaginationRange, NotificationsSortMethod, NotificationTreeItem } from './notificationTreeItem'; + +export class NotificationsProvider implements vscode.Disposable { + private _authProvider: AuthProvider | undefined; + private readonly _notifications = new Map(); + + private readonly _disposables: vscode.Disposable[] = []; + + private readonly _notificationsPaginationRange: NotificationsPaginationRange = { + startPage: 1, + endPage: 1 + } + + private _canLoadMoreNotifications: boolean = true; + + constructor( + private readonly _credentialStore: CredentialStore, + private readonly _repositoriesManager: RepositoriesManager + ) { + if (_credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + this._authProvider = AuthProvider.githubEnterprise; + } else if (_credentialStore.isAuthenticated(AuthProvider.github)) { + this._authProvider = AuthProvider.github; + } + this._disposables.push( + _credentialStore.onDidChangeSessions(_ => { + if (_credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) { + this._authProvider = AuthProvider.githubEnterprise; + } + if (_credentialStore.isAuthenticated(AuthProvider.github)) { + this._authProvider = AuthProvider.github; + } + }) + ); + } + + private _getGitHub(): GitHub | undefined { + return (this._authProvider !== undefined) ? + this._credentialStore.getHub(this._authProvider) : + undefined; + } + + public clearCache(): void { + this._notifications.clear(); + } + + public async getNotifications(sortingMethod: NotificationsSortMethod): Promise { + const gitHub = this._getGitHub(); + if (gitHub === undefined) { + return undefined; + } + if (this._repositoriesManager.folderManagers.length === 0) { + return undefined; + } + const notifications = await this._getResolvedNotifications(gitHub); + const filteredNotifications = notifications.filter(notification => notification !== undefined) as NotificationTreeItem[]; + if (sortingMethod === NotificationsSortMethod.Priority) { + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + const model = models[0]; + if (model) { + try { + return this._sortNotificationsByLLMPriority(filteredNotifications, model); + } catch (e) { + return this._sortNotificationsByTimestamp(filteredNotifications); + } + } + } + return this._sortNotificationsByTimestamp(filteredNotifications); + } + + public get canLoadMoreNotifications(): boolean { + return this._canLoadMoreNotifications; + } + + public loadMore(): void { + this._notificationsPaginationRange.endPage += 1; + } + + private async _getResolvedNotifications(gitHub: GitHub): Promise<(NotificationTreeItem | undefined)[]> { + const notificationPromises: Promise<(NotificationTreeItem | undefined)[]>[] = []; + for (let i = this._notificationsPaginationRange.startPage; i <= this._notificationsPaginationRange.endPage; i++) { + notificationPromises.push(this._getResolvedNotificationsForPage(gitHub, i)); + } + return (await Promise.all(notificationPromises)).flat(); + } + + private async _getResolvedNotificationsForPage(gitHub: GitHub, pageNumber: number): Promise<(NotificationTreeItem | undefined)[]> { + const pageSize = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(EXPERIMENTAL_NOTIFICATIONS_PAGE_SIZE, 50); + const { data } = await gitHub.octokit.call(gitHub.octokit.api.activity.listNotificationsForAuthenticatedUser, { + all: false, + page: pageNumber, + per_page: pageSize + }); + if (data.length < pageSize) { + this._canLoadMoreNotifications = false; + } + return Promise.all(data.map(async (notification: OctokitCommon.Notification): Promise => { + const parsedNotification = parseNotification(notification); + if (!parsedNotification) { + return undefined; + } + const cachedNotification = this._notifications.get(parsedNotification?.key); + if (cachedNotification && cachedNotification.notification.updatedAd === parsedNotification.updatedAd) { + return cachedNotification; + } + const model = await this._getNotificationModel(parsedNotification); + if (!model) { + return undefined; + } + const resolvedNotification = new NotificationTreeItem(parsedNotification, model); + this._notifications.set(parsedNotification.key, resolvedNotification); + return resolvedNotification; + })); + } + + private async _getNotificationModel(notification: Notification): Promise | undefined> { + const url = notification.subject.url; + if (!(typeof url === 'string')) { + return undefined; + } + const issueOrPrNumber = url.split('/').pop(); + if (issueOrPrNumber === undefined) { + return undefined; + } + const folderManager = this._repositoriesManager.getManagerForRepository(notification.owner, notification.name) ?? this._repositoriesManager.folderManagers[0]; + const model = notification.subject.type === NotificationSubjectType.Issue ? + await folderManager.resolveIssue(notification.owner, notification.name, parseInt(issueOrPrNumber), true) : + await folderManager.resolvePullRequest(notification.owner, notification.name, parseInt(issueOrPrNumber)); + return model; + } + + private _sortNotificationsByTimestamp(notifications: NotificationTreeItem[]): NotificationTreeItem[] { + return notifications.sort((n1, n2) => n1.notification.updatedAd > n2.notification.updatedAd ? -1 : 1); + } + + private async _sortNotificationsByLLMPriority(notifications: NotificationTreeItem[], model: vscode.LanguageModelChat): Promise { + const sortByPriority = (r1: NotificationTreeItem, r2: NotificationTreeItem): number => { + const priority1 = Number(r1.priority); + const priority2 = Number(r2.priority); + return priority2 - priority1; + }; + const notificationBatchSize = 5; + const notificationBatches: NotificationTreeItem[][] = []; + for (let i = 0; i < notifications.length; i += notificationBatchSize) { + notificationBatches.push(notifications.slice(i, i + notificationBatchSize)); + } + const prioritizedBatches = await Promise.all(notificationBatches.map(batch => this._prioritizeNotificationBatchWithLLM(batch, model))); + const prioritizedNotifications = prioritizedBatches.flat(); + const openNotifications = prioritizedNotifications.filter(notification => notification.model.isOpen); + const closedNotifications = prioritizedNotifications.filter(notification => notification.model.isClosed || notification.model.isMerged); + const sortedOpenNotifications = openNotifications.sort((r1, r2) => sortByPriority(r1, r2)); + const sortedClosedNotifications = closedNotifications.sort((r1, r2) => sortByPriority(r1, r2)); + return [...sortedOpenNotifications, ...sortedClosedNotifications]; + } + + private async _prioritizeNotificationBatchWithLLM(notifications: NotificationTreeItem[], model: vscode.LanguageModelChat): Promise { + try { + const userLogin = (await this._credentialStore.getCurrentUser(AuthProvider.github)).login; + const messages = [vscode.LanguageModelChatMessage.User(getPrioritizeNotificationsInstructions(userLogin))]; + for (const [notificationIndex, notification] of notifications.entries()) { + const issueModel = notification.model; + if (!issueModel) { + continue; + } + let notificationMessage = this._getBasePrompt(issueModel, notificationIndex); + notificationMessage += await this._getLabelsPrompt(issueModel); + notificationMessage += await this._getCommentsPrompt(issueModel); + messages.push(vscode.LanguageModelChatMessage.User(notificationMessage)); + } + messages.push(vscode.LanguageModelChatMessage.User('Please provide the priority for each notification in a separate text code block. Remember to place the title and the reasoning outside of the text code block.')); + const response = await model.sendRequest(messages, {}); + const responseText = await concatAsyncIterable(response.text); + const updatedNotifications = this._updateNotificationsWithPriorityFromLLM(notifications, responseText); + return updatedNotifications; + } catch (e) { + console.log(e); + return []; + } + } + + private _getBasePrompt(model: IssueModel | PullRequestModel, notificationIndex: number): string { + const assignees = model.assignees; + return ` +The following is the data for notification ${notificationIndex + 1}: +• Title: ${model.title} +• Author: ${model.author.login} +• Assignees: ${assignees?.map(assignee => assignee.login).join(', ') || 'none'} +• Body: + +${model.body} + +• Reaction Count: ${model.item.reactionCount ?? 0} +• isOpen: ${model.isOpen} +• isMerged: ${model.isMerged} +• Created At: ${model.createdAt} +• Updated At: ${model.updatedAt}`; + } + + private async _getLabelsPrompt(model: IssueModel | PullRequestModel): Promise { + const labels = model.item.labels; + if (!labels) { + return ''; + } + let labelsMessage = ''; + if (labels.length > 0) { + const labelListAsString = labels.map(label => label.name).join(', '); + labelsMessage = ` +• Labels: ${labelListAsString}`; + } + return labelsMessage; + } + + private async _getCommentsPrompt(model: IssueModel | PullRequestModel): Promise { + const issueComments = model.item.comments; + if (!issueComments || issueComments.length === 0) { + return ''; + } + let commentsMessage = ` + +The following is the data concerning the at most last 5 comments for the notification:`; + + let index = 1; + const lowerCommentIndexBound = Math.max(0, issueComments.length - 5); + for (let i = lowerCommentIndexBound; i < issueComments.length; i++) { + const comment = issueComments.at(i)!; + commentsMessage += ` + +Comment ${index} for notification: +• Body: +${comment.body} +• Reaction Count: ${comment.reactionCount}`; + index += 1; + } + return commentsMessage; + } + + private _updateNotificationsWithPriorityFromLLM(notifications: NotificationTreeItem[], text: string): NotificationTreeItem[] { + const regexReasoning = /```text\s*[\s\S]+?\s*=\s*([\S]+?)\s*```/gm; + const regexPriorityReasoning = /```(?!text)([\s\S]+?)(###|$)/g; + for (let i = 0; i < notifications.length; i++) { + const execResultForPriority = regexReasoning.exec(text); + if (execResultForPriority) { + notifications[i].priority = execResultForPriority[1]; + const execResultForPriorityReasoning = regexPriorityReasoning.exec(text); + if (execResultForPriorityReasoning) { + notifications[i].priorityReasoning = execResultForPriorityReasoning[1].trim(); + } + } + } + return notifications; + } + + dispose() { + this._disposables.forEach(d => d.dispose()); + } +} + +function getPrioritizeNotificationsInstructions(githubHandle: string) { + return ` +You are an intelligent assistant tasked with prioritizing GitHub notifications. +You are given a list of notifications for the current user ${githubHandle}, each related to an issue, pull request or discussion. In the case of an issue/PR, if there are comments, you are given the last 5 comments under it. +Use the following scoring mechanism to prioritize the notifications and assign them a score from 0 to 100: + + 1. Assign points from 0 to 40 for the relevance of the notification. Below when we talk about the current user, it is always the user with the GitHub login handle ${githubHandle}. + - 0-9 points: If the current user is neither assigned, nor requested for a review, nor mentioned in the issue/PR/discussion. + - 10-19 points: If the current user is mentioned or is the author of the issue/PR. In the case of an issue/PR, the current user should not be assigned to it. + - 20-40 points: If the current user is assigned to the issue/PR or is requested for a review. + - After having assigned a range, for example 10-29, use the following guidelines to assign a specific score within the range. The following guidelines should NOT make the score overflow past the chosen range: + - Consider if the issue/PR is open or closed. An open issue/PR should be assigned a higher score within the range. + - A more recent notification should be assigned a higher priority. + - Analyze the issue/PR/discussion and the comments to determine the extent to which it is urgent or important. In particular: + - Issues should generally be assigned a higher score than PRs and discussions. If a PR fixes a critical/important bug it can be assigned a higher score. + - Issues about bugs/regressions should be assigned a higher priority than issues about feature requests which are less critical. + - Evaluate the extent to which the current user is the main/sole person responsible to fix the issue/review the PR or respond to the discussion. For example if the current user is one of many users assigned and is not explicitly mentioned, you can assign a lower score in the range. + 2. Assign points from 0 to 30 to the importance of the notification. Consider the following points: + - In case of an issue, does the content/title suggest this is a critical issue? In the case of a PR, does the content/title suggest it fixes a critical issue? In the case of a discussion, do the comments suggest a critical discussion? A critical issue/pr/discussion has a higher priority. + - To evaluate the importance/criticality of a notification evaluate whether it references the following. Such notifications should be assigned a higher priority. + - security vulnerabilities + - major regressions + - data loss + - crashes + - performance issues + - memory leaks + - breaking changes + - Do the labels assigned to the issue/PR/discussion indicate it is critical? Labels that include the following: 'critical', 'urgent', 'important', 'high priority' should be assigned a higher priority. + - Is the issue/PR suggesting it is blocking for other work and must be addressed immediately? If so, the notification should be assigned a higher priority. + - Is the issue/PR user facing? User facing issues/PRs that have a clear negative impact on the user should be assigned a higher priority. + - Is the tone of voice urgent or neutral? An urgent tone of voice has a higher priority. + - For issues, do the comments mention that the issue is a duplicate of another issue or is already fixed? If so assign a lower priority. + - In contrast, issues/PRs about technical debt/code polishing/minor internal issues or generally that have low importance should be assigned lower priority. + 3. Assign points from 0 to 30 for the community engagement. Consider the following points: + - Reactions: Consider the number of reactions under an issue/PR/discussion that correspond to real users. A higher number of reactions should be assigned a higher priority. + - Comments: Evaluate the community engagmenent on the issue/PR through the last 5 comments. If you detect a comment comming from a bot, do not include it in the following evaluation. Consider the following: + - Does the issue/PR/discussion have a lot of comments indicating widespread interest? + - Does the issue/PR/discussion have comments from many different users which would indicate widespread interest? + - Evaluate the comments content. Do they indicate that the issue/PR is critical and touches many people? A critical issue/PR should be assigned a higher priority. + - Evaluate the effort/detail put into the comments, are the users invested in the issue/PR/disccusion? A higher effort should be assigned a higher priority. + - Evaluate the tone of voice in the comments, an urgent tone of voice should be assigned a higher priority. + - Evaluate the reactions under the comments, a higher number of reactions indicate widespread interest and issue/PR/discussion following. A higher number of reactions should be assigned a higher priority. + - Generally evaluate the issue/PR/discussion content quality. Consider the following points: + - Description: In the case of an issue, are there clear steps to reproduce the issue? In the case of a PR, is there a clear description of the change? A clearer, more complete description should be assigned a higher priority. + - Effort: Evaluate the general effort put into writing this issue/PR. Does the user provide a lengthy clear explanation? A higher effort should be assigned a higher priority. + +Use the above guidelines to assign points to each notification. Provide the sum of the individual points in a SEPARATE text code block for each notification. The points sum to 100 as a maximum. +After the text code block containing the priority, add a detailed summary of the notification and generally explain why it is important or not, do NOT reference the scoring mechanism above. This summary and reasoning will be displayed to the user. +The output should look as follow. Here corresponds to your summary and reasoning and corresponds to the notification title. The title should be placed after three hashtags: + +### <title> +\`\`\`text +30 + 20 + 20 = 70 +\`\`\`text +<summary + reasoning> + +The following is INCORRECT: + +<title> +30 + 20 + 20 = 70 +<summary + reasoning> +`; +} \ No newline at end of file diff --git a/src/notifications/notificationsView.ts b/src/notifications/notificationsView.ts new file mode 100644 index 0000000000..1f97f1e874 --- /dev/null +++ b/src/notifications/notificationsView.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NotificationSubjectType } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; +import { PullRequestModel } from '../github/pullRequestModel'; +import { NotificationsProvider } from './notificationsProvider'; +import { LoadMoreNotificationsTreeItem, NotificationsSortMethod, NotificationTreeDataItem, NotificationTreeItem } from './notificationTreeItem'; + +const devMode = false; // Boolean("true"); + +export class NotificationsTreeData implements vscode.TreeDataProvider<NotificationTreeDataItem>, vscode.Disposable { + private readonly _disposables: vscode.Disposable[] = []; + private _onDidChangeTreeData: vscode.EventEmitter<NotificationTreeDataItem | undefined | void> = new vscode.EventEmitter<NotificationTreeDataItem | undefined | void>(); + readonly onDidChangeTreeData: vscode.Event<NotificationTreeDataItem | undefined | void> = this._onDidChangeTreeData.event; + + private _sortingMethod: NotificationsSortMethod = NotificationsSortMethod.Timestamp; + + constructor(private readonly _notificationsProvider: NotificationsProvider) { + this._disposables.push(this._onDidChangeTreeData); + } + + async getTreeItem(element: NotificationTreeDataItem): Promise<vscode.TreeItem> { + if (element instanceof NotificationTreeItem) { + return this._resolveNotificationTreeItem(element); + } + return this._resolveLoadMoreNotificationsTreeItem(); + } + + private _resolveNotificationTreeItem(element: NotificationTreeItem): vscode.TreeItem { + const label = devMode ? `${element.priority}% - ${element.notification.subject.title}` : element.notification.subject.title; + const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); + const notification = element.notification; + const model = element.model; + + if (notification.subject.type === NotificationSubjectType.Issue && model instanceof IssueModel) { + item.iconPath = element.model.isOpen + ? new vscode.ThemeIcon('issues', new vscode.ThemeColor('issues.open')) + : new vscode.ThemeIcon('issue-closed', new vscode.ThemeColor('issues.closed')); + } + if (notification.subject.type === NotificationSubjectType.PullRequest && model instanceof PullRequestModel) { + item.iconPath = model.isOpen + ? new vscode.ThemeIcon('git-pull-request', new vscode.ThemeColor('pullRequests.open')) + : new vscode.ThemeIcon('git-pull-request', new vscode.ThemeColor('pullRequests.merged')); + } + item.description = `${notification.owner}/${notification.name}`; + item.contextValue = notification.subject.type; + if (element.priorityReasoning) { + item.tooltip = element.priorityReasoning; + } + // TODO: Issue webview needs polish before we do this + // item.command = { + // command: 'pr.openDescription', + // title: 'Open Description', + // arguments: [element.model] + // }; + return item; + } + + private _resolveLoadMoreNotificationsTreeItem(): vscode.TreeItem { + const item = new vscode.TreeItem(vscode.l10n.t('Load More Notifications...'), vscode.TreeItemCollapsibleState.None); + item.command = { + title: 'Load More Notifications', + command: 'notifications.loadMore' + }; + item.contextValue = 'loadMoreNotifications'; + return item; + } + + async getChildren(element?: unknown): Promise<NotificationTreeDataItem[] | undefined> { + if (element !== undefined) { + return undefined; + } + const result = await this._notificationsProvider.getNotifications(this._sortingMethod); + if (!result) { + return undefined; + } + const canLoadMoreNotifications = this._notificationsProvider.canLoadMoreNotifications; + if (canLoadMoreNotifications) { + return [...result, new LoadMoreNotificationsTreeItem()]; + } + return result; + } + + sortByTimestamp(): void { + this._sortingMethod = NotificationsSortMethod.Timestamp; + this.refresh(); + } + + sortByPriority(): void { + this._sortingMethod = NotificationsSortMethod.Priority; + this.refresh(); + } + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + loadMore(): void { + this._notificationsProvider.loadMore(); + this._onDidChangeTreeData.fire(); + } + + dispose() { + dispose(this._disposables); + } +} \ No newline at end of file diff --git a/src/test/builders/graphql/pullRequestBuilder.ts b/src/test/builders/graphql/pullRequestBuilder.ts index cc8201aa0a..96a533c59e 100644 --- a/src/test/builders/graphql/pullRequestBuilder.ts +++ b/src/test/builders/graphql/pullRequestBuilder.ts @@ -102,7 +102,9 @@ export const PullRequestBuilder = createBuilderClass<PullRequestResponse>()({ { commit: { message: 'commit 1' } }, ] } - }) + }), + reactions: { default: { totalCount: 0 } }, + comments: { default: { totalCount: 0 } } }) }), rateLimit: { linked: RateLimitBuilder }, diff --git a/yarn.lock b/yarn.lock index 754a373ac2..e7f6faa38e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -791,6 +791,11 @@ "@microsoft/applicationinsights-web-basic" "^2.8.9" applicationinsights "2.4.1" +"@vscode/prompt-tsx@^0.2.11-alpha": + version "0.2.11-alpha" + resolved "https://registry.yarnpkg.com/@vscode/prompt-tsx/-/prompt-tsx-0.2.11-alpha.tgz#1077f686770a0bf3e43cd4c8046eff0d9d98d521" + integrity sha512-U/hmOwcWla5EHCiwCAAZni6w9N6lbG56EpciNXDis0oYFu03fGD+BzTw7jxbYd9NCS8CSHb76JoW49jyLilFxg== + "@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"