Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/pullRequestDetectionService.spec.ts
13405 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 { beforeEach, describe, expect, it, vi } from 'vitest';6import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';7import { PullRequestSearchItem } from '../../../../platform/github/common/githubAPI';8import { IOctoKitService } from '../../../../platform/github/common/githubService';9import { ILogService } from '../../../../platform/log/common/logService';10import { mock } from '../../../../util/common/test/simpleMock';11import { Event } from '../../../../util/vs/base/common/event';12import { URI } from '../../../../util/vs/base/common/uri';13import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';14import { PullRequestDetectionService } from '../pullRequestDetectionService';1516class TestWorktreeService extends mock<IChatSessionWorktreeService>() {17declare readonly _serviceBrand: undefined;18override getWorktreeProperties = vi.fn(async (): Promise<ChatSessionWorktreeProperties | undefined> => undefined);19override setWorktreeProperties = vi.fn(async () => { });20}2122class TestGitService extends mock<IGitService>() {23declare readonly _serviceBrand: undefined;24override onDidOpenRepository = Event.None;25override onDidCloseRepository = Event.None;26override onDidFinishInitialization = Event.None;27override activeRepository = { get: () => undefined } as IGitService['activeRepository'];28override repositories: RepoContext[] = [];29override getRepository = vi.fn(async (): Promise<RepoContext | undefined> => this.repositories[0]);3031setRepo(repo: RepoContext): void {32this.repositories = [repo];33}34}3536class TestOctoKitService extends mock<IOctoKitService>() {37declare readonly _serviceBrand: undefined;38override findPullRequestByHeadBranch = vi.fn(async (): Promise<PullRequestSearchItem | undefined> => undefined);39}4041class TestLogService extends mock<ILogService>() {42declare readonly _serviceBrand: undefined;43override trace = vi.fn();44override debug = vi.fn();45override error = vi.fn();46}4748function createV2WorktreeProperties(overrides?: Partial<ChatSessionWorktreeProperties>): ChatSessionWorktreeProperties {49return {50version: 2,51baseCommit: 'abc123',52baseBranchName: 'main',53branchName: 'copilot/test-branch',54repositoryPath: '/repo',55worktreePath: '/worktree',56...overrides,57} as ChatSessionWorktreeProperties;58}5960function createPrSearchItem(overrides?: Partial<PullRequestSearchItem>): PullRequestSearchItem {61return {62id: 'pr-42',63number: 42,64title: 'Test PR',65url: 'https://github.com/owner/repo/pull/42',66state: 'OPEN',67isDraft: false,68createdAt: '2026-01-01T00:00:00Z',69updatedAt: '2026-01-01T00:00:00Z',70author: { login: 'user' },71repository: { owner: { login: 'owner' }, name: 'repo' },72additions: 1,73deletions: 0,74files: { totalCount: 1 },75fullDatabaseId: 42,76headRefOid: 'deadbeef',77headRefName: 'copilot/test-branch',78baseRefName: 'main',79body: '',80...overrides,81};82}8384function createGitRepo(path: string = '/repo'): RepoContext {85return {86rootUri: URI.file(path),87kind: 'repository',88remotes: ['origin'],89remoteFetchUrls: ['https://github.com/owner/repo.git'],90} as unknown as RepoContext;91}9293describe('PullRequestDetectionService', () => {94let worktreeService: TestWorktreeService;95let gitService: TestGitService;96let octoKitService: TestOctoKitService;97let logService: TestLogService;98let service: PullRequestDetectionService;99100beforeEach(() => {101vi.restoreAllMocks();102worktreeService = new TestWorktreeService();103gitService = new TestGitService();104octoKitService = new TestOctoKitService();105logService = new TestLogService();106service = new PullRequestDetectionService(worktreeService, gitService, octoKitService, logService);107});108109describe('detectPullRequest', () => {110it('does not query GitHub API when no worktree properties exist', async () => {111worktreeService.getWorktreeProperties.mockResolvedValue(undefined);112service.detectPullRequest('session-1');113await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());114expect(octoKitService.findPullRequestByHeadBranch).not.toHaveBeenCalled();115});116117it('does not query GitHub API when version is not 2', async () => {118worktreeService.getWorktreeProperties.mockResolvedValue({119version: 1,120autoCommit: true,121baseCommit: 'abc',122branchName: 'branch',123repositoryPath: '/repo',124worktreePath: '/wt',125});126service.detectPullRequest('session-1');127await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());128expect(octoKitService.findPullRequestByHeadBranch).not.toHaveBeenCalled();129});130131it('skips detection when pullRequestState is merged', async () => {132worktreeService.getWorktreeProperties.mockResolvedValue(133createV2WorktreeProperties({ pullRequestState: 'merged' })134);135service.detectPullRequest('session-1');136await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());137expect(octoKitService.findPullRequestByHeadBranch).not.toHaveBeenCalled();138});139140it('skips detection when branchName is missing', async () => {141worktreeService.getWorktreeProperties.mockResolvedValue(142createV2WorktreeProperties({ branchName: '' })143);144service.detectPullRequest('session-1');145await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());146});147148it('skips detection when repositoryPath is missing', async () => {149worktreeService.getWorktreeProperties.mockResolvedValue(150createV2WorktreeProperties({ repositoryPath: '' })151);152service.detectPullRequest('session-1');153await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());154});155156it('updates properties when PR is found', async () => {157worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());158gitService.setRepo(createGitRepo());159octoKitService.findPullRequestByHeadBranch.mockResolvedValue(createPrSearchItem());160161service.detectPullRequest('session-1');162await vi.waitFor(() => expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(163'session-1',164expect.objectContaining({165pullRequestUrl: 'https://github.com/owner/repo/pull/42',166pullRequestState: 'open',167}),168));169});170171it('fires onDidDetectPullRequest when PR is found on session open', async () => {172worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());173gitService.setRepo(createGitRepo());174octoKitService.findPullRequestByHeadBranch.mockResolvedValue(createPrSearchItem());175176const firedSessionIds: string[] = [];177service.onDidDetectPullRequest(id => firedSessionIds.push(id));178179service.detectPullRequest('session-1');180await vi.waitFor(() => expect(firedSessionIds).toEqual(['session-1']));181});182183it('does not fire onDidDetectPullRequest when no PR found on session open', async () => {184worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());185gitService.setRepo(createGitRepo());186octoKitService.findPullRequestByHeadBranch.mockResolvedValue(undefined);187188const firedSessionIds: string[] = [];189service.onDidDetectPullRequest(id => firedSessionIds.push(id));190191service.detectPullRequest('session-1');192await vi.waitFor(() => expect(octoKitService.findPullRequestByHeadBranch).toHaveBeenCalled());193expect(firedSessionIds).toEqual([]);194});195196it('does not update properties when PR url and state are unchanged', async () => {197worktreeService.getWorktreeProperties.mockResolvedValue(198createV2WorktreeProperties({199pullRequestUrl: 'https://github.com/owner/repo/pull/42',200pullRequestState: 'open',201})202);203gitService.setRepo(createGitRepo());204octoKitService.findPullRequestByHeadBranch.mockResolvedValue(createPrSearchItem());205206service.detectPullRequest('session-1');207await vi.waitFor(() => expect(octoKitService.findPullRequestByHeadBranch).toHaveBeenCalled());208expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();209});210211it('updates properties when PR state changed', async () => {212worktreeService.getWorktreeProperties.mockResolvedValue(213createV2WorktreeProperties({214pullRequestUrl: 'https://github.com/owner/repo/pull/42',215pullRequestState: 'open',216})217);218gitService.setRepo(createGitRepo());219octoKitService.findPullRequestByHeadBranch.mockResolvedValue(220createPrSearchItem({ state: 'CLOSED' })221);222223service.detectPullRequest('session-1');224await vi.waitFor(() => expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(225'session-1',226expect.objectContaining({ pullRequestState: 'closed' }),227));228});229230it('does not update properties when no PR is found via GitHub API', async () => {231worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());232gitService.setRepo(createGitRepo());233octoKitService.findPullRequestByHeadBranch.mockResolvedValue(undefined);234235service.detectPullRequest('session-1');236await vi.waitFor(() => expect(octoKitService.findPullRequestByHeadBranch).toHaveBeenCalled());237expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();238});239240it('does not throw on error', async () => {241worktreeService.getWorktreeProperties.mockRejectedValue(new Error('Service down'));242service.detectPullRequest('session-1');243await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());244});245246it('does not query GitHub API when git repository is not found', async () => {247worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());248gitService.getRepository.mockResolvedValue(undefined);249250service.detectPullRequest('session-1');251await vi.waitFor(() => expect(gitService.getRepository).toHaveBeenCalled());252expect(octoKitService.findPullRequestByHeadBranch).not.toHaveBeenCalled();253});254});255256describe('handlePullRequestCreated', () => {257it('does not persist when no worktree properties exist', async () => {258worktreeService.getWorktreeProperties.mockResolvedValue(undefined);259service.handlePullRequestCreated('session-1', 'https://github.com/owner/repo/pull/42');260await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());261expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();262});263264it('does not persist when version is not 2', async () => {265worktreeService.getWorktreeProperties.mockResolvedValue({266version: 1,267autoCommit: true,268baseCommit: 'abc',269branchName: 'branch',270repositoryPath: '/repo',271worktreePath: '/wt',272});273service.handlePullRequestCreated('session-1', 'https://github.com/owner/repo/pull/42');274await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());275expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();276});277278it('persists PR URL from session when provided', async () => {279worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());280service.handlePullRequestCreated('session-1', 'https://github.com/owner/repo/pull/99');281await vi.waitFor(() => expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(282'session-1',283expect.objectContaining({284pullRequestUrl: 'https://github.com/owner/repo/pull/99',285}),286));287});288289it('fires onDidDetectPullRequest when PR is persisted', async () => {290worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());291const firedSessionIds: string[] = [];292service.onDidDetectPullRequest(id => firedSessionIds.push(id));293294service.handlePullRequestCreated('session-1', 'https://github.com/owner/repo/pull/99');295await vi.waitFor(() => expect(firedSessionIds).toEqual(['session-1']));296});297298it('does not fire onDidDetectPullRequest when no PR detected', async () => {299worktreeService.getWorktreeProperties.mockResolvedValue(300createV2WorktreeProperties({ branchName: '', repositoryPath: '' })301);302const firedSessionIds: string[] = [];303service.onDidDetectPullRequest(id => firedSessionIds.push(id));304305service.handlePullRequestCreated('session-1', undefined);306await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());307expect(firedSessionIds).toEqual([]);308});309310it('does not persist when no PR URL and no branch/repo for retry', async () => {311worktreeService.getWorktreeProperties.mockResolvedValue(312createV2WorktreeProperties({ branchName: '', repositoryPath: '' })313);314service.handlePullRequestCreated('session-1', undefined);315await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());316expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();317});318319it('does not fire event when setWorktreeProperties throws', async () => {320worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());321worktreeService.setWorktreeProperties.mockRejectedValue(new Error('Write failed'));322const firedSessionIds: string[] = [];323service.onDidDetectPullRequest(id => firedSessionIds.push(id));324325service.handlePullRequestCreated('session-1', 'https://github.com/owner/repo/pull/42');326await vi.waitFor(() => expect(worktreeService.setWorktreeProperties).toHaveBeenCalled());327expect(firedSessionIds).toEqual([]);328});329});330});331332333