diff --git a/__tests__/link-work-item.test.js b/__tests__/link-work-item.test.js index 8146560..db88d48 100644 --- a/__tests__/link-work-item.test.js +++ b/__tests__/link-work-item.test.js @@ -16,19 +16,8 @@ const mockCore = { warning: mockWarning }; -// Mock azure-devops-node-api -const mockUpdateWorkItem = jest.fn(); -const mockGetWorkItem = jest.fn(); -const mockGetWorkItemTrackingApi = jest.fn(); -const mockWebApi = jest.fn(); -const mockGetPersonalAccessTokenHandler = jest.fn(); - // Setup module mocks jest.unstable_mockModule('@actions/core', () => mockCore); -jest.unstable_mockModule('azure-devops-node-api', () => ({ - WebApi: mockWebApi, - getPersonalAccessTokenHandler: mockGetPersonalAccessTokenHandler -})); describe('Azure DevOps Work Item Linker', () => { let originalEnv; @@ -41,27 +30,6 @@ describe('Azure DevOps Work Item Linker', () => { // Clear all mocks jest.clearAllMocks(); - mockSetFailed.mockClear(); - mockInfo.mockClear(); - mockError.mockClear(); - mockWarning.mockClear(); - mockUpdateWorkItem.mockClear(); - mockGetWorkItem.mockClear(); - mockGetWorkItemTrackingApi.mockClear(); - mockWebApi.mockClear(); - mockGetPersonalAccessTokenHandler.mockClear(); - - // Set up Azure DevOps API mocks - mockGetWorkItemTrackingApi.mockResolvedValue({ - updateWorkItem: mockUpdateWorkItem, - getWorkItem: mockGetWorkItem - }); - - mockWebApi.mockImplementation(() => ({ - getWorkItemTrackingApi: mockGetWorkItemTrackingApi - })); - - mockGetPersonalAccessTokenHandler.mockReturnValue({}); // Reset modules to ensure fresh imports jest.resetModules(); @@ -74,6 +42,19 @@ describe('Azure DevOps Work Item Linker', () => { jest.clearAllTimers(); }); + /** + * Helper to create a mock fetch response + */ + function mockFetchResponse(status, body) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)) + }); + } + describe('Basic functionality', () => { it('should export a run function', async () => { const mainModule = await import('../src/link-work-item.js'); @@ -82,8 +63,6 @@ describe('Azure DevOps Work Item Linker', () => { }); it('should handle already existing link gracefully', async () => { - // Set up environment variables - process.env.REPO_TOKEN = 'github-token'; process.env.AZURE_DEVOPS_ORG = 'test-org'; process.env.AZURE_DEVOPS_PAT = 'azdo-pat'; process.env.WORKITEMID = '12345'; @@ -93,37 +72,27 @@ describe('Azure DevOps Work Item Linker', () => { const internalRepoId = '12345678-1234-1234-1234-123456789abc'; - // Mock global fetch - global.fetch = jest.fn(() => { - return Promise.resolve({ - status: 200, - json: () => - Promise.resolve({ - data: { - 'ms.vss-work-web.github-link-data-provider': { - resolvedLinkItems: [ - { - repoInternalId: internalRepoId - } - ] - } + global.fetch = jest.fn(url => { + if (url.includes('dataProviders')) { + return mockFetchResponse(200, { + data: { + 'ms.vss-work-web.github-link-data-provider': { + resolvedLinkItems: [{ repoInternalId: internalRepoId }] } - }) - }); + } + }); + } + // PATCH work item — "already exists" error + return mockFetchResponse(409, 'The relation already exists'); }); - // Mock the work item API to return "already exists" error - mockUpdateWorkItem.mockRejectedValue(new Error('The relation already exists')); - const { run } = await import('../src/link-work-item.js'); await run(); - // Should not fail when link already exists expect(mockSetFailed).not.toHaveBeenCalled(); }); it('should send correct data provider request structure', async () => { - process.env.REPO_TOKEN = 'github-token'; process.env.AZURE_DEVOPS_ORG = 'test-org'; process.env.AZURE_DEVOPS_PAT = 'azdo-pat'; process.env.WORKITEMID = '12345'; @@ -132,43 +101,33 @@ describe('Azure DevOps Work Item Linker', () => { process.env.REPO = 'owner/repo'; const internalRepoId = '12345678-1234-1234-1234-123456789abc'; + let dataProviderBody; - let requestBody; - - // Mock global fetch global.fetch = jest.fn((url, options) => { - requestBody = JSON.parse(options.body); - return Promise.resolve({ - status: 200, - json: () => - Promise.resolve({ - data: { - 'ms.vss-work-web.github-link-data-provider': { - resolvedLinkItems: [ - { - repoInternalId: internalRepoId - } - ] - } + if (url.includes('dataProviders')) { + dataProviderBody = JSON.parse(options.body); + return mockFetchResponse(200, { + data: { + 'ms.vss-work-web.github-link-data-provider': { + resolvedLinkItems: [{ repoInternalId: internalRepoId }] } - }) - }); + } + }); + } + // PATCH work item — success + return mockFetchResponse(200, { id: 12345 }); }); - mockUpdateWorkItem.mockResolvedValue({ id: 12345 }); - const { run } = await import('../src/link-work-item.js'); await run(); - // Verify request body structure - expect(requestBody).toBeDefined(); - expect(requestBody.context.properties.workItemId).toBe('12345'); - expect(requestBody.context.properties.urls[0]).toBe('https://github.com/owner/repo/pull/42'); - expect(requestBody.contributionIds[0]).toBe('ms.vss-work-web.github-link-data-provider'); + expect(dataProviderBody).toBeDefined(); + expect(dataProviderBody.context.properties.workItemId).toBe('12345'); + expect(dataProviderBody.context.properties.urls[0]).toBe('https://github.com/owner/repo/pull/42'); + expect(dataProviderBody.contributionIds[0]).toBe('ms.vss-work-web.github-link-data-provider'); }); - it('should fail when Azure DevOps connection fails', async () => { - process.env.REPO_TOKEN = 'github-token'; + it('should use application/json-patch+json for work item PATCH', async () => { process.env.AZURE_DEVOPS_ORG = 'test-org'; process.env.AZURE_DEVOPS_PAT = 'azdo-pat'; process.env.WORKITEMID = '12345'; @@ -176,17 +135,31 @@ describe('Azure DevOps Work Item Linker', () => { process.env.PULLREQUESTID = '42'; process.env.REPO = 'owner/repo'; - // Mock Azure DevOps connection to fail - mockGetWorkItemTrackingApi.mockRejectedValue(new Error('Connection failed')); + const internalRepoId = '12345678-1234-1234-1234-123456789abc'; + let patchOptions; + + global.fetch = jest.fn((url, options) => { + if (url.includes('dataProviders')) { + return mockFetchResponse(200, { + data: { + 'ms.vss-work-web.github-link-data-provider': { + resolvedLinkItems: [{ repoInternalId: internalRepoId }] + } + } + }); + } + patchOptions = options; + return mockFetchResponse(200, { id: 12345 }); + }); const { run } = await import('../src/link-work-item.js'); await run(); - expect(mockSetFailed).toHaveBeenCalledWith('Failed connection to dev ops!'); + expect(patchOptions.method).toBe('PATCH'); + expect(patchOptions.headers['Content-Type']).toBe('application/json-patch+json'); }); it('should fail when internal repo ID cannot be resolved', async () => { - process.env.REPO_TOKEN = 'github-token'; process.env.AZURE_DEVOPS_ORG = 'test-org'; process.env.AZURE_DEVOPS_PAT = 'azdo-pat'; process.env.WORKITEMID = '12345'; @@ -194,22 +167,13 @@ describe('Azure DevOps Work Item Linker', () => { process.env.PULLREQUESTID = '42'; process.env.REPO = 'owner/repo'; - // Mock fetch to return empty internal repo ID global.fetch = jest.fn(() => { - return Promise.resolve({ - status: 200, - json: () => - Promise.resolve({ - data: { - 'ms.vss-work-web.github-link-data-provider': { - resolvedLinkItems: [ - { - repoInternalId: null - } - ] - } - } - }) + return mockFetchResponse(200, { + data: { + 'ms.vss-work-web.github-link-data-provider': { + resolvedLinkItems: [{ repoInternalId: null }] + } + } }); }); @@ -220,7 +184,6 @@ describe('Azure DevOps Work Item Linker', () => { }); it('should handle 401 authorization error', async () => { - process.env.REPO_TOKEN = 'github-token'; process.env.AZURE_DEVOPS_ORG = 'test-org'; process.env.AZURE_DEVOPS_PAT = 'invalid-pat'; process.env.WORKITEMID = '12345'; @@ -228,11 +191,13 @@ describe('Azure DevOps Work Item Linker', () => { process.env.PULLREQUESTID = '42'; process.env.REPO = 'owner/repo'; - // Mock fetch to return 401 global.fetch = jest.fn(() => { return Promise.resolve({ + ok: false, status: 401, - json: () => Promise.resolve({}) + statusText: 'Unauthorized', + json: () => Promise.resolve({}), + text: () => Promise.resolve('Unauthorized') }); }); @@ -245,49 +210,38 @@ describe('Azure DevOps Work Item Linker', () => { describe('validateWorkItemExists', () => { it('should return { exists: true } when work item exists', async () => { - // Mock getWorkItem to return a valid work item - mockGetWorkItem.mockResolvedValue({ - id: 12345, - fields: { - 'System.Title': 'Test work item' - } - }); + global.fetch = jest.fn(() => mockFetchResponse(200, { id: 12345, fields: { 'System.Title': 'Test work item' } })); const { validateWorkItemExists } = await import('../src/link-work-item.js'); const result = await validateWorkItemExists('test-org', 'azdo-token', '12345'); expect(result).toEqual({ exists: true }); - expect(mockGetWorkItem).toHaveBeenCalledWith(12345); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/_apis/wit/workitems/12345'), + expect.any(Object) + ); }); it('should return { exists: false } when work item does not exist (404)', async () => { - // Mock getWorkItem to throw a 404 error - const error = new Error('Work item not found'); - error.statusCode = 404; - mockGetWorkItem.mockRejectedValue(error); + global.fetch = jest.fn(() => mockFetchResponse(404, 'Work item not found')); const { validateWorkItemExists } = await import('../src/link-work-item.js'); const result = await validateWorkItemExists('test-org', 'azdo-token', '99999'); expect(result).toEqual({ exists: false }); - expect(mockGetWorkItem).toHaveBeenCalledWith(99999); }); it('should return { exists: false } when work item API call fails', async () => { - // Mock getWorkItem to throw a network error - mockGetWorkItem.mockRejectedValue(new Error('Network error')); + global.fetch = jest.fn(() => Promise.reject(new Error('Network error'))); const { validateWorkItemExists } = await import('../src/link-work-item.js'); const result = await validateWorkItemExists('test-org', 'azdo-token', '12345'); expect(result).toEqual({ exists: false }); - expect(mockGetWorkItem).toHaveBeenCalledWith(12345); }); it('should return authError when PAT has expired (status 401)', async () => { - const error = new Error('Unauthorized'); - error.statusCode = 401; - mockGetWorkItem.mockRejectedValue(error); + global.fetch = jest.fn(() => mockFetchResponse(401, 'Unauthorized')); const { validateWorkItemExists } = await import('../src/link-work-item.js'); const result = await validateWorkItemExists('test-org', 'azdo-token', '12345'); @@ -296,9 +250,7 @@ describe('Azure DevOps Work Item Linker', () => { }); it('should return authError when PAT has expired (status 403)', async () => { - const error = new Error('Forbidden'); - error.statusCode = 403; - mockGetWorkItem.mockRejectedValue(error); + global.fetch = jest.fn(() => mockFetchResponse(403, 'Forbidden')); const { validateWorkItemExists } = await import('../src/link-work-item.js'); const result = await validateWorkItemExists('test-org', 'azdo-token', '12345'); @@ -307,8 +259,9 @@ describe('Azure DevOps Work Item Linker', () => { }); it('should return authError when error message indicates expired PAT', async () => { - const error = new Error('Access Denied: The Personal Access Token used has expired.'); - mockGetWorkItem.mockRejectedValue(error); + global.fetch = jest.fn(() => + mockFetchResponse(400, 'Access Denied: The Personal Access Token used has expired.') + ); const { validateWorkItemExists } = await import('../src/link-work-item.js'); const result = await validateWorkItemExists('test-org', 'azdo-token', '12345'); @@ -323,23 +276,25 @@ describe('Azure DevOps Work Item Linker', () => { describe('getWorkItemTitle', () => { it('should return title and type when work item exists', async () => { - mockGetWorkItem.mockResolvedValue({ - id: 12345, - fields: { - 'System.Title': 'Fix login bug', - 'System.WorkItemType': 'Bug' - } - }); + global.fetch = jest.fn(() => + mockFetchResponse(200, { + id: 12345, + fields: { 'System.Title': 'Fix login bug', 'System.WorkItemType': 'Bug' } + }) + ); const { getWorkItemTitle } = await import('../src/link-work-item.js'); const result = await getWorkItemTitle('test-org', 'azdo-token', '12345'); expect(result).toEqual({ title: 'Fix login bug', type: 'Bug' }); - expect(mockGetWorkItem).toHaveBeenCalledWith(12345); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/_apis/wit/workitems/12345'), + expect.any(Object) + ); }); it('should return null when work item is not found', async () => { - mockGetWorkItem.mockResolvedValue(null); + global.fetch = jest.fn(() => mockFetchResponse(200, null)); const { getWorkItemTitle } = await import('../src/link-work-item.js'); const result = await getWorkItemTitle('test-org', 'azdo-token', '99999'); @@ -349,7 +304,7 @@ describe('Azure DevOps Work Item Linker', () => { }); it('should return null when work item has no fields', async () => { - mockGetWorkItem.mockResolvedValue({ id: 12345 }); + global.fetch = jest.fn(() => mockFetchResponse(200, { id: 12345 })); const { getWorkItemTitle } = await import('../src/link-work-item.js'); const result = await getWorkItemTitle('test-org', 'azdo-token', '12345'); @@ -358,7 +313,7 @@ describe('Azure DevOps Work Item Linker', () => { }); it('should return null and warn on API error', async () => { - mockGetWorkItem.mockRejectedValue(new Error('Network error')); + global.fetch = jest.fn(() => Promise.reject(new Error('Network error'))); const { getWorkItemTitle } = await import('../src/link-work-item.js'); const result = await getWorkItemTitle('test-org', 'azdo-token', '12345'); @@ -368,9 +323,7 @@ describe('Azure DevOps Work Item Linker', () => { }); it('should return authError when PAT has expired (status 401)', async () => { - const error = new Error('Unauthorized'); - error.statusCode = 401; - mockGetWorkItem.mockRejectedValue(error); + global.fetch = jest.fn(() => mockFetchResponse(401, 'Unauthorized')); const { getWorkItemTitle } = await import('../src/link-work-item.js'); const result = await getWorkItemTitle('test-org', 'azdo-token', '12345'); @@ -382,9 +335,7 @@ describe('Azure DevOps Work Item Linker', () => { }); it('should return authError when PAT has expired (status 403)', async () => { - const error = new Error('Forbidden'); - error.statusCode = 403; - mockGetWorkItem.mockRejectedValue(error); + global.fetch = jest.fn(() => mockFetchResponse(403, 'Forbidden')); const { getWorkItemTitle } = await import('../src/link-work-item.js'); const result = await getWorkItemTitle('test-org', 'azdo-token', '12345'); @@ -393,8 +344,9 @@ describe('Azure DevOps Work Item Linker', () => { }); it('should return authError when error message indicates access denied', async () => { - const error = new Error('Access Denied: The Personal Access Token used has expired.'); - mockGetWorkItem.mockRejectedValue(error); + global.fetch = jest.fn(() => + mockFetchResponse(400, 'Access Denied: The Personal Access Token used has expired.') + ); const { getWorkItemTitle } = await import('../src/link-work-item.js'); const result = await getWorkItemTitle('test-org', 'azdo-token', '12345'); @@ -406,10 +358,7 @@ describe('Azure DevOps Work Item Linker', () => { }); it('should handle missing title and type fields gracefully', async () => { - mockGetWorkItem.mockResolvedValue({ - id: 12345, - fields: {} - }); + global.fetch = jest.fn(() => mockFetchResponse(200, { id: 12345, fields: {} })); const { getWorkItemTitle } = await import('../src/link-work-item.js'); const result = await getWorkItemTitle('test-org', 'azdo-token', '12345'); diff --git a/badges/coverage.svg b/badges/coverage.svg index 97829b8..d50e7e5 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 87.4%Coverage87.4% \ No newline at end of file +Coverage: 88.75%Coverage88.75% \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7ace260..88f261d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,16 @@ { "name": "azure-devops-work-item-link-enforcer-and-linker", - "version": "4.1.4", + "version": "4.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "azure-devops-work-item-link-enforcer-and-linker", - "version": "4.1.4", + "version": "4.1.5", "license": "MIT", "dependencies": { "@actions/core": "^3.0.1", - "@actions/github": "^9.1.1", - "azure-devops-node-api": "^15.1.2" + "@actions/github": "^9.1.1" }, "devDependencies": { "@joshjohanning/make-coverage-badge-better": "^1.0.1", @@ -2476,19 +2475,6 @@ "node": ">= 0.4" } }, - "node_modules/azure-devops-node-api": { - "version": "15.1.2", - "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-15.1.2.tgz", - "integrity": "sha512-PfJTGK8oaBB3e0hilF5QE5BT0axpxssZlc0pRq3Rb0XsKr4qk1Cma+jBRRz0hE+zuUsu/7cz0WTCVo+kcVvOjw==", - "license": "MIT", - "dependencies": { - "tunnel": "0.0.6", - "typed-rest-client": "2.1.0" - }, - "engines": { - "node": ">= 16.0.0" - } - }, "node_modules/babel-jest": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", @@ -2698,6 +2684,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2711,6 +2698,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3101,16 +3089,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/des.js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3138,6 +3116,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3264,6 +3243,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3273,6 +3253,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3282,6 +3263,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4299,6 +4281,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4369,6 +4352,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4403,6 +4387,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4537,6 +4522,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4607,6 +4593,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4635,6 +4622,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4730,7 +4718,8 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "node_modules/internal-slot": { "version": "1.1.0", @@ -5856,12 +5845,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/js-md4": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", - "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6098,6 +6081,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6120,12 +6104,6 @@ "node": ">=6" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -6239,6 +6217,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6733,21 +6712,6 @@ ], "license": "MIT" }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -7005,6 +6969,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7024,6 +6989,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7040,6 +7006,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7058,6 +7025,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7694,22 +7662,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typed-rest-client": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.1.0.tgz", - "integrity": "sha512-Nel9aPbgSzRxfs1+4GoSB4wexCF+4Axlk7OSGVQCMa+4fWcyxIsN/YNmkp0xTT2iQzMD98h8yFLav/cNaULmRA==", - "license": "MIT", - "dependencies": { - "des.js": "^1.1.0", - "js-md4": "^0.3.2", - "qs": "^6.10.3", - "tunnel": "0.0.6", - "underscore": "^1.12.1" - }, - "engines": { - "node": ">= 16.0.0" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -7743,12 +7695,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "license": "MIT" - }, "node_modules/undici": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", diff --git a/package.json b/package.json index 5ed0b1e..9c08317 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "azure-devops-work-item-link-enforcer-and-linker", - "version": "4.1.4", + "version": "4.1.5", "private": true, "type": "module", "description": "GitHub Action to enforce that each commit in a pull request be linked to an Azure DevOps work item and automatically link the pull request to each work item ", @@ -39,8 +39,7 @@ "license": "MIT", "dependencies": { "@actions/core": "^3.0.1", - "@actions/github": "^9.1.1", - "azure-devops-node-api": "^15.1.2" + "@actions/github": "^9.1.1" }, "devDependencies": { "@joshjohanning/make-coverage-badge-better": "^1.0.1", diff --git a/src/azure-devops-rest.js b/src/azure-devops-rest.js new file mode 100644 index 0000000..2818920 --- /dev/null +++ b/src/azure-devops-rest.js @@ -0,0 +1,54 @@ +/** + * Azure DevOps REST API helpers + * + * Shared utilities for making authenticated requests to the Azure DevOps + * REST API using a Personal Access Token (PAT). + * + * @module azure-devops-rest + */ + +/** + * Build the standard Authorization header for Azure DevOps PAT auth. + * + * @param {string} azToken - Azure DevOps personal access token + * @returns {Record} Headers object + */ +export function azureDevOpsHeaders(azToken) { + return { + Authorization: `Basic ${Buffer.from(`:${azToken}`).toString('base64')}`, + 'Content-Type': 'application/json', + Accept: 'application/json' + }; +} + +/** + * Make a request to the Azure DevOps REST API and return the parsed JSON. + * Throws an error with `statusCode` and `status` on non-2xx responses so + * callers can inspect the HTTP status for auth-error detection. + * + * @param {string} url - Full request URL + * @param {string} azToken - Azure DevOps PAT + * @param {RequestInit} [options] - Additional fetch options (method, body, headers, etc.) + * @returns {Promise} Parsed JSON response + */ +export async function azureDevOpsRequest(url, azToken, options = {}) { + const res = await fetch(url, { + ...options, + headers: { ...azureDevOpsHeaders(azToken), ...options.headers } + }); + + if (!res.ok) { + let body; + try { + body = await res.text(); + } catch { + body = res.statusText; + } + const err = new Error(body); + err.statusCode = res.status; + err.status = res.status; + throw err; + } + + return res.json(); +} diff --git a/src/link-work-item.js b/src/link-work-item.js index 0428862..0fb6e3d 100644 --- a/src/link-work-item.js +++ b/src/link-work-item.js @@ -9,8 +9,7 @@ */ import * as core from '@actions/core'; -import * as azdev from 'azure-devops-node-api'; -import { WorkItemExpand } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js'; +import { azureDevOpsHeaders, azureDevOpsRequest } from './azure-devops-rest.js'; const relArtifactLink = 'ArtifactLink'; const relNameGitHubPr = 'GitHub Pull Request'; @@ -32,30 +31,12 @@ export async function run() { const dataProviderUrl = dataProviderUrlBase.replace('%DEVOPS_ORG%', devOpsOrg); const repo = process.env.REPO; - core.info('Initialize dev ops connection ...'); - let azWorkApi; - try { - const orgUrl = `https://dev.azure.com/${devOpsOrg}`; - const authHandler = azdev.getPersonalAccessTokenHandler(azToken); - const azWebApi = new azdev.WebApi(orgUrl, authHandler); - azWorkApi = await azWebApi.getWorkItemTrackingApi(); - } catch (exception) { - core.info(`... failed! ${exception}`); - core.setFailed('Failed connection to dev ops!'); - return; - } - core.info('... success!'); - hasError = false; core.info('Retrieving internalRepoId ...'); try { const dataProviderResponse = await fetch(dataProviderUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Basic ${Buffer.from(`:${azToken}`).toString('base64')}`, - Accept: 'application/json' - }, + headers: azureDevOpsHeaders(azToken), body: JSON.stringify({ context: { properties: { @@ -85,33 +66,32 @@ export async function run() { const artifactUrl = `vstfs:///GitHub/PullRequest/${internalRepoId}%2F${prRequestId}`; try { core.info('trying to create the pull request link ...'); - await azWorkApi.updateWorkItem( - {}, - [ - { - op: 'add', - path: '/relations/-', - value: { - rel: relArtifactLink, - url: artifactUrl, - attributes: { - name: relNameGitHubPr, - comment: `Pull Request ${prRequestId}` - } + const patchDoc = [ + { + op: 'add', + path: '/relations/-', + value: { + rel: relArtifactLink, + url: artifactUrl, + attributes: { + name: relNameGitHubPr, + comment: `Pull Request ${prRequestId}` } } - ], - workItemId, - undefined, - undefined, - undefined, - undefined, - WorkItemExpand.Relations + } + ]; + await azureDevOpsRequest( + `https://dev.azure.com/${devOpsOrg}/_apis/wit/workitems/${workItemId}?$expand=relations&api-version=7.1`, + azToken, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json-patch+json' }, + body: JSON.stringify(patchDoc) + } ); core.info('... success!'); } catch (exception) { - const errorMessage = exception.toString(); - if (-1 !== errorMessage.indexOf('already exists')) { + if (exception.toString().indexOf('already exists') !== -1) { core.info('... (already exists) ...'); } else { throw exception; @@ -175,12 +155,10 @@ function detectAuthError(error) { export async function validateWorkItemExists(devOpsOrg, azToken, workItemId) { try { core.info(`Validating work item ${workItemId} exists...`); - const orgUrl = `https://dev.azure.com/${devOpsOrg}`; - const authHandler = azdev.getPersonalAccessTokenHandler(azToken); - const azWebApi = new azdev.WebApi(orgUrl, authHandler); - const azWorkApi = await azWebApi.getWorkItemTrackingApi(); - - const workItem = await azWorkApi.getWorkItem(parseInt(workItemId, 10)); + const workItem = await azureDevOpsRequest( + `https://dev.azure.com/${devOpsOrg}/_apis/wit/workitems/${parseInt(workItemId, 10)}?api-version=7.1`, + azToken + ); if (workItem && workItem.id) { core.info(`... work item ${workItemId} exists`); @@ -213,12 +191,10 @@ export async function validateWorkItemExists(devOpsOrg, azToken, workItemId) { export async function getWorkItemTitle(devOpsOrg, azToken, workItemId) { try { core.info(`Fetching work item ${workItemId} title...`); - const orgUrl = `https://dev.azure.com/${devOpsOrg}`; - const authHandler = azdev.getPersonalAccessTokenHandler(azToken); - const azWebApi = new azdev.WebApi(orgUrl, authHandler); - const azWorkApi = await azWebApi.getWorkItemTrackingApi(); - - const workItem = await azWorkApi.getWorkItem(parseInt(workItemId, 10)); + const workItem = await azureDevOpsRequest( + `https://dev.azure.com/${devOpsOrg}/_apis/wit/workitems/${parseInt(workItemId, 10)}?api-version=7.1`, + azToken + ); if (workItem && workItem.fields) { const title = workItem.fields['System.Title'] || ''; diff --git a/src/main.js b/src/main.js index 9d634c6..c807217 100644 --- a/src/main.js +++ b/src/main.js @@ -9,8 +9,7 @@ */ import * as core from '@actions/core'; -import * as azdev from 'azure-devops-node-api'; -import { WorkItemExpand } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js'; +import { azureDevOpsHeaders, azureDevOpsRequest } from './azure-devops-rest.js'; const relArtifactLink = 'ArtifactLink'; const relNameGitHubPr = 'GitHub Pull Request'; @@ -33,30 +32,12 @@ export async function run() { const dataProviderUrl = dataProviderUrlBase.replace('%DEVOPS_ORG%', devOpsOrg); const repo = process.env.REPO; - core.info('Initialize dev ops connection ...'); - let azWorkApi; - try { - const orgUrl = `https://dev.azure.com/${devOpsOrg}`; - const authHandler = azdev.getPersonalAccessTokenHandler(azToken); - const azWebApi = new azdev.WebApi(orgUrl, authHandler); - azWorkApi = await azWebApi.getWorkItemTrackingApi(); - } catch (exception) { - core.info(`... failed! ${exception}`); - core.setFailed('Failed connection to dev ops!'); - return; - } - core.info('... success!'); - hasError = false; core.info('Retrieving internalRepoId ...'); try { const dataProviderResponse = await fetch(dataProviderUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Basic ${Buffer.from(`:${azToken}`).toString('base64')}`, - Accept: 'application/json' - }, + headers: azureDevOpsHeaders(azToken), body: JSON.stringify({ context: { properties: { @@ -86,33 +67,32 @@ export async function run() { const artifactUrl = `vstfs:///GitHub/PullRequest/${internalRepoId}%2F${prRequestId}`; try { core.info('trying to create the pull request link ...'); - await azWorkApi.updateWorkItem( - {}, - [ - { - op: 'add', - path: '/relations/-', - value: { - rel: relArtifactLink, - url: artifactUrl, - attributes: { - name: relNameGitHubPr, - comment: `Pull Request ${prRequestId}` - } + const patchDoc = [ + { + op: 'add', + path: '/relations/-', + value: { + rel: relArtifactLink, + url: artifactUrl, + attributes: { + name: relNameGitHubPr, + comment: `Pull Request ${prRequestId}` } } - ], - workItemId, - undefined, - undefined, - undefined, - undefined, - WorkItemExpand.Relations + } + ]; + await azureDevOpsRequest( + `https://dev.azure.com/${devOpsOrg}/_apis/wit/workitems/${workItemId}?$expand=relations&api-version=7.1`, + azToken, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json-patch+json' }, + body: JSON.stringify(patchDoc) + } ); core.info('... success!'); } catch (exception) { - const errorMessage = exception.toString(); - if (-1 !== errorMessage.indexOf('already exists')) { + if (exception.toString().indexOf('already exists') !== -1) { core.info('... (already exists) ...'); } else { throw exception;