import { Readable } from 'node:stream'; import { isString } from '@sindresorhus/is'; import type { ICoreApi } from 'azure-devops-node-api/CoreApi.js'; import type { IGitApi } from 'azure-devops-node-api/GitApi.js'; import type { GitPullRequest, GitVersionDescriptor, } from 'azure-devops-node-api/interfaces/GitInterfaces.js'; import { GitPullRequestMergeStrategy, GitStatusState, GitVersionType, PullRequestStatus, } from 'azure-devops-node-api/interfaces/GitInterfaces.js'; import type { PolicyConfiguration, PolicyEvaluationRecord, PolicyTypeRef, } from 'azure-devops-node-api/interfaces/PolicyInterfaces.js'; import { PolicyEvaluationStatus } from 'azure-devops-node-api/interfaces/PolicyInterfaces.js'; import type { IWorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi.js'; import type { Mocked, MockedObject } from 'vitest'; import { vi } from 'vitest'; import { mockDeep } from 'vitest-mock-extended'; import { partial } from '~test/util.ts'; import { GlobalConfig } from '../../../config/global.ts'; import { REPOSITORY_ARCHIVED, REPOSITORY_NOT_FOUND, } from '../../../constants/error-messages.ts'; import type { logger as _logger } from '../../../logger/index.ts'; import type * as _git from '../../../util/git/index.ts'; import type * as _hostRules from '../../../util/host-rules.ts'; import type { Platform, RepoParams } from '../types.ts'; import { AzurePrVote } from './types.ts'; vi.mock('../../../config/global.ts', async (importOriginal) => importOriginal(), ); vi.mock('./azure-got-wrapper.ts', () => mockDeep()); vi.mock('./azure-helper.ts', () => mockDeep()); vi.mock('../../../util/sanitize.ts', () => mockDeep({ sanitize: (s: string) => s }), ); vi.mock('timers/promises'); describe('modules/platform/azure/index', () => { let hostRules: typeof _hostRules; let azure: Platform; let azureApi: Mocked; let azureHelper: Mocked; let git: Mocked; let logger: MockedObject; beforeEach(async () => { // reset module vi.resetModules(); GlobalConfig.reset(); hostRules = await vi.importActual('../../../util/host-rules.ts'); azure = await vi.importActual('./index.ts'); azureApi = await vi.importMock('./azure-got-wrapper.ts'); azureHelper = await vi.importMock('./azure-helper.ts'); logger = ( await vi.importMock( '../../../logger/index.ts', ) ).logger; git = await vi.importMock('../../../util/git/index.ts'); git.branchExists.mockReturnValue(true); git.isBranchBehindBase.mockResolvedValue(false); hostRules.clear(); hostRules.add({ token: 'token' }); azureHelper.getPolicyEvaluations.mockResolvedValue([]); azureApi.getAuthenticatedUserId.mockResolvedValue('renovate-user-id'); // Default to the hosted (cloud) endpoint used across these tests. azureApi.isHosted.mockResolvedValue(true); await azure.initPlatform({ endpoint: 'https://dev.azure.com/renovate12345', token: 'token', }); }); // do we need the args? function getRepos(_token: string, _endpoint: string) { azureApi.gitApi.mockResolvedValueOnce( partial({ getRepositories: vi.fn().mockResolvedValue([ { name: 'repo1', project: { name: 'prj1', }, }, { name: 'repo2', project: { name: 'prj1', }, isDisabled: false, }, { name: 'repoDisabled', project: { name: 'prj1', }, isDisabled: true, }, ]), }), ); return azure.getRepos(); } describe('initPlatform()', () => { it('should throw if no endpoint', () => { expect.assertions(1); expect(() => azure.initPlatform({})).toThrow( 'Init: You must configure an Azure DevOps endpoint', ); }); it('should throw if no token nor a username and password', () => { expect.assertions(1); expect(() => azure.initPlatform({ endpoint: 'https://dev.azure.com/renovate12345', }), ).toThrow( 'Init: You must configure an Azure DevOps token, or a username and', ); }); it('should throw if a username but no password', () => { expect.assertions(1); expect(() => azure.initPlatform({ endpoint: 'https://dev.azure.com/renovate12345', username: 'user', }), ).toThrow( 'Init: You must configure an Azure DevOps token, or a username and', ); }); it('should throw if a password but no username', () => { expect.assertions(1); expect(() => azure.initPlatform({ endpoint: 'https://dev.azure.com/renovate12345', password: 'pass', }), ).toThrow( 'Init: You must configure an Azure DevOps token, or a username and', ); }); it('should init', async () => { await expect( azure.initPlatform({ endpoint: 'https://dev.azure.com/renovate12345', token: 'token', }), ).resolves.toEqual({ endpoint: 'https://dev.azure.com/renovate12345/', }); expect(azureApi.getAuthenticatedUserId).toHaveBeenLastCalledWith({ token: 'token', }); }); it('should discover the authenticated user with basic credentials', async () => { await azure.initPlatform({ endpoint: 'https://dev.azure.com/renovate12345', username: 'user', password: 'pass', }); expect(azureApi.getAuthenticatedUserId).toHaveBeenLastCalledWith({ username: 'user', password: 'pass', }); }); }); describe('getRepos()', () => { it('should return an array of repos', async () => { const repos = await getRepos( 'sometoken', 'https://dev.azure.com/renovate12345', ); expect(azureApi.gitApi.mock.calls).toEqual([[]]); expect(repos).toEqual(['prj1/repo1', 'prj1/repo2']); }); }); function initRepo(args?: Partial | string) { azureApi.gitApi.mockResolvedValueOnce( partial({ getRepositories: vi.fn().mockResolvedValue([ { name: 'repo', id: '1', privateRepo: true, isFork: false, defaultBranch: 'defBr', project: { name: 'some', }, }, { name: 'repo2', project: { name: 'prj2', }, }, { name: 'repo3', project: { name: 'some', }, isDisabled: true, }, ]), }), ); if (isString(args)) { return azure.initRepo({ repository: args, }); } return azure.initRepo({ repository: 'some/repo', ...args, }); } describe('initRepo', () => { it(`should initialise the config for a repo`, async () => { const config = await initRepo({ repository: 'some/repo', }); expect(azureApi.gitApi.mock.calls).toEqual([[]]); expect(config).toEqual({ defaultBranch: 'defBr', isFork: false, repoFingerprint: '02574de485149547c1a071aa7921da3d0afadcd6162f3bd49ba3ced29be589f8b9bac689fd0badb212bd21c3f48bd8566beaf31cdca2b083bd855808a9c129e2', }); }); it(`throws if repo is disabled`, async () => { await expect( initRepo({ repository: 'some/repo3', }), ).rejects.toThrow(REPOSITORY_ARCHIVED); }); it(`throws if repo is not in repos list`, async () => { await expect( initRepo({ repository: 'some/missing', }), ).rejects.toThrow(REPOSITORY_NOT_FOUND); }); }); describe('findPr(branchName, prTitle, state, targetBranch)', () => { it('returns pr if found it open', async () => { azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests: vi .fn() .mockReturnValue([]) .mockReturnValueOnce([ { pullRequestId: 1, sourceRefName: 'refs/heads/branch-a', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', status: PullRequestStatus.Active, }, ]), getPullRequestCommits: vi.fn().mockReturnValue([]), }), ); const res = await azure.findPr({ branchName: 'branch-a', prTitle: 'branch a pr', state: 'open', }); expect(res).toEqual({ bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, createdAt: undefined, number: 1, pullRequestId: 1, sourceBranch: 'branch-a', sourceRefName: 'refs/heads/branch-a', state: 'open', status: 1, targetBranch: 'branch-b', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', }); }); it('returns pr if found not open', async () => { azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests: vi .fn() .mockReturnValue([]) .mockReturnValueOnce([ { pullRequestId: 1, sourceRefName: 'refs/heads/branch-a', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', status: PullRequestStatus.Completed, }, ]), getPullRequestCommits: vi.fn().mockReturnValue([]), }), ); const res = await azure.findPr({ branchName: 'branch-a', prTitle: 'branch a pr', state: '!open', }); expect(res).toEqual({ bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, createdAt: undefined, number: 1, pullRequestId: 1, sourceBranch: 'branch-a', sourceRefName: 'refs/heads/branch-a', state: 'merged', status: 3, targetBranch: 'branch-b', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', }); }); it('returns pr if found it close', async () => { azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests: vi .fn() .mockReturnValue([]) .mockReturnValueOnce([ { pullRequestId: 1, sourceRefName: 'refs/heads/branch-a', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', status: PullRequestStatus.Abandoned, }, ]), getPullRequestCommits: vi.fn().mockReturnValue([]), }), ); const res = await azure.findPr({ branchName: 'branch-a', prTitle: 'branch a pr', state: 'closed', }); expect(res).toEqual({ bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, createdAt: undefined, number: 1, pullRequestId: 1, sourceBranch: 'branch-a', sourceRefName: 'refs/heads/branch-a', state: 'closed', status: 2, targetBranch: 'branch-b', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', }); }); it('returns pr if found it all state', async () => { azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests: vi .fn() .mockReturnValue([]) .mockReturnValueOnce([ { pullRequestId: 1, sourceRefName: 'refs/heads/branch-a', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', status: PullRequestStatus.Abandoned, }, ]), getPullRequestCommits: vi.fn().mockReturnValue([]), }), ); const res = await azure.findPr({ branchName: 'branch-a', prTitle: 'branch a pr', }); expect(res).toEqual({ bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, createdAt: undefined, number: 1, pullRequestId: 1, sourceBranch: 'branch-a', sourceRefName: 'refs/heads/branch-a', state: 'closed', status: 2, targetBranch: 'branch-b', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', }); }); it('returns pr if found matches targetBranch', async () => { azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests: vi .fn() .mockReturnValue([]) .mockReturnValueOnce([ { pullRequestId: 1, sourceRefName: 'refs/heads/branch-a', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', status: PullRequestStatus.Active, }, { pullRequestId: 2, sourceRefName: 'refs/heads/branch-a', targetRefName: 'refs/heads/branch-c', title: 'branch a pr', status: PullRequestStatus.Active, }, ]), getPullRequestCommits: vi.fn().mockReturnValue([]), }), ); const res = await azure.findPr({ branchName: 'branch-a', prTitle: 'branch a pr', state: 'open', targetBranch: 'branch-c', }); expect(res).toEqual({ bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, createdAt: undefined, number: 2, pullRequestId: 2, sourceBranch: 'branch-a', sourceRefName: 'refs/heads/branch-a', state: 'open', status: 1, targetBranch: 'branch-c', targetRefName: 'refs/heads/branch-c', title: 'branch a pr', }); }); it('returns first pr if found does not match targetBranch', async () => { azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests: vi .fn() .mockReturnValue([]) .mockReturnValueOnce([ { pullRequestId: 1, sourceRefName: 'refs/heads/branch-a', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', status: PullRequestStatus.Active, }, { pullRequestId: 2, sourceRefName: 'refs/heads/branch-a', targetRefName: 'refs/heads/branch-c', title: 'branch a pr', status: PullRequestStatus.Active, }, ]), getPullRequestCommits: vi.fn().mockReturnValue([]), }), ); const res = await azure.findPr({ branchName: 'branch-a', prTitle: 'branch a pr', state: 'open', targetBranch: 'branch-d', }); expect(res).toEqual({ bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, createdAt: undefined, number: 1, pullRequestId: 1, sourceBranch: 'branch-a', sourceRefName: 'refs/heads/branch-a', state: 'open', status: 1, targetBranch: 'branch-b', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', }); }); it('queries the exact branch when including other authors', async () => { await initRepo(); const getPullRequests = vi.fn().mockResolvedValue([ { pullRequestId: 1, sourceRefName: 'refs/heads/branch-a', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', status: 1, }, ]); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests, }), ); const res = await azure.findPr({ branchName: 'branch-a', state: 'open', targetBranch: 'branch-b', includeOtherAuthors: true, }); expect(res).toMatchObject({ number: 1, sourceBranch: 'branch-a', state: 'open', targetBranch: 'branch-b', }); expect(getPullRequests).toHaveBeenCalledExactlyOnceWith( '1', { sourceRefName: 'refs/heads/branch-a', sourceRepositoryId: '1', status: 1, targetRefName: 'refs/heads/branch-b', }, 'some', 0, 0, 1, ); }); it('returns null when no PR from another author matches', async () => { await initRepo(); const getPullRequests = vi.fn().mockResolvedValue([]); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests, }), ); const res = await azure.findPr({ branchName: 'branch-a', state: 'open', includeOtherAuthors: true, }); expect(res).toBeNull(); expect(getPullRequests).toHaveBeenCalledExactlyOnceWith( '1', { sourceRefName: 'refs/heads/branch-a', sourceRepositoryId: '1', status: 1, }, 'some', 0, 0, 1, ); }); it('catches errors', async () => { azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests: vi.fn().mockRejectedValueOnce(new Error()), }), ); const res = await azure.findPr({ branchName: 'branch-a', prTitle: 'branch a pr', }); expect(res).toBeNull(); }); }); describe('getPrList()', () => { it('filters PRs by repository and authenticated user', async () => { await initRepo(); const getPullRequests = vi.fn().mockResolvedValue([]); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests, }), ); await expect(azure.getPrList()).resolves.toEqual([]); expect(azureApi.getAuthenticatedUserId).toHaveBeenCalledExactlyOnceWith({ token: 'token', }); expect(azureApi.gitApi).toHaveBeenLastCalledWith(); expect(getPullRequests).toHaveBeenCalledExactlyOnceWith( '1', { creatorId: 'renovate-user-id', sourceRepositoryId: '1', status: 4, }, 'some', 0, 0, 100, ); }); it('does not filter by authenticated user when ignorePrAuthor is enabled', async () => { GlobalConfig.set({ ignorePrAuthor: true }); await initRepo(); const getPullRequests = vi.fn().mockResolvedValue([]); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests, }), ); await expect(azure.getPrList()).resolves.toEqual([]); expect(getPullRequests).toHaveBeenCalledExactlyOnceWith( '1', { sourceRepositoryId: '1', status: 4, }, 'some', 0, 0, 100, ); }); it('does not filter by authenticated user when the ID is unavailable', async () => { azureApi.getAuthenticatedUserId.mockResolvedValueOnce(undefined); await azure.initPlatform({ endpoint: 'https://dev.azure.com/renovate12345', token: 'token', }); await initRepo(); const getPullRequests = vi.fn().mockResolvedValue([]); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests, }), ); await expect(azure.getPrList()).resolves.toEqual([]); expect(getPullRequests).toHaveBeenCalledExactlyOnceWith( '1', { sourceRepositoryId: '1', status: 4, }, 'some', 0, 0, 100, ); }); it('reuses the cached PR list', async () => { await initRepo(); const getPullRequests = vi.fn().mockResolvedValue([]); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests }), ); await azure.getPrList(); await azure.getPrList(); expect(getPullRequests).toHaveBeenCalledOnce(); }); }); describe('getBranchPr(branchName, targetBranch)', () => { it('should return null if no PR exists', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValue( partial({ getPullRequests: vi.fn().mockResolvedValueOnce([]), }), ); const pr = await azure.getBranchPr('somebranch'); expect(pr).toBeNull(); }); it('should return the pr', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValue( partial({ getPullRequests: vi .fn() .mockResolvedValueOnce([ { pullRequestId: 1, sourceRefName: 'refs/heads/branch-a', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', status: 1, }, ]) .mockResolvedValueOnce([]), getPullRequestLabels: vi.fn().mockResolvedValue([]), }), ); const pr = await azure.getBranchPr('branch-a', 'branch-b'); expect(pr).toEqual({ bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, createdAt: undefined, labels: [], number: 1, pullRequestId: 1, sourceBranch: 'branch-a', sourceRefName: 'refs/heads/branch-a', state: 'open', status: 1, targetBranch: 'branch-b', targetRefName: 'refs/heads/branch-b', title: 'branch a pr', }); }); }); describe('getBranchStatusCheck(branchName, context)', () => { it('should return green if status is succeeded', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi.fn().mockResolvedValue([ { state: GitStatusState.Succeeded, context: { genre: 'a-genre', name: 'a-name' }, }, ]), }), ); const res = await azure.getBranchStatusCheck( 'somebranch', 'a-genre/a-name', ); expect(res).toBe('green'); }); it('should return green if status is not applicable', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi.fn().mockResolvedValue([ { state: GitStatusState.NotApplicable, context: { genre: 'a-genre', name: 'a-name' }, }, ]), }), ); const res = await azure.getBranchStatusCheck( 'somebranch', 'a-genre/a-name', ); expect(res).toBe('green'); }); it('should return red if status is failed', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi.fn().mockResolvedValue([ { state: GitStatusState.Failed, context: { genre: 'a-genre', name: 'a-name' }, }, ]), }), ); const res = await azure.getBranchStatusCheck( 'somebranch', 'a-genre/a-name', ); expect(res).toBe('red'); }); it('should return red if context status is error', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi.fn().mockResolvedValue([ { state: GitStatusState.Error, context: { genre: 'a-genre', name: 'a-name' }, }, ]), }), ); const res = await azure.getBranchStatusCheck( 'somebranch', 'a-genre/a-name', ); expect(res).toBe('red'); }); it('should return yellow if status is pending', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi.fn().mockResolvedValue([ { state: GitStatusState.Pending, context: { genre: 'a-genre', name: 'a-name' }, }, ]), }), ); const res = await azure.getBranchStatusCheck( 'somebranch', 'a-genre/a-name', ); expect(res).toBe('yellow'); }); it('should return yellow if status is not set', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi.fn().mockResolvedValue([ { state: GitStatusState.NotSet, context: { genre: 'a-genre', name: 'a-name' }, }, ]), }), ); const res = await azure.getBranchStatusCheck( 'somebranch', 'a-genre/a-name', ); expect(res).toBe('yellow'); }); it('should return yellow if status is unknown', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi.fn().mockResolvedValue([ { state: -1, context: { genre: 'a-genre', name: 'a-name' }, }, ]), }), ); const res = await azure.getBranchStatusCheck( 'somebranch', 'a-genre/a-name', ); expect(res).toBe('yellow'); }); it('should return null if status not found', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi.fn().mockResolvedValue([ { state: GitStatusState.Pending, context: { genre: 'another-genre', name: 'a-name' }, }, ]), }), ); const res = await azure.getBranchStatusCheck( 'somebranch', 'a-genre/a-name', ); expect(res).toBeNull(); }); }); describe('getBranchStatus(branchName, ignoreTests)', () => { it('should pass through success', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi.fn().mockResolvedValue([ { state: GitStatusState.Succeeded, context: { genre: 'renovate' }, }, ]), }), ); const res = await azure.getBranchStatus('somebranch', true); expect(res).toBe('green'); }); it('should not treat internal checks as success', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi.fn().mockResolvedValue([ { state: GitStatusState.Succeeded, context: { genre: 'renovate' }, }, ]), }), ); const res = await azure.getBranchStatus('somebranch', false); expect(res).toBe('yellow'); }); it('should pass through failed', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi .fn() .mockResolvedValue([{ state: GitStatusState.Error }]), }), ); const res = await azure.getBranchStatus('somebranch', true); expect(res).toBe('red'); }); it('should pass through pending', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi .fn() .mockResolvedValue([{ state: GitStatusState.Pending }]), }), ); const res = await azure.getBranchStatus('somebranch', true); expect(res).toBe('yellow'); }); it('should fall back to yellow if no statuses returned', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), getStatuses: vi.fn().mockResolvedValue([]), }), ); const res = await azure.getBranchStatus('somebranch', true); expect(res).toBe('yellow'); }); }); describe('getPr(prNo)', () => { it('should return null if no prNo is passed', async () => { const pr = await azure.getPr(0); expect(pr).toBeNull(); }); it('should return null if no PR is returned from azure', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequests: vi.fn().mockResolvedValue([]), }), ); const pr = await azure.getPr(1234); expect(pr).toBeNull(); }); it('should return a pr in the right format', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValue( partial({ getPullRequests: vi .fn() .mockReturnValue([]) .mockReturnValueOnce([ { pullRequestId: 1234, }, ]), getPullRequestLabels: vi .fn() .mockReturnValue([{ active: true, name: 'renovate' }]), getPullRequestCommits: vi.fn().mockReturnValue([ { author: { name: 'renovate', }, }, ]), }), ); const pr = await azure.getPr(1234); expect(pr).toEqual({ bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, labels: ['renovate'], number: 1234, pullRequestId: 1234, state: 'open', }); }); }); describe('createPr()', () => { it('should create and return a PR object', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ createPullRequest: vi.fn().mockResolvedValue({ pullRequestId: 456, }), createPullRequestLabel: vi.fn().mockResolvedValue({}), }), ); const pr = await azure.createPr({ sourceBranch: 'some-branch', targetBranch: 'master', prTitle: 'The Title', prBody: 'Hello world', labels: ['deps', 'renovate'], }); expect(pr).toEqual({ bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, number: 456, pullRequestId: 456, state: 'open', }); }); it('should create and return a PR object from base branch', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ createPullRequest: vi.fn().mockResolvedValue({ pullRequestId: 456, }), createPullRequestLabel: vi.fn().mockResolvedValue({}), }), ); const pr = await azure.createPr({ sourceBranch: 'some-branch', targetBranch: 'master', prTitle: 'The Title', prBody: 'Hello world', labels: ['deps', 'renovate'], }); expect(pr).toEqual({ bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, number: 456, pullRequestId: 456, state: 'open', }); }); describe('when usePlatformAutomerge is set', () => { it('should create and return a PR object with auto-complete set', async () => { await initRepo({ repository: 'some/repo' }); const prResult = { pullRequestId: 456, title: 'The Title', createdBy: { id: '123', }, }; const prUpdateResult = { ...prResult, autoCompleteSetBy: { id: prResult.createdBy.id, }, completionOptions: { mergeStrategy: GitPullRequestMergeStrategy.Squash, deleteSourceBranch: true, mergeCommitMessage: 'The Title', }, }; const updateFn = vi.fn().mockResolvedValue(prUpdateResult); azureApi.gitApi.mockResolvedValueOnce( partial({ createPullRequest: vi.fn().mockResolvedValue(prResult), createPullRequestLabel: vi.fn().mockResolvedValue({}), updatePullRequest: updateFn, }), ); const pr = await azure.createPr({ sourceBranch: 'some-branch', targetBranch: 'dev', prTitle: 'The Title', prBody: 'Hello world', labels: ['deps', 'renovate'], platformPrOptions: { usePlatformAutomerge: true }, }); expect(updateFn).toHaveBeenCalled(); expect(pr).toEqual({ autoCompleteSetBy: { id: '123', }, bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, completionOptions: { deleteSourceBranch: true, mergeCommitMessage: 'The Title', mergeStrategy: GitPullRequestMergeStrategy.Squash, }, createdBy: { id: '123', }, number: 456, pullRequestId: 456, state: 'open', title: 'The Title', }); }); it('should only call getMergeMethod once per run when automergeStrategy is auto', async () => { await initRepo({ repository: 'some/repo' }); const prResult = [ { pullRequestId: 456, title: 'The Title', createdBy: { id: '123', }, }, { pullRequestId: 457, title: 'The Second Title', createdBy: { id: '123', }, }, ]; const prUpdateResults = [ { ...prResult[0], autoCompleteSetBy: { id: prResult[0].createdBy.id, }, completionOptions: { mergeStrategy: GitPullRequestMergeStrategy.Squash, deleteSourceBranch: true, mergeCommitMessage: 'The Title', }, }, { ...prResult[1], autoCompleteSetBy: { id: prResult[1].createdBy.id, }, completionOptions: { mergeStrategy: GitPullRequestMergeStrategy.Squash, deleteSourceBranch: true, mergeCommitMessage: 'The Second Title', }, }, ]; const updateFn = vi.fn(() => Promise.resolve(prUpdateResults.shift()!)); azureHelper.getMergeMethod.mockResolvedValueOnce( GitPullRequestMergeStrategy.Squash, ); azureApi.gitApi.mockResolvedValue( partial({ createPullRequest: vi.fn(() => Promise.resolve(prResult.shift()!)), createPullRequestLabel: vi.fn().mockResolvedValue({}), updatePullRequest: updateFn, }), ); await azure.createPr({ sourceBranch: 'some-branch', targetBranch: 'dev', prTitle: 'The Title', prBody: 'Hello world', labels: ['deps', 'renovate'], platformPrOptions: { automergeStrategy: 'auto', usePlatformAutomerge: true, }, }); await azure.createPr({ sourceBranch: 'some-other-branch', targetBranch: 'dev', prTitle: 'The Second Title', prBody: 'Hello world', labels: ['deps', 'renovate'], platformPrOptions: { automergeStrategy: 'auto', usePlatformAutomerge: true, }, }); expect(updateFn).toHaveBeenCalledTimes(2); expect(azureHelper.getMergeMethod).toHaveBeenCalledTimes(1); }); it.each` automergeStrategy ${'fast-forward'} ${'merge-commit'} ${'rebase'} ${'rebase-merge'} ${'squash'} `( 'should not call getMergeMethod when automergeStrategy is $automergeStrategy', async (automergeStrategy) => { await initRepo({ repository: 'some/repo' }); const prResult = { pullRequestId: 123, title: 'The Title', createdBy: { id: '123', }, }; const prUpdateResults = { ...prResult, autoCompleteSetBy: { id: prResult.createdBy.id, }, completionOptions: { mergeStrategy: GitPullRequestMergeStrategy.Squash, deleteSourceBranch: true, mergeCommitMessage: 'The Title', }, }; const updateFn = vi.fn(() => Promise.resolve(prUpdateResults)); azureApi.gitApi.mockResolvedValue( partial({ createPullRequest: vi.fn(() => Promise.resolve(prResult)), createPullRequestLabel: vi.fn().mockResolvedValue({}), updatePullRequest: updateFn, }), ); await azure.createPr({ sourceBranch: 'some-branch', targetBranch: 'dev', prTitle: 'The Title', prBody: 'Hello world', labels: ['deps', 'renovate'], platformPrOptions: { automergeStrategy, usePlatformAutomerge: true, }, }); expect(azureHelper.getMergeMethod).toHaveBeenCalledTimes(0); }, ); it.each` automergeStrategy | prMergeStrategy ${'fast-forward'} | ${GitPullRequestMergeStrategy.Rebase} ${'merge-commit'} | ${GitPullRequestMergeStrategy.NoFastForward} ${'rebase'} | ${GitPullRequestMergeStrategy.Rebase} ${'rebase-merge'} | ${GitPullRequestMergeStrategy.RebaseMerge} ${'squash'} | ${GitPullRequestMergeStrategy.Squash} `( 'should create PR with mergeStrategy $prMergeStrategy', async ({ automergeStrategy, prMergeStrategy }) => { await initRepo({ repository: 'some/repo' }); const prResult = { pullRequestId: 456, title: 'The Title', createdBy: { id: '123', }, }; const prUpdateResult = { ...prResult, autoCompleteSetBy: { id: prResult.createdBy.id, }, completionOptions: { mergeStrategy: prMergeStrategy, deleteSourceBranch: true, mergeCommitMessage: 'The Title', }, }; const updateFn = vi.fn().mockResolvedValue(prUpdateResult); azureApi.gitApi.mockResolvedValueOnce( partial({ createPullRequest: vi.fn().mockResolvedValue(prResult), createPullRequestLabel: vi.fn().mockResolvedValue({}), updatePullRequest: updateFn, }), ); const pr = await azure.createPr({ sourceBranch: 'some-branch', targetBranch: 'dev', prTitle: 'The Title', prBody: 'Hello world', labels: ['deps', 'renovate'], platformPrOptions: { automergeStrategy, usePlatformAutomerge: true, }, }); expect((pr as GitPullRequest).completionOptions?.mergeStrategy).toBe( prMergeStrategy, ); expect(updateFn).toHaveBeenCalled(); expect( updateFn.mock.calls[0][0].completionOptions.mergeStrategy, ).toBe(prMergeStrategy); expect(azureHelper.getMergeMethod).toHaveBeenCalledTimes(0); }, ); }); it('should create and return an approved PR object', async () => { await initRepo({ repository: 'some/repo' }); const prResult = { pullRequestId: 456, createdBy: { id: 123, url: 'user-url', }, }; const prUpdateResult = { reviewerUrl: prResult.createdBy.url, vote: AzurePrVote.Approved, isFlagged: false, isRequired: false, }; const updateFn = vi .fn() .mockResolvedValue(prUpdateResult) .mockName('createPullRequestReviewer'); azureApi.gitApi.mockResolvedValueOnce( partial({ createPullRequest: vi.fn().mockResolvedValue(prResult), createPullRequestLabel: vi.fn().mockResolvedValue({}), createPullRequestReviewer: updateFn, }), ); const pr = await azure.createPr({ sourceBranch: 'some-branch', targetBranch: 'dev', prTitle: 'The Title', prBody: 'Hello world', labels: ['deps', 'renovate'], platformPrOptions: { autoApprove: true }, }); expect(updateFn).toHaveBeenCalled(); expect(pr).toEqual({ bodyStruct: { hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', }, createdBy: { id: 123, url: 'user-url', }, number: 456, pullRequestId: 456, state: 'open', }); }); }); describe('updatePr(prNo, title, body, platformPrOptions)', () => { it('should update the PR', async () => { await initRepo({ repository: 'some/repo' }); const updatePullRequest = vi.fn().mockResolvedValue({}); azureApi.gitApi.mockResolvedValueOnce( partial({ updatePullRequest, }), ); await azure.updatePr({ number: 1234, prTitle: 'The New Title', prBody: 'Hello world again', targetBranch: 'new_base', }); expect(updatePullRequest).toHaveBeenCalledTimes(1); expect(updatePullRequest).toHaveBeenCalledWith( { description: 'Hello world again', targetRefName: 'refs/heads/new_base', title: 'The New Title', }, '1', 1234, ); }); it('should update the PR including cache', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValue( partial({ createPullRequest: vi.fn().mockResolvedValue({ pullRequestId: 456, title: 'Title 1', }), createPullRequestLabel: vi.fn().mockResolvedValue({}), getPullRequests: vi.fn().mockResolvedValue([]), updatePullRequest: vi.fn().mockResolvedValue({ pullRequestId: 456, title: 'Title 2', }), }), ); await expect(azure.getPrList()).resolves.toEqual([]); const createdPr = await azure.createPr({ sourceBranch: 'some-branch', targetBranch: 'master', prTitle: 'Title 1', prBody: 'Hello world', labels: [], }); expect(createdPr).toMatchObject({ number: 456, title: 'Title 1' }); await expect(azure.getPrList()).resolves.toHaveLength(1); await azure.updatePr({ number: 456, prTitle: 'Title 2', }); const prList = await azure.getPrList(); expect(prList).toHaveLength(1); expect(prList[0]).toMatchObject({ number: 456, title: 'Title 2' }); }); it('should update the PR without description', async () => { await initRepo({ repository: 'some/repo' }); const updatePullRequest = vi.fn().mockResolvedValue({}); azureApi.gitApi.mockResolvedValueOnce( partial({ updatePullRequest, }), ); await azure.updatePr({ number: 1234, prTitle: 'The New Title - autoclose', }); expect(updatePullRequest).toHaveBeenCalledTimes(1); expect(updatePullRequest).toHaveBeenCalledWith( { title: 'The New Title - autoclose', }, '1', 1234, ); }); it('should close the PR', async () => { await initRepo({ repository: 'some/repo' }); const updatePullRequest = vi.fn().mockResolvedValue({}); azureApi.gitApi.mockResolvedValueOnce( partial({ updatePullRequest, }), ); await azure.updatePr({ number: 1234, prTitle: 'The New Title', prBody: 'Hello world again', state: 'closed', }); expect(updatePullRequest).toHaveBeenCalledTimes(1); expect(updatePullRequest).toHaveBeenCalledWith( { description: 'Hello world again', status: PullRequestStatus.Abandoned, title: 'The New Title', }, '1', 1234, ); }); it('should reopen the PR', async () => { await initRepo({ repository: 'some/repo' }); const updatePullRequest = vi.fn().mockResolvedValue({}); azureApi.gitApi.mockResolvedValueOnce( partial({ updatePullRequest, }), ); await azure.updatePr({ number: 1234, prTitle: 'The New Title', prBody: 'Hello world again', state: 'open', }); expect(updatePullRequest.mock.calls).toEqual([ [ { status: PullRequestStatus.Active, }, '1', 1234, ], [ { description: 'Hello world again', title: 'The New Title', }, '1', 1234, ], ]); }); it('should re-approve the PR', async () => { await initRepo({ repository: 'some/repo' }); const prResult = { pullRequestId: 456, createdBy: { id: 123, url: 'user-url', }, }; const prUpdateResult = { reviewerUrl: prResult.createdBy.url, vote: AzurePrVote.Approved, isFlagged: false, isRequired: false, }; const updateFn = vi.fn().mockResolvedValue(prUpdateResult); azureApi.gitApi.mockResolvedValueOnce( partial({ updatePullRequest: vi.fn().mockResolvedValue(prResult), createPullRequestReviewer: updateFn, getPullRequestById: vi.fn().mockResolvedValue({ pullRequestId: prResult.pullRequestId, createdBy: prResult.createdBy, }), }), ); const pr = await azure.updatePr({ number: prResult.pullRequestId, prTitle: 'The Title', prBody: 'Hello world', platformPrOptions: { autoApprove: true }, }); expect(updateFn).toHaveBeenCalled(); expect(pr).toBeUndefined(); }); }); describe('ensureComment', () => { it('adds comment if missing', async () => { await initRepo({ repository: 'some/repo' }); const gitApiMock = { createThread: vi.fn(() => [{ id: 123 }]), getThreads: vi.fn().mockReturnValue([ { comments: [{ content: 'end-user comment', id: 1 }], id: 2, }, ]), updateComment: vi.fn(() => ({ id: 123 })), }; azureApi.gitApi.mockImplementation(() => gitApiMock as any); await azure.ensureComment({ number: 42, topic: 'some-subject', content: 'some\ncontent', }); expect(gitApiMock.createThread).toHaveBeenCalledTimes(1); expect(gitApiMock.createThread).toHaveBeenCalledWith( { comments: [ { commentType: 1, content: '### some-subject\n\nsome\ncontent', parentCommentId: 0, }, ], status: 1, }, '1', 42, ); expect(gitApiMock.updateComment).not.toHaveBeenCalled(); }); it('updates comment if missing', async () => { await initRepo({ repository: 'some/repo' }); const gitApiMock = { createThread: vi.fn(() => [{ id: 123 }]), getThreads: vi.fn().mockReturnValue([ { comments: [{ content: 'end-user comment', id: 1 }], id: 3, }, { comments: [{ content: '### some-subject\n\nsome\ncontent', id: 2 }], id: 4, }, ]), updateComment: vi.fn(() => ({ id: 123 })), }; azureApi.gitApi.mockImplementation(() => gitApiMock as any); await azure.ensureComment({ number: 42, topic: 'some-subject', content: 'some\nnew\ncontent', }); expect(gitApiMock.createThread).not.toHaveBeenCalled(); expect(gitApiMock.updateComment).toHaveBeenCalledTimes(1); expect(gitApiMock.updateComment).toHaveBeenCalledWith( { content: '### some-subject\n\nsome\nnew\ncontent', }, '1', 42, 4, 2, ); }); it('does nothing if comment exists and is the same', async () => { await initRepo({ repository: 'some/repo' }); const gitApiMock = { createThread: vi.fn(() => [{ id: 123 }]), getThreads: vi.fn().mockReturnValue([ { comments: [{ content: 'end-user comment', id: 1 }], id: 3, }, { comments: [{ content: '### some-subject\n\nsome\ncontent', id: 2 }], id: 4, }, ]), updateComment: vi.fn(() => ({ id: 123 })), }; azureApi.gitApi.mockImplementation(() => gitApiMock as any); await azure.ensureComment({ number: 42, topic: 'some-subject', content: 'some\ncontent', }); expect(gitApiMock.createThread).not.toHaveBeenCalled(); expect(gitApiMock.updateComment).not.toHaveBeenCalled(); }); it('does nothing if comment exists and is the same when there is no topic', async () => { await initRepo({ repository: 'some/repo' }); const gitApiMock = { createThread: vi.fn(() => [{ id: 123 }]), getThreads: vi.fn().mockReturnValue([ { comments: [{ content: 'some\ncontent', id: 2 }], id: 4, }, ]), updateComment: vi.fn(() => ({ id: 123 })), }; azureApi.gitApi.mockImplementation(() => gitApiMock as any); await azure.ensureComment({ number: 42, topic: null, content: 'some\ncontent', }); expect(gitApiMock.createThread).not.toHaveBeenCalled(); expect(gitApiMock.updateComment).not.toHaveBeenCalled(); }); it('passes comment through massageMarkdown', async () => { await initRepo({ repository: 'some/repo' }); const gitApiMock = { createThread: vi.fn().mockResolvedValue([{ id: 123 }]), getThreads: vi.fn().mockResolvedValue([ { comments: [{ content: 'end-user comment', id: 1 }], id: 2, }, ]), updateComment: vi.fn().mockResolvedValue({ id: 123 }), }; azureApi.gitApi.mockResolvedValue(partial(gitApiMock)); await azure.ensureComment({ number: 42, topic: 'some-subject', content: 'You can manually request rebase by checking the rebase/retry box above.', }); const commentContent = gitApiMock.createThread.mock.calls[0]; expect(JSON.stringify(commentContent)).not.toContain( 'checking the rebase/retry box above', ); expect(JSON.stringify(commentContent)).toContain( 'renaming the PR to start with \\"rebase!\\"', ); }); }); describe('ensureCommentRemoval', () => { // TODO: fix types #22198 let gitApiMock: any; beforeEach(() => { gitApiMock = { getThreads: vi.fn(() => [ { comments: [{ content: '### some-subject\n\nblabla' }], id: 123, }, { comments: [{ content: 'some-content\n' }], id: 124, }, ]), updateThread: vi.fn(), }; azureApi.gitApi.mockImplementation(() => gitApiMock); }); it('deletes comment by topic if found', async () => { await initRepo({ repository: 'some/repo' }); await azure.ensureCommentRemoval({ type: 'by-topic', number: 42, topic: 'some-subject', }); expect(gitApiMock.getThreads).toHaveBeenCalledExactlyOnceWith('1', 42); expect(gitApiMock.updateThread).toHaveBeenCalledExactlyOnceWith( { status: 4 }, '1', 42, 123, ); }); it('deletes comment by content if found', async () => { await initRepo({ repository: 'some/repo' }); await azure.ensureCommentRemoval({ type: 'by-content', number: 42, content: 'some-content', }); expect(gitApiMock.getThreads).toHaveBeenCalledExactlyOnceWith('1', 42); expect(gitApiMock.updateThread).toHaveBeenCalledExactlyOnceWith( { status: 4 }, '1', 42, 124, ); }); it('comment not found', async () => { await initRepo({ repository: 'some/repo' }); await azure.ensureCommentRemoval({ type: 'by-topic', number: 42, topic: 'does-not-exist', }); expect(gitApiMock.getThreads).toHaveBeenCalledExactlyOnceWith('1', 42); expect(gitApiMock.updateThread).not.toHaveBeenCalled(); }); }); describe('Assignees', () => { it('addAssignees', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValue( partial({ getRepositories: vi .fn() .mockResolvedValue([{ id: '1', project: { id: 2 } }]), createThread: vi.fn().mockResolvedValue([{ id: 123 }]), getThreads: vi.fn().mockResolvedValue([]), }), ); azureApi.coreApi.mockResolvedValue( partial({ getTeamMembersWithExtendedProperties: vi .fn() .mockResolvedValue([ { identity: { displayName: 'jyc', uniqueName: 'jyc', id: 123 } }, ]), }), ); azureHelper.getAllProjectTeams = vi.fn().mockReturnValue([ { id: 3, name: 'abc' }, { id: 4, name: 'def' }, ]); await azure.addAssignees(123, ['test@bonjour.fr', 'jyc', 'def']); expect(azureApi.gitApi).toHaveBeenCalledTimes(3); }); }); describe('Reviewers', () => { it('addReviewers one valid', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValue( partial({ getRepositories: vi .fn() .mockResolvedValue([{ id: '1', project: { id: 2 } }]), createPullRequestReviewer: vi.fn(), }), ); azureApi.coreApi.mockResolvedValue( partial({ getTeamMembersWithExtendedProperties: vi .fn() .mockResolvedValue([ { identity: { displayName: 'jyc', uniqueName: 'jyc', id: 123 } }, ]), }), ); azureHelper.getAllProjectTeams = vi.fn().mockReturnValue([ { id: 3, name: 'abc' }, { id: 4, name: 'def' }, ]); await azure.addReviewers(123, ['test@bonjour.fr', 'jyc', 'required:def']); expect(azureApi.gitApi).toHaveBeenCalledTimes(3); expect(logger.once.info).toHaveBeenCalledTimes(1); }); it('addReviewers all valid', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValue( partial({ getRepositories: vi .fn() .mockResolvedValue([{ id: '1', project: { id: 2 } }]), createPullRequestReviewer: vi.fn(), }), ); azureApi.coreApi.mockResolvedValue( partial({ getTeamMembersWithExtendedProperties: vi .fn() .mockResolvedValue([ { identity: { displayName: 'jyc', uniqueName: 'jyc', id: 123 } }, ]), }), ); azureHelper.getAllProjectTeams = vi.fn().mockReturnValue([ { id: 3, name: 'abc' }, { id: 4, name: 'def' }, ]); await azure.addReviewers(123, ['required:jyc']); expect(azureApi.gitApi).toHaveBeenCalledTimes(3); expect(logger.once.info).toHaveBeenCalledTimes(0); }); }); describe('massageMarkdown(input)', () => { it('returns updated pr body', () => { const prBody = '\n---\n\n - [ ] rebase\n' + 'plus also [a link](https://github.com/foo/bar/issues/5)'; expect(azure.massageMarkdown(prBody)).toBe( 'plus also [a link](https://github.com/foo/bar/issues/5)', ); }); it('returns updated comment content', () => { const commentContent = 'You can manually request rebase by checking the rebase/retry box above.\n\n' + 'plus also [a link](https://github.com/foo/bar/issues/5)'; expect(azure.massageMarkdown(commentContent)).toBe( 'You can manually request rebase by renaming the PR to start with "rebase!".\n\nplus also [a link](https://github.com/foo/bar/issues/5)', ); }); it('converts standalone PR references to Azure format', () => { expect(azure.massageMarkdown('see #123 and (#456)')).toBe( 'see !123 and (!456)', ); }); it('does not corrupt HTML entities or URL anchors', () => { const input = '[#​32124](https://github.com/org/repo/issues/32124) ' + '[`v4.78.0`](https://github.com/org/repo/blob/HEAD/CHANGELOG.md#4780-june-18-2026)'; expect(azure.massageMarkdown(input)).toBe(input); }); }); describe('setBranchStatus', () => { it('should build and call the create status api properly', async () => { await initRepo({ repository: 'some/repo' }); const createCommitStatusMock = vi.fn(); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), createCommitStatus: createCommitStatusMock, }), ); await azure.setBranchStatus({ branchName: 'test', context: 'test', description: 'test', state: 'yellow', url: 'test.com', }); expect(createCommitStatusMock).toHaveBeenCalledExactlyOnceWith( { context: { genre: undefined, name: 'test', }, description: 'test', state: GitStatusState.Pending, targetUrl: 'test.com', }, 'abcd1234', '1', ); }); it('should build and call the create status api properly with a complex context', async () => { await initRepo({ repository: 'some/repo' }); const createCommitStatusMock = vi.fn(); azureApi.gitApi.mockResolvedValueOnce( partial({ getBranch: vi .fn() .mockResolvedValue({ commit: { commitId: 'abcd1234' } }), createCommitStatus: createCommitStatusMock, }), ); await azure.setBranchStatus({ branchName: 'test', context: 'renovate/artifact/test', description: 'test', state: 'green', url: 'test.com', }); expect(createCommitStatusMock).toHaveBeenCalledExactlyOnceWith( { context: { genre: 'renovate/artifact', name: 'test', }, description: 'test', state: GitStatusState.Succeeded, targetUrl: 'test.com', }, 'abcd1234', '1', ); }); }); describe('mergePr', () => { it('should not complete the PR if there are pending blocking policy evaluations', async () => { await initRepo({ repository: 'some/repo' }); const pullRequestIdMock = 12345; const branchNameMock = 'test'; const updatePullRequestMock = vi.fn(); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequestById: vi.fn(), updatePullRequest: updatePullRequestMock, }), ); const pendingEvaluationMock = partial({ configuration: partial({ isBlocking: true, type: partial({ displayName: 'Minimum number of reviewers', }), }), status: PolicyEvaluationStatus.Running, }); azureHelper.getPolicyEvaluations.mockResolvedValueOnce([ pendingEvaluationMock, ]); const res = await azure.mergePr({ branchName: branchNameMock, id: pullRequestIdMock, strategy: 'auto', }); expect(logger.debug).toHaveBeenCalledWith( { pullRequestId: pullRequestIdMock, artifactId: `vstfs:///CodeReview/CodeReviewId/undefined/${pullRequestIdMock}`, policyEvaluations: [pendingEvaluationMock], }, 'Retrieved policy evaluations for PR', ); expect(logger.debug).toHaveBeenCalledWith( { pullRequestId: pullRequestIdMock, pendingPolicies: [ { name: 'Minimum number of reviewers', status: 'Running' }, ], }, 'Not completing PR because branch policies have not been satisfied yet', ); expect(updatePullRequestMock).not.toHaveBeenCalled(); expect(res).toBeFalse(); }); it('should complete the PR if blocking policies have been approved or are not applicable', async () => { await initRepo({ repository: 'some/repo' }); const pullRequestIdMock = 12345; const branchNameMock = 'test'; const lastMergeSourceCommitMock = { commitId: 'abcd1234' }; const updatePullRequestMock = vi.fn().mockResolvedValue({ status: 3, }); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequestById: vi.fn().mockResolvedValue({ lastMergeSourceCommit: lastMergeSourceCommitMock, targetRefName: 'refs/heads/ding', title: 'title', }), updatePullRequest: updatePullRequestMock, }), ); azureHelper.getMergeMethod = vi .fn() .mockReturnValue(GitPullRequestMergeStrategy.Squash); azureHelper.getPolicyEvaluations.mockResolvedValueOnce([ partial({ configuration: partial({ isBlocking: true }), status: PolicyEvaluationStatus.Approved, }), partial({ configuration: partial({ isBlocking: true }), status: PolicyEvaluationStatus.NotApplicable, }), partial({ configuration: partial({ isBlocking: false }), status: PolicyEvaluationStatus.Running, }), ]); const res = await azure.mergePr({ branchName: branchNameMock, id: pullRequestIdMock, strategy: 'auto', }); expect(updatePullRequestMock).toHaveBeenCalledOnce(); expect(res).toBeTrue(); }); it('should complete the PR', async () => { await initRepo({ repository: 'some/repo' }); const pullRequestIdMock = 12345; const branchNameMock = 'test'; const lastMergeSourceCommitMock = { commitId: 'abcd1234' }; const updatePullRequestMock = vi.fn().mockResolvedValue({ status: 3, }); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequestById: vi.fn().mockResolvedValue({ lastMergeSourceCommit: lastMergeSourceCommitMock, targetRefName: 'refs/heads/ding', title: 'title', }), updatePullRequest: updatePullRequestMock, }), ); azureHelper.getMergeMethod = vi .fn() .mockReturnValue(GitPullRequestMergeStrategy.Squash); const res = await azure.mergePr({ branchName: branchNameMock, id: pullRequestIdMock, strategy: 'auto', }); expect(updatePullRequestMock).toHaveBeenCalledExactlyOnceWith( { status: PullRequestStatus.Completed, lastMergeSourceCommit: lastMergeSourceCommitMock, completionOptions: { mergeStrategy: GitPullRequestMergeStrategy.Squash, deleteSourceBranch: true, mergeCommitMessage: 'title', }, }, '1', pullRequestIdMock, ); expect(res).toBeTrue(); }); it.each` automergeStrategy | prMergeStrategy ${'fast-forward'} | ${GitPullRequestMergeStrategy.Rebase} ${'merge-commit'} | ${GitPullRequestMergeStrategy.NoFastForward} ${'rebase'} | ${GitPullRequestMergeStrategy.Rebase} ${'rebase-merge'} | ${GitPullRequestMergeStrategy.RebaseMerge} ${'squash'} | ${GitPullRequestMergeStrategy.Squash} `( 'should complete PR with mergeStrategy $prMergeStrategy', async ({ automergeStrategy, prMergeStrategy }) => { await initRepo({ repository: 'some/repo' }); const pullRequestIdMock = 12345; const branchNameMock = 'test'; const lastMergeSourceCommitMock = { commitId: 'abcd1234' }; const updatePullRequestMock = vi.fn().mockResolvedValue({ status: 3, }); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequestById: vi.fn().mockResolvedValue({ lastMergeSourceCommit: lastMergeSourceCommitMock, targetRefName: 'refs/heads/ding', title: 'title', }), updatePullRequest: updatePullRequestMock, }), ); azureHelper.getMergeMethod = vi.fn().mockReturnValue(prMergeStrategy); const res = await azure.mergePr({ branchName: branchNameMock, id: pullRequestIdMock, strategy: automergeStrategy, }); expect(updatePullRequestMock).toHaveBeenCalledExactlyOnceWith( { status: PullRequestStatus.Completed, lastMergeSourceCommit: lastMergeSourceCommitMock, completionOptions: { mergeStrategy: prMergeStrategy, deleteSourceBranch: true, mergeCommitMessage: 'title', }, }, '1', pullRequestIdMock, ); expect(res).toBeTrue(); }, ); it('should return false if the PR does not update successfully', async () => { await initRepo({ repository: 'some/repo' }); const pullRequestIdMock = 12345; const branchNameMock = 'test'; const lastMergeSourceCommitMock = { commitId: 'abcd1234' }; azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequestById: vi.fn().mockResolvedValue({ lastMergeSourceCommit: lastMergeSourceCommitMock, }), updatePullRequest: vi .fn() .mockRejectedValue(new Error(`oh no pr couldn't be updated`)), }), ); azureHelper.getMergeMethod = vi .fn() .mockReturnValue(GitPullRequestMergeStrategy.Squash); const res = await azure.mergePr({ branchName: branchNameMock, id: pullRequestIdMock, }); expect(res).toBeFalse(); }); it('should cache the mergeMethod for subsequent merges', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValue( partial({ getPullRequestById: vi.fn().mockResolvedValue({ lastMergeSourceCommit: { commitId: 'abcd1234' }, targetRefName: 'refs/heads/ding', }), updatePullRequest: vi.fn(), }), ); azureHelper.getMergeMethod = vi .fn() .mockReturnValue(GitPullRequestMergeStrategy.Squash); await azure.mergePr({ branchName: 'test-branch-1', id: 1234, strategy: 'auto', }); await azure.mergePr({ branchName: 'test-branch-2', id: 5678, strategy: 'auto', }); expect(azureHelper.getMergeMethod).toHaveBeenCalledTimes(1); }); it('should refetch the PR if the update response has not yet been set to completed', async () => { await initRepo({ repository: 'some/repo' }); const pullRequestIdMock = 12345; const branchNameMock = 'test'; const lastMergeSourceCommitMock = { commitId: 'abcd1234' }; const getPullRequestByIdMock = vi.fn().mockResolvedValue({ lastMergeSourceCommit: lastMergeSourceCommitMock, targetRefName: 'refs/heads/ding', status: 3, }); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequestById: getPullRequestByIdMock, updatePullRequest: vi.fn().mockResolvedValue({ status: 1, }), }), ); azureHelper.getMergeMethod = vi .fn() .mockReturnValue(GitPullRequestMergeStrategy.Squash); const res = await azure.mergePr({ branchName: branchNameMock, id: pullRequestIdMock, }); expect(getPullRequestByIdMock).toHaveBeenCalledTimes(2); expect(res).toBeTrue(); }); it('should log a warning after retrying if the PR has still not yet been set to completed', async () => { await initRepo({ repository: 'some/repo' }); const pullRequestIdMock = 12345; const branchNameMock = 'test'; const lastMergeSourceCommitMock = { commitId: 'abcd1234' }; const expectedNumRetries = 5; const getPullRequestByIdMock = vi.fn().mockResolvedValue({ lastMergeSourceCommit: lastMergeSourceCommitMock, targetRefName: 'refs/heads/ding', status: 1, }); azureApi.gitApi.mockResolvedValueOnce( partial({ getPullRequestById: getPullRequestByIdMock, updatePullRequest: vi.fn().mockResolvedValue({ status: 1, }), }), ); azureHelper.getMergeMethod = vi .fn() .mockReturnValue(GitPullRequestMergeStrategy.Squash); const res = await azure.mergePr({ branchName: branchNameMock, id: pullRequestIdMock, }); expect(getPullRequestByIdMock).toHaveBeenCalledTimes( expectedNumRetries + 1, ); expect(logger.warn).toHaveBeenCalled(); expect(res).toBeTrue(); }); }); describe('deleteLabel()', () => { it('Should delete a label', async () => { await initRepo({ repository: 'some/repo' }); azureApi.gitApi.mockResolvedValueOnce( partial({ deletePullRequestLabels: vi.fn(), }), ); await azure.deleteLabel(1234, 'rebase'); expect(azureApi.gitApi.mock.calls).toEqual([[], []]); }); }); describe('getJsonFile()', () => { beforeEach(async () => { await initRepo(); }); it('returns file content', async () => { const data = { foo: 'bar' }; azureApi.gitApi.mockImplementationOnce( vi.fn().mockImplementationOnce(() => ({ getItem: vi.fn(() => Promise.resolve({ content: JSON.stringify(data) }), ), })), ); const res = await azure.getJsonFile('file.json'); expect(res).toEqual(data); }); it('returns null when file not found', async () => { azureApi.gitApi.mockImplementationOnce( vi.fn().mockImplementationOnce(() => ({ getItem: vi.fn(() => Promise.resolve(null)), })), ); const res = await azure.getJsonFile('file.json'); expect(res).toBeNull(); }); it('returns file content in json5 format', async () => { const json5Data = ` { // json5 comment foo: 'bar' } `; azureApi.gitApi.mockImplementationOnce( vi.fn().mockImplementationOnce(() => ({ getItem: vi.fn(() => Promise.resolve({ content: json5Data })), })), ); const res = await azure.getJsonFile('file.json5'); expect(res).toEqual({ foo: 'bar' }); }); it('returns file content from branch or tag', async () => { const data = { foo: 'bar' }; azureApi.gitApi.mockResolvedValueOnce( partial({ getItem: vi.fn(() => Promise.resolve({ content: JSON.stringify(data) }), ), }), ); const res = await azure.getJsonFile('file.json', undefined, 'dev'); expect(res).toEqual(data); }); it('throws on malformed JSON', async () => { azureApi.gitApi.mockResolvedValueOnce( partial({ getItemContent: vi.fn(() => Promise.resolve(Readable.from('!@#'))), }), ); await expect(azure.getJsonFile('file.json')).rejects.toThrow( 'azureApiGit.getItem is not a function', ); }); it('throws on errors', async () => { azureApi.gitApi.mockResolvedValueOnce( partial({ getItemContent: vi.fn(() => { throw new Error('some error'); }), }), ); await expect(azure.getJsonFile('file.json')).rejects.toThrow( 'azureApiGit.getItem is not a function', ); }); it('supports fetch from another repo', async () => { const data = { foo: 'bar' }; const getItemFn = vi .fn() .mockResolvedValueOnce({ content: JSON.stringify(data) }); azureApi.gitApi.mockResolvedValueOnce( partial({ getItem: getItemFn, getRepositories: vi .fn() .mockResolvedValue([ { id: '123456', name: 'bar', project: { name: 'foo' } }, ]), }), ); const res = await azure.getJsonFile('file.json', 'foo/bar'); expect(res).toEqual(data); expect(getItemFn.mock.calls).toEqual([ [ '123456', 'file.json', undefined, undefined, undefined, undefined, undefined, undefined, undefined, true, ], ]); }); it('returns null', async () => { azureApi.gitApi.mockResolvedValueOnce( partial({ getRepositories: vi.fn(() => Promise.resolve([])), }), ); const res = await azure.getJsonFile('file.json', 'foo/bar'); expect(res).toBeNull(); }); }); it('getRawFile should check tag first and then return branch if tag was not found', async () => { await initRepo({ repository: 'some/repo' }); const callArgs: any[] = []; const getItemMock = vi.fn( ( repositoryId: string, path: string, project?: string, scopePath?: string, recursionLevel?: any, includeContentMetadata?: boolean, latestProcessedChange?: boolean, download?: boolean, versionDescriptor?: GitVersionDescriptor, _includeContent?: boolean, _resolveLfs?: boolean, _sanitize?: boolean, ) => { callArgs.push(versionDescriptor?.versionType); if (versionDescriptor?.versionType === GitVersionType.Branch) { return Promise.resolve({ content: '{ "test": "branch content" }' }); } if (versionDescriptor?.versionType === GitVersionType.Tag) { return Promise.resolve(null); } throw new Error('Unexpected call'); }, ); azureApi.gitApi.mockResolvedValueOnce( partial({ getItem: getItemMock as IGitApi['getItem'], getRepositories: vi .fn() .mockResolvedValue([ { id: '123456', name: 'repo', project: { name: 'some' } }, ]), }), ); const result = await azure.getJsonFile( 'file.json', 'some/repo', 'some-branch', ); expect(result).toEqual({ test: 'branch content' }); expect(callArgs[0]).toBe(GitVersionType.Tag); expect(callArgs[1]).toBe(GitVersionType.Branch); }); it('findIssue returns null if not found', async () => { await initRepo({ repository: 'some/repo' }); azureApi.workItemTrackingApi.mockResolvedValueOnce( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [] }), }), ); const res = await azure.findIssue('Nonexistent'); expect(res).toBeNull(); }); it('findIssue return first issue if multiple found', async () => { await initRepo({ repository: 'some/repo' }); azureApi.workItemTrackingApi.mockResolvedValueOnce( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [{ id: 123 }, { id: 456 }], }), getWorkItems: vi.fn().mockResolvedValue([ { id: 123, fields: { 'System.Title': 'The title', 'System.WorkItemType': 'Issue', 'System.State': 'Active', 'System.Description': 'fake', }, }, { id: 456, fields: { 'System.Title': 'The title', 'System.WorkItemType': 'Issue', 'System.State': 'New', 'System.Description': 'another fake description', }, }, ]), }), ); const res = await azure.findIssue('The title'); expect(res).toMatchObject({ number: 123, title: 'The title', state: 'open', body: 'fake', }); }); it('ensureIssue creates a new issue if none existing found', async () => { await initRepo({ repository: 'some/repo' }); const createWorkItemMock = vi.fn().mockResolvedValue({ id: 123, }); azureApi.workItemTrackingApi.mockResolvedValueOnce( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [] }), createWorkItem: createWorkItemMock, getWorkItemTypes: vi.fn().mockResolvedValue([{ name: 'Issue' }]), }), ); const result = await azure.ensureIssue({ title: 'Test Issue', body: 'Test body', }); expect(result).toBe('created'); expect(createWorkItemMock).toHaveBeenCalledWith( undefined, // no headers expect.arrayContaining([ expect.objectContaining({ op: 'add', path: '/fields/System.Title', value: 'Test Issue', }), expect.objectContaining({ op: 'add', path: '/fields/System.Description', value: 'Test body', }), expect.objectContaining({ op: 'add', path: '/multilineFieldsFormat/System.Description', value: 'Markdown', }), ]), 'some', 'Issue', ); }); it('ensureIssue updates an existing issue', async () => { await initRepo({ repository: 'some/repo' }); const updateWorkItemMock = vi.fn(); const createWorkItemMock = vi.fn(); azureApi.workItemTrackingApi.mockResolvedValue( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [{ id: 123 }], }), getWorkItems: vi.fn().mockResolvedValue([ { id: 123, fields: { 'System.Title': 'Test Issue', 'System.WorkItemType': 'Issue', 'System.State': 'Active', 'System.Description': 'fake', }, }, ]), updateWorkItem: updateWorkItemMock, createWorkItem: createWorkItemMock, }), ); const result = await azure.ensureIssue({ title: 'Test Issue', body: 'Updated body', }); expect(result).toBe('updated'); expect(createWorkItemMock).not.toHaveBeenCalled(); expect(updateWorkItemMock).toHaveBeenCalledWith( undefined, // no headers expect.arrayContaining([ expect.objectContaining({ op: 'replace', path: '/fields/System.Title', value: 'Test Issue', }), expect.objectContaining({ op: 'replace', path: '/fields/System.Description', value: 'Updated body', }), expect.objectContaining({ op: 'replace', path: '/multilineFieldsFormat/System.Description', value: 'Markdown', }), ]), 123, 'some', ); }); it('ensureIssue do not touch issues when not needed', async () => { await initRepo({ repository: 'some/repo' }); const updateWorkItemMock = vi.fn(); const createWorkItemMock = vi.fn(); azureApi.workItemTrackingApi.mockResolvedValue( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [{ id: 123 }], }), getWorkItems: vi.fn().mockResolvedValue([ { id: 123, fields: { 'System.Title': 'Test Issue', 'System.WorkItemType': 'Issue', 'System.State': 'Active', 'System.Description': 'fake body', }, }, ]), updateWorkItem: updateWorkItemMock, createWorkItem: createWorkItemMock, }), ); const result = await azure.ensureIssue({ title: 'Test Issue', body: 'fake body', }); expect(result).toBe('updated'); expect(createWorkItemMock).not.toHaveBeenCalled(); expect(updateWorkItemMock).not.toHaveBeenCalled(); }); it('ensureIssue do not recreate issue when once == true', async () => { await initRepo({ repository: 'some/repo' }); const updateWorkItemMock = vi.fn(); const createWorkItemMock = vi.fn(); azureApi.workItemTrackingApi.mockResolvedValue( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [{ id: 123 }], }), getWorkItems: vi.fn().mockResolvedValue([ { id: 123, fields: { 'System.Title': 'Test Issue', 'System.WorkItemType': 'Issue', 'System.State': 'Closed', 'System.Description': 'fake body', }, }, ]), updateWorkItem: updateWorkItemMock, createWorkItem: createWorkItemMock, }), ); const result = await azure.ensureIssue({ title: 'Test Issue', body: 'Updated body', once: true, }); expect(result).toBeNull(); }); it('ensureIssue handles Azure API error', async () => { await initRepo({ repository: 'some/repo' }); const updateWorkItemMock = vi .fn() .mockRejectedValue(new Error('Test error')); const createWorkItemMock = vi.fn(); azureApi.workItemTrackingApi.mockResolvedValue( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [{ id: 123 }], }), getWorkItems: vi.fn().mockResolvedValue([ { id: 123, fields: { 'System.Title': 'Test Issue', 'System.WorkItemType': 'Issue', 'System.State': 'Closed', 'System.Description': 'fake body', }, }, ]), updateWorkItem: updateWorkItemMock, createWorkItem: createWorkItemMock, }), ); const result = await azure.ensureIssue({ title: 'Test Issue', body: 'Updated body', }); expect(result).toBeNull(); }); it('ensureIssue updates an existing issue and reopens it if needed', async () => { await initRepo({ repository: 'some/repo' }); const updateWorkItemMock = vi.fn(); const createWorkItemMock = vi.fn(); azureApi.workItemTrackingApi.mockResolvedValue( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [{ id: 123 }], }), getWorkItems: vi.fn().mockResolvedValue([ { id: 123, fields: { 'System.Title': 'Test Issue', 'System.WorkItemType': 'Issue', 'System.State': 'Closed', 'System.Description': 'fake', }, }, ]), updateWorkItem: updateWorkItemMock, createWorkItem: createWorkItemMock, }), ); await azure.ensureIssue({ title: 'Test Issue', body: 'Updated body', }); expect(createWorkItemMock).not.toHaveBeenCalled(); expect(updateWorkItemMock).toHaveBeenCalledWith( undefined, // no headers expect.arrayContaining([ expect.objectContaining({ op: 'replace', path: '/fields/System.Title', value: 'Test Issue', }), expect.objectContaining({ op: 'replace', path: '/fields/System.Description', value: 'Updated body', }), expect.objectContaining({ op: 'replace', path: '/fields/System.State', value: 'New', }), expect.objectContaining({ op: 'replace', path: '/multilineFieldsFormat/System.Description', value: 'Markdown', }), ]), 123, 'some', ); }); it('ensureIssue close duplicates and updates an existing issue', async () => { await initRepo({ repository: 'some/repo' }); const updateWorkItemMock = vi.fn(); const createWorkItemMock = vi.fn(); azureApi.workItemTrackingApi.mockResolvedValue( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [{ id: 123 }, { id: 456 }], }), getWorkItems: vi.fn().mockResolvedValue([ { id: 123, fields: { 'System.Title': 'Test Issue', 'System.WorkItemType': 'Issue', 'System.State': 'Active', 'System.Description': 'fake', }, }, { id: 456, fields: { 'System.Title': 'Test Issue', 'System.WorkItemType': 'Issue', 'System.State': 'New', 'System.Description': 'another fake', }, }, ]), updateWorkItem: updateWorkItemMock, createWorkItem: createWorkItemMock, }), ); await azure.ensureIssue({ title: 'Test Issue', body: 'Updated body', }); expect(createWorkItemMock).not.toHaveBeenCalled(); expect(updateWorkItemMock).toHaveBeenCalledWith( undefined, // no headers expect.arrayContaining([ expect.objectContaining({ op: 'replace', path: '/fields/System.Title', value: 'Test Issue', }), expect.objectContaining({ op: 'replace', path: '/fields/System.Description', value: 'Updated body', }), expect.objectContaining({ op: 'replace', path: '/multilineFieldsFormat/System.Description', value: 'Markdown', }), ]), 123, 'some', ); }); it('ensureIssueClosing closes an existing issue', async () => { await initRepo({ repository: 'some/repo' }); const updateWorkItemMock = vi.fn(); azureApi.workItemTrackingApi.mockResolvedValue( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [{ id: 123 }], }), getWorkItems: vi.fn().mockResolvedValue([ { id: 123, fields: { 'System.Title': 'Test Issue', 'System.WorkItemType': 'Issue', 'System.State': 'Active', 'System.Description': 'fake', }, }, ]), updateWorkItem: updateWorkItemMock, }), ); await azure.ensureIssueClosing('Test Issue'); expect(updateWorkItemMock).toHaveBeenCalledWith( undefined, // no headers expect.arrayContaining([ expect.objectContaining({ op: 'replace', path: '/fields/System.State', value: 'Closed', }), ]), 123, 'some', ); }); it('ensureIssueClosing handles Azure API error', async () => { await initRepo({ repository: 'some/repo' }); const updateWorkItemMock = vi .fn() .mockRejectedValue(new Error('Test error')); azureApi.workItemTrackingApi.mockResolvedValue( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [{ id: 123 }], }), getWorkItems: vi.fn().mockResolvedValue([ { id: 123, fields: { 'System.Title': 'Test Issue', 'System.WorkItemType': 'Issue', 'System.State': 'Active', 'System.Description': 'fake', }, }, ]), updateWorkItem: updateWorkItemMock, }), ); await azure.ensureIssueClosing('Test Issue'); expect(updateWorkItemMock).toHaveBeenCalledWith( undefined, // no headers expect.arrayContaining([ expect.objectContaining({ op: 'replace', path: '/fields/System.State', value: 'Closed', }), ]), 123, 'some', ); }); it('getIssueList handles empty wiql result', async () => { await initRepo({ repository: 'some/repo' }); azureApi.workItemTrackingApi.mockResolvedValue( partial({ queryByWiql: vi.fn().mockResolvedValue({ workItems: [], }), }), ); const result = await azure.getIssueList(); expect(result).toBeEmptyArray(); }); });