From 597a3eea1268b087f953b41bab0f1b8633d40e31 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Fri, 6 Mar 2026 15:45:29 -0600 Subject: [PATCH 1/9] feat: add automatic AB# tagging from branch name Add new `add-ab-tag-from-branch` input that extracts work item IDs from the head branch name and appends AB#xxx to the PR body if not already present. Supports common branch formats like task/12345/description, task-12345, 12345-description, and more. closes #151 --- README.md | 4 +- __tests__/index.test.js | 294 +++++++++++++++++++++++++++++++++++++++- action.yml | 4 + badges/coverage.svg | 2 +- package-lock.json | 4 +- package.json | 2 +- src/index.js | 88 ++++++++++++ 7 files changed, 391 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d3a235f..aa078cf 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 @@ -72,6 +73,7 @@ jobs: | `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-ab-tag-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 | `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 }}` | diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 7731437..874f8aa 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-ab-tag-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,291 @@ 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([]); + }); + }); + + 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-ab-tag-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-ab-tag-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-ab-tag-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-ab-tag-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-ab-tag-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-ab-tag-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-ab-tag-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-ab-tag-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..59575eb 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-ab-tag-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)' + 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 @@ -Coverage: 85.12%Coverage85.12% \ No newline at end of file +Coverage: 86.26%Coverage86.26% \ 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..f44484a 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 (digit sequences preceded by start or separator) */ +const BRANCH_WORK_ITEM_PATTERN = /(?:^|[/\-_])(\d+)/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 addAbTagFromBranch = core.getInput('add-ab-tag-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 (addAbTagFromBranch) { + 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 * From dcb9d929727038a49b0a4a4072c516d026379235 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Fri, 6 Mar 2026 15:49:07 -0600 Subject: [PATCH 2/9] refactor: rename add-ab-tag-from-branch to add-work-item-from-branch --- README.md | 2 +- __tests__/index.test.js | 18 +++++++++--------- action.yml | 2 +- src/index.js | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index aa078cf..e1a371a 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ jobs: | `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-ab-tag-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 | `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 | `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 }}` | diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 874f8aa..77a966e 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -93,7 +93,7 @@ describe('Azure DevOps Commit Validator', () => { 'comment-on-failure': 'true', 'validate-work-item-exists': 'false', 'append-work-item-title': 'false', - 'add-ab-tag-from-branch': 'false' + 'add-work-item-from-branch': 'false' }; return defaults[name] || ''; }); @@ -2259,7 +2259,7 @@ describe('Azure DevOps Commit Validator', () => { 'comment-on-failure': 'false', 'validate-work-item-exists': 'false', 'append-work-item-title': 'false', - 'add-ab-tag-from-branch': 'true', + 'add-work-item-from-branch': 'true', 'github-token': 'github-token', 'azure-devops-token': '', 'azure-devops-organization': '' @@ -2294,7 +2294,7 @@ describe('Azure DevOps Commit Validator', () => { 'comment-on-failure': 'false', 'validate-work-item-exists': 'false', 'append-work-item-title': 'false', - 'add-ab-tag-from-branch': 'true', + 'add-work-item-from-branch': 'true', 'github-token': 'github-token', 'azure-devops-token': '', 'azure-devops-organization': '' @@ -2326,7 +2326,7 @@ describe('Azure DevOps Commit Validator', () => { 'comment-on-failure': 'false', 'validate-work-item-exists': 'false', 'append-work-item-title': 'false', - 'add-ab-tag-from-branch': 'true', + 'add-work-item-from-branch': 'true', 'github-token': 'github-token', 'azure-devops-token': '', 'azure-devops-organization': '' @@ -2342,7 +2342,7 @@ describe('Azure DevOps Commit Validator', () => { expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled(); }); - it('should not run when add-ab-tag-from-branch is false', async () => { + 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 => { @@ -2354,7 +2354,7 @@ describe('Azure DevOps Commit Validator', () => { 'comment-on-failure': 'false', 'validate-work-item-exists': 'false', 'append-work-item-title': 'false', - 'add-ab-tag-from-branch': 'false', + 'add-work-item-from-branch': 'false', 'github-token': 'github-token', 'azure-devops-token': '', 'azure-devops-organization': '' @@ -2382,7 +2382,7 @@ describe('Azure DevOps Commit Validator', () => { 'comment-on-failure': 'false', 'validate-work-item-exists': 'false', 'append-work-item-title': 'false', - 'add-ab-tag-from-branch': 'true', + 'add-work-item-from-branch': 'true', 'github-token': 'github-token', 'azure-devops-token': '', 'azure-devops-organization': '' @@ -2418,7 +2418,7 @@ describe('Azure DevOps Commit Validator', () => { 'comment-on-failure': 'false', 'validate-work-item-exists': 'false', 'append-work-item-title': 'false', - 'add-ab-tag-from-branch': 'true', + 'add-work-item-from-branch': 'true', 'github-token': 'github-token', 'azure-devops-token': '', 'azure-devops-organization': '' @@ -2458,7 +2458,7 @@ describe('Azure DevOps Commit Validator', () => { 'comment-on-failure': 'false', 'validate-work-item-exists': 'false', 'append-work-item-title': 'false', - 'add-ab-tag-from-branch': 'true', + 'add-work-item-from-branch': 'true', 'github-token': 'github-token', 'azure-devops-token': '', 'azure-devops-organization': '' diff --git a/action.yml b/action.yml index 59575eb..1f603ee 100644 --- a/action.yml +++ b/action.yml @@ -48,7 +48,7 @@ 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-ab-tag-from-branch: + 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)' required: false default: 'false' diff --git a/src/index.js b/src/index.js index f44484a..029a919 100644 --- a/src/index.js +++ b/src/index.js @@ -47,7 +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 addAbTagFromBranch = core.getInput('add-ab-tag-from-branch') === '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)) { @@ -94,7 +94,7 @@ export async function run() { const octokit = github.getOctokit(githubToken); // Automatically add AB# tags from branch name if enabled - if (addAbTagFromBranch) { + if (addWorkItemFromBranch) { await addWorkItemsToPRBody(octokit, context, pullNumber); } From c8c6c63f16d62a887be9a2c46f14458dc8b229ab Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Fri, 6 Mar 2026 15:54:57 -0600 Subject: [PATCH 3/9] docs: update description for add-work-item-from-branch input to clarify usage with check-pull-request --- README.md | 2 +- action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e1a371a..3246a94 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ jobs: | `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 | `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. Best used with `check-pull-request=true` | `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 }}` | diff --git a/action.yml b/action.yml index 1f603ee..678cbaa 100644 --- a/action.yml +++ b/action.yml @@ -49,7 +49,7 @@ inputs: 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)' + 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). Best used with check-pull-request=true.' required: false default: 'false' From edec35039615e1a0cd27264bdefc76d9b46f767b Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Fri, 6 Mar 2026 19:55:39 -0600 Subject: [PATCH 4/9] feat: enhance work item extraction from branch names to match only 3+ digit IDs and update related documentation --- README.md | 28 ++++++++++++++-------------- __tests__/index.test.js | 14 ++++++++++++++ action.yml | 2 +- src/index.js | 4 ++-- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3246a94..60ca58b 100644 --- a/README.md +++ b/README.md @@ -64,20 +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` | -| `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. Best used with `check-pull-request=true` | `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 77a966e..02fa3d2 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -2244,6 +2244,20 @@ describe('Azure DevOps Commit Validator', () => { 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', () => { diff --git a/action.yml b/action.yml index 678cbaa..5f3eba4 100644 --- a/action.yml +++ b/action.yml @@ -49,7 +49,7 @@ inputs: 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). Best used with check-pull-request=true.' + 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' diff --git a/src/index.js b/src/index.js index 029a919..b2b516e 100644 --- a/src/index.js +++ b/src/index.js @@ -15,8 +15,8 @@ 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 (digit sequences preceded by start or separator) */ -const BRANCH_WORK_ITEM_PATTERN = /(?:^|[/\-_])(\d+)/g; +/** 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 = { From 02e5f7dd8f06abb876b3bd809516645b8bef4227 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Wed, 18 Mar 2026 14:54:00 -0500 Subject: [PATCH 5/9] feat!: require Azure DevOps validation for branch work item extraction - add-work-item-from-branch now requires azure-devops-token and azure-devops-organization; extracted IDs are always validated against Azure DevOps before being added to the PR body - Remove 3-digit minimum from branch regex since validation catches false positives - Sanitize branch name in job summary to prevent markdown injection - Fix JSDoc param type for extractWorkItemIdsFromBranch - Clean up dangling append-work-item-title test references - Add tests for validation of branch-extracted IDs and missing token --- README.md | 28 ++++---- __tests__/index.test.js | 143 +++++++++++++++++++++++++++++++++------- action.yml | 2 +- badges/coverage.svg | 2 +- src/index.js | 42 +++++++++--- 5 files changed, 169 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index bda0e2d..9926ed2 100644 --- a/README.md +++ b/README.md @@ -70,20 +70,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` | -| `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 matches 3+ digit IDs | `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. 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` | +| `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 fe5614a..176fac9 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -2256,17 +2256,19 @@ describe('Azure DevOps Commit Validator', () => { expect(extractWorkItemIdsFromBranch(undefined)).toEqual([]); }); - it('should ignore short numbers to avoid false positives from version numbers', () => { + it('should extract numbers preceded by separators but not letters', () => { + // v2 and v3 don't match because 'v' is not a separator expect(extractWorkItemIdsFromBranch('feature-v2-add-logging')).toEqual([]); - expect(extractWorkItemIdsFromBranch('release-1-2-3')).toEqual([]); expect(extractWorkItemIdsFromBranch('hotfix/v3')).toEqual([]); + // Numbers directly after separators do match + expect(extractWorkItemIdsFromBranch('release-1-2-3')).toEqual(['1', '2', '3']); }); - it('should match exactly 3 digit IDs', () => { + it('should match 3 digit IDs', () => { expect(extractWorkItemIdsFromBranch('task/123/fix')).toEqual(['123']); }); - it('should ignore 2-digit numbers but match longer ones in same branch', () => { + it('should extract all numbers from branch', () => { expect(extractWorkItemIdsFromBranch('hotfix/2024-bugfix')).toEqual(['2024']); }); }); @@ -2283,11 +2285,10 @@ describe('Azure DevOps Commit Validator', () => { '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': '' + 'azure-devops-token': 'fake-token', + 'azure-devops-organization': 'my-org' }; return inputs[name] || ''; }); @@ -2296,6 +2297,7 @@ describe('Azure DevOps Commit Validator', () => { data: { title: 'My PR', body: 'Some description' } }); + mockValidateWorkItemExists.mockResolvedValueOnce(true); mockOctokit.paginate.mockResolvedValueOnce([]); // commits await run(); @@ -2318,11 +2320,10 @@ describe('Azure DevOps Commit Validator', () => { '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': '' + 'azure-devops-token': 'fake-token', + 'azure-devops-organization': 'my-org' }; return inputs[name] || ''; }); @@ -2350,11 +2351,10 @@ describe('Azure DevOps Commit Validator', () => { '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': '' + 'azure-devops-token': 'fake-token', + 'azure-devops-organization': 'my-org' }; return inputs[name] || ''; }); @@ -2378,7 +2378,6 @@ describe('Azure DevOps Commit Validator', () => { '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': '', @@ -2406,11 +2405,10 @@ describe('Azure DevOps Commit Validator', () => { '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': '' + 'azure-devops-token': 'fake-token', + 'azure-devops-organization': 'my-org' }; return inputs[name] || ''; }); @@ -2419,6 +2417,7 @@ describe('Azure DevOps Commit Validator', () => { data: { title: 'My PR', body: '' } }); + mockValidateWorkItemExists.mockResolvedValueOnce(true); mockOctokit.paginate.mockResolvedValueOnce([]); // commits await run(); @@ -2442,11 +2441,10 @@ describe('Azure DevOps Commit Validator', () => { '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': '' + 'azure-devops-token': 'fake-token', + 'azure-devops-organization': 'my-org' }; return inputs[name] || ''; }); @@ -2455,6 +2453,7 @@ describe('Azure DevOps Commit Validator', () => { data: { title: 'My PR', body: 'Description' } }); + mockValidateWorkItemExists.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mockOctokit.paginate.mockResolvedValueOnce([]); // commits await run(); @@ -2482,11 +2481,10 @@ describe('Azure DevOps Commit Validator', () => { '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': '' + 'azure-devops-token': 'fake-token', + 'azure-devops-organization': 'my-org' }; return inputs[name] || ''; }); @@ -2495,6 +2493,8 @@ describe('Azure DevOps Commit Validator', () => { 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(); @@ -2504,5 +2504,102 @@ describe('Azure DevOps Commit Validator', () => { 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', + '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: 'fix/12345/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', + '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', + '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')); + }); }); }); diff --git a/action.yml b/action.yml index b5702cb..45a4e00 100644 --- a/action.yml +++ b/action.yml @@ -49,7 +49,7 @@ inputs: 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.' + 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). 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' diff --git a/badges/coverage.svg b/badges/coverage.svg index 158139f..93c1222 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 86.12%Coverage86.12% \ No newline at end of file +Coverage: 86.55%Coverage86.55% \ No newline at end of file diff --git a/src/index.js b/src/index.js index 1bc2c4d..acd5355 100644 --- a/src/index.js +++ b/src/index.js @@ -15,8 +15,8 @@ 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; +/** Regex pattern to extract work item IDs from branch names (digit sequences preceded by start or separator) */ +const BRANCH_WORK_ITEM_PATTERN = /(?:^|[/\-_])(\d+)/g; /** HTML comment markers for identifying different validation scenarios */ export const COMMENT_MARKERS = { @@ -73,8 +73,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'); @@ -84,6 +84,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(', ')}` ); @@ -95,7 +96,7 @@ export async function run() { // Automatically add AB# tags from branch name if enabled if (addWorkItemFromBranch) { - await addWorkItemsToPRBody(octokit, context, pullNumber); + await addWorkItemsToPRBody(octokit, context, pullNumber, azureDevopsOrganization, azureDevopsToken); } // Store work item to commit mapping and validation results @@ -714,7 +715,7 @@ 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 + * @param {string | null | undefined} 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) { @@ -735,12 +736,15 @@ export function extractWorkItemIdsFromBranch(branchName) { /** * 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 */ -async function addWorkItemsToPRBody(octokit, context, pullNumber) { +async function addWorkItemsToPRBody(octokit, context, pullNumber, azureDevopsOrganization, azureDevopsToken) { const { owner, repo } = context.repo; const branchName = context.payload.pull_request?.head?.ref || ''; @@ -774,8 +778,27 @@ async function addWorkItemsToPRBody(octokit, context, pullNumber) { 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 = missingIds.map(id => `AB#${id}`).join(' '); + 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}`); @@ -786,7 +809,8 @@ async function addWorkItemsToPRBody(octokit, context, 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`); + const sanitizedBranchName = branchName.replace(/`/g, '\\`'); + core.summary.addRaw(`- :link: **Added from branch:** ${abTags} extracted from branch \`${sanitizedBranchName}\`\n`); } /** From e71bfcaa6b3756d454aea26d5a893fb287a40bb6 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Wed, 18 Mar 2026 15:05:10 -0500 Subject: [PATCH 6/9] fix: address PR review findings for branch extraction - Fix GHAS incomplete string escaping: escape backslashes in branchName - Allow add-work-item-from-branch as standalone (update guard condition) - Cap branch ID extraction at 5 to limit API calls - Fix misleading azure-devops-token/organization descriptions in README - Add test for standalone branch extraction usage --- README.md | 4 ++-- __tests__/index.test.js | 39 +++++++++++++++++++++++++++++++++++++-- badges/coverage.svg | 2 +- src/index.js | 15 ++++++++++++--- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9926ed2..dc80181 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,8 @@ jobs: | `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. 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` | -| `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` | `''` | +| `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` | diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 176fac9..a99a299 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -151,7 +151,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'; @@ -162,7 +162,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." ); }); @@ -2601,5 +2601,40 @@ describe('Azure DevOps Commit Validator', () => { 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', + '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/badges/coverage.svg b/badges/coverage.svg index 93c1222..bf05a12 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 86.55%Coverage86.55% \ No newline at end of file +Coverage: 86.23%Coverage86.23% \ No newline at end of file diff --git a/src/index.js b/src/index.js index acd5355..1dac471 100644 --- a/src/index.js +++ b/src/index.js @@ -57,9 +57,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; } @@ -756,6 +756,15 @@ async function addWorkItemsToPRBody(octokit, context, pullNumber, azureDevopsOrg 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 @@ -809,7 +818,7 @@ async function addWorkItemsToPRBody(octokit, context, pullNumber, azureDevopsOrg body: updatedBody }); core.info('PR body updated with work item tag(s) from branch name'); - const sanitizedBranchName = branchName.replace(/`/g, '\\`'); + const sanitizedBranchName = branchName.replace(/\\/g, '\\\\').replace(/`/g, '\\`'); core.summary.addRaw(`- :link: **Added from branch:** ${abTags} extracted from branch \`${sanitizedBranchName}\`\n`); } From 77299354528ca4fa84f49cefde9c910b53fef486 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Wed, 18 Mar 2026 15:06:37 -0500 Subject: [PATCH 7/9] chore: bump version to 4.1.0 in package.json --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 465e9cd..7e8d9bb 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 c4f4ca5..abddbe2 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 ", From 02650b8be7a4c59dc6da38630d7d236f573a4137 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Sun, 29 Mar 2026 09:54:59 -0500 Subject: [PATCH 8/9] feat: restrict branch work item extraction to keyword prefixes Only extract work item IDs from branch names when they follow a recognized keyword prefix (e.g. task/12345, bug-67890) instead of matching any bare number. This prevents false positives from year-like numbers or version segments in branch names. Add configurable `branch-work-item-prefixes` input (default: task, bug, bugfix) so users can customize the keyword list. Resolves the false positive concern raised in #151. --- README.md | 29 +++++----- __tests__/index.test.js | 124 ++++++++++++++++++++++++++++------------ action.yml | 6 +- badges/coverage.svg | 2 +- package-lock.json | 4 +- package.json | 2 +- src/index.js | 59 ++++++++++++++----- 7 files changed, 159 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index dc80181..d31933e 100644 --- a/README.md +++ b/README.md @@ -70,20 +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` | -| `add-work-item-from-branch` | Automatically extract work item ID(s) from the head branch name and add `AB#xxx` to the PR body if not already present. Each ID is always validated against Azure DevOps before being added (regardless of the `validate-work-item-exists` setting). Requires `azure-devops-token` and `azure-devops-organization` | `false` | `false` | -| `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` | +| 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 a99a299..87c1857 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -93,7 +93,8 @@ describe('Azure DevOps Commit Validator', () => { 'comment-on-failure': 'true', 'validate-work-item-exists': 'false', 'add-work-item-table': 'false', - 'add-work-item-from-branch': 'false' + 'add-work-item-from-branch': 'false', + 'branch-work-item-prefixes': 'task, bug, bugfix' }; return defaults[name] || ''; }); @@ -162,7 +163,7 @@ describe('Azure DevOps Commit Validator', () => { await run(); expect(mockSetFailed).toHaveBeenCalledWith( - "At least one of 'check-commits', 'check-pull-request', or 'add-work-item-from-branch' must be set to true." + `At least one of 'check-commits', 'check-pull-request', or 'add-work-item-from-branch' must be set to true.` ); }); @@ -778,7 +779,7 @@ describe('Azure DevOps Commit Validator', () => { await run(); expect(mockWarning).toHaveBeenCalledWith( - expect.stringContaining("Invalid value 'invalid-value' for 'pull-request-check-scope'") + expect.stringContaining(`Invalid value 'invalid-value' for 'pull-request-check-scope'`) ); // Should still pass because it falls back to title-or-body and title has AB# expect(mockSetFailed).not.toHaveBeenCalled(); @@ -2206,70 +2207,113 @@ 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')).toEqual(['12345']); + 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')).toEqual(['12345']); + expect(extractWorkItemIdsFromBranch('task/12345-make-it-better', defaultPrefixes)).toEqual(['12345']); }); it('should extract work item ID from task/12345', () => { - expect(extractWorkItemIdsFromBranch('task/12345')).toEqual(['12345']); + expect(extractWorkItemIdsFromBranch('task/12345', defaultPrefixes)).toEqual(['12345']); }); it('should extract work item ID from task-12345', () => { - expect(extractWorkItemIdsFromBranch('task-12345')).toEqual(['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 12345-make-it-better', () => { - expect(extractWorkItemIdsFromBranch('12345-make-it-better')).toEqual(['12345']); + it('should extract work item ID from bugfix/12345', () => { + expect(extractWorkItemIdsFromBranch('bugfix/12345', defaultPrefixes)).toEqual(['12345']); }); - it('should extract work item ID from 12345make-it-better', () => { - expect(extractWorkItemIdsFromBranch('12345make-it-better')).toEqual(['12345']); + it('should NOT extract bare number from 12345-make-it-better', () => { + expect(extractWorkItemIdsFromBranch('12345-make-it-better', defaultPrefixes)).toEqual([]); }); - it('should extract work item ID from 12345', () => { - expect(extractWorkItemIdsFromBranch('12345')).toEqual(['12345']); + it('should NOT extract bare number from 12345', () => { + expect(extractWorkItemIdsFromBranch('12345', defaultPrefixes)).toEqual([]); }); - it('should extract work item ID from feature_12345_description', () => { - expect(extractWorkItemIdsFromBranch('feature_12345_description')).toEqual(['12345']); + 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('fix/12345/12345-again')).toEqual(['12345']); + expect(extractWorkItemIdsFromBranch('bug/12345/task/12345-again', defaultPrefixes)).toEqual(['12345']); }); it('should extract multiple different work item IDs', () => { - expect(extractWorkItemIdsFromBranch('fix/12345/67890-combined')).toEqual(['12345', '67890']); + expect(extractWorkItemIdsFromBranch('bug/12345/task/67890-combined', defaultPrefixes)).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 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('')).toEqual([]); - expect(extractWorkItemIdsFromBranch(null)).toEqual([]); - expect(extractWorkItemIdsFromBranch(undefined)).toEqual([]); + 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 extract numbers preceded by separators but not letters', () => { - // v2 and v3 don't match because 'v' is not a separator - expect(extractWorkItemIdsFromBranch('feature-v2-add-logging')).toEqual([]); - expect(extractWorkItemIdsFromBranch('hotfix/v3')).toEqual([]); - // Numbers directly after separators do match - expect(extractWorkItemIdsFromBranch('release-1-2-3')).toEqual(['1', '2', '3']); + 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 match 3 digit IDs', () => { - expect(extractWorkItemIdsFromBranch('task/123/fix')).toEqual(['123']); + 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 extract all numbers from branch', () => { - expect(extractWorkItemIdsFromBranch('hotfix/2024-bugfix')).toEqual(['2024']); + it('should work with prefix nested after user segment', () => { + expect(extractWorkItemIdsFromBranch('users/josh/task/12345/fix', defaultPrefixes)).toEqual(['12345']); }); }); @@ -2286,6 +2330,7 @@ describe('Azure DevOps Commit Validator', () => { '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' @@ -2321,6 +2366,7 @@ describe('Azure DevOps Commit Validator', () => { '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' @@ -2352,6 +2398,7 @@ describe('Azure DevOps Commit Validator', () => { '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' @@ -2406,6 +2453,7 @@ describe('Azure DevOps Commit Validator', () => { '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' @@ -2431,7 +2479,7 @@ describe('Azure DevOps Commit Validator', () => { }); it('should add multiple AB# tags from branch with multiple IDs', async () => { - mockContext.payload.pull_request = { number: 42, head: { ref: 'fix/12345/67890-combined' } }; + mockContext.payload.pull_request = { number: 42, head: { ref: 'bug/12345/task/67890-combined' } }; mockGetInput.mockImplementation(name => { const inputs = { @@ -2442,6 +2490,7 @@ describe('Azure DevOps Commit Validator', () => { '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' @@ -2471,7 +2520,7 @@ describe('Azure DevOps Commit Validator', () => { }); 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' } }; + mockContext.payload.pull_request = { number: 42, head: { ref: 'bug/12345/task/67890-combined' } }; mockGetInput.mockImplementation(name => { const inputs = { @@ -2482,6 +2531,7 @@ describe('Azure DevOps Commit Validator', () => { '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' @@ -2517,6 +2567,7 @@ describe('Azure DevOps Commit Validator', () => { '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': '' @@ -2531,7 +2582,7 @@ describe('Azure DevOps Commit Validator', () => { }); 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: 'fix/12345/99999-combined' } }; + mockContext.payload.pull_request = { number: 42, head: { ref: 'bug/12345/task/99999-combined' } }; mockGetInput.mockImplementation(name => { const inputs = { @@ -2542,6 +2593,7 @@ describe('Azure DevOps Commit Validator', () => { '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' @@ -2579,6 +2631,7 @@ describe('Azure DevOps Commit Validator', () => { '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' @@ -2614,6 +2667,7 @@ describe('Azure DevOps Commit Validator', () => { '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' diff --git a/action.yml b/action.yml index 45a4e00..00fd571 100644 --- a/action.yml +++ b/action.yml @@ -49,9 +49,13 @@ inputs: 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). 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.' + 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 bf05a12..c445d44 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 86.23%Coverage86.23% \ No newline at end of file +Coverage: 86.38%Coverage86.38% \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7e8d9bb..526fc91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "azure-devops-work-item-link-enforcer-and-linker", - "version": "4.1.0", + "version": "4.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "azure-devops-work-item-link-enforcer-and-linker", - "version": "4.1.0", + "version": "4.2.0", "license": "MIT", "dependencies": { "@actions/core": "^3.0.0", diff --git a/package.json b/package.json index abddbe2..ff55791 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "azure-devops-work-item-link-enforcer-and-linker", - "version": "4.1.0", + "version": "4.2.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 1dac471..bf4c95d 100644 --- a/src/index.js +++ b/src/index.js @@ -15,8 +15,19 @@ 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 (digit sequences preceded by start or separator) */ -const BRANCH_WORK_ITEM_PATTERN = /(?:^|[/\-_])(\d+)/g; +/** + * 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 = { @@ -48,6 +59,11 @@ export async function run() { 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)) { @@ -96,7 +112,14 @@ export async function run() { // Automatically add AB# tags from branch name if enabled if (addWorkItemFromBranch) { - await addWorkItemsToPRBody(octokit, context, pullNumber, azureDevopsOrganization, azureDevopsToken); + await addWorkItemsToPRBody( + octokit, + context, + pullNumber, + azureDevopsOrganization, + azureDevopsToken, + branchWorkItemPrefixes + ); } // Store work item to commit mapping and validation results @@ -712,20 +735,22 @@ async function appendWorkItemTitlesToPRBody( } /** - * Extract work item IDs from a branch name - * Matches digit sequences preceded by start of string or separators (/, -, _) + * 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) { - if (!branchName) return []; +export function extractWorkItemIdsFromBranch(branchName, prefixes) { + if (!branchName || !prefixes || prefixes.length === 0) return []; + const pattern = buildBranchWorkItemPattern(prefixes); 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) { + while ((match = pattern.exec(branchName)) !== null) { ids.push(match[1]); } @@ -743,13 +768,21 @@ export function extractWorkItemIdsFromBranch(branchName) { * @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) { +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}`); - const workItemIds = extractWorkItemIdsFromBranch(branchName); + 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'); From 5803d42c31ba80a6d0bf5e769d03039b4be032da Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Sun, 29 Mar 2026 11:12:30 -0500 Subject: [PATCH 9/9] fix: revert version number to 4.1.0 in package.json --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6a50a77..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.2.0", + "version": "4.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "azure-devops-work-item-link-enforcer-and-linker", - "version": "4.2.0", + "version": "4.1.0", "license": "MIT", "dependencies": { "@actions/core": "^3.0.0", diff --git a/package.json b/package.json index d12b0ac..3de1656 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "azure-devops-work-item-link-enforcer-and-linker", - "version": "4.2.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 ",