Skip to content

Commit 0851f88

Browse files
feat: add append-work-item-title input to annotate AB# references in PR body (#150)
* feat: add `append-work-item-title` input to annotate AB# references in PR body closes #147 * fix: add regex digit boundary, safe replacement, defensive error handling, and consolidate append logic * chore: update coverage badge * fix: ensure proper parsing of work item ID and improve error message handling * chore: update version to 3.2.0 in package.json
1 parent bdbe8c0 commit 0851f88

9 files changed

Lines changed: 427 additions & 36 deletions

File tree

README.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,19 @@ jobs:
6363
6464
### Inputs
6565
66-
| Name | Description | Required | Default |
67-
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------- |
68-
| `check-pull-request` | Check the pull request for `AB#xxx` (scope configurable via `pull-request-check-scope`) | `true` | `false` |
69-
| `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` |
70-
| `check-commits` | Check each commit in the pull request for `AB#xxx` | `true` | `true` |
71-
| `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` |
72-
| `link-commits-to-pull-request` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `true` |
73-
| `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` |
74-
| `azure-devops-organization` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `''` |
75-
| `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` | `''` |
76-
| `github-token` | The GitHub token that has contents-read and pull_request-write access | `true` | `${{ github.token }}` |
77-
| `comment-on-failure` | Comment on the pull request if the action fails | `true` | `true` |
66+
| Name | Description | Required | Default |
67+
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------- |
68+
| `check-pull-request` | Check the pull request for `AB#xxx` (scope configurable via `pull-request-check-scope`) | `true` | `false` |
69+
| `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` |
70+
| `check-commits` | Check each commit in the pull request for `AB#xxx` | `true` | `true` |
71+
| `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` |
72+
| `link-commits-to-pull-request` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `true` |
73+
| `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` |
74+
| `append-work-item-title` | Append the work item title to `AB#xxx` references in the PR body (e.g. `AB#123` becomes `AB#123 - Fix bug`). Requires `azure-devops-token` and `azure-devops-organization` | `false` | `false` |
75+
| `azure-devops-organization` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `''` |
76+
| `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` | `''` |
77+
| `github-token` | The GitHub token that has contents-read and pull_request-write access | `true` | `${{ github.token }}` |
78+
| `comment-on-failure` | Comment on the pull request if the action fails | `true` | `true` |
7879

7980
## Screenshots
8081

__tests__/index.test.js

