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