Skip to content

Commit b210d62

Browse files
fix: propagate auth errors from getWorkItemTitle and short-circuit run() on addWorkItemsToPRBody auth failure (#186)
* fix: propagate auth errors from getWorkItemTitle and short-circuit run() on addWorkItemsToPRBody auth failure Resolves #169: getWorkItemTitle() now uses detectAuthError() to identify auth errors (401, 403, expired PAT) and returns { authError, errorMessage } instead of silently returning null. Resolves #172: addWorkItemsToPRBody() now returns { authError: true } on auth failure so run() can short-circuit instead of continuing to execute subsequent commit/PR checks. Agent-Logs-Url: https://github.com/joshjohanning/azdo_commit_message_validator/sessions/6edbda9a-8711-4a1c-911d-58f076d74159 Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> * fix: change version bump from minor (4.2.0) to patch (4.1.2) since this is a bug fix Agent-Logs-Url: https://github.com/joshjohanning/azdo_commit_message_validator/sessions/572dd5a6-60c2-48b1-bb60-11bd36236059 Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: joshjohanning <19912012+joshjohanning@users.noreply.github.com>
1 parent d8df56a commit b210d62

7 files changed

Lines changed: 107 additions & 12 deletions

File tree

__tests__/index.test.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2820,7 +2820,42 @@ describe('Azure DevOps Commit Validator', () => {
28202820
errorMessage: 'Access Denied: The Personal Access Token used has expired.'
28212821
});
28222822

2823-
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
2823+
await run();
2824+
2825+
expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('Personal Access Token (PAT) may be expired'));
2826+
expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
2827+
// Should short-circuit and not proceed to commit checks
2828+
expect(mockOctokit.paginate).not.toHaveBeenCalled();
2829+
});
2830+
2831+
it('should handle auth error from getWorkItemTitle in appendWorkItemTitlesToPRBody', async () => {
2832+
mockGetInput.mockImplementation(name => {
2833+
const inputs = {
2834+
'check-commits': 'false',
2835+
'check-pull-request': 'true',
2836+
'github-token': 'github-token',
2837+
'comment-on-failure': 'false',
2838+
'validate-work-item-exists': 'false',
2839+
'add-work-item-table': 'true',
2840+
'azure-devops-token': 'azdo-token',
2841+
'azure-devops-organization': 'my-org',
2842+
'add-work-item-from-branch': 'false'
2843+
};
2844+
return inputs[name] || '';
2845+
});
2846+
2847+
mockOctokit.rest.pulls.get.mockResolvedValue({
2848+
data: {
2849+
title: 'feat: new feature',
2850+
body: 'This PR implements AB#12345'
2851+
}
2852+
});
2853+
2854+
// Simulate auth error from getWorkItemTitle
2855+
mockGetWorkItemTitle.mockResolvedValue({
2856+
authError: true,
2857+
errorMessage: 'Access Denied: The Personal Access Token used has expired.'
2858+
});
28242859

28252860
await run();
28262861

__tests__/link-work-item.test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,44 @@ describe('Azure DevOps Work Item Linker', () => {
367367
expect(mockWarning).toHaveBeenCalledWith(expect.stringContaining('failed to fetch work item 12345 title'));
368368
});
369369

370+
it('should return authError when PAT has expired (status 401)', async () => {
371+
const error = new Error('Unauthorized');
372+
error.statusCode = 401;
373+
mockGetWorkItem.mockRejectedValue(error);
374+
375+
const { getWorkItemTitle } = await import('../src/link-work-item.js');
376+
const result = await getWorkItemTitle('test-org', 'azdo-token', '12345');
377+
378+
expect(result).toEqual({ authError: true, errorMessage: 'Unauthorized' });
379+
expect(mockError).toHaveBeenCalledWith(
380+
expect.stringContaining('authentication error while fetching work item 12345 title')
381+
);
382+
});
383+
384+
it('should return authError when PAT has expired (status 403)', async () => {
385+
const error = new Error('Forbidden');
386+
error.statusCode = 403;
387+
mockGetWorkItem.mockRejectedValue(error);
388+
389+
const { getWorkItemTitle } = await import('../src/link-work-item.js');
390+
const result = await getWorkItemTitle('test-org', 'azdo-token', '12345');
391+
392+
expect(result).toEqual({ authError: true, errorMessage: 'Forbidden' });
393+
});
394+
395+
it('should return authError when error message indicates access denied', async () => {
396+
const error = new Error('Access Denied: The Personal Access Token used has expired.');
397+
mockGetWorkItem.mockRejectedValue(error);
398+
399+
const { getWorkItemTitle } = await import('../src/link-work-item.js');
400+
const result = await getWorkItemTitle('test-org', 'azdo-token', '12345');
401+
402+
expect(result).toEqual({
403+
authError: true,
404+
errorMessage: 'Access Denied: The Personal Access Token used has expired.'
405+
});
406+
});
407+
370408
it('should handle missing title and type fields gracefully', async () => {
371409
mockGetWorkItem.mockResolvedValue({
372410
id: 12345,

badges/coverage.svg

Lines changed: 1 addition & 1 deletion
Loading

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "azure-devops-work-item-link-enforcer-and-linker",
3-
"version": "4.1.1",
3+
"version": "4.1.2",
44
"private": true,
55
"type": "module",
66
"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 ",

src/index.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export async function run() {
140140

141141
// Automatically add AB# tags from branch name if enabled
142142
if (addWorkItemFromBranch) {
143-
await addWorkItemsToPRBody(
143+
const branchResult = await addWorkItemsToPRBody(
144144
octokit,
145145
context,
146146
pullNumber,
@@ -149,6 +149,11 @@ export async function run() {
149149
branchWorkItemPrefixes,
150150
branchWorkItemMinDigits
151151
);
152+
153+
// If auth error was detected, stop processing - setFailed was already called
154+
if (branchResult && branchResult.authError) {
155+
return;
156+
}
152157
}
153158

154159
// Store work item to commit mapping and validation results
@@ -675,7 +680,7 @@ async function checkPullRequestForWorkItems(
675680

676681
// Append work item titles to PR body if enabled
677682
if (addWorkItemTable && azureDevopsOrganization && azureDevopsToken) {
678-
await appendWorkItemTitlesToPRBody(
683+
const appendResult = await appendWorkItemTitlesToPRBody(
679684
octokit,
680685
context,
681686
pullNumber,
@@ -684,6 +689,12 @@ async function checkPullRequestForWorkItems(
684689
azureDevopsOrganization,
685690
azureDevopsToken
686691
);
692+
693+
if (appendResult && appendResult.authError) {
694+
const authMessage = `Azure DevOps authentication failed while fetching work item titles. Your Personal Access Token (PAT) may be expired, revoked, or lack the required scopes. Details: ${appendResult.errorMessage}`;
695+
core.setFailed(authMessage);
696+
return { authError: true };
697+
}
687698
}
688699

689700
return [];
@@ -728,6 +739,11 @@ async function appendWorkItemTitlesToPRBody(
728739
for (const workItem of workItems) {
729740
const workItemNumber = workItem.substring(3); // Remove "AB#" prefix
730741
const workItemInfo = await getWorkItemTitle(azureDevopsOrganization, azureDevopsToken, workItemNumber);
742+
743+
if (workItemInfo && workItemInfo.authError) {
744+
return { authError: true, errorMessage: workItemInfo.errorMessage };
745+
}
746+
731747
if (workItemInfo && workItemInfo.title) {
732748
workItemInfos.push({ id: workItemNumber, title: workItemInfo.title, type: workItemInfo.type });
733749
core.summary.addRaw(
@@ -828,6 +844,7 @@ export function extractWorkItemIdsFromBranch(branchName, prefixes, minDigits = 1
828844
* @param {string} azureDevopsToken - Azure DevOps PAT token
829845
* @param {string[]} branchPrefixes - Keyword prefixes for identifying work item IDs in branch names
830846
* @param {number} [minDigits=1] - Minimum number of digits for a work item ID
847+
* @returns {Promise<{authError: true}|undefined>} - Auth error status if auth failed, undefined otherwise
831848
*/
832849
async function addWorkItemsToPRBody(
833850
octokit,
@@ -891,7 +908,7 @@ async function addWorkItemsToPRBody(
891908
if (result.authError) {
892909
const authMessage = `Azure DevOps authentication failed while validating work items from branch. Your Personal Access Token (PAT) may be expired, revoked, or lack the required scopes. Details: ${result.errorMessage}`;
893910
core.setFailed(authMessage);
894-
return;
911+
return { authError: true };
895912
}
896913

897914
if (result.exists) {

src/link-work-item.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ export async function validateWorkItemExists(devOpsOrg, azToken, workItemId) {
208208
* @param {string} devOpsOrg - Azure DevOps organization name
209209
* @param {string} azToken - Azure DevOps PAT token
210210
* @param {string} workItemId - Work item ID to fetch
211-
* @returns {Promise<{title: string, type: string}|null>} - Work item title and type, or null if not found
211+
* @returns {Promise<{title: string, type: string}|{authError: true, errorMessage: string}|null>} - Work item title and type, auth error info, or null if not found
212212
*/
213213
export async function getWorkItemTitle(devOpsOrg, azToken, workItemId) {
214214
try {
@@ -230,9 +230,14 @@ export async function getWorkItemTitle(devOpsOrg, azToken, workItemId) {
230230
core.warning(`... work item ${workItemId} not found`);
231231
return null;
232232
} catch (error) {
233-
core.warning(
234-
`... failed to fetch work item ${workItemId} title: ${error instanceof Error ? error.message : String(error)}`
235-
);
233+
const { isAuthError, message } = detectAuthError(error);
234+
235+
if (isAuthError) {
236+
core.error(`... authentication error while fetching work item ${workItemId} title: ${message}`);
237+
return { authError: true, errorMessage: message };
238+
}
239+
240+
core.warning(`... failed to fetch work item ${workItemId} title: ${message}`);
236241
return null;
237242
}
238243
}

0 commit comments

Comments
 (0)