From 8cabb56678af2a7d52abd1deb89688cfcd13ff65 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:46:48 +0100 Subject: [PATCH] Implement PR and issue chat context providers Part of microsoft/vscode#271104 --- package.json | 19 +++- .../vscode.proposed.chatContextProvider.d.ts | 58 +++++++++++++ src/extension.ts | 9 +- src/github/copilotPrWatcher.ts | 2 +- src/github/folderRepositoryManager.ts | 2 +- src/github/issueModel.ts | 42 ++++----- src/github/issueOverview.ts | 4 +- src/github/pullRequestModel.ts | 54 +++++++----- src/github/pullRequestOverview.ts | 2 + src/issues/issueFeatureRegistrar.ts | 3 +- src/lm/issueContextProvider.ts | 87 +++++++++++++++++++ src/lm/pullRequestContextProvider.ts | 75 ++++++++++++++++ src/lm/tools/activePullRequestTool.ts | 2 +- src/view/prStatusDecorationProvider.ts | 2 +- src/view/prsTreeModel.ts | 2 +- src/view/treeNodes/pullRequestNode.ts | 2 +- 16 files changed, 310 insertions(+), 55 deletions(-) create mode 100644 src/@types/vscode.proposed.chatContextProvider.d.ts create mode 100644 src/lm/issueContextProvider.ts create mode 100644 src/lm/pullRequestContextProvider.ts diff --git a/package.json b/package.json index 37f7c53366..c561d0a9d6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "enabledApiProposals": [ "activeComment", + "chatContextProvider", "chatParticipantAdditions", "chatParticipantPrivate", "chatSessionsProvider@3", @@ -42,7 +43,7 @@ "version": "0.122.0", "publisher": "GitHub", "engines": { - "vscode": "^1.106.0" + "vscode": "^1.107.0" }, "categories": [ "Other", @@ -60,7 +61,9 @@ "onFileSystem:githubcommit", "onFileSystem:review", "onWebviewPanel:IssueOverview", - "onWebviewPanel:PullRequestOverview" + "onWebviewPanel:PullRequestOverview", + "onChatContextProvider:githubpr", + "onChatContextProvider:githubissue" ], "browser": "./dist/browser/extension", "l10n": "./dist/browser/extension", @@ -72,6 +75,18 @@ "virtualWorkspaces": true }, "contributes": { + "chatContext": [ + { + "id": "githubpr", + "icon": "$(github)", + "displayName": "GitHub Pull Requests" + }, + { + "id": "githubissue", + "icon": "$(issues)", + "displayName": "GitHub Issues" + } + ], "chatSessions": [ { "type": "copilot-swe-agent", diff --git a/src/@types/vscode.proposed.chatContextProvider.d.ts b/src/@types/vscode.proposed.chatContextProvider.d.ts new file mode 100644 index 0000000000..38ea26573a --- /dev/null +++ b/src/@types/vscode.proposed.chatContextProvider.d.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/271104 @alexr00 + + export namespace chat { + + // TODO@alexr00 API: + // selector is confusing + export function registerChatContextProvider(selector: DocumentSelector, id: string, provider: ChatContextProvider): Disposable; + + } + + export interface ChatContextItem { + icon: ThemeIcon; + label: string; + modelDescription?: string; + value?: string; + } + + export interface ChatContextProvider { + + /** + * Provide a list of chat context items that a user can choose from. These context items are shown as options when the user explicitly attaches context. + * Chat context items can be provided without a `value`, as the `value` can be resolved later using `resolveChatContext`. + * `resolveChatContext` is only called for items that do not have a `value`. + * + * @param options + * @param token + */ + provideChatContextExplicit?(token: CancellationToken): ProviderResult; + + /** + * Given a particular resource, provide a chat context item for it. This is used for implicit context (see the settings `chat.implicitContext.enabled` and `chat.implicitContext.suggestedContext`). + * Chat context items can be provided without a `value`, as the `value` can be resolved later using `resolveChatContext`. + * `resolveChatContext` is only called for items that do not have a `value`. + * + * @param resource + * @param options + * @param token + */ + provideChatContextForResource?(options: { resource: Uri }, token: CancellationToken): ProviderResult; + + /** + * If a chat context item is provided without a `value`, from either of the `provide` methods, this method is called to resolve the `value` for the item. + * + * @param context + * @param token + */ + resolveChatContext(context: T, token: CancellationToken): ProviderResult; + } + +} diff --git a/src/extension.ts b/src/extension.ts index d7ef5fbcdc..daaf4297b8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -32,7 +32,10 @@ import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitP import { GitHubContactServiceProvider } from './gitProviders/GitHubContactServiceProvider'; import { GitLensIntegration } from './integrations/gitlens/gitlensImpl'; import { IssueFeatureRegistrar } from './issues/issueFeatureRegistrar'; +import { StateManager } from './issues/stateManager'; +import { IssueContextProvider } from './lm/issueContextProvider'; import { ChatParticipant, ChatParticipantState } from './lm/participants'; +import { PullRequestContextProvider } from './lm/pullRequestContextProvider'; import { registerTools } from './lm/tools/tools'; import { migrate } from './migrations'; import { NotificationsFeatureRegister } from './notifications/notificationsFeatureRegistar'; @@ -253,10 +256,14 @@ async function init( const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); await vscode.commands.executeCommand('setContext', 'fileListLayout:flat', layout === 'flat'); - const issuesFeatures = new IssueFeatureRegistrar(git, reposManager, reviewsManager, context, telemetry, copilotRemoteAgentManager); + const issueStateManager = new StateManager(git, reposManager, context); + const issuesFeatures = new IssueFeatureRegistrar(git, reposManager, reviewsManager, context, telemetry, issueStateManager, copilotRemoteAgentManager); context.subscriptions.push(issuesFeatures); await issuesFeatures.initialize(); + vscode.chat.registerChatContextProvider({ scheme: 'webview-panel', pattern: '**/webview-PullRequestOverview**' }, 'githubpr', new PullRequestContextProvider(prsTreeModel, reposManager)); + vscode.chat.registerChatContextProvider({ scheme: 'webview-panel', pattern: '**/webview-IssueOverview**' }, 'githubissue', new IssueContextProvider(issueStateManager, reposManager)); + const notificationsFeatures = new NotificationsFeatureRegister(credentialStore, reposManager, telemetry, notificationsManager); context.subscriptions.push(notificationsFeatures); diff --git a/src/github/copilotPrWatcher.ts b/src/github/copilotPrWatcher.ts index 5a81e9ad06..3382ac6825 100644 --- a/src/github/copilotPrWatcher.ts +++ b/src/github/copilotPrWatcher.ts @@ -268,7 +268,7 @@ export class CopilotPRWatcher extends Disposable { private async _updateSingleState(pr: PullRequestModel): Promise { const changes: CodingAgentPRAndStatus[] = []; - const copilotEvents = await pr.getCopilotTimelineEvents(pr, false, !this._model.isInitialized); + const copilotEvents = await pr.getCopilotTimelineEvents(false, !this._model.isInitialized); let latestEvent = copilotEventToStatus(copilotEvents[copilotEvents.length - 1]); if (latestEvent === CopilotPRStatus.None) { if (!COPILOT_ACCOUNTS[pr.author.login]) { diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index 8448c00f95..9dd9002c84 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -2499,7 +2499,7 @@ export class FolderRepositoryManager extends Disposable { private async promptPullBrach(pr: PullRequestModel, branch: Branch, autoStashSetting?: boolean) { if (!this._updateMessageShown || autoStashSetting) { // When the PR is from Copilot, we only want to show the notification when Copilot is done working - const copilotStatus = await pr.copilotWorkingStatus(pr); + const copilotStatus = await pr.copilotWorkingStatus(); if (copilotStatus === CopilotWorkingStatus.InProgress) { return; } diff --git a/src/github/issueModel.ts b/src/github/issueModel.ts index 2793661eee..d64ab70a19 100644 --- a/src/github/issueModel.ts +++ b/src/github/issueModel.ts @@ -85,8 +85,8 @@ export class IssueModel extends Disposable { } } - get timelineEvents(): readonly TimelineEvent[] { - return this._timelineEvents ?? []; + get timelineEvents(): readonly TimelineEvent[] | undefined { + return this._timelineEvents; } protected set timelineEvents(timelineEvents: readonly TimelineEvent[]) { @@ -469,8 +469,8 @@ export class IssueModel extends Disposable { } } - async getIssueTimelineEvents(issueModel: IssueModel): Promise { - Logger.debug(`Fetch timeline events of issue #${issueModel.number} - enter`, GitHubRepository.ID); + async getIssueTimelineEvents(): Promise { + Logger.debug(`Fetch timeline events of issue #${this.number} - enter`, GitHubRepository.ID); const { query, remote, schema } = await this.githubRepository.ensure(); try { @@ -479,7 +479,7 @@ export class IssueModel extends Disposable { variables: { owner: remote.owner, name: remote.repositoryName, - number: issueModel.number, + number: this.number, }, }); @@ -489,11 +489,11 @@ export class IssueModel extends Disposable { } const ret = data.repository.pullRequest.timelineItems.nodes; - const events = await parseCombinedTimelineEvents(ret, await this.getCopilotTimelineEvents(issueModel, true), this.githubRepository); + const events = await parseCombinedTimelineEvents(ret, await this.getCopilotTimelineEvents(true), this.githubRepository); const crossRefs = events.filter((event): event is CrossReferencedEvent => { if ((event.event === EventType.CrossReferenced) && !event.source.isIssue) { - return !this.githubRepository.getExistingPullRequestModel(event.source.number) && (compareIgnoreCase(event.source.owner, issueModel.remote.owner) === 0 && compareIgnoreCase(event.source.repo, issueModel.remote.repositoryName) === 0); + return !this.githubRepository.getExistingPullRequestModel(event.source.number) && (compareIgnoreCase(event.source.owner, this.remote.owner) === 0 && compareIgnoreCase(event.source.repo, this.remote.repositoryName) === 0); } return false; @@ -504,7 +504,7 @@ export class IssueModel extends Disposable { this.githubRepository.getPullRequest(unseenPrs.source.number); } - issueModel.timelineEvents = events; + this.timelineEvents = events; return events; } catch (e) { console.log(e); @@ -515,15 +515,15 @@ export class IssueModel extends Disposable { /** * TODO: @alexr00 we should delete this https://github.com/microsoft/vscode-pull-request-github/issues/6965 */ - async getCopilotTimelineEvents(issueModel: IssueModel, skipMerge: boolean = false, useCache: boolean = false): Promise { - if (!COPILOT_ACCOUNTS[issueModel.author.login]) { + async getCopilotTimelineEvents(skipMerge: boolean = false, useCache: boolean = false): Promise { + if (!COPILOT_ACCOUNTS[this.author.login]) { return []; } - Logger.debug(`Fetch Copilot timeline events of issue #${issueModel.number} - enter`, GitHubRepository.ID); + Logger.debug(`Fetch Copilot timeline events of issue #${this.number} - enter`, GitHubRepository.ID); if (useCache && this._copilotTimelineEvents) { - Logger.debug(`Fetch Copilot timeline events of issue #${issueModel.number} (used cache) - exit`, GitHubRepository.ID); + Logger.debug(`Fetch Copilot timeline events of issue #${this.number} (used cache) - exit`, GitHubRepository.ID); return this._copilotTimelineEvents; } @@ -531,39 +531,39 @@ export class IssueModel extends Disposable { const { octokit, remote } = await this.githubRepository.ensure(); try { const timeline = await restPaginate(octokit.api.issues.listEventsForTimeline, { - issue_number: issueModel.number, + issue_number: this.number, owner: remote.owner, repo: remote.repositoryName, per_page: 100 }); - const timelineEvents = parseSelectRestTimelineEvents(issueModel, timeline); + const timelineEvents = parseSelectRestTimelineEvents(this, timeline); this._copilotTimelineEvents = timelineEvents; if (timelineEvents.length === 0) { return []; } if (!skipMerge) { - const oldLastEvent = issueModel.timelineEvents.length > 0 ? issueModel.timelineEvents[issueModel.timelineEvents.length - 1] : undefined; + const oldLastEvent = this.timelineEvents ? (this.timelineEvents.length > 0 ? this.timelineEvents[this.timelineEvents.length - 1] : undefined) : undefined; let allEvents: TimelineEvent[]; if (!oldLastEvent) { allEvents = timelineEvents; } else { const oldEventTime = (eventTime(oldLastEvent) ?? 0); const newEvents = timelineEvents.filter(event => (eventTime(event) ?? 0) > oldEventTime); - allEvents = [...issueModel.timelineEvents, ...newEvents]; + allEvents = [...(this.timelineEvents ?? []), ...newEvents]; } - issueModel.timelineEvents = allEvents; + this.timelineEvents = allEvents; } - Logger.debug(`Fetch Copilot timeline events of issue #${issueModel.number} - exit`, GitHubRepository.ID); + Logger.debug(`Fetch Copilot timeline events of issue #${this.number} - exit`, GitHubRepository.ID); return timelineEvents; } catch (e) { - Logger.error(`Error fetching Copilot timeline events of issue #${issueModel.number} - ${formatError(e)}`, GitHubRepository.ID); + Logger.error(`Error fetching Copilot timeline events of issue #${this.number} - ${formatError(e)}`, GitHubRepository.ID); return []; } } - async copilotWorkingStatus(issueModel: IssueModel): Promise { - const copilotEvents = await this.getCopilotTimelineEvents(issueModel); + async copilotWorkingStatus(): Promise { + const copilotEvents = await this.getCopilotTimelineEvents(); if (copilotEvents.length > 0) { const lastEvent = copilotEvents[copilotEvents.length - 1]; if (lastEvent.event === EventType.CopilotFinished) { diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 41f0e08629..506ee37221 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -255,7 +255,7 @@ export class IssueOverviewPanel extends W issueModel.remote.repositoryName, issueModel.number, ), - issueModel.getIssueTimelineEvents(issueModel), + issueModel.getIssueTimelineEvents(), this._folderRepositoryManager.getPullRequestRepositoryAccessAndMergeMethods(issueModel), issueModel.canEdit(), this._folderRepositoryManager.getAssignableUsers(), @@ -509,7 +509,7 @@ export class IssueOverviewPanel extends W } protected _getTimeline(): Promise { - return this._item.getIssueTimelineEvents(this._item); + return this._item.getIssueTimelineEvents(); } private async changeAssignees(message: IRequestMessage): Promise { diff --git a/src/github/pullRequestModel.ts b/src/github/pullRequestModel.ts index 0859707f7f..c11ddff619 100644 --- a/src/github/pullRequestModel.ts +++ b/src/github/pullRequestModel.ts @@ -137,7 +137,7 @@ export class PullRequestModel extends IssueModel implements IPullRe private _onDidChangePendingReviewState: vscode.EventEmitter = this._register(new vscode.EventEmitter()); public onDidChangePendingReviewState = this._onDidChangePendingReviewState.event; - private _reviewThreadsCache: IReviewThread[] = []; + private _reviewThreadsCache: IReviewThread[] | undefined; private _reviewThreadsCacheInitialized = false; private _onDidChangeReviewThreads = this._register(new vscode.EventEmitter()); public onDidChangeReviewThreads = this._onDidChangeReviewThreads.event; @@ -184,7 +184,7 @@ export class PullRequestModel extends IssueModel implements IPullRe public clear() { this.comments = []; this._reviewThreadsCacheInitialized = false; - this._reviewThreadsCache = []; + this._reviewThreadsCache = undefined; } public async initializeReviewThreadCache(): Promise { @@ -193,7 +193,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } public get reviewThreadsCache(): IReviewThread[] { - return this._reviewThreadsCache; + return this._reviewThreadsCache ?? []; } public get reviewThreadsCacheReady(): boolean { @@ -459,7 +459,7 @@ export class PullRequestModel extends IssueModel implements IPullRe */ this._telemetry.sendTelemetryEvent('pr.merge.success'); this._onDidChange.fire({ state: true }); - return { merged: true, message: '', timeline: await parseCombinedTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], await this.getCopilotTimelineEvents(this), this.githubRepository) }; + return { merged: true, message: '', timeline: await parseCombinedTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], await this.getCopilotTimelineEvents(), this.githubRepository) }; }) .catch(e => { /* __GDPR__ @@ -560,7 +560,7 @@ export class PullRequestModel extends IssueModel implements IPullRe await this.updateDraftModeContext(); const reviewEvent = parseGraphQLReviewEvent(data!.submitPullRequestReview.pullRequestReview, this.githubRepository); - const threadWithComment = this._reviewThreadsCache.find(thread => + const threadWithComment = (this._reviewThreadsCache ?? []).find(thread => thread.comments.length ? (thread.comments[0].pullRequestReviewId === reviewEvent.id) : undefined, ); if (threadWithComment) { @@ -646,6 +646,9 @@ export class PullRequestModel extends IssueModel implements IPullRe const deletedCommentIds = new Set(deletedReviewComments.map(c => c.id)); const changedThreads: IReviewThread[] = []; const removedThreads: IReviewThread[] = []; + if (!this._reviewThreadsCache) { + this._reviewThreadsCache = []; + } for (let i = this._reviewThreadsCache.length - 1; i >= 0; i--) { const thread = this._reviewThreadsCache[i]; const originalLength = thread.comments.length; @@ -761,6 +764,9 @@ export class PullRequestModel extends IssueModel implements IPullRe const thread = data.addPullRequestReviewThread.thread; const newThread = parseGraphQLReviewThread(thread, this.githubRepository); + if (!this._reviewThreadsCache) { + this._reviewThreadsCache = []; + } this._reviewThreadsCache.push(newThread); this._onDidChangeReviewThreads.fire({ added: [newThread], changed: [], removed: [] }); this._onDidChange.fire({ timeline: true }); @@ -815,7 +821,7 @@ export class PullRequestModel extends IssueModel implements IPullRe newComment.isDraft = false; } - const threadWithComment = this._reviewThreadsCache.find(thread => + const threadWithComment = this._reviewThreadsCache?.find(thread => thread.comments.some(comment => comment.graphNodeId === inReplyTo), ); if (threadWithComment) { @@ -885,7 +891,7 @@ export class PullRequestModel extends IssueModel implements IPullRe const ret = data?.repository?.pullRequest.timelineItems.nodes ?? []; - const events = await parseCombinedTimelineEvents(ret, await this.getCopilotTimelineEvents(this, true), this.githubRepository); + const events = await parseCombinedTimelineEvents(ret, await this.getCopilotTimelineEvents(true), this.githubRepository); this.addReviewTimelineEventComments(events, reviewThreads); insertNewCommitsSinceReview(events, latestReviewCommitInfo?.sha, currentUser, this.head); @@ -961,7 +967,7 @@ export class PullRequestModel extends IssueModel implements IPullRe */ async editReviewComment(comment: IComment, text: string): Promise { const { mutate, schema } = await this.githubRepository.ensure(); - let threadWithComment = this._reviewThreadsCache.find(thread => + let threadWithComment = this._reviewThreadsCache?.find(thread => thread.comments.some(c => c.graphNodeId === comment.graphNodeId), ); @@ -1006,7 +1012,7 @@ export class PullRequestModel extends IssueModel implements IPullRe try { const { octokit, remote } = await this.githubRepository.ensure(); const id = Number(commentId); - const threadIndex = this._reviewThreadsCache.findIndex(thread => thread.comments.some(c => c.id === id)); + const threadIndex = this._reviewThreadsCache?.findIndex(thread => thread.comments.some(c => c.id === id)) ?? -1; if (threadIndex === -1) { this.deleteIssueComment(commentId); @@ -1018,11 +1024,11 @@ export class PullRequestModel extends IssueModel implements IPullRe }); if (threadIndex > -1) { - const threadWithComment = this._reviewThreadsCache[threadIndex]; + const threadWithComment = this._reviewThreadsCache![threadIndex]; const index = threadWithComment.comments.findIndex(c => c.id === id); threadWithComment.comments.splice(index, 1); if (threadWithComment.comments.length === 0) { - this._reviewThreadsCache.splice(threadIndex, 1); + this._reviewThreadsCache?.splice(threadIndex, 1); this._onDidChangeReviewThreads.fire({ added: [], changed: [], removed: [threadWithComment] }); } else { this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] }); @@ -1327,7 +1333,7 @@ export class PullRequestModel extends IssueModel implements IPullRe private setReviewThreadCacheFromRaw(raw: ReviewThread[]): IReviewThread[] { const reviewThreads: IReviewThread[] = raw.map(thread => parseGraphQLReviewThread(thread, this.githubRepository)); - const oldReviewThreads = this._reviewThreadsCache; + const oldReviewThreads = this._reviewThreadsCache ?? []; this._reviewThreadsCache = reviewThreads; this.diffThreads(oldReviewThreads, reviewThreads); return reviewThreads; @@ -1614,6 +1620,11 @@ export class PullRequestModel extends IssueModel implements IPullRe return this._fileChanges; } + private _rawFileChangesCache: IRawFileChange[] | undefined; + get rawFileChanges(): IRawFileChange[] | undefined { + return this._rawFileChangesCache; + } + async getFileChangesInfo() { this._fileChanges.clear(); const data = await this.getRawFileChangesInfo(); @@ -1664,7 +1675,7 @@ export class PullRequestModel extends IssueModel implements IPullRe /** * List the changed files in a pull request. */ - private async getRawFileChangesInfo(): Promise { + public async getRawFileChangesInfo(): Promise { Logger.debug(`Fetch file changes, base, head and merge base of PR #${this.number} - enter`, PullRequestModel.ID); const githubRepository = this.githubRepository; @@ -1700,7 +1711,7 @@ export class PullRequestModel extends IssueModel implements IPullRe // Use the original base to compare against for merged PRs this.mergeBase = this.base.sha; - + this._rawFileChangesCache = response; return response; } @@ -1713,6 +1724,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } Logger.debug(`Fetch file changes and merge base of PR #${this.number} - done, total files ${files.length} `, PullRequestModel.ID,); + this._rawFileChangesCache = files; return files; } @@ -1826,7 +1838,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } private updateCommentReactions(graphNodeId: string, reactionGroups: ReactionGroup[]) { - const reviewThread = this._reviewThreadsCache.find(thread => + const reviewThread = this._reviewThreadsCache?.find(thread => thread.comments.some(c => c.graphNodeId === graphNodeId), ); if (reviewThread) { @@ -1903,7 +1915,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } async resolveReviewThread(threadId: string): Promise { - const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId); + const oldThread = this._reviewThreadsCache?.find(thread => thread.id === threadId); try { Logger.debug(`Resolve review thread - enter`, PullRequestModel.ID); @@ -1932,10 +1944,10 @@ export class PullRequestModel extends IssueModel implements IPullRe throw new Error('Resolve review thread failed.'); } - const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); + const index = this._reviewThreadsCache?.findIndex(thread => thread.id === threadId) ?? -1; if (index > -1) { const thread = parseGraphQLReviewThread(data.resolveReviewThread.thread, this.githubRepository); - this._reviewThreadsCache.splice(index, 1, thread); + this._reviewThreadsCache?.splice(index, 1, thread); this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); } Logger.debug(`Resolve review thread - done`, PullRequestModel.ID); @@ -1946,7 +1958,7 @@ export class PullRequestModel extends IssueModel implements IPullRe } async unresolveReviewThread(threadId: string): Promise { - const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId); + const oldThread = this._reviewThreadsCache?.find(thread => thread.id === threadId); try { Logger.debug(`Unresolve review thread - enter`, PullRequestModel.ID); @@ -1975,10 +1987,10 @@ export class PullRequestModel extends IssueModel implements IPullRe throw new Error('Unresolve review thread failed.'); } - const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId); + const index = this._reviewThreadsCache?.findIndex(thread => thread.id === threadId) ?? -1; if (index > -1) { const thread = parseGraphQLReviewThread(data.unresolveReviewThread.thread, this.githubRepository); - this._reviewThreadsCache.splice(index, 1, thread); + this._reviewThreadsCache?.splice(index, 1, thread); this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] }); } Logger.debug(`Unresolve review thread - done`, PullRequestModel.ID); diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index 2a81aef95b..7e44516474 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -223,6 +223,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { + this._item = pullRequestModel; + if (this._isUpdating) { throw new Error('Already updating pull request webview'); } diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index cd575b7d33..a4f253a57d 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -75,7 +75,6 @@ const CREATING_ISSUE_FROM_FILE_CONTEXT = 'issues.creatingFromFile'; export class IssueFeatureRegistrar extends Disposable { private static readonly ID = 'IssueFeatureRegistrar'; - private _stateManager: StateManager; private _newIssueCache: NewIssueCache; private createIssueInfo: @@ -93,10 +92,10 @@ export class IssueFeatureRegistrar extends Disposable { private reviewsManager: ReviewsManager, private context: vscode.ExtensionContext, private telemetry: ITelemetry, + private readonly _stateManager: StateManager, private copilotRemoteAgentManager: CopilotRemoteAgentManager, ) { super(); - this._stateManager = new StateManager(gitAPI, this.manager, this.context); this._newIssueCache = new NewIssueCache(context); } diff --git a/src/lm/issueContextProvider.ts b/src/lm/issueContextProvider.ts new file mode 100644 index 0000000000..81796099bb --- /dev/null +++ b/src/lm/issueContextProvider.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { CommentEvent, EventType } from '../common/timelineEvent'; +import { IssueModel } from '../github/issueModel'; +import { IssueOverviewPanel } from '../github/issueOverview'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { getIssueNumberLabel } from '../github/utils'; +import { IssueQueryResult, StateManager } from '../issues/stateManager'; + +interface IssueChatContextItem extends vscode.ChatContextItem { + issue: IssueModel; +} + +export class IssueContextProvider implements vscode.ChatContextProvider { + constructor(private readonly _stateManager: StateManager, + private readonly _reposManager: RepositoriesManager + ) { } + + async provideChatContextForResource(_options: { resource: vscode.Uri }, _token: vscode.CancellationToken): Promise { + const item = IssueOverviewPanel.currentPanel?.getCurrentItem(); + if (item) { + return this._issueToUnresolvedContext(item); + } + } + + async resolveChatContext(context: IssueChatContextItem, _token: vscode.CancellationToken): Promise { + context.value = await this._resolvedIssueValue(context.issue); + context.modelDescription = 'All the information about the GitHub issue the user is viewing, including comments.'; + return context; + } + + async provideChatContextExplicit(_token: vscode.CancellationToken): Promise { + const contextItems: IssueChatContextItem[] = []; + const seenIssues: Set = new Set(); + for (const folderManager of this._reposManager.folderManagers) { + const issueData = this._stateManager.getIssueCollection(folderManager?.repository.rootUri); + + for (const issueQuery of issueData) { + const issuesOrMilestones: IssueQueryResult = await issueQuery[1]; + + if ((issuesOrMilestones.issues ?? []).length === 0) { + continue; + } + for (const issue of (issuesOrMilestones.issues ?? [])) { + const issueKey = getIssueNumberLabel(issue as IssueModel); + // Only add the issue if we haven't seen it before (first query wins) + if (seenIssues.has(issueKey)) { + continue; + } + seenIssues.add(issueKey); + contextItems.push(this._issueToUnresolvedContext(issue as IssueModel)); + + } + } + } + return contextItems; + } + + private _issueToUnresolvedContext(issue: IssueModel): IssueChatContextItem { + return { + icon: new vscode.ThemeIcon('issues'), + label: `#${issue.number} ${issue.title}`, + modelDescription: 'The GitHub issue the user is viewing.', + issue, + }; + } + + private async _resolvedIssueValue(issue: IssueModel): Promise { + const timeline = issue.timelineEvents ?? await issue.getIssueTimelineEvents(); + return JSON.stringify({ + issueNumber: issue.number, + owner: issue.remote.owner, + repo: issue.remote.repositoryName, + title: issue.title, + body: issue.body, + comments: timeline.filter(e => e.event === EventType.Commented).map((e: CommentEvent) => ({ + author: e.user?.login, + body: e.body, + createdAt: e.createdAt + })) + }); + } +} \ No newline at end of file diff --git a/src/lm/pullRequestContextProvider.ts b/src/lm/pullRequestContextProvider.ts new file mode 100644 index 0000000000..31203b236e --- /dev/null +++ b/src/lm/pullRequestContextProvider.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { PullRequestModel } from '../github/pullRequestModel'; +import { PullRequestOverviewPanel } from '../github/pullRequestOverview'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { PrsTreeModel } from '../view/prsTreeModel'; + +interface PRChatContextItem extends vscode.ChatContextItem { + pr: PullRequestModel; +} + +export class PullRequestContextProvider implements vscode.ChatContextProvider { + constructor(private readonly _prsTreeModel: PrsTreeModel, + private readonly _reposManager: RepositoriesManager + ) { } + + async provideChatContextForResource(_options: { resource: vscode.Uri }, _token: vscode.CancellationToken): Promise { + const item = PullRequestOverviewPanel.currentPanel?.getCurrentItem(); + if (item) { + return this._prToUnresolvedContext(item); + } + } + + async resolveChatContext(context: PRChatContextItem, _token: vscode.CancellationToken): Promise { + context.value = await this._resolvedPrValue(context.pr); + context.modelDescription = 'All the information about the GitHub pull request the user is viewing, including comments, review threads, and changes.'; + return context; + } + + async provideChatContextExplicit(_token: vscode.CancellationToken): Promise { + const prs = await this._prsTreeModel.getAllPullRequests(this._reposManager.folderManagers[0], false); + return prs.items.map(pr => { + return this._prToUnresolvedContext(pr); + }); + } + + private _prToUnresolvedContext(pr: PullRequestModel): PRChatContextItem { + return { + icon: new vscode.ThemeIcon('git-pull-request'), + label: `#${pr.number} ${pr.title}`, + modelDescription: 'The GitHub pull request the user is viewing.', + pr, + }; + } + + private async _resolvedPrValue(pr: PullRequestModel): Promise { + return JSON.stringify({ + prNumber: pr.number, + owner: pr.remote.owner, + repo: pr.remote.repositoryName, + title: pr.title, + body: pr.body, + comments: pr.comments.map(comment => ({ + author: comment.user?.login, + body: comment.body, + createdAt: comment.createdAt + })), + threads: (pr.reviewThreadsCache ?? await pr.getReviewThreads()).map(thread => ({ + comments: thread.comments.map(comment => ({ + author: comment.user?.login, + body: comment.body, + createdAt: comment.createdAt + })), + isResolved: thread.isResolved + })), + changes: (pr.rawFileChanges ?? await pr.getRawFileChangesInfo()).map(change => { + return change.patch; + }) + }); + } +} \ No newline at end of file diff --git a/src/lm/tools/activePullRequestTool.ts b/src/lm/tools/activePullRequestTool.ts index 4cd4db515d..010c1fb2e4 100644 --- a/src/lm/tools/activePullRequestTool.ts +++ b/src/lm/tools/activePullRequestTool.ts @@ -132,7 +132,7 @@ export abstract class PullRequestTool implements vscode.LanguageModelTool 0 ? pullRequest.timelineEvents : await pullRequest.getTimelineEvents(); + const timeline = (pullRequest.timelineEvents && pullRequest.timelineEvents.length > 0) ? pullRequest.timelineEvents : await pullRequest.getTimelineEvents(); const pullRequestInfo = { title: pullRequest.title, body: pullRequest.body, diff --git a/src/view/prStatusDecorationProvider.ts b/src/view/prStatusDecorationProvider.ts index e9709303f7..c8f44b1eba 100644 --- a/src/view/prStatusDecorationProvider.ts +++ b/src/view/prStatusDecorationProvider.ts @@ -48,7 +48,7 @@ export class PRStatusDecorationProvider extends Disposable implements vscode.Fil const addUriForRefresh = (uris: vscode.Uri[], pullRequest: unknown) => { if (pullRequest instanceof PullRequestModel) { uris.push(createPRNodeUri(pullRequest)); - if (pullRequest.timelineEvents.some(t => t.event === EventType.CopilotStarted)) { + if (pullRequest.timelineEvents?.some(t => t.event === EventType.CopilotStarted)) { // The pr nodes in the Copilot category have a different uri so we need to refresh those too uris.push(createPRNodeUri(pullRequest, true)); } diff --git a/src/view/prsTreeModel.ts b/src/view/prsTreeModel.ts index 2ceec9d84b..8d25580ccb 100644 --- a/src/view/prsTreeModel.ts +++ b/src/view/prsTreeModel.ts @@ -528,7 +528,7 @@ export class PrsTreeModel extends Disposable { for (const pr of items) { unseenKeys.delete(this.copilotStateModel.makeKey(pr.remote.owner, pr.remote.repositoryName, pr.number)); - const copilotEvents = await pr.getCopilotTimelineEvents(pr, false, !this.copilotStateModel.isInitialized); + const copilotEvents = await pr.getCopilotTimelineEvents(false, !this.copilotStateModel.isInitialized); let latestEvent = copilotEventToStatus(copilotEvents[copilotEvents.length - 1]); if (latestEvent === CopilotPRStatus.None) { if (!COPILOT_ACCOUNTS[pr.author.login]) { diff --git a/src/view/treeNodes/pullRequestNode.ts b/src/view/treeNodes/pullRequestNode.ts index 6ebae209e0..3d8f8337c1 100644 --- a/src/view/treeNodes/pullRequestNode.ts +++ b/src/view/treeNodes/pullRequestNode.ts @@ -269,7 +269,7 @@ export class PRNode extends TreeNode implements vscode.CommentingRangeProvider2 } private async _getIcon(): Promise { - const copilotWorkingStatus = await this.pullRequestModel.copilotWorkingStatus(this.pullRequestModel); + const copilotWorkingStatus = await this.pullRequestModel.copilotWorkingStatus(); const theme = this._folderReposManager.themeWatcher.themeData; if (copilotWorkingStatus === CopilotWorkingStatus.NotCopilotIssue) { return (await DataUri.avatarCirclesAsImageDataUris(this._folderReposManager.context, [this.pullRequestModel.author], 16, 16))[0]