diff --git a/README.md b/README.md
index 5189a12..d31933e 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,8 @@ Please refer to the [release page](https://github.com/joshjohanning/azdo_commit_
2. **Validates Commits** - Ensures each commit in a pull request has an Azure DevOps work item link (e.g. `AB#123`) in the commit message
3. **Automatically Links PRs to Work Items** - When a work item is referenced in a commit message, the action adds a GitHub Pull Request link to that work item in Azure DevOps
- 🎯 **This is the key differentiator**: By default, Azure DevOps only adds the Pull Request link to work items mentioned directly in the PR title or body, but this action also links work items found in commit messages!
-4. **Visibility & Tracking** - Work item linkages are added to the job summary for easy visibility
+4. **Auto-Tag from Branch** - Optionally extracts work item IDs from the head branch name (e.g. `task/12345/fix-bug`) and adds `AB#12345` to the PR body automatically
+5. **Visibility & Tracking** - Work item linkages are added to the job summary for easy visibility
## Action Output
@@ -69,19 +70,21 @@ jobs:
### Inputs
-| Name | Description | Required | Default |
-| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------- |
-| `check-pull-request` | Check the pull request for `AB#xxx` (scope configurable via `pull-request-check-scope`) | `true` | `false` |
-| `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` |
-| `check-commits` | Check each commit in the pull request for `AB#xxx` | `true` | `true` |
-| `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` |
-| `link-commits-to-pull-request` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `true` |
-| `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` |
-| `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` |
-| `azure-devops-organization` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `''` |
-| `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` | `''` |
-| `github-token` | The GitHub token that has contents-read and pull_request-write access | `true` | `${{ github.token }}` |
-| `comment-on-failure` | Comment on the pull request if the action fails | `true` | `true` |
+| Name | Description | Required | Default |
+| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------- |
+| `check-pull-request` | Check the pull request for `AB#xxx` (scope configurable via `pull-request-check-scope`) | `true` | `false` |
+| `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` |
+| `check-commits` | Check each commit in the pull request for `AB#xxx` | `true` | `true` |
+| `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` |
+| `link-commits-to-pull-request` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `true` |
+| `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` |
+| `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` |
+| `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 numbers following one of the configured `branch-work-item-prefixes` keywords are extracted. 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` |
+| `branch-work-item-prefixes` | Comma-separated list of keyword prefixes used to identify work item IDs in branch names (e.g. `task/12345`). Only numbers following one of these keywords (separated by `/`, `-`, or `_`) are extracted. Only used when `add-work-item-from-branch` is `true` | `false` | `task, bug, bugfix` |
+| `azure-devops-organization` | The name of the Azure DevOps organization. Required when any of these are enabled: `link-commits-to-pull-request`, `validate-work-item-exists`, `add-work-item-table`, or `add-work-item-from-branch` | `false` | `''` |
+| `azure-devops-token` | Azure DevOps PAT (needs to be a `full` PAT). Required when any of these are enabled: `link-commits-to-pull-request`, `validate-work-item-exists`, `add-work-item-table`, or `add-work-item-from-branch` | `false` | `''` |
+| `github-token` | The GitHub token that has contents-read and pull_request-write access | `true` | `${{ github.token }}` |
+| `comment-on-failure` | Comment on the pull request if the action fails | `true` | `true` |
## Screenshots
diff --git a/__tests__/index.test.js b/__tests__/index.test.js
index 58fa8b7..87c1857 100644
--- a/__tests__/index.test.js
+++ b/__tests__/index.test.js
@@ -58,6 +58,7 @@ describe('Azure DevOps Commit Validator', () => {
let mockOctokit;
let run;
let COMMENT_MARKERS;
+ let extractWorkItemIdsFromBranch;
beforeAll(async () => {
// Set NODE_ENV to test to prevent auto-execution
@@ -67,6 +68,7 @@ describe('Azure DevOps Commit Validator', () => {
const indexModule = await import('../src/index.js');
run = indexModule.run;
COMMENT_MARKERS = indexModule.COMMENT_MARKERS;
+ extractWorkItemIdsFromBranch = indexModule.extractWorkItemIdsFromBranch;
});
beforeEach(() => {
@@ -90,7 +92,9 @@ describe('Azure DevOps Commit Validator', () => {
'github-token': 'github-token',
'comment-on-failure': 'true',
'validate-work-item-exists': 'false',
- 'add-work-item-table': 'false'
+ 'add-work-item-table': 'false',
+ 'add-work-item-from-branch': 'false',
+ 'branch-work-item-prefixes': 'task, bug, bugfix'
};
return defaults[name] || '';
});
@@ -125,7 +129,7 @@ describe('Azure DevOps Commit Validator', () => {
};
mockGetOctokit.mockReturnValue(mockOctokit);
- mockContext.payload.pull_request = { number: 42 };
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'feature/test-branch' } };
// Default mock for validateWorkItemExists (returns true by default)
mockValidateWorkItemExists.mockResolvedValue(true);
@@ -148,7 +152,7 @@ describe('Azure DevOps Commit Validator', () => {
mockContext.payload.pull_request = originalPR;
});
- it('should fail if both check-commits and check-pull-request are false', async () => {
+ it('should fail if both check-commits and check-pull-request are false and add-work-item-from-branch is false', async () => {
mockGetInput.mockImplementation(name => {
if (name === 'check-commits') return 'false';
if (name === 'check-pull-request') return 'false';
@@ -159,7 +163,7 @@ describe('Azure DevOps Commit Validator', () => {
await run();
expect(mockSetFailed).toHaveBeenCalledWith(
- `At least one of 'check-commits' or 'check-pull-request' must be set to true. Both are currently set to false.`
+ `At least one of 'check-commits', 'check-pull-request', or 'add-work-item-from-branch' must be set to true.`
);
});
@@ -2201,4 +2205,490 @@ describe('Azure DevOps Commit Validator', () => {
);
});
});
+
+ describe('extractWorkItemIdsFromBranch', () => {
+ const defaultPrefixes = ['task', 'bug', 'bugfix'];
+
+ it('should extract work item ID from task/12345/make-it-better', () => {
+ expect(extractWorkItemIdsFromBranch('task/12345/make-it-better', defaultPrefixes)).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from task/12345-make-it-better', () => {
+ expect(extractWorkItemIdsFromBranch('task/12345-make-it-better', defaultPrefixes)).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from task/12345', () => {
+ expect(extractWorkItemIdsFromBranch('task/12345', defaultPrefixes)).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from task-12345', () => {
+ expect(extractWorkItemIdsFromBranch('task-12345', defaultPrefixes)).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from task_12345', () => {
+ expect(extractWorkItemIdsFromBranch('task_12345', defaultPrefixes)).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from bug/12345', () => {
+ expect(extractWorkItemIdsFromBranch('bug/12345', defaultPrefixes)).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from bugfix/12345', () => {
+ expect(extractWorkItemIdsFromBranch('bugfix/12345', defaultPrefixes)).toEqual(['12345']);
+ });
+
+ it('should NOT extract bare number from 12345-make-it-better', () => {
+ expect(extractWorkItemIdsFromBranch('12345-make-it-better', defaultPrefixes)).toEqual([]);
+ });
+
+ it('should NOT extract bare number from 12345', () => {
+ expect(extractWorkItemIdsFromBranch('12345', defaultPrefixes)).toEqual([]);
+ });
+
+ it('should NOT extract number from feature_12345_description without feature prefix', () => {
+ expect(extractWorkItemIdsFromBranch('feature_12345_description', defaultPrefixes)).toEqual([]);
+ });
+
+ it('should return unique IDs when branch contains duplicates', () => {
+ expect(extractWorkItemIdsFromBranch('bug/12345/task/12345-again', defaultPrefixes)).toEqual(['12345']);
+ });
+
+ it('should extract multiple different work item IDs', () => {
+ expect(extractWorkItemIdsFromBranch('bug/12345/task/67890-combined', defaultPrefixes)).toEqual([
+ '12345',
+ '67890'
+ ]);
+ });
+
+ it('should return empty array for branch with no keyword-prefixed numbers', () => {
+ expect(extractWorkItemIdsFromBranch('feature/add-new-stuff', defaultPrefixes)).toEqual([]);
+ });
+
+ it('should return empty array for null/empty input', () => {
+ expect(extractWorkItemIdsFromBranch('', defaultPrefixes)).toEqual([]);
+ expect(extractWorkItemIdsFromBranch(null, defaultPrefixes)).toEqual([]);
+ expect(extractWorkItemIdsFromBranch(undefined, defaultPrefixes)).toEqual([]);
+ });
+
+ it('should return empty array when prefixes is empty', () => {
+ expect(extractWorkItemIdsFromBranch('task/12345', [])).toEqual([]);
+ });
+
+ it('should NOT extract numbers that are not preceded by a keyword prefix', () => {
+ expect(extractWorkItemIdsFromBranch('feature-v2-add-logging', defaultPrefixes)).toEqual([]);
+ expect(extractWorkItemIdsFromBranch('hotfix/v3', defaultPrefixes)).toEqual([]);
+ expect(extractWorkItemIdsFromBranch('release-1-2-3', defaultPrefixes)).toEqual([]);
+ });
+
+ it('should NOT extract year-like numbers from non-keyword branches', () => {
+ expect(extractWorkItemIdsFromBranch('hotfix/2024-bugfix', defaultPrefixes)).toEqual([]);
+ expect(extractWorkItemIdsFromBranch('feature/update-2025-calendar', defaultPrefixes)).toEqual([]);
+ });
+
+ it('should match 3 digit IDs after a keyword prefix', () => {
+ expect(extractWorkItemIdsFromBranch('task/123/fix', defaultPrefixes)).toEqual(['123']);
+ });
+
+ it('should be case-insensitive for prefixes', () => {
+ expect(extractWorkItemIdsFromBranch('Task/12345', defaultPrefixes)).toEqual(['12345']);
+ expect(extractWorkItemIdsFromBranch('BUG/12345', defaultPrefixes)).toEqual(['12345']);
+ expect(extractWorkItemIdsFromBranch('BUGFIX/12345', defaultPrefixes)).toEqual(['12345']);
+ });
+
+ it('should work with custom prefixes', () => {
+ const customPrefixes = ['story', 'epic', 'pbi'];
+ expect(extractWorkItemIdsFromBranch('story/12345/fix', customPrefixes)).toEqual(['12345']);
+ expect(extractWorkItemIdsFromBranch('epic-67890', customPrefixes)).toEqual(['67890']);
+ expect(extractWorkItemIdsFromBranch('pbi_11111', customPrefixes)).toEqual(['11111']);
+ expect(extractWorkItemIdsFromBranch('task/12345', customPrefixes)).toEqual([]);
+ });
+
+ it('should NOT match prefix as substring of a longer word', () => {
+ // "hotfix" contains "fix" but should not match when prefix is just "fix"
+ // because "fix" in "hotfix" is preceded by "t", not a separator
+ const prefixes = ['fix'];
+ expect(extractWorkItemIdsFromBranch('hotfix/12345', prefixes)).toEqual([]);
+ // but "some-fix/12345" should match because "fix" is preceded by "-"
+ expect(extractWorkItemIdsFromBranch('some-fix/12345', prefixes)).toEqual(['12345']);
+ });
+
+ it('should work with prefix nested after user segment', () => {
+ expect(extractWorkItemIdsFromBranch('users/josh/task/12345/fix', defaultPrefixes)).toEqual(['12345']);
+ });
+ });
+
+ describe('Add AB# tag from branch', () => {
+ it('should add AB# tag to PR body when work item found in branch name', async () => {
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/make-it-better' } };
+
+ mockGetInput.mockImplementation(name => {
+ const inputs = {
+ 'check-commits': 'true',
+ 'check-pull-request': 'false',
+ 'fail-if-missing-workitem-commit-link': 'false',
+ 'link-commits-to-pull-request': 'false',
+ 'comment-on-failure': 'false',
+ 'validate-work-item-exists': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'branch-work-item-prefixes': 'task, bug, bugfix',
+ 'github-token': 'github-token',
+ 'azure-devops-token': 'fake-token',
+ 'azure-devops-organization': 'my-org'
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: 'Some description' }
+ });
+
+ mockValidateWorkItemExists.mockResolvedValueOnce(true);
+ mockOctokit.paginate.mockResolvedValueOnce([]); // commits
+
+ await run();
+
+ expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.stringContaining('AB#12345')
+ })
+ );
+ });
+
+ it('should not add AB# tag when it already exists in PR body', async () => {
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/make-it-better' } };
+
+ mockGetInput.mockImplementation(name => {
+ const inputs = {
+ 'check-commits': 'true',
+ 'check-pull-request': 'false',
+ 'fail-if-missing-workitem-commit-link': 'false',
+ 'link-commits-to-pull-request': 'false',
+ 'comment-on-failure': 'false',
+ 'validate-work-item-exists': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'branch-work-item-prefixes': 'task, bug, bugfix',
+ 'github-token': 'github-token',
+ 'azure-devops-token': 'fake-token',
+ 'azure-devops-organization': 'my-org'
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: 'Fix AB#12345 bug' }
+ });
+
+ mockOctokit.paginate.mockResolvedValueOnce([]); // commits
+
+ await run();
+
+ // Should NOT call update since AB#12345 is already in the body
+ expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
+ });
+
+ it('should not update PR when no work item IDs found in branch', async () => {
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'feature/add-new-stuff' } };
+
+ mockGetInput.mockImplementation(name => {
+ const inputs = {
+ 'check-commits': 'true',
+ 'check-pull-request': 'false',
+ 'fail-if-missing-workitem-commit-link': 'false',
+ 'link-commits-to-pull-request': 'false',
+ 'comment-on-failure': 'false',
+ 'validate-work-item-exists': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'branch-work-item-prefixes': 'task, bug, bugfix',
+ 'github-token': 'github-token',
+ 'azure-devops-token': 'fake-token',
+ 'azure-devops-organization': 'my-org'
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.paginate.mockResolvedValueOnce([]); // commits
+
+ await run();
+
+ // Should NOT call pulls.get or pulls.update since no IDs found
+ expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
+ });
+
+ it('should not run when add-work-item-from-branch is false', async () => {
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/make-it-better' } };
+
+ mockGetInput.mockImplementation(name => {
+ const inputs = {
+ 'check-commits': 'true',
+ 'check-pull-request': 'false',
+ 'fail-if-missing-workitem-commit-link': 'false',
+ 'link-commits-to-pull-request': 'false',
+ 'comment-on-failure': 'false',
+ 'validate-work-item-exists': 'false',
+ 'add-work-item-from-branch': 'false',
+ 'github-token': 'github-token',
+ 'azure-devops-token': '',
+ 'azure-devops-organization': ''
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.paginate.mockResolvedValueOnce([]); // commits
+
+ await run();
+
+ // Should NOT call pulls.update since feature is disabled
+ expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
+ });
+
+ it('should handle empty PR body when adding AB# tag', async () => {
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/fix' } };
+
+ mockGetInput.mockImplementation(name => {
+ const inputs = {
+ 'check-commits': 'true',
+ 'check-pull-request': 'false',
+ 'fail-if-missing-workitem-commit-link': 'false',
+ 'link-commits-to-pull-request': 'false',
+ 'comment-on-failure': 'false',
+ 'validate-work-item-exists': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'branch-work-item-prefixes': 'task, bug, bugfix',
+ 'github-token': 'github-token',
+ 'azure-devops-token': 'fake-token',
+ 'azure-devops-organization': 'my-org'
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: '' }
+ });
+
+ mockValidateWorkItemExists.mockResolvedValueOnce(true);
+ mockOctokit.paginate.mockResolvedValueOnce([]); // commits
+
+ await run();
+
+ // Should set body to just the AB# tag (no leading newlines)
+ expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: 'AB#12345'
+ })
+ );
+ });
+
+ it('should add multiple AB# tags from branch with multiple IDs', async () => {
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'bug/12345/task/67890-combined' } };
+
+ mockGetInput.mockImplementation(name => {
+ const inputs = {
+ 'check-commits': 'true',
+ 'check-pull-request': 'false',
+ 'fail-if-missing-workitem-commit-link': 'false',
+ 'link-commits-to-pull-request': 'false',
+ 'comment-on-failure': 'false',
+ 'validate-work-item-exists': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'branch-work-item-prefixes': 'task, bug, bugfix',
+ 'github-token': 'github-token',
+ 'azure-devops-token': 'fake-token',
+ 'azure-devops-organization': 'my-org'
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: 'Description' }
+ });
+
+ mockValidateWorkItemExists.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
+ mockOctokit.paginate.mockResolvedValueOnce([]); // commits
+
+ await run();
+
+ expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.stringContaining('AB#12345')
+ })
+ );
+ expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.stringContaining('AB#67890')
+ })
+ );
+ });
+
+ it('should only add missing AB# tags when some already exist in body', async () => {
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'bug/12345/task/67890-combined' } };
+
+ mockGetInput.mockImplementation(name => {
+ const inputs = {
+ 'check-commits': 'true',
+ 'check-pull-request': 'false',
+ 'fail-if-missing-workitem-commit-link': 'false',
+ 'link-commits-to-pull-request': 'false',
+ 'comment-on-failure': 'false',
+ 'validate-work-item-exists': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'branch-work-item-prefixes': 'task, bug, bugfix',
+ 'github-token': 'github-token',
+ 'azure-devops-token': 'fake-token',
+ 'azure-devops-organization': 'my-org'
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: 'Fixes AB#12345' }
+ });
+
+ // Only 67890 needs validation (12345 already in body)
+ mockValidateWorkItemExists.mockResolvedValueOnce(true);
+ mockOctokit.paginate.mockResolvedValueOnce([]); // commits
+
+ await run();
+
+ // Should only add AB#67890 since AB#12345 already exists
+ const updateCall = mockOctokit.rest.pulls.update.mock.calls[0][0];
+ expect(updateCall.body).toContain('AB#67890');
+ expect(updateCall.body).toContain('Fixes AB#12345'); // original body preserved
+ });
+
+ it('should fail if azure-devops-token is missing when add-work-item-from-branch is enabled', async () => {
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/fix' } };
+
+ mockGetInput.mockImplementation(name => {
+ const inputs = {
+ 'check-commits': 'true',
+ 'check-pull-request': 'false',
+ 'fail-if-missing-workitem-commit-link': 'false',
+ 'link-commits-to-pull-request': 'false',
+ 'comment-on-failure': 'false',
+ 'validate-work-item-exists': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'branch-work-item-prefixes': 'task, bug, bugfix',
+ 'github-token': 'github-token',
+ 'azure-devops-token': '',
+ 'azure-devops-organization': ''
+ };
+ return inputs[name] || '';
+ });
+
+ await run();
+
+ expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('add-work-item-from-branch'));
+ expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('azure-devops-token'));
+ });
+
+ it('should skip branch-extracted IDs that do not exist in Azure DevOps when validation is enabled', async () => {
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'bug/12345/task/99999-combined' } };
+
+ mockGetInput.mockImplementation(name => {
+ const inputs = {
+ 'check-commits': 'true',
+ 'check-pull-request': 'false',
+ 'fail-if-missing-workitem-commit-link': 'false',
+ 'link-commits-to-pull-request': 'false',
+ 'comment-on-failure': 'false',
+ 'validate-work-item-exists': 'true',
+ 'add-work-item-from-branch': 'true',
+ 'branch-work-item-prefixes': 'task, bug, bugfix',
+ 'github-token': 'github-token',
+ 'azure-devops-token': 'fake-token',
+ 'azure-devops-organization': 'my-org'
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: 'Description' }
+ });
+
+ // 12345 exists, 99999 does not
+ mockValidateWorkItemExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
+
+ mockOctokit.paginate.mockResolvedValueOnce([]); // commits
+
+ await run();
+
+ // Should only add AB#12345 (the valid one), not AB#99999
+ const updateCall = mockOctokit.rest.pulls.update.mock.calls[0][0];
+ expect(updateCall.body).toContain('AB#12345');
+ expect(updateCall.body).not.toContain('AB#99999');
+ expect(mockWarning).toHaveBeenCalledWith(expect.stringContaining('99999'));
+ });
+
+ it('should not update PR body when all branch-extracted IDs fail validation', async () => {
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'task/99999/fix' } };
+
+ mockGetInput.mockImplementation(name => {
+ const inputs = {
+ 'check-commits': 'true',
+ 'check-pull-request': 'false',
+ 'fail-if-missing-workitem-commit-link': 'false',
+ 'link-commits-to-pull-request': 'false',
+ 'comment-on-failure': 'false',
+ 'validate-work-item-exists': 'true',
+ 'add-work-item-from-branch': 'true',
+ 'branch-work-item-prefixes': 'task, bug, bugfix',
+ 'github-token': 'github-token',
+ 'azure-devops-token': 'fake-token',
+ 'azure-devops-organization': 'my-org'
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: 'Description' }
+ });
+
+ // 99999 does not exist
+ mockValidateWorkItemExists.mockResolvedValueOnce(false);
+
+ mockOctokit.paginate.mockResolvedValueOnce([]); // commits
+
+ await run();
+
+ // Should NOT update PR body since the only ID was invalid
+ expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
+ expect(mockWarning).toHaveBeenCalledWith(expect.stringContaining('99999'));
+ });
+
+ it('should work standalone when check-commits and check-pull-request are both false', async () => {
+ mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/fix' } };
+
+ mockGetInput.mockImplementation(name => {
+ const inputs = {
+ 'check-commits': 'false',
+ 'check-pull-request': 'false',
+ 'fail-if-missing-workitem-commit-link': 'false',
+ 'link-commits-to-pull-request': 'false',
+ 'comment-on-failure': 'false',
+ 'validate-work-item-exists': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'branch-work-item-prefixes': 'task, bug, bugfix',
+ 'github-token': 'github-token',
+ 'azure-devops-token': 'fake-token',
+ 'azure-devops-organization': 'my-org'
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: 'Description' }
+ });
+
+ mockValidateWorkItemExists.mockResolvedValueOnce(true);
+
+ await run();
+
+ expect(mockSetFailed).not.toHaveBeenCalled();
+ expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.stringContaining('AB#12345')
+ })
+ );
+ });
+ });
});
diff --git a/action.yml b/action.yml
index 1bb886e..00fd571 100644
--- a/action.yml
+++ b/action.yml
@@ -48,6 +48,14 @@ inputs:
description: 'Add a Linked Work Items table to the PR body showing titles for AB#xxx references (original AB# references are preserved). Requires azure-devops-token and azure-devops-organization to be set.'
required: false
default: 'false'
+ add-work-item-from-branch:
+ description: 'Automatically extract work item ID(s) from the head branch name and add AB#xxx to the PR body if not already present (e.g. branch task/12345/fix-bug adds AB#12345 to the PR body). Only numbers following one of the configured branch-work-item-prefixes keywords are extracted. Each extracted 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 to be set.'
+ required: false
+ default: 'false'
+ branch-work-item-prefixes:
+ description: 'Comma-separated list of keyword prefixes used to identify work item IDs in branch names (e.g. task/12345). Only numbers following one of these keywords (separated by /, -, or _) are extracted. Only used when add-work-item-from-branch is true.'
+ required: false
+ default: 'task, bug, bugfix'
runs:
using: 'node24'
diff --git a/badges/coverage.svg b/badges/coverage.svg
index ff7d5ef..c445d44 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 8facb1a..8342326 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "azure-devops-work-item-link-enforcer-and-linker",
- "version": "4.0.0",
+ "version": "4.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "azure-devops-work-item-link-enforcer-and-linker",
- "version": "4.0.0",
+ "version": "4.1.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^3.0.0",
diff --git a/package.json b/package.json
index 7adcc72..3de1656 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "azure-devops-work-item-link-enforcer-and-linker",
- "version": "4.0.0",
+ "version": "4.1.0",
"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 ",
diff --git a/src/index.js b/src/index.js
index eab0848..bf4c95d 100644
--- a/src/index.js
+++ b/src/index.js
@@ -15,6 +15,20 @@ import { run as linkWorkItem, validateWorkItemExists, getWorkItemTitle } from '.
/** Regex pattern to match Azure DevOps work item references (AB#123) */
const AB_PATTERN = /AB#[0-9]+/gi;
+/**
+ * Build a regex that matches work item IDs (digit sequences) preceded by one
+ * of the given keyword prefixes and a separator (/, -, _).
+ * The keyword itself must be preceded by start-of-string or a separator to
+ * avoid partial matches (e.g. "hotfix" won't match "fix").
+ *
+ * @param {string[]} prefixes - Keyword prefixes (e.g. ['task', 'bug', 'bugfix'])
+ * @returns {RegExp} A global, case-insensitive regex with a single capture group for the digits
+ */
+function buildBranchWorkItemPattern(prefixes) {
+ const escaped = prefixes.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
+ return new RegExp(`(?:^|[/\\-_])(?:${escaped.join('|')})[/\\-_](\\d+)`, 'gi');
+}
+
/** HTML comment markers for identifying different validation scenarios */
export const COMMENT_MARKERS = {
COMMITS_NOT_LINKED: '',
@@ -44,6 +58,12 @@ export async function run() {
const commentOnFailure = core.getInput('comment-on-failure') === 'true';
const validateWorkItemExistsFlag = core.getInput('validate-work-item-exists') === 'true';
const addWorkItemTable = core.getInput('add-work-item-table') === 'true';
+ const addWorkItemFromBranch = core.getInput('add-work-item-from-branch') === 'true';
+ const branchWorkItemPrefixes = core
+ .getInput('branch-work-item-prefixes')
+ .split(',')
+ .map(p => p.trim())
+ .filter(p => p.length > 0);
// Warn if an invalid scope value was provided
if (checkPullRequest && pullRequestCheckScopeRaw && !validScopes.includes(pullRequestCheckScopeRaw)) {
@@ -53,9 +73,9 @@ export async function run() {
}
// Validate that at least one check is enabled
- if (!checkPullRequest && !checkCommits) {
+ if (!checkPullRequest && !checkCommits && !addWorkItemFromBranch) {
core.setFailed(
- `At least one of 'check-commits' or 'check-pull-request' must be set to true. Both are currently set to false.`
+ `At least one of 'check-commits', 'check-pull-request', or 'add-work-item-from-branch' must be set to true.`
);
return;
}
@@ -69,8 +89,8 @@ export async function run() {
return;
}
- // Validate Azure DevOps configuration if linking, work item validation, or title appending is enabled
- if (linkCommitsToPullRequest || validateWorkItemExistsFlag || addWorkItemTable) {
+ // Validate Azure DevOps configuration if linking, work item validation, title appending, or branch extraction is enabled
+ if (linkCommitsToPullRequest || validateWorkItemExistsFlag || addWorkItemTable || addWorkItemFromBranch) {
const missingConfig = [];
if (!azureDevopsOrganization) missingConfig.push('azure-devops-organization');
if (!azureDevopsToken) missingConfig.push('azure-devops-token');
@@ -80,6 +100,7 @@ export async function run() {
if (linkCommitsToPullRequest) features.push('link-commits-to-pull-request');
if (validateWorkItemExistsFlag) features.push('validate-work-item-exists');
if (addWorkItemTable) features.push('add-work-item-table');
+ if (addWorkItemFromBranch) features.push('add-work-item-from-branch');
core.setFailed(
`The following input${missingConfig.length === 1 ? ' is' : 's are'} required when ${features.join(' or ')} ${features.length === 1 ? 'is' : 'are'} enabled: ${missingConfig.join(', ')}`
);
@@ -89,6 +110,18 @@ export async function run() {
const octokit = github.getOctokit(githubToken);
+ // Automatically add AB# tags from branch name if enabled
+ if (addWorkItemFromBranch) {
+ await addWorkItemsToPRBody(
+ octokit,
+ context,
+ pullNumber,
+ azureDevopsOrganization,
+ azureDevopsToken,
+ branchWorkItemPrefixes
+ );
+ }
+
// Store work item to commit mapping and validation results
let workItemToCommitMap = new Map();
let invalidWorkItemsFromCommits = [];
@@ -701,6 +734,127 @@ async function appendWorkItemTitlesToPRBody(
}
}
+/**
+ * Extract work item IDs from a branch name.
+ * Only matches digit sequences that follow one of the given keyword prefixes
+ * (e.g. task/12345, bug-67890). The prefix must be preceded by start-of-string
+ * or a separator (/, -, _) to prevent partial-word matches.
+ *
+ * @param {string | null | undefined} branchName - The branch name to extract work item IDs from
+ * @param {string[]} prefixes - Keyword prefixes that identify work item IDs (e.g. ['task', 'bug', 'bugfix'])
+ * @returns {string[]} Array of unique work item ID strings (e.g. ['12345', '67890'])
+ */
+export function extractWorkItemIdsFromBranch(branchName, prefixes) {
+ if (!branchName || !prefixes || prefixes.length === 0) return [];
+
+ const pattern = buildBranchWorkItemPattern(prefixes);
+ const ids = [];
+ let match;
+ while ((match = pattern.exec(branchName)) !== null) {
+ ids.push(match[1]);
+ }
+
+ // Return unique IDs only
+ return [...new Set(ids)];
+}
+
+/**
+ * Add AB# work item tags to the PR body based on work item IDs found in the branch name.
+ * Skips IDs that are already referenced in the PR body.
+ * Always validates IDs against Azure DevOps before adding them.
+ *
+ * @param {Object} octokit - GitHub API client
+ * @param {Object} context - GitHub Actions context
+ * @param {number} pullNumber - Pull request number
+ * @param {string} azureDevopsOrganization - Azure DevOps organization name
+ * @param {string} azureDevopsToken - Azure DevOps PAT token
+ * @param {string[]} branchPrefixes - Keyword prefixes for identifying work item IDs in branch names
+ */
+async function addWorkItemsToPRBody(
+ octokit,
+ context,
+ pullNumber,
+ azureDevopsOrganization,
+ azureDevopsToken,
+ branchPrefixes
+) {
+ const { owner, repo } = context.repo;
+ const branchName = context.payload.pull_request?.head?.ref || '';
+
+ core.info(`Extracting work item IDs from branch name: ${branchName} (prefixes: ${branchPrefixes.join(', ')})`);
+ const workItemIds = extractWorkItemIdsFromBranch(branchName, branchPrefixes);
+
+ if (workItemIds.length === 0) {
+ core.info('No work item IDs found in branch name');
+ return;
+ }
+
+ // Cap the number of IDs to validate to avoid excessive API calls
+ const MAX_BRANCH_IDS = 5;
+ if (workItemIds.length > MAX_BRANCH_IDS) {
+ core.warning(
+ `Found ${workItemIds.length} potential work item IDs in branch name, only processing the first ${MAX_BRANCH_IDS}`
+ );
+ workItemIds.length = MAX_BRANCH_IDS;
+ }
+
+ core.info(`Found work item ID(s) in branch: ${workItemIds.join(', ')}`);
+
+ // Get current PR body
+ const pullRequest = await octokit.rest.pulls.get({
+ owner,
+ repo,
+ pull_number: pullNumber
+ });
+
+ const currentBody = pullRequest.data.body || '';
+
+ // Filter to only IDs not already in the PR body
+ const missingIds = workItemIds.filter(id => {
+ const pattern = new RegExp(`AB#${id}(?!\\d)`, 'i');
+ return !pattern.test(currentBody);
+ });
+
+ if (missingIds.length === 0) {
+ core.info('All work item IDs from branch are already in the PR body');
+ return;
+ }
+
+ // Validate IDs against Azure DevOps before adding
+ let idsToAdd = missingIds;
+ const validatedIds = [];
+ for (const id of missingIds) {
+ const exists = await validateWorkItemExists(azureDevopsOrganization, azureDevopsToken, id);
+ if (exists) {
+ validatedIds.push(id);
+ } else {
+ core.warning(
+ `Work item ID ${id} extracted from branch '${branchName}' does not exist in Azure DevOps - skipping`
+ );
+ }
+ }
+ idsToAdd = validatedIds;
+ if (idsToAdd.length === 0) {
+ core.info('No valid work item IDs from branch to add (all failed validation)');
+ return;
+ }
+
+ // Build the AB# tags to add
+ const abTags = idsToAdd.map(id => `AB#${id}`).join(' ');
+ const updatedBody = currentBody ? `${currentBody}\n\n${abTags}` : abTags;
+
+ core.info(`Adding work item tag(s) to PR body: ${abTags}`);
+ await octokit.rest.pulls.update({
+ owner,
+ repo,
+ pull_number: pullNumber,
+ body: updatedBody
+ });
+ core.info('PR body updated with work item tag(s) from branch name');
+ const sanitizedBranchName = branchName.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
+ core.summary.addRaw(`- :link: **Added from branch:** ${abTags} extracted from branch \`${sanitizedBranchName}\`\n`);
+}
+
/**
* Escape special regex characters in a string
*