diff --git a/README.md b/README.md
index d3a235f..60ca58b 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,8 @@ This action validates that pull requests and commits contain Azure DevOps work i
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
@@ -63,19 +64,20 @@ 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` |
-| `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` |
-| `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` |
+| `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` |
+| `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. Requires `check-pull-request` or `check-commits` to also be enabled | `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` |
## Screenshots
diff --git a/__tests__/index.test.js b/__tests__/index.test.js
index 7731437..02fa3d2 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,8 @@ describe('Azure DevOps Commit Validator', () => {
'github-token': 'github-token',
'comment-on-failure': 'true',
'validate-work-item-exists': 'false',
- 'append-work-item-title': 'false'
+ 'append-work-item-title': 'false',
+ 'add-work-item-from-branch': 'false'
};
return defaults[name] || '';
});
@@ -125,7 +128,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);
@@ -2190,4 +2193,305 @@ describe('Azure DevOps Commit Validator', () => {
);
});
});
+
+ describe('extractWorkItemIdsFromBranch', () => {
+ it('should extract work item ID from task/12345/make-it-better', () => {
+ expect(extractWorkItemIdsFromBranch('task/12345/make-it-better')).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from task/12345-make-it-better', () => {
+ expect(extractWorkItemIdsFromBranch('task/12345-make-it-better')).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from task/12345', () => {
+ expect(extractWorkItemIdsFromBranch('task/12345')).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from task-12345', () => {
+ expect(extractWorkItemIdsFromBranch('task-12345')).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from 12345-make-it-better', () => {
+ expect(extractWorkItemIdsFromBranch('12345-make-it-better')).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from 12345make-it-better', () => {
+ expect(extractWorkItemIdsFromBranch('12345make-it-better')).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from 12345', () => {
+ expect(extractWorkItemIdsFromBranch('12345')).toEqual(['12345']);
+ });
+
+ it('should extract work item ID from feature_12345_description', () => {
+ expect(extractWorkItemIdsFromBranch('feature_12345_description')).toEqual(['12345']);
+ });
+
+ it('should return unique IDs when branch contains duplicates', () => {
+ expect(extractWorkItemIdsFromBranch('fix/12345/12345-again')).toEqual(['12345']);
+ });
+
+ it('should extract multiple different work item IDs', () => {
+ expect(extractWorkItemIdsFromBranch('fix/12345/67890-combined')).toEqual(['12345', '67890']);
+ });
+
+ it('should return empty array for branch with no numbers', () => {
+ expect(extractWorkItemIdsFromBranch('feature/add-new-stuff')).toEqual([]);
+ });
+
+ it('should return empty array for null/empty input', () => {
+ expect(extractWorkItemIdsFromBranch('')).toEqual([]);
+ expect(extractWorkItemIdsFromBranch(null)).toEqual([]);
+ expect(extractWorkItemIdsFromBranch(undefined)).toEqual([]);
+ });
+
+ it('should ignore short numbers to avoid false positives from version numbers', () => {
+ expect(extractWorkItemIdsFromBranch('feature-v2-add-logging')).toEqual([]);
+ expect(extractWorkItemIdsFromBranch('release-1-2-3')).toEqual([]);
+ expect(extractWorkItemIdsFromBranch('hotfix/v3')).toEqual([]);
+ });
+
+ it('should match exactly 3 digit IDs', () => {
+ expect(extractWorkItemIdsFromBranch('task/123/fix')).toEqual(['123']);
+ });
+
+ it('should ignore 2-digit numbers but match longer ones in same branch', () => {
+ expect(extractWorkItemIdsFromBranch('hotfix/2024-bugfix')).toEqual(['2024']);
+ });
+ });
+
+ 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',
+ 'append-work-item-title': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'github-token': 'github-token',
+ 'azure-devops-token': '',
+ 'azure-devops-organization': ''
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: 'Some description' }
+ });
+
+ 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',
+ 'append-work-item-title': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'github-token': 'github-token',
+ 'azure-devops-token': '',
+ 'azure-devops-organization': ''
+ };
+ 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',
+ 'append-work-item-title': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'github-token': 'github-token',
+ 'azure-devops-token': '',
+ 'azure-devops-organization': ''
+ };
+ 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',
+ 'append-work-item-title': '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',
+ 'append-work-item-title': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'github-token': 'github-token',
+ 'azure-devops-token': '',
+ 'azure-devops-organization': ''
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: '' }
+ });
+
+ 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: 'fix/12345/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',
+ 'append-work-item-title': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'github-token': 'github-token',
+ 'azure-devops-token': '',
+ 'azure-devops-organization': ''
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: 'Description' }
+ });
+
+ 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: 'fix/12345/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',
+ 'append-work-item-title': 'false',
+ 'add-work-item-from-branch': 'true',
+ 'github-token': 'github-token',
+ 'azure-devops-token': '',
+ 'azure-devops-organization': ''
+ };
+ return inputs[name] || '';
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: { title: 'My PR', body: 'Fixes AB#12345' }
+ });
+
+ 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
+ });
+ });
});
diff --git a/action.yml b/action.yml
index 8bbe8ca..5f3eba4 100644
--- a/action.yml
+++ b/action.yml
@@ -48,6 +48,10 @@ inputs:
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.'
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 matches IDs with 3+ digits to avoid false positives from version numbers. Requires check-pull-request or check-commits to also be enabled.'
+ required: false
+ default: 'false'
runs:
using: 'node20'
diff --git a/badges/coverage.svg b/badges/coverage.svg
index 63742a8..37916cc 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 ce57268..72cc109 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "azure-devops-work-item-link-enforcer-and-linker",
- "version": "3.2.0",
+ "version": "3.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "azure-devops-work-item-link-enforcer-and-linker",
- "version": "3.2.0",
+ "version": "3.3.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^3.0.0",
diff --git a/package.json b/package.json
index 5076e75..22e999a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "azure-devops-work-item-link-enforcer-and-linker",
- "version": "3.2.0",
+ "version": "3.3.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 44c6076..b2b516e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -15,6 +15,9 @@ 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;
+/** Regex pattern to extract work item IDs from branch names (3+ digit sequences preceded by start or separator) */
+const BRANCH_WORK_ITEM_PATTERN = /(?:^|[/\-_])(\d{3,})/g;
+
/** HTML comment markers for identifying different validation scenarios */
export const COMMENT_MARKERS = {
COMMITS_NOT_LINKED: '',
@@ -44,6 +47,7 @@ export async function run() {
const commentOnFailure = core.getInput('comment-on-failure') === 'true';
const validateWorkItemExistsFlag = core.getInput('validate-work-item-exists') === 'true';
const appendWorkItemTitle = core.getInput('append-work-item-title') === 'true';
+ const addWorkItemFromBranch = core.getInput('add-work-item-from-branch') === 'true';
// Warn if an invalid scope value was provided
if (checkPullRequest && pullRequestCheckScopeRaw && !validScopes.includes(pullRequestCheckScopeRaw)) {
@@ -89,6 +93,11 @@ 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);
+ }
+
// Store work item to commit mapping and validation results
let workItemToCommitMap = new Map();
let invalidWorkItemsFromCommits = [];
@@ -674,6 +683,85 @@ async function appendWorkItemTitlesToPRBody(
}
}
+/**
+ * Extract work item IDs from a branch name
+ * Matches digit sequences preceded by start of string or separators (/, -, _)
+ *
+ * @param {string} branchName - The branch name to extract work item IDs from
+ * @returns {string[]} Array of unique work item ID strings (e.g. ['12345', '67890'])
+ */
+export function extractWorkItemIdsFromBranch(branchName) {
+ if (!branchName) return [];
+
+ const ids = [];
+ let match;
+ // Reset lastIndex since we're using a global regex
+ BRANCH_WORK_ITEM_PATTERN.lastIndex = 0;
+ while ((match = BRANCH_WORK_ITEM_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.
+ *
+ * @param {Object} octokit - GitHub API client
+ * @param {Object} context - GitHub Actions context
+ * @param {number} pullNumber - Pull request number
+ */
+async function addWorkItemsToPRBody(octokit, context, pullNumber) {
+ const { owner, repo } = context.repo;
+ const branchName = context.payload.pull_request?.head?.ref || '';
+
+ core.info(`Extracting work item IDs from branch name: ${branchName}`);
+ const workItemIds = extractWorkItemIdsFromBranch(branchName);
+
+ if (workItemIds.length === 0) {
+ core.info('No work item IDs found in branch name');
+ return;
+ }
+
+ 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;
+ }
+
+ // Build the AB# tags to add
+ const abTags = missingIds.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');
+ core.summary.addRaw(`- :link: **Added from branch:** ${abTags} extracted from branch \`${branchName}\`\n`);
+}
+
/**
* Add or update a comment on the pull request
*