Lines changed: 213 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ jest.unstable_mockModule('@actions/github', () => ({
4747
// Mock ./link-work-item.js
4848
const mockLinkWorkItem = jest.fn();
4949
const mockValidateWorkItemExists = jest.fn();
50+
const mockGetWorkItemTitle = jest.fn();
5051
jest.unstable_mockModule('../src/link-work-item.js', () => ({
5152
run: mockLinkWorkItem,
52-
validateWorkItemExists: mockValidateWorkItemExists
53+
validateWorkItemExists: mockValidateWorkItemExists,
54+
getWorkItemTitle: mockGetWorkItemTitle
5355
}));
5456

5557
describe('Azure DevOps Commit Validator', () => {
@@ -87,7 +89,8 @@ describe('Azure DevOps Commit Validator', () => {
8789
'azure-devops-organization': '',
8890
'github-token': 'github-token',
8991
'comment-on-failure': 'true',
90-
'validate-work-item-exists': 'false'
92+
'validate-work-item-exists': 'false',
93+
'append-work-item-title': 'false'
9194
};
9295
return defaults[name] || '';
9396
});
@@ -105,7 +108,8 @@ describe('Azure DevOps Commit Validator', () => {
105108
}),
106109
listComments: jest.fn().mockResolvedValue({ data: [] }),
107110
createComment: jest.fn().mockResolvedValue({ data: { id: 123 } }),
108-
updateComment: jest.fn().mockResolvedValue({ data: { id: 123 } })
111+
updateComment: jest.fn().mockResolvedValue({ data: { id: 123 } }),
112+
update: jest.fn().mockResolvedValue({ data: {} })
109113
},
110114
issues: {
111115
createComment: jest.fn().mockResolvedValue({ data: { id: 123 } }),
@@ -125,6 +129,9 @@ describe('Azure DevOps Commit Validator', () => {
125129

126130
// Default mock for validateWorkItemExists (returns true by default)
127131
mockValidateWorkItemExists.mockResolvedValue(true);
132+
133+
// Default mock for getWorkItemTitle
134+
mockGetWorkItemTitle.mockResolvedValue({ title: 'Test Work Item', type: 'User Story' });
128135
});
129136

130137
describe('Input validation', () => {
@@ -802,6 +809,209 @@ describe('Azure DevOps Commit Validator', () => {
802809
});
803810
});
804811

812+
describe('Append work item title', () => {
813+
it('should append work item title to AB# in PR body when enabled', async () => {
814+
mockGetInput.mockImplementation(name => {
815+
if (name === 'check-commits') return 'false';
816+
if (name === 'check-pull-request') return 'true';
817+
if (name === 'github-token') return 'github-token';
818+
if (name === 'comment-on-failure') return 'false';
819+
if (name === 'validate-work-item-exists') return 'false';
820+
if (name === 'append-work-item-title') return 'true';
821+
if (name === 'azure-devops-token') return 'azdo-token';
822+
if (name === 'azure-devops-organization') return 'my-org';
823+
return '';
824+
});
825+
826+
mockOctokit.rest.pulls.get.mockResolvedValue({
827+
data: {
828+
title: 'feat: new feature',
829+
body: 'This PR implements AB#12345'
830+
}
831+
});
832+
833+
mockGetWorkItemTitle.mockResolvedValue({ title: 'Fix login bug', type: 'Bug' });
834+
835+
await run();
836+
837+
expect(mockSetFailed).not.toHaveBeenCalled();
838+
expect(mockGetWorkItemTitle).toHaveBeenCalledWith('my-org', 'azdo-token', '12345');
839+
expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
840+
expect.objectContaining({
841+
body: 'This PR implements AB#12345 - Fix login bug'
842+
})
843+
);
844+
});
845+
846+
it('should not update PR body when work item already has title appended', async () => {
847+
mockGetInput.mockImplementation(name => {
848+
if (name === 'check-commits') return 'false';
849+
if (name === 'check-pull-request') return 'true';
850+
if (name === 'github-token') return 'github-token';
851+
if (name === 'comment-on-failure') return 'false';
852+
if (name === 'validate-work-item-exists') return 'false';
853+
if (name === 'append-work-item-title') return 'true';
854+
if (name === 'azure-devops-token') return 'azdo-token';
855+
if (name === 'azure-devops-organization') return 'my-org';
856+
return '';
857+
});
858+
859+
mockOctokit.rest.pulls.get.mockResolvedValue({
860+
data: {
861+
title: 'feat: new feature',
862+
body: 'This PR implements AB#12345 - Fix login bug'
863+
}
864+
});
865+
866+
await run();
867+
868+
expect(mockSetFailed).not.toHaveBeenCalled();
869+
expect(mockGetWorkItemTitle).not.toHaveBeenCalled();
870+
expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
871+
});
872+
873+
it('should not append when append-work-item-title is false', async () => {
874+
mockGetInput.mockImplementation(name => {
875+
if (name === 'check-commits') return 'false';
876+
if (name === 'check-pull-request') return 'true';
877+
if (name === 'github-token') return 'github-token';
878+
if (name === 'comment-on-failure') return 'false';
879+
if (name === 'validate-work-item-exists') return 'false';
880+
if (name === 'append-work-item-title') return 'false';
881+
return '';
882+
});
883+
884+
mockOctokit.rest.pulls.get.mockResolvedValue({
885+
data: {
886+
title: 'feat: new feature',
887+
body: 'This PR implements AB#12345'
888+
}
889+
});
890+
891+
await run();
892+
893+
expect(mockSetFailed).not.toHaveBeenCalled();
894+
expect(mockGetWorkItemTitle).not.toHaveBeenCalled();
895+
expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
896+
});
897+
898+
it('should handle multiple work items in PR body', async () => {
899+
mockGetInput.mockImplementation(name => {
900+
if (name === 'check-commits') return 'false';
901+
if (name === 'check-pull-request') return 'true';
902+
if (name === 'github-token') return 'github-token';
903+
if (name === 'comment-on-failure') return 'false';
904+
if (name === 'validate-work-item-exists') return 'false';
905+
if (name === 'append-work-item-title') return 'true';
906+
if (name === 'azure-devops-token') return 'azdo-token';
907+
if (name === 'azure-devops-organization') return 'my-org';
908+
return '';
909+
});
910+
911+
mockOctokit.rest.pulls.get.mockResolvedValue({
912+
data: {
913+
title: 'feat: new feature',
914+
body: 'This PR implements AB#111 and AB#222'
915+
}
916+
});
917+
918+
mockGetWorkItemTitle
919+
.mockResolvedValueOnce({ title: 'First item', type: 'User Story' })
920+
.mockResolvedValueOnce({ title: 'Second item', type: 'Bug' });
921+
922+
await run();
923+
924+
expect(mockSetFailed).not.toHaveBeenCalled();
925+
expect(mockGetWorkItemTitle).toHaveBeenCalledTimes(2);
926+
expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
927+
expect.objectContaining({
928+
body: 'This PR implements AB#111 - First item and AB#222 - Second item'
929+
})
930+
);
931+
});
932+
933+
it('should gracefully handle when getWorkItemTitle returns null', async () => {
934+
mockGetInput.mockImplementation(name => {
935+
if (name === 'check-commits') return 'false';
936+
if (name === 'check-pull-request') return 'true';
937+
if (name === 'github-token') return 'github-token';
938+
if (name === 'comment-on-failure') return 'false';
939+
if (name === 'validate-work-item-exists') return 'false';
940+
if (name === 'append-work-item-title') return 'true';
941+
if (name === 'azure-devops-token') return 'azdo-token';
942+
if (name === 'azure-devops-organization') return 'my-org';
943+
return '';
944+
});
945+
946+
mockOctokit.rest.pulls.get.mockResolvedValue({
947+
data: {
948+
title: 'feat: new feature',
949+
body: 'This PR implements AB#12345'
950+
}
951+
});
952+
953+
mockGetWorkItemTitle.mockResolvedValue(null);
954+
955+
await run();
956+
957+
expect(mockSetFailed).not.toHaveBeenCalled();
958+
expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
959+
});
960+
961+
it('should require azure-devops-token and azure-devops-organization when enabled', async () => {
962+
mockGetInput.mockImplementation(name => {
963+
if (name === 'check-commits') return 'false';
964+
if (name === 'check-pull-request') return 'true';
965+
if (name === 'github-token') return 'github-token';
966+
if (name === 'comment-on-failure') return 'false';
967+
if (name === 'validate-work-item-exists') return 'false';
968+
if (name === 'append-work-item-title') return 'true';
969+
if (name === 'azure-devops-token') return '';
970+
if (name === 'azure-devops-organization') return '';
971+
return '';
972+
});
973+
974+
await run();
975+
976+
expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('append-work-item-title'));
977+
});
978+
979+
it('should append title with validate-work-item-exists also enabled', async () => {
980+
mockGetInput.mockImplementation(name => {
981+
if (name === 'check-commits') return 'false';
982+
if (name === 'check-pull-request') return 'true';
983+
if (name === 'github-token') return 'github-token';
984+
if (name === 'comment-on-failure') return 'false';
985+
if (name === 'validate-work-item-exists') return 'true';
986+
if (name === 'append-work-item-title') return 'true';
987+
if (name === 'azure-devops-token') return 'azdo-token';
988+
if (name === 'azure-devops-organization') return 'my-org';
989+
return '';
990+
});
991+
992+
mockOctokit.rest.pulls.get.mockResolvedValue({
993+
data: {
994+
title: 'feat: new feature',
995+
body: 'This PR implements AB#12345'
996+
}
997+
});
998+
999+
mockValidateWorkItemExists.mockResolvedValue(true);
1000+
mockGetWorkItemTitle.mockResolvedValue({ title: 'Fix login bug', type: 'Bug' });
1001+
1002+
await run();
1003+
1004+
expect(mockSetFailed).not.toHaveBeenCalled();
1005+
expect(mockValidateWorkItemExists).toHaveBeenCalled();
1006+
expect(mockGetWorkItemTitle).toHaveBeenCalledWith('my-org', 'azdo-token', '12345');
1007+
expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
1008+
expect.objectContaining({
1009+
body: 'This PR implements AB#12345 - Fix login bug'
1010+
})
1011+
);
1012+
});
1013+
});
1014+
8051015
describe('Comment management', () => {
8061016
it('should not comment when comment-on-failure is false', async () => {
8071017
mockGetInput.mockImplementation(name => {

__tests__/link-work-item.test.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,4 +284,63 @@ describe('Azure DevOps Work Item Linker', () => {
284284
expect(mockGetWorkItem).toHaveBeenCalledWith(12345);
285285
});
286286
});
287+
288+
describe('getWorkItemTitle', () => {
289+
it('should return title and type when work item exists', async () => {
290+
mockGetWorkItem.mockResolvedValue({
291+
id: 12345,
292+
fields: {
293+
'System.Title': 'Fix login bug',
294+
'System.WorkItemType': 'Bug'
295+
}
296+
});
297+
298+
const { getWorkItemTitle } = await import('../src/link-work-item.js');
299+
const result = await getWorkItemTitle('test-org', 'azdo-token', '12345');
300+
301+
expect(result).toEqual({ title: 'Fix login bug', type: 'Bug' });
302+
expect(mockGetWorkItem).toHaveBeenCalledWith(12345);
303+
});
304+
305+
it('should return null when work item is not found', async () => {
306+
mockGetWorkItem.mockResolvedValue(null);
307+
308+
const { getWorkItemTitle } = await import('../src/link-work-item.js');
309+
const result = await getWorkItemTitle('test-org', 'azdo-token', '99999');
310+
311+
expect(result).toBeNull();
312+
expect(mockWarning).toHaveBeenCalled();
313+
});
314+
315+
it('should return null when work item has no fields', async () => {
316+
mockGetWorkItem.mockResolvedValue({ id: 12345 });
317+
318+
const { getWorkItemTitle } = await import('../src/link-work-item.js');
319+
const result = await getWorkItemTitle('test-org', 'azdo-token', '12345');
320+
321+
expect(result).toBeNull();
322+
});
323+
324+
it('should return null and warn on API error', async () => {
325+
mockGetWorkItem.mockRejectedValue(new Error('Network error'));
326+
327+
const { getWorkItemTitle } = await import('../src/link-work-item.js');
328+
const result = await getWorkItemTitle('test-org', 'azdo-token', '12345');
329+
330+
expect(result).toBeNull();
331+
expect(mockWarning).toHaveBeenCalledWith(expect.stringContaining('failed to fetch work item 12345 title'));
332+
});
333+
334+
it('should handle missing title and type fields gracefully', async () => {
335+
mockGetWorkItem.mockResolvedValue({
336+
id: 12345,
337+
fields: {}
338+
});
339+
340+
const { getWorkItemTitle } = await import('../src/link-work-item.js');
341+
const result = await getWorkItemTitle('test-org', 'azdo-token', '12345');
342+
343+
expect(result).toEqual({ title: '', type: '' });
344+
});
345+
});
287346
});

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ inputs:
4444
description: 'Validate that the work item(s) referenced in commits and PR exist in Azure DevOps. Requires azure-devops-token and azure-devops-organization to be set.'
4545
required: false
4646
default: 'true'
47+
append-work-item-title:
48+
description: 'Append the work item title to AB#xxx references in the PR body (e.g. AB#123 becomes AB#123 - Fix bug). Requires azure-devops-token and azure-devops-organization to be set.'
49+
required: false
50+
default: 'false'
4751

4852
runs:
4953
using: 'node20'

0 commit comments

Comments
 (0)