diff --git a/README.md b/README.md index d3a235f..60ca58b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ This action validates that pull requests and commits contain Azure DevOps work i 2. **Validates Commits** - Ensures each commit in a pull request has an Azure DevOps work item link (e.g. `AB#123`) in the commit message 3. **Automatically Links PRs to Work Items** - When a work item is referenced in a commit message, the action adds a GitHub Pull Request link to that work item in Azure DevOps - 🎯 **This is the key differentiator**: By default, Azure DevOps only adds the Pull Request link to work items mentioned directly in the PR title or body, but this action also links work items found in commit messages! -4. **Visibility & Tracking** - Work item linkages are added to the job summary for easy visibility +4. **Auto-Tag from Branch** - Optionally extracts work item IDs from the head branch name (e.g. `task/12345/fix-bug`) and adds `AB#12345` to the PR body automatically +5. **Visibility & Tracking** - Work item linkages are added to the job summary for easy visibility ## Action Output @@ -63,19 +64,20 @@ jobs: ### Inputs -| Name | Description | Required | Default | -| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------- | -| `check-pull-request` | Check the pull request for `AB#xxx` (scope configurable via `pull-request-check-scope`) | `true` | `false` | -| `pull-request-check-scope` | Only if `check-pull-request=true`, where to look for `AB#` in the PR: `title-or-body`, `body-only`, or `title-only` | `false` | `title-or-body` | -| `check-commits` | Check each commit in the pull request for `AB#xxx` | `true` | `true` | -| `fail-if-missing-workitem-commit-link` | Only if `check-commits=true`, fail the action if a commit in the pull request is missing AB# in every commit message | `false` | `true` | -| `link-commits-to-pull-request` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `true` | -| `validate-work-item-exists` | Validate that the work item(s) referenced in commits and PR exist in Azure DevOps (requires `azure-devops-token` and `azure-devops-organization`) | `false` | `true` | -| `append-work-item-title` | Append the work item title to `AB#xxx` references in the PR body (e.g. `AB#123` becomes `AB#123 - Fix bug`). Requires `azure-devops-token` and `azure-devops-organization` | `false` | `false` | -| `azure-devops-organization` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `''` | -| `azure-devops-token` | Only required if `link-commits-to-pull-request=true`, Azure DevOps PAT used to link work item to PR (needs to be a `full` PAT) | `false` | `''` | -| `github-token` | The GitHub token that has contents-read and pull_request-write access | `true` | `${{ github.token }}` | -| `comment-on-failure` | Comment on the pull request if the action fails | `true` | `true` | +| Name | Description | Required | Default | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------- | +| `check-pull-request` | Check the pull request for `AB#xxx` (scope configurable via `pull-request-check-scope`) | `true` | `false` | +| `pull-request-check-scope` | Only if `check-pull-request=true`, where to look for `AB#` in the PR: `title-or-body`, `body-only`, or `title-only` | `false` | `title-or-body` | +| `check-commits` | Check each commit in the pull request for `AB#xxx` | `true` | `true` | +| `fail-if-missing-workitem-commit-link` | Only if `check-commits=true`, fail the action if a commit in the pull request is missing AB# in every commit message | `false` | `true` | +| `link-commits-to-pull-request` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `true` | +| `validate-work-item-exists` | Validate that the work item(s) referenced in commits and PR exist in Azure DevOps (requires `azure-devops-token` and `azure-devops-organization`) | `false` | `true` | +| `append-work-item-title` | Append the work item title to `AB#xxx` references in the PR body (e.g. `AB#123` becomes `AB#123 - Fix bug`). Requires `azure-devops-token` and `azure-devops-organization` | `false` | `false` | +| `add-work-item-from-branch` | Automatically extract work item ID(s) from the head branch name and add `AB#xxx` to the PR body if not already present. Only matches 3+ digit IDs. Requires `check-pull-request` or `check-commits` to also be enabled | `false` | `false` | +| `azure-devops-organization` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `''` | +| `azure-devops-token` | Only required if `link-commits-to-pull-request=true`, Azure DevOps PAT used to link work item to PR (needs to be a `full` PAT) | `false` | `''` | +| `github-token` | The GitHub token that has contents-read and pull_request-write access | `true` | `${{ github.token }}` | +| `comment-on-failure` | Comment on the pull request if the action fails | `true` | `true` | ## Screenshots diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 7731437..02fa3d2 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -58,6 +58,7 @@ describe('Azure DevOps Commit Validator', () => { let mockOctokit; let run; let COMMENT_MARKERS; + let extractWorkItemIdsFromBranch; beforeAll(async () => { // Set NODE_ENV to test to prevent auto-execution @@ -67,6 +68,7 @@ describe('Azure DevOps Commit Validator', () => { const indexModule = await import('../src/index.js'); run = indexModule.run; COMMENT_MARKERS = indexModule.COMMENT_MARKERS; + extractWorkItemIdsFromBranch = indexModule.extractWorkItemIdsFromBranch; }); beforeEach(() => { @@ -90,7 +92,8 @@ describe('Azure DevOps Commit Validator', () => { 'github-token': 'github-token', 'comment-on-failure': 'true', 'validate-work-item-exists': 'false', - 'append-work-item-title': 'false' + 'append-work-item-title': 'false', + 'add-work-item-from-branch': 'false' }; return defaults[name] || ''; }); @@ -125,7 +128,7 @@ describe('Azure DevOps Commit Validator', () => { }; mockGetOctokit.mockReturnValue(mockOctokit); - mockContext.payload.pull_request = { number: 42 }; + mockContext.payload.pull_request = { number: 42, head: { ref: 'feature/test-branch' } }; // Default mock for validateWorkItemExists (returns true by default) mockValidateWorkItemExists.mockResolvedValue(true); @@ -2190,4 +2193,305 @@ describe('Azure DevOps Commit Validator', () => { ); }); }); + + describe('extractWorkItemIdsFromBranch', () => { + it('should extract work item ID from task/12345/make-it-better', () => { + expect(extractWorkItemIdsFromBranch('task/12345/make-it-better')).toEqual(['12345']); + }); + + it('should extract work item ID from task/12345-make-it-better', () => { + expect(extractWorkItemIdsFromBranch('task/12345-make-it-better')).toEqual(['12345']); + }); + + it('should extract work item ID from task/12345', () => { + expect(extractWorkItemIdsFromBranch('task/12345')).toEqual(['12345']); + }); + + it('should extract work item ID from task-12345', () => { + expect(extractWorkItemIdsFromBranch('task-12345')).toEqual(['12345']); + }); + + it('should extract work item ID from 12345-make-it-better', () => { + expect(extractWorkItemIdsFromBranch('12345-make-it-better')).toEqual(['12345']); + }); + + it('should extract work item ID from 12345make-it-better', () => { + expect(extractWorkItemIdsFromBranch('12345make-it-better')).toEqual(['12345']); + }); + + it('should extract work item ID from 12345', () => { + expect(extractWorkItemIdsFromBranch('12345')).toEqual(['12345']); + }); + + it('should extract work item ID from feature_12345_description', () => { + expect(extractWorkItemIdsFromBranch('feature_12345_description')).toEqual(['12345']); + }); + + it('should return unique IDs when branch contains duplicates', () => { + expect(extractWorkItemIdsFromBranch('fix/12345/12345-again')).toEqual(['12345']); + }); + + it('should extract multiple different work item IDs', () => { + expect(extractWorkItemIdsFromBranch('fix/12345/67890-combined')).toEqual(['12345', '67890']); + }); + + it('should return empty array for branch with no numbers', () => { + expect(extractWorkItemIdsFromBranch('feature/add-new-stuff')).toEqual([]); + }); + + it('should return empty array for null/empty input', () => { + expect(extractWorkItemIdsFromBranch('')).toEqual([]); + expect(extractWorkItemIdsFromBranch(null)).toEqual([]); + expect(extractWorkItemIdsFromBranch(undefined)).toEqual([]); + }); + + it('should ignore short numbers to avoid false positives from version numbers', () => { + expect(extractWorkItemIdsFromBranch('feature-v2-add-logging')).toEqual([]); + expect(extractWorkItemIdsFromBranch('release-1-2-3')).toEqual([]); + expect(extractWorkItemIdsFromBranch('hotfix/v3')).toEqual([]); + }); + + it('should match exactly 3 digit IDs', () => { + expect(extractWorkItemIdsFromBranch('task/123/fix')).toEqual(['123']); + }); + + it('should ignore 2-digit numbers but match longer ones in same branch', () => { + expect(extractWorkItemIdsFromBranch('hotfix/2024-bugfix')).toEqual(['2024']); + }); + }); + + describe('Add AB# tag from branch', () => { + it('should add AB# tag to PR body when work item found in branch name', async () => { + mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/make-it-better' } }; + + mockGetInput.mockImplementation(name => { + const inputs = { + 'check-commits': 'true', + 'check-pull-request': 'false', + 'fail-if-missing-workitem-commit-link': 'false', + 'link-commits-to-pull-request': 'false', + 'comment-on-failure': 'false', + 'validate-work-item-exists': 'false', + 'append-work-item-title': 'false', + 'add-work-item-from-branch': 'true', + 'github-token': 'github-token', + 'azure-devops-token': '', + 'azure-devops-organization': '' + }; + return inputs[name] || ''; + }); + + mockOctokit.rest.pulls.get.mockResolvedValue({ + data: { title: 'My PR', body: 'Some description' } + }); + + mockOctokit.paginate.mockResolvedValueOnce([]); // commits + + await run(); + + expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('AB#12345') + }) + ); + }); + + it('should not add AB# tag when it already exists in PR body', async () => { + mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/make-it-better' } }; + + mockGetInput.mockImplementation(name => { + const inputs = { + 'check-commits': 'true', + 'check-pull-request': 'false', + 'fail-if-missing-workitem-commit-link': 'false', + 'link-commits-to-pull-request': 'false', + 'comment-on-failure': 'false', + 'validate-work-item-exists': 'false', + 'append-work-item-title': 'false', + 'add-work-item-from-branch': 'true', + 'github-token': 'github-token', + 'azure-devops-token': '', + 'azure-devops-organization': '' + }; + return inputs[name] || ''; + }); + + mockOctokit.rest.pulls.get.mockResolvedValue({ + data: { title: 'My PR', body: 'Fix AB#12345 bug' } + }); + + mockOctokit.paginate.mockResolvedValueOnce([]); // commits + + await run(); + + // Should NOT call update since AB#12345 is already in the body + expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled(); + }); + + it('should not update PR when no work item IDs found in branch', async () => { + mockContext.payload.pull_request = { number: 42, head: { ref: 'feature/add-new-stuff' } }; + + mockGetInput.mockImplementation(name => { + const inputs = { + 'check-commits': 'true', + 'check-pull-request': 'false', + 'fail-if-missing-workitem-commit-link': 'false', + 'link-commits-to-pull-request': 'false', + 'comment-on-failure': 'false', + 'validate-work-item-exists': 'false', + 'append-work-item-title': 'false', + 'add-work-item-from-branch': 'true', + 'github-token': 'github-token', + 'azure-devops-token': '', + 'azure-devops-organization': '' + }; + return inputs[name] || ''; + }); + + mockOctokit.paginate.mockResolvedValueOnce([]); // commits + + await run(); + + // Should NOT call pulls.get or pulls.update since no IDs found + expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled(); + }); + + it('should not run when add-work-item-from-branch is false', async () => { + mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/make-it-better' } }; + + mockGetInput.mockImplementation(name => { + const inputs = { + 'check-commits': 'true', + 'check-pull-request': 'false', + 'fail-if-missing-workitem-commit-link': 'false', + 'link-commits-to-pull-request': 'false', + 'comment-on-failure': 'false', + 'validate-work-item-exists': 'false', + 'append-work-item-title': 'false', + 'add-work-item-from-branch': 'false', + 'github-token': 'github-token', + 'azure-devops-token': '', + 'azure-devops-organization': '' + }; + return inputs[name] || ''; + }); + + mockOctokit.paginate.mockResolvedValueOnce([]); // commits + + await run(); + + // Should NOT call pulls.update since feature is disabled + expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled(); + }); + + it('should handle empty PR body when adding AB# tag', async () => { + mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/fix' } }; + + mockGetInput.mockImplementation(name => { + const inputs = { + 'check-commits': 'true', + 'check-pull-request': 'false', + 'fail-if-missing-workitem-commit-link': 'false', + 'link-commits-to-pull-request': 'false', + 'comment-on-failure': 'false', + 'validate-work-item-exists': 'false', + 'append-work-item-title': 'false', + 'add-work-item-from-branch': 'true', + 'github-token': 'github-token', + 'azure-devops-token': '', + 'azure-devops-organization': '' + }; + return inputs[name] || ''; + }); + + mockOctokit.rest.pulls.get.mockResolvedValue({ + data: { title: 'My PR', body: '' } + }); + + mockOctokit.paginate.mockResolvedValueOnce([]); // commits + + await run(); + + // Should set body to just the AB# tag (no leading newlines) + expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: 'AB#12345' + }) + ); + }); + + it('should add multiple AB# tags from branch with multiple IDs', async () => { + mockContext.payload.pull_request = { number: 42, head: { ref: 'fix/12345/67890-combined' } }; + + mockGetInput.mockImplementation(name => { + const inputs = { + 'check-commits': 'true', + 'check-pull-request': 'false', + 'fail-if-missing-workitem-commit-link': 'false', + 'link-commits-to-pull-request': 'false', + 'comment-on-failure': 'false', + 'validate-work-item-exists': 'false', + 'append-work-item-title': 'false', + 'add-work-item-from-branch': 'true', + 'github-token': 'github-token', + 'azure-devops-token': '', + 'azure-devops-organization': '' + }; + return inputs[name] || ''; + }); + + mockOctokit.rest.pulls.get.mockResolvedValue({ + data: { title: 'My PR', body: 'Description' } + }); + + mockOctokit.paginate.mockResolvedValueOnce([]); // commits + + await run(); + + expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('AB#12345') + }) + ); + expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('AB#67890') + }) + ); + }); + + it('should only add missing AB# tags when some already exist in body', async () => { + mockContext.payload.pull_request = { number: 42, head: { ref: 'fix/12345/67890-combined' } }; + + mockGetInput.mockImplementation(name => { + const inputs = { + 'check-commits': 'true', + 'check-pull-request': 'false', + 'fail-if-missing-workitem-commit-link': 'false', + 'link-commits-to-pull-request': 'false', + 'comment-on-failure': 'false', + 'validate-work-item-exists': 'false', + 'append-work-item-title': 'false', + 'add-work-item-from-branch': 'true', + 'github-token': 'github-token', + 'azure-devops-token': '', + 'azure-devops-organization': '' + }; + return inputs[name] || ''; + }); + + mockOctokit.rest.pulls.get.mockResolvedValue({ + data: { title: 'My PR', body: 'Fixes AB#12345' } + }); + + mockOctokit.paginate.mockResolvedValueOnce([]); // commits + + await run(); + + // Should only add AB#67890 since AB#12345 already exists + const updateCall = mockOctokit.rest.pulls.update.mock.calls[0][0]; + expect(updateCall.body).toContain('AB#67890'); + expect(updateCall.body).toContain('Fixes AB#12345'); // original body preserved + }); + }); }); diff --git a/action.yml b/action.yml index 8bbe8ca..5f3eba4 100644 --- a/action.yml +++ b/action.yml @@ -48,6 +48,10 @@ inputs: description: 'Append the work item title to AB#xxx references in the PR body (e.g. AB#123 becomes AB#123 - Fix bug). Requires azure-devops-token and azure-devops-organization to be set.' required: false default: 'false' + add-work-item-from-branch: + description: 'Automatically extract work item ID(s) from the head branch name and add AB#xxx to the PR body if not already present (e.g. branch task/12345/fix-bug adds AB#12345 to the PR body). Only matches IDs with 3+ digits to avoid false positives from version numbers. Requires check-pull-request or check-commits to also be enabled.' + required: false + default: 'false' runs: using: 'node20' diff --git a/badges/coverage.svg b/badges/coverage.svg index 63742a8..37916cc 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -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..b2b516e 100644 --- a/src/index.js +++ b/src/index.js @@ -15,6 +15,9 @@ import { run as linkWorkItem, validateWorkItemExists, getWorkItemTitle } from '. /** Regex pattern to match Azure DevOps work item references (AB#123) */ const AB_PATTERN = /AB#[0-9]+/gi; +/** Regex pattern to extract work item IDs from branch names (3+ digit sequences preceded by start or separator) */ +const BRANCH_WORK_ITEM_PATTERN = /(?:^|[/\-_])(\d{3,})/g; + /** HTML comment markers for identifying different validation scenarios */ export const COMMENT_MARKERS = { COMMITS_NOT_LINKED: '', @@ -44,6 +47,7 @@ export async function run() { const commentOnFailure = core.getInput('comment-on-failure') === 'true'; const validateWorkItemExistsFlag = core.getInput('validate-work-item-exists') === 'true'; const appendWorkItemTitle = core.getInput('append-work-item-title') === 'true'; + const addWorkItemFromBranch = core.getInput('add-work-item-from-branch') === 'true'; // Warn if an invalid scope value was provided if (checkPullRequest && pullRequestCheckScopeRaw && !validScopes.includes(pullRequestCheckScopeRaw)) { @@ -89,6 +93,11 @@ export async function run() { const octokit = github.getOctokit(githubToken); + // Automatically add AB# tags from branch name if enabled + if (addWorkItemFromBranch) { + await addWorkItemsToPRBody(octokit, context, pullNumber); + } + // Store work item to commit mapping and validation results let workItemToCommitMap = new Map(); let invalidWorkItemsFromCommits = []; @@ -674,6 +683,85 @@ async function appendWorkItemTitlesToPRBody( } } +/** + * Extract work item IDs from a branch name + * Matches digit sequences preceded by start of string or separators (/, -, _) + * + * @param {string} branchName - The branch name to extract work item IDs from + * @returns {string[]} Array of unique work item ID strings (e.g. ['12345', '67890']) + */ +export function extractWorkItemIdsFromBranch(branchName) { + if (!branchName) return []; + + const ids = []; + let match; + // Reset lastIndex since we're using a global regex + BRANCH_WORK_ITEM_PATTERN.lastIndex = 0; + while ((match = BRANCH_WORK_ITEM_PATTERN.exec(branchName)) !== null) { + ids.push(match[1]); + } + + // Return unique IDs only + return [...new Set(ids)]; +} + +/** + * Add AB# work item tags to the PR body based on work item IDs found in the branch name. + * Skips IDs that are already referenced in the PR body. + * + * @param {Object} octokit - GitHub API client + * @param {Object} context - GitHub Actions context + * @param {number} pullNumber - Pull request number + */ +async function addWorkItemsToPRBody(octokit, context, pullNumber) { + const { owner, repo } = context.repo; + const branchName = context.payload.pull_request?.head?.ref || ''; + + core.info(`Extracting work item IDs from branch name: ${branchName}`); + const workItemIds = extractWorkItemIdsFromBranch(branchName); + + if (workItemIds.length === 0) { + core.info('No work item IDs found in branch name'); + return; + } + + core.info(`Found work item ID(s) in branch: ${workItemIds.join(', ')}`); + + // Get current PR body + const pullRequest = await octokit.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber + }); + + const currentBody = pullRequest.data.body || ''; + + // Filter to only IDs not already in the PR body + const missingIds = workItemIds.filter(id => { + const pattern = new RegExp(`AB#${id}(?!\\d)`, 'i'); + return !pattern.test(currentBody); + }); + + if (missingIds.length === 0) { + core.info('All work item IDs from branch are already in the PR body'); + return; + } + + // Build the AB# tags to add + const abTags = missingIds.map(id => `AB#${id}`).join(' '); + const updatedBody = currentBody ? `${currentBody}\n\n${abTags}` : abTags; + + core.info(`Adding work item tag(s) to PR body: ${abTags}`); + await octokit.rest.pulls.update({ + owner, + repo, + pull_number: pullNumber, + body: updatedBody + }); + core.info('PR body updated with work item tag(s) from branch name'); + core.summary.addRaw(`- :link: **Added from branch:** ${abTags} extracted from branch \`${branchName}\`\n`); +} + /** * Add or update a comment on the pull request *