Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/pullRequestDetectionService.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService';6import { derivePullRequestState } from '../../../platform/github/common/githubAPI';7import { IOctoKitService } from '../../../platform/github/common/githubService';8import { ILogService } from '../../../platform/log/common/logService';9import { createServiceIdentifier } from '../../../util/common/services';10import { Emitter, Event } from '../../../util/vs/base/common/event';11import { Disposable } from '../../../util/vs/base/common/lifecycle';12import { URI } from '../../../util/vs/base/common/uri';13import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';1415const PR_DETECTION_RETRY_COUNT = 5;16const PR_DETECTION_INITIAL_DELAY_MS = 2_000;1718export interface IPullRequestDetectionService {19readonly _serviceBrand: undefined;2021/**22* Fired when a pull request is detected or updated for a session.23* Consumers should refresh the session UI in response.24*/25readonly onDidDetectPullRequest: Event<string>;2627/**28* Detects a pull request for a session when the user opens it.29* If a PR is found, persists the URL and notifies the UI.30*/31detectPullRequest(sessionId: string): void;3233/**34* Called after a request completes to persist PR metadata on the session.35* If a PR URL is provided, uses that; otherwise attempts detection36* via the GitHub API with exponential-backoff retry.37*/38handlePullRequestCreated(sessionId: string, createdPullRequestUrl: string | undefined): void;39}40export const IPullRequestDetectionService = createServiceIdentifier<IPullRequestDetectionService>('IPullRequestDetectionService');4142/**43* Queries the GitHub API to find a pull request whose head branch matches the44* given worktree branch. This covers cases where the MCP tool failed to report45* a PR URL, or the user created the PR externally (e.g., via github.com).46*/47async function detectPullRequestFromGitHubAPI(48branchName: string,49repositoryPath: string,50gitService: IGitService,51octoKitService: IOctoKitService,52logService: ILogService,53): Promise<{ url: string; state: string } | undefined> {54const repoContext = await gitService.getRepository(URI.file(repositoryPath));55if (!repoContext) {56logService.debug(`[detectPullRequestFromGitHubAPI] No git repository found for path: ${repositoryPath}`);57return undefined;58}5960const repoInfo = getGitHubRepoInfoFromContext(repoContext);61if (!repoInfo) {62logService.debug(`[detectPullRequestFromGitHubAPI] Could not extract GitHub repo info from repository at: ${repositoryPath}`);63return undefined;64}6566logService.debug(`[detectPullRequestFromGitHubAPI] Querying GitHub API for PR on ${repoInfo.id.org}/${repoInfo.id.repo}, branch=${branchName}`);6768const pr = await octoKitService.findPullRequestByHeadBranch(69repoInfo.id.org,70repoInfo.id.repo,71branchName,72{},73);7475if (pr?.url) {76const prState = derivePullRequestState(pr);77logService.trace(`[detectPullRequestFromGitHubAPI] Detected pull request via GitHub API: ${pr.url} ${prState}`);78return { url: pr.url, state: prState };79}8081logService.debug(`[detectPullRequestFromGitHubAPI] No PR found for ${repoInfo.id.org}/${repoInfo.id.repo}, branch=${branchName}`);82return undefined;83}8485/**86* Encapsulates all pull-request detection and persistence logic for chat sessions.87*/88export class PullRequestDetectionService extends Disposable implements IPullRequestDetectionService {89declare readonly _serviceBrand: undefined;9091private readonly _onDidDetectPullRequest = this._register(new Emitter<string>());92readonly onDidDetectPullRequest: Event<string> = this._onDidDetectPullRequest.event;9394constructor(95@IChatSessionWorktreeService private readonly chatSessionWorktreeService: IChatSessionWorktreeService,96@IGitService private readonly gitService: IGitService,97@IOctoKitService private readonly octoKitService: IOctoKitService,98@ILogService private readonly logService: ILogService,99) {100super();101}102103/**104* Detects a pull request for a session when the user opens it.105* If a PR is found, persists the URL and notifies the UI.106*/107detectPullRequest(sessionId: string): void {108this.doDetectPullRequestOnSessionOpen(sessionId).catch(ex =>109this.logService.error(ex instanceof Error ? ex : new Error(String(ex)), `Failed to detect pull request on session open for ${sessionId}`));110}111112private async doDetectPullRequestOnSessionOpen(sessionId: string): Promise<void> {113const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);114if (worktreeProperties?.version !== 2115|| worktreeProperties.pullRequestState === 'merged'116|| !worktreeProperties.branchName117|| !worktreeProperties.repositoryPath) {118this.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}`);119return;120}121122this.logService.debug(`[PullRequestDetectionService] Detecting PR on session open for ${sessionId}, branch=${worktreeProperties.branchName}, existingPrUrl=${worktreeProperties.pullRequestUrl ?? 'none'}`);123124const prResult = await this.detectPullRequestForSession(sessionId);125126if (prResult) {127// Re-read to get the latest information.128const currentProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);129if (currentProperties?.version === 2130&& (currentProperties.pullRequestUrl !== prResult.url || currentProperties.pullRequestState !== prResult.state)) {131await this.chatSessionWorktreeService.setWorktreeProperties(sessionId, {132...currentProperties, // use fresh copy133pullRequestUrl: prResult.url,134pullRequestState: prResult.state,135changes: undefined,136});137this._onDidDetectPullRequest.fire(sessionId);138} else {139this.logService.debug(`[PullRequestDetectionService] PR metadata unchanged for ${sessionId}, skipping update`);140}141} else {142this.logService.debug(`[PullRequestDetectionService] No PR found via GitHub API for ${sessionId}`);143}144}145146/**147* Called after a request completes to persist PR metadata on the session.148* If the session reported a PR URL, uses that; otherwise attempts detection149* via the GitHub API with exponential-backoff retry.150* Fires {@link onDidDetectPullRequest} if a PR is detected and persisted.151*/152handlePullRequestCreated(sessionId: string, createdPullRequestUrl: string | undefined): void {153this.doHandlePullRequestCreated(sessionId, createdPullRequestUrl).catch(ex =>154this.logService.error(ex instanceof Error ? ex : new Error(String(ex)), `Failed to handle pull request creation for session ${sessionId}`));155}156157private async doHandlePullRequestCreated(sessionId: string, createdPullRequestUrl: string | undefined): Promise<void> {158let prUrl = createdPullRequestUrl;159let prState = '';160161this.logService.debug(`[PullRequestDetectionService] handlePullRequestCreated for ${sessionId}: createdPullRequestUrl=${prUrl ?? 'none'}`);162163const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);164if (!worktreeProperties || worktreeProperties.version !== 2) {165return;166}167168if (!prUrl) {169if (worktreeProperties.branchName && worktreeProperties.repositoryPath) {170this.logService.debug(`[PullRequestDetectionService] No PR URL from session, attempting retry detection for ${sessionId}, branch=${worktreeProperties.branchName}`);171const prResult = await this.detectPullRequestWithRetry(sessionId);172prUrl = prResult?.url;173prState = prResult?.state ?? (prResult?.url ? 'open' : '');174} else {175this.logService.debug(`[PullRequestDetectionService] Skipping retry detection for ${sessionId}: branch=${worktreeProperties.branchName ?? 'none'}, repoPath=${!!worktreeProperties.repositoryPath}`);176}177}178179if (!prUrl) {180this.logService.debug(`[PullRequestDetectionService] No PR detected for ${sessionId} after all attempts`);181return;182}183184try {185await this.chatSessionWorktreeService.setWorktreeProperties(sessionId, {186...worktreeProperties,187pullRequestUrl: prUrl,188pullRequestState: prState,189changes: undefined,190});191this._onDidDetectPullRequest.fire(sessionId);192} catch (error) {193this.logService.error(error instanceof Error ? error : new Error(String(error)), `Failed to persist pull request metadata for session ${sessionId}`);194}195}196197/**198* Attempts to detect a pull request for a freshly-completed session using199* exponential backoff. The GitHub API may not have indexed the PR immediately200* after `gh pr create` returns, so we retry with increasing delays:201* attempt 1: 2s, attempt 2: 4s, attempt 3: 8s, ...202*/203private async detectPullRequestWithRetry(sessionId: string): Promise<{ url: string; state: string } | undefined> {204for (let attempt = 0; attempt < PR_DETECTION_RETRY_COUNT; attempt++) {205const delay = PR_DETECTION_INITIAL_DELAY_MS * Math.pow(2, attempt);206this.logService.debug(`[PullRequestDetectionService] PR detection retry for ${sessionId}: attempt ${attempt + 1}/${PR_DETECTION_RETRY_COUNT}, waiting ${delay}ms`);207await new Promise<void>(resolve => setTimeout(resolve, delay));208209const prResult = await this.detectPullRequestForSession(sessionId);210if (prResult) {211this.logService.debug(`[PullRequestDetectionService] PR detected on attempt ${attempt + 1} for ${sessionId}: url=${prResult.url}, state=${prResult.state}`);212return prResult;213}214}215216this.logService.debug(`[PullRequestDetectionService] PR detection exhausted all ${PR_DETECTION_RETRY_COUNT} retries for ${sessionId}`);217return undefined;218}219220/**221* Queries the GitHub API to find a pull request whose head branch matches the222* session's worktree branch.223*/224private async detectPullRequestForSession(sessionId: string): Promise<{ url: string; state: string } | undefined> {225try {226const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);227if (!worktreeProperties?.branchName || !worktreeProperties.repositoryPath) {228this.logService.debug(`[PullRequestDetectionService] detectPullRequestForSession: missing worktree info for ${sessionId}, branch=${worktreeProperties?.branchName ?? 'none'}, repoPath=${!!worktreeProperties?.repositoryPath}`);229return undefined;230}231232return await detectPullRequestFromGitHubAPI(233worktreeProperties.branchName,234worktreeProperties.repositoryPath,235this.gitService,236this.octoKitService,237this.logService,238);239} catch (error) {240this.logService.debug(`[PullRequestDetectionService] Failed to detect pull request via GitHub API: ${error instanceof Error ? error.message : String(error)}`);241return undefined;242}243}244}245246247