Skip to content

Commit 02e5f7d

Browse files
committed
feat!: require Azure DevOps validation for branch work item extraction
- add-work-item-from-branch now requires azure-devops-token and azure-devops-organization; extracted IDs are always validated against Azure DevOps before being added to the PR body - Remove 3-digit minimum from branch regex since validation catches false positives - Sanitize branch name in job summary to prevent markdown injection - Fix JSDoc param type for extractWorkItemIdsFromBranch - Clean up dangling append-work-item-title test references - Add tests for validation of branch-extracted IDs and missing token
1 parent bfd6b66 commit 02e5f7d

5 files changed

Lines changed: 169 additions & 48 deletions

File tree

README.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,20 +70,20 @@ jobs:
7070
7171
### Inputs
7272
73-
| Name | Description | Required | Default |
74-
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------- |
75-
| `check-pull-request` | Check the pull request for `AB#xxx` (scope configurable via `pull-request-check-scope`) | `true` | `false` |
76-
| `pull-request-check-scope` | Only if `check-pull-request=true`, where to look for `AB#` in the PR: `title-or-body`, `body-only`, or `title-only` | `false` | `title-or-body` |
77-
| `check-commits` | Check each commit in the pull request for `AB#xxx` | `true` | `true` |
78-
| `fail-if-missing-workitem-commit-link` | Only if `check-commits=true`, fail the action if a commit in the pull request is missing AB# in every commit message | `false` | `true` |
79-
| `link-commits-to-pull-request` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `true` |
80-
| `validate-work-item-exists` | Validate that the work item(s) referenced in commits and PR exist in Azure DevOps (requires `azure-devops-token` and `azure-devops-organization`) | `false` | `true` |
81-
| `add-work-item-table` | Add a "Linked Work Items" table to the PR body showing titles for `AB#xxx` references (original references are preserved). Requires `azure-devops-token` and `azure-devops-organization` | `false` | `false` |
82-
| `add-work-item-from-branch` | Automatically extract work item ID(s) from the head branch name and add `AB#xxx` to the PR body if not already present. Only matches 3+ digit IDs | `false` | `false` |
83-
| `azure-devops-organization` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `''` |
84-
| `azure-devops-token` | Only required if `link-commits-to-pull-request=true`, Azure DevOps PAT used to link work item to PR (needs to be a `full` PAT) | `false` | `''` |
85-
| `github-token` | The GitHub token that has contents-read and pull_request-write access | `true` | `${{ github.token }}` |
86-
| `comment-on-failure` | Comment on the pull request if the action fails | `true` | `true` |
73+
| Name | Description | Required | Default |
74+
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | --------------------- |
75+
| `check-pull-request` | Check the pull request for `AB#xxx` (scope configurable via `pull-request-check-scope`) | `true` | `false` |
76+
| `pull-request-check-scope` | Only if `check-pull-request=true`, where to look for `AB#` in the PR: `title-or-body`, `body-only`, or `title-only` | `false` | `title-or-body` |
77+
| `check-commits` | Check each commit in the pull request for `AB#xxx` | `true` | `true` |
78+
| `fail-if-missing-workitem-commit-link` | Only if `check-commits=true`, fail the action if a commit in the pull request is missing AB# in every commit message | `false` | `true` |
79+
| `link-commits-to-pull-request` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `true` |
80+
| `validate-work-item-exists` | Validate that the work item(s) referenced in commits and PR exist in Azure DevOps (requires `azure-devops-token` and `azure-devops-organization`) | `false` | `true` |
81+
| `add-work-item-table` | Add a "Linked Work Items" table to the PR body showing titles for `AB#xxx` references (original references are preserved). Requires `azure-devops-token` and `azure-devops-organization` | `false` | `false` |
82+
| `add-work-item-from-branch` | Automatically extract work item ID(s) from the head branch name and add `AB#xxx` to the PR body if not already present. Each ID is always validated against Azure DevOps before being added (regardless of the `validate-work-item-exists` setting). Requires `azure-devops-token` and `azure-devops-organization` | `false` | `false` |
83+
| `azure-devops-organization` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `''` |
84+
| `azure-devops-token` | Only required if `link-commits-to-pull-request=true`, Azure DevOps PAT used to link work item to PR (needs to be a `full` PAT) | `false` | `''` |
85+
| `github-token` | The GitHub token that has contents-read and pull_request-write access | `true` | `${{ github.token }}` |
86+
| `comment-on-failure` | Comment on the pull request if the action fails | `true` | `true` |
8787

