Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/pullRequestDetectionService.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService';
7
import { derivePullRequestState } from '../../../platform/github/common/githubAPI';
8
import { IOctoKitService } from '../../../platform/github/common/githubService';
9
import { ILogService } from '../../../platform/log/common/logService';
10
import { createServiceIdentifier } from '../../../util/common/services';
11
import { Emitter, Event } from '../../../util/vs/base/common/event';
12
import { Disposable } from '../../../util/vs/base/common/lifecycle';
13
import { URI } from '../../../util/vs/base/common/uri';
14
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
15
16
const PR_DETECTION_RETRY_COUNT = 5;
17
const PR_DETECTION_INITIAL_DELAY_MS = 2_000;
18
19
export interface IPullRequestDetectionService {
20
readonly _serviceBrand: undefined;
21
22
/**
23
* Fired when a pull request is detected or updated for a session.
24
* Consumers should refresh the session UI in response.
25
*/
26
readonly onDidDetectPullRequest: Event<string>;
27
28
/**
29
* Detects a pull request for a session when the user opens it.
30
* If a PR is found, persists the URL and notifies the UI.
31
*/
32
detectPullRequest(sessionId: string): void;
33
34
/**
35
* Called after a request completes to persist PR metadata on the session.
36
* If a PR URL is provided, uses that; otherwise attempts detection
37
* via the GitHub API with exponential-backoff retry.
38
*/
39
handlePullRequestCreated(sessionId: string, createdPullRequestUrl: string | undefined): void;
40
}
41
export const IPullRequestDetectionService = createServiceIdentifier<IPullRequestDetectionService>('IPullRequestDetectionService');
42
43
/**
44
* Queries the GitHub API to find a pull request whose head branch matches the
45
* given worktree branch. This covers cases where the MCP tool failed to report
46
* a PR URL, or the user created the PR externally (e.g., via github.com).
47
*/
48
async function detectPullRequestFromGitHubAPI(
49
branchName: string,
50
repositoryPath: string,
51
gitService: IGitService,
52
octoKitService: IOctoKitService,
53
logService: ILogService,
54
): Promise<{ url: string; state: string } | undefined> {
55
const repoContext = await gitService.getRepository(URI.file(repositoryPath));
56
if (!repoContext) {
57
logService.debug(`[detectPullRequestFromGitHubAPI] No git repository found for path: ${repositoryPath}`);
58
return undefined;
59
}
60
61
const repoInfo = getGitHubRepoInfoFromContext(repoContext);
62
if (!repoInfo) {
63
logService.debug(`[detectPullRequestFromGitHubAPI] Could not extract GitHub repo info from repository at: ${repositoryPath}`);
64
return undefined;
65
}
66
67
logService.debug(`[detectPullRequestFromGitHubAPI] Querying GitHub API for PR on ${repoInfo.id.org}/${repoInfo.id.repo}, branch=${branchName}`);
68
69
const pr = await octoKitService.findPullRequestByHeadBranch(
70
repoInfo.id.org,
71
repoInfo.id.repo,
72
branchName,
73
{},
74
);
75
76
if (pr?.url) {
77
const prState = derivePullRequestState(pr);
78
logService.trace(`[detectPullRequestFromGitHubAPI] Detected pull request via GitHub API: ${pr.url} ${prState}`);
79
return { url: pr.url, state: prState };
80
}
81
82
logService.debug(`[detectPullRequestFromGitHubAPI] No PR found for ${repoInfo.id.org}/${repoInfo.id.repo}, branch=${branchName}`);
83
return undefined;
84
}
85
86
/**
87
* Encapsulates all pull-request detection and persistence logic for chat sessions.
88
*/
89
export class PullRequestDetectionService extends Disposable implements IPullRequestDetectionService {
90
declare readonly _serviceBrand: undefined;
91
92
private readonly _onDidDetectPullRequest = this._register(new Emitter<string>());
93
readonly onDidDetectPullRequest: Event<string> = this._onDidDetectPullRequest.event;
94
95
constructor(
96
@IChatSessionWorktreeService private readonly chatSessionWorktreeService: IChatSessionWorktreeService,
97
@IGitService private readonly gitService: IGitService,
98
@IOctoKitService private readonly octoKitService: IOctoKitService,
99
@ILogService private readonly logService: ILogService,
100
) {
101
super();
102
}
103
104
/**
105
* Detects a pull request for a session when the user opens it.
106
* If a PR is found, persists the URL and notifies the UI.
107
*/
108
detectPullRequest(sessionId: string): void {
109
this.doDetectPullRequestOnSessionOpen(sessionId).catch(ex =>
110
this.logService.error(ex instanceof Error ? ex : new Error(String(ex)), `Failed to detect pull request on session open for ${sessionId}`));
111
}
112
113
private async doDetectPullRequestOnSessionOpen(sessionId: string): Promise<void> {
114
const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);
115
if (worktreeProperties?.version !== 2
116
|| worktreeProperties.pullRequestState === 'merged'
117
|| !worktreeProperties.branchName
118
|| !worktreeProperties.repositoryPath) {
119
this.logService.debug(`[PullRequestDetectionService] Skipping PR detection on session open for ${sessionId}: version=${worktreeProperties?.version}, prState=${worktreeProperties?.version === 2 ? worktreeProperties.pullRequestState : 'n/a'}, branch=${!!worktreeProperties?.branchName}, repoPath=${!!worktreeProperties?.repositoryPath}`);
120
return;
121
}
122
123
this.logService.debug(`[PullRequestDetectionService] Detecting PR on session open for ${sessionId}, branch=${worktreeProperties.branchName}, existingPrUrl=${worktreeProperties.pullRequestUrl ?? 'none'}`);
124
125
const prResult = await this.detectPullRequestForSession(sessionId);
126
127
if (prResult) {
128
// Re-read to get the latest information.
129
const currentProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);
130
if (currentProperties?.version === 2
131
&& (currentProperties.pullRequestUrl !== prResult.url || currentProperties.pullRequestState !== prResult.state)) {
132
await this.chatSessionWorktreeService.setWorktreeProperties(sessionId, {
133
...currentProperties, // use fresh copy
134
pullRequestUrl: prResult.url,
135
pullRequestState: prResult.state,
136
changes: undefined,
137
});
138
this._onDidDetectPullRequest.fire(sessionId);
139
} else {
140
this.logService.debug(`[PullRequestDetectionService] PR metadata unchanged for ${sessionId}, skipping update`);
141
}
142
} else {
143
this.logService.debug(`[PullRequestDetectionService] No PR found via GitHub API for ${sessionId}`);
144
}
145
}
146
147
/**
148
* Called after a request completes to persist PR metadata on the session.
149
* If the session reported a PR URL, uses that; otherwise attempts detection
150
* via the GitHub API with exponential-backoff retry.
151
* Fires {@link onDidDetectPullRequest} if a PR is detected and persisted.
152
*/
153
handlePullRequestCreated(sessionId: string, createdPullRequestUrl: string | undefined): void {
154
this.doHandlePullRequestCreated(sessionId, createdPullRequestUrl).catch(ex =>
155
this.logService.error(ex instanceof Error ? ex : new Error(String(ex)), `Failed to handle pull request creation for session ${sessionId}`));
156
}
157
158
private async doHandlePullRequestCreated(sessionId: string, createdPullRequestUrl: string | undefined): Promise<void> {
159
let prUrl = createdPullRequestUrl;
160
let prState = '';
161
162
this.logService.debug(`[PullRequestDetectionService] handlePullRequestCreated for ${sessionId}: createdPullRequestUrl=${prUrl ?? 'none'}`);
163
164
const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);
165
if (!worktreeProperties || worktreeProperties.version !== 2) {
166
return;
167
}
168
169
if (!prUrl) {
170
if (worktreeProperties.branchName && worktreeProperties.repositoryPath) {
171
this.logService.debug(`[PullRequestDetectionService] No PR URL from session, attempting retry detection for ${sessionId}, branch=${worktreeProperties.branchName}`);
172
const prResult = await this.detectPullRequestWithRetry(sessionId);
173
prUrl = prResult?.url;
174
prState = prResult?.state ?? (prResult?.url ? 'open' : '');
175
} else {
176
this.logService.debug(`[PullRequestDetectionService] Skipping retry detection for ${sessionId}: branch=${worktreeProperties.branchName ?? 'none'}, repoPath=${!!worktreeProperties.repositoryPath}`);
177
}
178
}
179
180
if (!prUrl) {
181
this.logService.debug(`[PullRequestDetectionService] No PR detected for ${sessionId} after all attempts`);
182
return;
183
}
184
185
try {
186
await this.chatSessionWorktreeService.setWorktreeProperties(sessionId, {
187
...worktreeProperties,
188
pullRequestUrl: prUrl,
189
pullRequestState: prState,
190
changes: undefined,
191
});
192
this._onDidDetectPullRequest.fire(sessionId);
193
} catch (error) {
194
this.logService.error(error instanceof Error ? error : new Error(String(error)), `Failed to persist pull request metadata for session ${sessionId}`);
195
}
196
}
197
198
/**
199
* Attempts to detect a pull request for a freshly-completed session using
200
* exponential backoff. The GitHub API may not have indexed the PR immediately
201
* after `gh pr create` returns, so we retry with increasing delays:
202
* attempt 1: 2s, attempt 2: 4s, attempt 3: 8s, ...
203
*/
204
private async detectPullRequestWithRetry(sessionId: string): Promise<{ url: string; state: string } | undefined> {
205
for (let attempt = 0; attempt < PR_DETECTION_RETRY_COUNT; attempt++) {
206
const delay = PR_DETECTION_INITIAL_DELAY_MS * Math.pow(2, attempt);
207
this.logService.debug(`[PullRequestDetectionService] PR detection retry for ${sessionId}: attempt ${attempt + 1}/${PR_DETECTION_RETRY_COUNT}, waiting ${delay}ms`);
208
await new Promise<void>(resolve => setTimeout(resolve, delay));
209
210
const prResult = await this.detectPullRequestForSession(sessionId);
211
if (prResult) {
212
this.logService.debug(`[PullRequestDetectionService] PR detected on attempt ${attempt + 1} for ${sessionId}: url=${prResult.url}, state=${prResult.state}`);
213
return prResult;
214
}
215
}
216
217
this.logService.debug(`[PullRequestDetectionService] PR detection exhausted all ${PR_DETECTION_RETRY_COUNT} retries for ${sessionId}`);
218
return undefined;
219
}
220
221
/**
222
* Queries the GitHub API to find a pull request whose head branch matches the
223
* session's worktree branch.
224
*/
225
private async detectPullRequestForSession(sessionId: string): Promise<{ url: string; state: string } | undefined> {
226
try {
227
const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);
228
if (!worktreeProperties?.branchName || !worktreeProperties.repositoryPath) {
229
this.logService.debug(`[PullRequestDetectionService] detectPullRequestForSession: missing worktree info for ${sessionId}, branch=${worktreeProperties?.branchName ?? 'none'}, repoPath=${!!worktreeProperties?.repositoryPath}`);
230
return undefined;
231
}
232
233
return await detectPullRequestFromGitHubAPI(
234
worktreeProperties.branchName,
235
worktreeProperties.repositoryPath,
236
this.gitService,
237
this.octoKitService,
238
this.logService,
239
);
240
} catch (error) {
241
this.logService.debug(`[PullRequestDetectionService] Failed to detect pull request via GitHub API: ${error instanceof Error ? error.message : String(error)}`);
242
return undefined;
243
}
244
}
245
}
246
247