Skip to content

Commit bdbe8c0

Browse files
feat: add pull-request-check-scope input to control where AB# is checked (#149)
* feat: add `pull-request-check-scope` input to control where AB# is checked closes #146 * chore: update package-lock.json * fix: align descriptions and defaults for check-pull-request across action.yml, README, and JSDoc
1 parent d131cdb commit bdbe8c0

7 files changed

Lines changed: 222 additions & 13 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ jobs:
6565
6666
| Name | Description | Required | Default |
6767
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------- |
68-
| `check-pull-request` | Check the pull request body and title for `AB#xxx` | `true` | `true` |
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` |
6970
| `check-commits` | Check each commit in the pull request for `AB#xxx` | `true` | `true` |
7071
| `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` |
7172
| `link-commits-to-pull-request` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `true` |

__tests__/index.test.js

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const mockGetInput = jest.fn();
99
const mockSetFailed = jest.fn();
1010
const mockInfo = jest.fn();
1111
const mockError = jest.fn();
12+
const mockWarning = jest.fn();
1213
const mockSummary = {
1314
addRaw: jest.fn().mockReturnThis(),
1415
write: jest.fn().mockResolvedValue(undefined)
@@ -19,6 +20,7 @@ jest.unstable_mockModule('@actions/core', () => ({
1920
setFailed: mockSetFailed,
2021
info: mockInfo,
2122
error: mockError,
23+
warning: mockWarning,
2224
summary: mockSummary
2325
}));
2426

@@ -77,6 +79,7 @@ describe('Azure DevOps Commit Validator', () => {
7779
mockGetInput.mockImplementation(name => {
7880
const defaults = {
7981
'check-pull-request': 'false',
82+
'pull-request-check-scope': 'title-or-body',
8083
'check-commits': 'true',
8184
'fail-if-missing-workitem-commit-link': 'true',
8285
'link-commits-to-pull-request': 'false',
@@ -634,6 +637,171 @@ describe('Azure DevOps Commit Validator', () => {
634637
});
635638
});
636639

640+
describe('Pull request check scope', () => {
641+
it('should pass with body-only scope when work item is in body', async () => {
642+
mockGetInput.mockImplementation(name => {
643+
if (name === 'check-commits') return 'false';
644+
if (name === 'check-pull-request') return 'true';
645+
if (name === 'pull-request-check-scope') return 'body-only';
646+
if (name === 'github-token') return 'github-token';
647+
if (name === 'comment-on-failure') return 'false';
648+
return 'false';
649+
});
650+
651+
mockOctokit.rest.pulls.get.mockResolvedValue({
652+
data: {
653+
title: 'feat: new feature',
654+
body: 'This PR implements AB#12345'
655+
}
656+
});
657+
658+
await run();
659+
660+
expect(mockSetFailed).not.toHaveBeenCalled();
661+
});
662+
663+
it('should fail with body-only scope when work item is only in title', async () => {
664+
mockGetInput.mockImplementation(name => {
665+
if (name === 'check-commits') return 'false';
666+
if (name === 'check-pull-request') return 'true';
667+
if (name === 'pull-request-check-scope') return 'body-only';
668+
if (name === 'github-token') return 'github-token';
669+
if (name === 'comment-on-failure') return 'false';
670+
return 'false';
671+
});
672+
673+
mockOctokit.rest.pulls.get.mockResolvedValue({
674+
data: {
675+
title: 'feat: new feature AB#12345',
676+
body: 'This is a test PR'
677+
}
678+
});
679+
680+
await run();
681+
682+
expect(mockSetFailed).toHaveBeenCalled();
683+
});
684+
685+
it('should pass with title-only scope when work item is in title', async () => {
686+
mockGetInput.mockImplementation(name => {
687+
if (name === 'check-commits') return 'false';
688+
if (name === 'check-pull-request') return 'true';
689+
if (name === 'pull-request-check-scope') return 'title-only';
690+
if (name === 'github-token') return 'github-token';
691+
if (name === 'comment-on-failure') return 'false';
692+
return 'false';
693+
});
694+
695+
mockOctokit.rest.pulls.get.mockResolvedValue({
696+
data: {
697+
title: 'feat: new feature AB#12345',
698+
body: 'This is a test PR'
699+
}
700+
});
701+
702+
await run();
703+
704+
expect(mockSetFailed).not.toHaveBeenCalled();
705+
});
706+
707+
it('should fail with title-only scope when work item is only in body', async () => {
708+
mockGetInput.mockImplementation(name => {
709+
if (name === 'check-commits') return 'false';
710+
if (name === 'check-pull-request') return 'true';
711+
if (name === 'pull-request-check-scope') return 'title-only';
712+
if (name === 'github-token') return 'github-token';
713+
if (name === 'comment-on-failure') return 'false';
714+
return 'false';
715+
});
716+
717+
mockOctokit.rest.pulls.get.mockResolvedValue({
718+
data: {
719+
title: 'feat: new feature',
720+
body: 'This PR implements AB#12345'
721+
}
722+
});
723+
724+
await run();
725+
726+
expect(mockSetFailed).toHaveBeenCalled();
727+
});
728+
729+
it('should pass with title-or-body scope (default) when work item is in either', async () => {
730+
mockGetInput.mockImplementation(name => {
731+
if (name === 'check-commits') return 'false';
732+
if (name === 'check-pull-request') return 'true';
733+
if (name === 'pull-request-check-scope') return 'title-or-body';
734+
if (name === 'github-token') return 'github-token';
735+
if (name === 'comment-on-failure') return 'false';
736+
return 'false';
737+
});
738+
739+
mockOctokit.rest.pulls.get.mockResolvedValue({
740+
data: {
741+
title: 'feat: new feature AB#12345',
742+
body: 'This is a test PR'
743+
}
744+
});
745+
746+
await run();
747+
748+
expect(mockSetFailed).not.toHaveBeenCalled();
749+
});
750+
751+
it('should warn and use default scope with invalid pull-request-check-scope value', async () => {
752+
mockGetInput.mockImplementation(name => {
753+
if (name === 'check-commits') return 'false';
754+
if (name === 'check-pull-request') return 'true';
755+
if (name === 'pull-request-check-scope') return 'invalid-value';
756+
if (name === 'github-token') return 'github-token';
757+
if (name === 'comment-on-failure') return 'false';
758+
return 'false';
759+
});
760+
761+
mockOctokit.rest.pulls.get.mockResolvedValue({
762+
data: {
763+
title: 'feat: new feature AB#12345',
764+
body: 'Test body'
765+
}
766+
});
767+
768+
await run();
769+
770+
expect(mockWarning).toHaveBeenCalledWith(
771+
expect.stringContaining("Invalid value 'invalid-value' for 'pull-request-check-scope'")
772+
);
773+
// Should still pass because it falls back to title-or-body and title has AB#
774+
expect(mockSetFailed).not.toHaveBeenCalled();
775+
});
776+
777+
it('should include scope-aware message in failure comment with body-only scope', async () => {
778+
mockGetInput.mockImplementation(name => {
779+
if (name === 'check-commits') return 'false';
780+
if (name === 'check-pull-request') return 'true';
781+
if (name === 'pull-request-check-scope') return 'body-only';
782+
if (name === 'github-token') return 'github-token';
783+
if (name === 'comment-on-failure') return 'true';
784+
return 'false';
785+
});
786+
787+
mockOctokit.rest.pulls.get.mockResolvedValue({
788+
data: {
789+
title: 'feat: new feature AB#12345',
790+
body: 'This is a test PR without work item in body'
791+
}
792+
});
793+
794+
await run();
795+
796+
expect(mockSetFailed).toHaveBeenCalled();
797+
expect(mockOctokit.rest.issues.createComment).toHaveBeenCalledWith(
798+
expect.objectContaining({
799+
body: expect.stringContaining('Please update the body to include a work item')
800+
})
801+
);
802+
});
803+
});
804+
637805
describe('Comment management', () => {
638806
it('should not comment when comment-on-failure is false', async () => {
639807
mockGetInput.mockImplementation(name => {

action.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ branding:
77

88
inputs:
99
check-pull-request:
10-
description: 'Check the pull request body and title for AB#xxx'
10+
description: 'Check the pull request for AB#xxx (scope configurable via pull-request-check-scope)'
1111
required: true
1212
default: 'false'
13+
pull-request-check-scope:
14+
description: 'Only if check-pull-request=true, where to look for AB# in the pull request. Options: title-or-body, body-only, title-only'
15+
required: false
16+
default: 'title-or-body'
1317
check-commits:
1418
description: 'Check each commit in the pull request for AB#xxx'
1519
required: true

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": "3.0.13",
3+
"version": "3.1.0",
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: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export async function run() {
3030
try {
3131
// Get inputs
3232
const checkPullRequest = core.getInput('check-pull-request') === 'true';
33+
const validScopes = ['title-or-body', 'body-only', 'title-only'];
34+
const pullRequestCheckScopeRaw = core.getInput('pull-request-check-scope');
35+
const pullRequestCheckScope = validScopes.includes(pullRequestCheckScopeRaw)
36+
? pullRequestCheckScopeRaw
37+
: 'title-or-body';
3338
const checkCommits = core.getInput('check-commits') === 'true';
3439
const failIfMissingWorkitemCommitLink = core.getInput('fail-if-missing-workitem-commit-link') === 'true';
3540
const linkCommitsToPullRequest = core.getInput('link-commits-to-pull-request') === 'true';
@@ -39,6 +44,13 @@ export async function run() {
3944
const commentOnFailure = core.getInput('comment-on-failure') === 'true';
4045
const validateWorkItemExistsFlag = core.getInput('validate-work-item-exists') === 'true';
4146

47+
// Warn if an invalid scope value was provided
48+
if (checkPullRequest && pullRequestCheckScopeRaw && !validScopes.includes(pullRequestCheckScopeRaw)) {
49+
core.warning(
50+
`Invalid value '${pullRequestCheckScopeRaw}' for 'pull-request-check-scope'. Using default 'title-or-body'. Valid values are: ${validScopes.join(', ')}`
51+
);
52+
}
53+
4254
// Validate that at least one check is enabled
4355
if (!checkPullRequest && !checkCommits) {
4456
core.setFailed(
@@ -108,7 +120,8 @@ export async function run() {
108120
validateWorkItemExistsFlag,
109121
azureDevopsOrganization,
110122
azureDevopsToken,
111-
workItemToCommitMap
123+
workItemToCommitMap,
124+
pullRequestCheckScope
112125
);
113126
}
114127

@@ -415,7 +428,8 @@ async function checkCommitsForWorkItems(
415428
* @param {string} azureDevopsOrganization - Azure DevOps organization name
416429
* @param {string} azureDevopsToken - Azure DevOps PAT token
417430
* @param {Map} workItemToCommitMap - Map of work item IDs to commit info from checkCommitsForWorkItems
418-
* @returns {Array} Returns array of invalid work item IDs found in PR title/body
431+
* @param {string} pullRequestCheckScope - Where to look for AB# in the PR: 'title-or-body', 'body-only', or 'title-only'
432+
* @returns {Array} Returns array of invalid work item IDs found in the PR based on pullRequestCheckScope
419433
*/
420434
async function checkPullRequestForWorkItems(
421435
octokit,
@@ -425,7 +439,8 @@ async function checkPullRequestForWorkItems(
425439
validateWorkItemExistsFlag,
426440
azureDevopsOrganization,
427441
azureDevopsToken,
428-
workItemToCommitMap
442+
workItemToCommitMap,
443+
pullRequestCheckScope = 'title-or-body'
429444
) {
430445
const { owner, repo } = context.repo;
431446

@@ -439,11 +454,32 @@ async function checkPullRequestForWorkItems(
439454
const pullBody = pullRequest.data.body || '';
440455
const pullTitle = pullRequest.data.title || '';
441456

457+
// Determine which text to check based on pull-request-check-scope
458+
let textToCheck;
459+
let scopeDescription;
460+
switch (pullRequestCheckScope) {
461+
case 'body-only':
462+
textToCheck = pullBody;
463+
scopeDescription = 'body';
464+
break;
465+
case 'title-only':
466+
textToCheck = pullTitle;
467+
scopeDescription = 'title';
468+
break;
469+
case 'title-or-body':
470+
default:
471+
textToCheck = `${pullTitle} ${pullBody}`;
472+
scopeDescription = 'title or body';
473+
break;
474+
}
475+
476+
core.info(`Checking PR ${scopeDescription} for work item links (scope: ${pullRequestCheckScope})`);
477+
442478
// Define common comment text patterns
443479
const FAILURE_COMMENT_TEXT = ':x: This pull request is not linked to a work item.';
444480
const SUCCESS_COMMENT_TEXT = ':white_check_mark: This pull request is now linked to a work item.';
445481

446-
if (!AB_PATTERN.test(`${pullTitle} ${pullBody}`)) {
482+
if (!AB_PATTERN.test(textToCheck)) {
447483
core.info('PR not linked to a work item');
448484
core.error(
449485
`Pull Request not linked to work item(s): The pull request #${pullNumber} is not linked to any work item(s)`
@@ -455,7 +491,7 @@ async function checkPullRequestForWorkItems(
455491
octokit,
456492
context,
457493
pullNumber,
458-
`${FAILURE_COMMENT_TEXT} Please update the title or body to include a work item and re-run the failed job to continue. Any new commits to the pull request will also re-run the job.`,
494+
`${FAILURE_COMMENT_TEXT} Please update the ${scopeDescription} to include a work item and re-run the failed job to continue. Any new commits to the pull request will also re-run the job.`,
459495
FAILURE_COMMENT_TEXT
460496
);
461497
}
@@ -489,8 +525,8 @@ async function checkPullRequestForWorkItems(
489525
core.info('... PR comment updated to success');
490526
}
491527

492-
// Extract work items from PR body and title and validate they exist
493-
const workItems = `${pullBody} ${pullTitle}`.match(AB_PATTERN);
528+
// Extract work items from the checked scope and validate they exist
529+
const workItems = textToCheck.match(AB_PATTERN);
494530
if (workItems) {
495531
const uniqueWorkItems = [...new Set(workItems)];
496532

0 commit comments

Comments
 (0)