8888
## Screenshots
8989

__tests__/index.test.js

Lines changed: 120 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2256,17 +2256,19 @@ describe('Azure DevOps Commit Validator', () => {
22562256
expect(extractWorkItemIdsFromBranch(undefined)).toEqual([]);
22572257
});
22582258

2259-
it('should ignore short numbers to avoid false positives from version numbers', () => {
2259+
it('should extract numbers preceded by separators but not letters', () => {
2260+
// v2 and v3 don't match because 'v' is not a separator
22602261
expect(extractWorkItemIdsFromBranch('feature-v2-add-logging')).toEqual([]);
2261-
expect(extractWorkItemIdsFromBranch('release-1-2-3')).toEqual([]);
22622262
expect(extractWorkItemIdsFromBranch('hotfix/v3')).toEqual([]);
2263+
// Numbers directly after separators do match
2264+
expect(extractWorkItemIdsFromBranch('release-1-2-3')).toEqual(['1', '2', '3']);
22632265
});
22642266

2265-
it('should match exactly 3 digit IDs', () => {
2267+
it('should match 3 digit IDs', () => {
22662268
expect(extractWorkItemIdsFromBranch('task/123/fix')).toEqual(['123']);
22672269
});
22682270

2269-
it('should ignore 2-digit numbers but match longer ones in same branch', () => {
2271+
it('should extract all numbers from branch', () => {
22702272
expect(extractWorkItemIdsFromBranch('hotfix/2024-bugfix')).toEqual(['2024']);
22712273
});
22722274
});
@@ -2283,11 +2285,10 @@ describe('Azure DevOps Commit Validator', () => {
22832285
'link-commits-to-pull-request': 'false',
22842286
'comment-on-failure': 'false',
22852287
'validate-work-item-exists': 'false',
2286-
'append-work-item-title': 'false',
22872288
'add-work-item-from-branch': 'true',
22882289
'github-token': 'github-token',
2289-
'azure-devops-token': '',
2290-
'azure-devops-organization': ''
2290+
'azure-devops-token': 'fake-token',
2291+
'azure-devops-organization': 'my-org'
22912292
};
22922293
return inputs[name] || '';
22932294
});
@@ -2296,6 +2297,7 @@ describe('Azure DevOps Commit Validator', () => {
22962297
data: { title: 'My PR', body: 'Some description' }
22972298
});
22982299

2300+
mockValidateWorkItemExists.mockResolvedValueOnce(true);
22992301
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
23002302

23012303
await run();
@@ -2318,11 +2320,10 @@ describe('Azure DevOps Commit Validator', () => {
23182320
'link-commits-to-pull-request': 'false',
23192321
'comment-on-failure': 'false',
23202322
'validate-work-item-exists': 'false',
2321-
'append-work-item-title': 'false',
23222323
'add-work-item-from-branch': 'true',
23232324
'github-token': 'github-token',
2324-
'azure-devops-token': '',
2325-
'azure-devops-organization': ''
2325+
'azure-devops-token': 'fake-token',
2326+
'azure-devops-organization': 'my-org'
23262327
};
23272328
return inputs[name] || '';
23282329
});
@@ -2350,11 +2351,10 @@ describe('Azure DevOps Commit Validator', () => {
23502351
'link-commits-to-pull-request': 'false',
23512352
'comment-on-failure': 'false',
23522353
'validate-work-item-exists': 'false',
2353-
'append-work-item-title': 'false',
23542354
'add-work-item-from-branch': 'true',
23552355
'github-token': 'github-token',
2356-
'azure-devops-token': '',
2357-
'azure-devops-organization': ''
2356+
'azure-devops-token': 'fake-token',
2357+
'azure-devops-organization': 'my-org'
23582358
};
23592359
return inputs[name] || '';
23602360
});
@@ -2378,7 +2378,6 @@ describe('Azure DevOps Commit Validator', () => {
23782378
'link-commits-to-pull-request': 'false',
23792379
'comment-on-failure': 'false',
23802380
'validate-work-item-exists': 'false',
2381-
'append-work-item-title': 'false',
23822381
'add-work-item-from-branch': 'false',
23832382
'github-token': 'github-token',
23842383
'azure-devops-token': '',
@@ -2406,11 +2405,10 @@ describe('Azure DevOps Commit Validator', () => {
24062405
'link-commits-to-pull-request': 'false',
24072406
'comment-on-failure': 'false',
24082407
'validate-work-item-exists': 'false',
2409-
'append-work-item-title': 'false',
24102408
'add-work-item-from-branch': 'true',
24112409
'github-token': 'github-token',
2412-
'azure-devops-token': '',
2413-
'azure-devops-organization': ''
2410+
'azure-devops-token': 'fake-token',
2411+
'azure-devops-organization': 'my-org'
24142412
};
24152413
return inputs[name] || '';
24162414
});
@@ -2419,6 +2417,7 @@ describe('Azure DevOps Commit Validator', () => {
24192417
data: { title: 'My PR', body: '' }
24202418
});
24212419

2420+
mockValidateWorkItemExists.mockResolvedValueOnce(true);
24222421
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
24232422

24242423
await run();
@@ -2442,11 +2441,10 @@ describe('Azure DevOps Commit Validator', () => {
24422441
'link-commits-to-pull-request': 'false',
24432442
'comment-on-failure': 'false',
24442443
'validate-work-item-exists': 'false',
2445-
'append-work-item-title': 'false',
24462444
'add-work-item-from-branch': 'true',
24472445
'github-token': 'github-token',
2448-
'azure-devops-token': '',
2449-
'azure-devops-organization': ''
2446+
'azure-devops-token': 'fake-token',
2447+
'azure-devops-organization': 'my-org'
24502448
};
24512449
return inputs[name] || '';
24522450
});
@@ -2455,6 +2453,7 @@ describe('Azure DevOps Commit Validator', () => {
24552453
data: { title: 'My PR', body: 'Description' }
24562454
});
24572455

2456+
mockValidateWorkItemExists.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
24582457
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
24592458

24602459
await run();
@@ -2482,11 +2481,10 @@ describe('Azure DevOps Commit Validator', () => {
24822481
'link-commits-to-pull-request': 'false',
24832482
'comment-on-failure': 'false',
24842483
'validate-work-item-exists': 'false',
2485-
'append-work-item-title': 'false',
24862484
'add-work-item-from-branch': 'true',
24872485
'github-token': 'github-token',
2488-
'azure-devops-token': '',
2489-
'azure-devops-organization': ''
2486+
'azure-devops-token': 'fake-token',
2487+
'azure-devops-organization': 'my-org'
24902488
};
24912489
return inputs[name] || '';
24922490
});
@@ -2495,6 +2493,8 @@ describe('Azure DevOps Commit Validator', () => {
24952493
data: { title: 'My PR', body: 'Fixes AB#12345' }
24962494
});
24972495

