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 @@
-
\ No newline at end of file
+
\ 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;