From 3a24d076ab1ce6d908184205dadfcecdd552298d Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Tue, 5 May 2026 12:10:32 -0500 Subject: [PATCH] fix: replace azure-devops-node-api with direct REST calls Remove the azure-devops-node-api dependency and replace all SDK usage with direct fetch calls to the Azure DevOps REST API. This eliminates the DEP0169 url.parse() deprecation warning caused by the SDK and its typed-rest-client dependency (microsoft/azure-devops-node-api#664). Shared helpers (azureDevOpsHeaders, azureDevOpsRequest) are extracted into src/azure-devops-rest.js and imported by both entrypoints. Benefits: - Fixes DEP0169 warning at its root (no monkey-patching needed) - Reduces bundle size by ~70% (4,254kB -> 1,264kB) - Uses the same fetch + Basic auth pattern already in the codebase - Removes heavy transitive dependency tree (typed-rest-client, tunnel) The action API surface and behavior are unchanged. Closes #191 --- __tests__/link-work-item.test.js | 251 ++++++++++++------------------- badges/coverage.svg | 2 +- package-lock.json | 100 +++--------- package.json | 5 +- src/azure-devops-rest.js | 54 +++++++ src/link-work-item.js | 86 ++++------- src/main.js | 66 +++----- 7 files changed, 234 insertions(+), 330 deletions(-) create mode 100644 src/azure-devops-rest.js 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;