2496+
// Only 67890 needs validation (12345 already in body)
2497+
mockValidateWorkItemExists.mockResolvedValueOnce(true);
24982498
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
24992499

25002500
await run();
@@ -2504,5 +2504,102 @@ describe('Azure DevOps Commit Validator', () => {
25042504
expect(updateCall.body).toContain('AB#67890');
25052505
expect(updateCall.body).toContain('Fixes AB#12345'); // original body preserved
25062506
});
2507+
2508+
it('should fail if azure-devops-token is missing when add-work-item-from-branch is enabled', async () => {
2509+
mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/fix' } };
2510+
2511+
mockGetInput.mockImplementation(name => {
2512+
const inputs = {
2513+
'check-commits': 'true',
2514+
'check-pull-request': 'false',
2515+
'fail-if-missing-workitem-commit-link': 'false',
2516+
'link-commits-to-pull-request': 'false',
2517+
'comment-on-failure': 'false',
2518+
'validate-work-item-exists': 'false',
2519+
'add-work-item-from-branch': 'true',
2520+
'github-token': 'github-token',
2521+
'azure-devops-token': '',
2522+
'azure-devops-organization': ''
2523+
};
2524+
return inputs[name] || '';
2525+
});
2526+
2527+
await run();
2528+
2529+
expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('add-work-item-from-branch'));
2530+
expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('azure-devops-token'));
2531+
});
2532+
2533+
it('should skip branch-extracted IDs that do not exist in Azure DevOps when validation is enabled', async () => {
2534+
mockContext.payload.pull_request = { number: 42, head: { ref: 'fix/12345/99999-combined' } };
2535+
2536+
mockGetInput.mockImplementation(name => {
2537+
const inputs = {
2538+
'check-commits': 'true',
2539+
'check-pull-request': 'false',
2540+
'fail-if-missing-workitem-commit-link': 'false',
2541+
'link-commits-to-pull-request': 'false',
2542+
'comment-on-failure': 'false',
2543+
'validate-work-item-exists': 'true',
2544+
'add-work-item-from-branch': 'true',
2545+
'github-token': 'github-token',
2546+
'azure-devops-token': 'fake-token',
2547+
'azure-devops-organization': 'my-org'
2548+
};
2549+
return inputs[name] || '';
2550+
});
2551+
2552+
mockOctokit.rest.pulls.get.mockResolvedValue({
2553+
data: { title: 'My PR', body: 'Description' }
2554+
});
2555+
2556+
// 12345 exists, 99999 does not
2557+
mockValidateWorkItemExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
2558+
2559+
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
2560+
2561+
await run();
2562+
2563+
// Should only add AB#12345 (the valid one), not AB#99999
2564+
const updateCall = mockOctokit.rest.pulls.update.mock.calls[0][0];
2565+
expect(updateCall.body).toContain('AB#12345');
2566+
expect(updateCall.body).not.toContain('AB#99999');
2567+
expect(mockWarning).toHaveBeenCalledWith(expect.stringContaining('99999'));
2568+
});
2569+
2570+
it('should not update PR body when all branch-extracted IDs fail validation', async () => {
2571+
mockContext.payload.pull_request = { number: 42, head: { ref: 'task/99999/fix' } };
2572+
2573+
mockGetInput.mockImplementation(name => {
2574+
const inputs = {
2575+
'check-commits': 'true',
2576+
'check-pull-request': 'false',
2577+
'fail-if-missing-workitem-commit-link': 'false',
2578+
'link-commits-to-pull-request': 'false',
2579+
'comment-on-failure': 'false',
2580+
'validate-work-item-exists': 'true',
2581+
'add-work-item-from-branch': 'true',
2582+
'github-token': 'github-token',
2583+
'azure-devops-token': 'fake-token',
2584+
'azure-devops-organization': 'my-org'
2585+
};
2586+
return inputs[name] || '';
2587+
});
2588+
2589+
mockOctokit.rest.pulls.get.mockResolvedValue({
2590+
data: { title: 'My PR', body: 'Description' }
2591+
});
2592+
2593+
// 99999 does not exist
2594+
mockValidateWorkItemExists.mockResolvedValueOnce(false);
2595+
2596+
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
2597+
2598+
await run();
2599+
2600+
// Should NOT update PR body since the only ID was invalid
2601+
expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
2602+
expect(mockWarning).toHaveBeenCalledWith(expect.stringContaining('99999'));
2603+
});
25072604
});
25082605
});

0 commit comments

Comments
 (0)