diff --git a/README.md b/README.md index d3a235f..98a25ca 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ on: jobs: pr-commit-message-enforcer-and-linker: runs-on: ubuntu-latest + # Skip runs triggered by azure-boards bot editing the PR body to avoid duplicate workflow runs + if: github.actor != 'azure-boards[bot]' permissions: contents: read pull-requests: write @@ -63,19 +65,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` | +| `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` | +| `append-work-item-title` | **Deprecated** - use `add-work-item-table` instead. Will be removed in a future major version. | `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..d316ed5 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -90,7 +90,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-table': 'false' }; return defaults[name] || ''; }); @@ -809,15 +810,15 @@ describe('Azure DevOps Commit Validator', () => { }); }); - describe('Append work item title', () => { - it('should append work item title to AB# in PR body when enabled', async () => { + describe('Work item title table', () => { + it('should add work item title table to PR body when enabled', async () => { mockGetInput.mockImplementation(name => { if (name === 'check-commits') return 'false'; if (name === 'check-pull-request') return 'true'; if (name === 'github-token') return 'github-token'; if (name === 'comment-on-failure') return 'false'; if (name === 'validate-work-item-exists') return 'false'; - if (name === 'append-work-item-title') return 'true'; + if (name === 'add-work-item-table') return 'true'; if (name === 'azure-devops-token') return 'azdo-token'; if (name === 'azure-devops-organization') return 'my-org'; return ''; @@ -836,14 +837,16 @@ describe('Azure DevOps Commit Validator', () => { expect(mockSetFailed).not.toHaveBeenCalled(); expect(mockGetWorkItemTitle).toHaveBeenCalledWith('my-org', 'azdo-token', '12345'); - expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith( - expect.objectContaining({ - body: 'This PR implements AB#12345 - Fix login bug' - }) + const updateCall = mockOctokit.rest.pulls.update.mock.calls[0][0]; + expect(updateCall.body).toContain('This PR implements AB#12345'); + expect(updateCall.body).toContain(''); + expect(updateCall.body).toContain('### Linked Work Items'); + expect(updateCall.body).toContain( + '| [12345](https://dev.azure.com/my-org/_workitems/edit/12345) | Bug | Fix login bug |' ); }); - it('should not update PR body when work item already has title appended', async () => { + it('should support deprecated append-work-item-title input as alias', async () => { mockGetInput.mockImplementation(name => { if (name === 'check-commits') return 'false'; if (name === 'check-pull-request') return 'true'; @@ -859,25 +862,59 @@ describe('Azure DevOps Commit Validator', () => { mockOctokit.rest.pulls.get.mockResolvedValue({ data: { title: 'feat: new feature', - body: 'This PR implements AB#12345 - Fix login bug' + body: 'This PR implements AB#12345' } }); + mockGetWorkItemTitle.mockResolvedValue({ title: 'Fix login bug', type: 'Bug' }); + await run(); expect(mockSetFailed).not.toHaveBeenCalled(); - expect(mockGetWorkItemTitle).not.toHaveBeenCalled(); - expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled(); + expect(mockOctokit.rest.pulls.update).toHaveBeenCalled(); + }); + + it('should update section when work item titles section already exists', async () => { + mockGetInput.mockImplementation(name => { + if (name === 'check-commits') return 'false'; + if (name === 'check-pull-request') return 'true'; + if (name === 'github-token') return 'github-token'; + if (name === 'comment-on-failure') return 'false'; + if (name === 'validate-work-item-exists') return 'false'; + if (name === 'add-work-item-table') return 'true'; + if (name === 'azure-devops-token') return 'azdo-token'; + if (name === 'azure-devops-organization') return 'my-org'; + return ''; + }); + + mockOctokit.rest.pulls.get.mockResolvedValue({ + data: { + title: 'feat: new feature', + body: 'This PR implements AB#12345\n\n---\n\n### Linked Work Items\n| Work Item | Type | Title |\n|---|---|---|\n| [12345](https://dev.azure.com/my-org/_workitems/edit/12345) | Bug | Old title |\n' + } + }); + + mockGetWorkItemTitle.mockResolvedValue({ title: 'Fix login bug', type: 'Bug' }); + + await run(); + + expect(mockSetFailed).not.toHaveBeenCalled(); + const updateCall = mockOctokit.rest.pulls.update.mock.calls[0][0]; + expect(updateCall.body).toContain('This PR implements AB#12345'); + expect(updateCall.body).toContain( + '| [12345](https://dev.azure.com/my-org/_workitems/edit/12345) | Bug | Fix login bug |' + ); + expect(updateCall.body).not.toContain('Old title'); }); - it('should not append when append-work-item-title is false', async () => { + it('should not add table when add-work-item-table is false', async () => { mockGetInput.mockImplementation(name => { if (name === 'check-commits') return 'false'; if (name === 'check-pull-request') return 'true'; if (name === 'github-token') return 'github-token'; if (name === 'comment-on-failure') return 'false'; if (name === 'validate-work-item-exists') return 'false'; - if (name === 'append-work-item-title') return 'false'; + if (name === 'add-work-item-table') return 'false'; return ''; }); @@ -902,7 +939,7 @@ describe('Azure DevOps Commit Validator', () => { if (name === 'github-token') return 'github-token'; if (name === 'comment-on-failure') return 'false'; if (name === 'validate-work-item-exists') return 'false'; - if (name === 'append-work-item-title') return 'true'; + if (name === 'add-work-item-table') return 'true'; if (name === 'azure-devops-token') return 'azdo-token'; if (name === 'azure-devops-organization') return 'my-org'; return ''; @@ -923,10 +960,13 @@ describe('Azure DevOps Commit Validator', () => { expect(mockSetFailed).not.toHaveBeenCalled(); expect(mockGetWorkItemTitle).toHaveBeenCalledTimes(2); - expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith( - expect.objectContaining({ - body: 'This PR implements AB#111 - First item and AB#222 - Second item' - }) + const updateCall = mockOctokit.rest.pulls.update.mock.calls[0][0]; + expect(updateCall.body).toContain('This PR implements AB#111 and AB#222'); + expect(updateCall.body).toContain( + '| [111](https://dev.azure.com/my-org/_workitems/edit/111) | User Story | First item |' + ); + expect(updateCall.body).toContain( + '| [222](https://dev.azure.com/my-org/_workitems/edit/222) | Bug | Second item |' ); }); @@ -937,7 +977,7 @@ describe('Azure DevOps Commit Validator', () => { if (name === 'github-token') return 'github-token'; if (name === 'comment-on-failure') return 'false'; if (name === 'validate-work-item-exists') return 'false'; - if (name === 'append-work-item-title') return 'true'; + if (name === 'add-work-item-table') return 'true'; if (name === 'azure-devops-token') return 'azdo-token'; if (name === 'azure-devops-organization') return 'my-org'; return ''; @@ -965,7 +1005,7 @@ describe('Azure DevOps Commit Validator', () => { if (name === 'github-token') return 'github-token'; if (name === 'comment-on-failure') return 'false'; if (name === 'validate-work-item-exists') return 'false'; - if (name === 'append-work-item-title') return 'true'; + if (name === 'add-work-item-table') return 'true'; if (name === 'azure-devops-token') return ''; if (name === 'azure-devops-organization') return ''; return ''; @@ -973,17 +1013,17 @@ describe('Azure DevOps Commit Validator', () => { await run(); - expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('append-work-item-title')); + expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('add-work-item-table')); }); - it('should append title with validate-work-item-exists also enabled', async () => { + it('should add table with validate-work-item-exists also enabled', async () => { mockGetInput.mockImplementation(name => { if (name === 'check-commits') return 'false'; if (name === 'check-pull-request') return 'true'; if (name === 'github-token') return 'github-token'; if (name === 'comment-on-failure') return 'false'; if (name === 'validate-work-item-exists') return 'true'; - if (name === 'append-work-item-title') return 'true'; + if (name === 'add-work-item-table') return 'true'; if (name === 'azure-devops-token') return 'azdo-token'; if (name === 'azure-devops-organization') return 'my-org'; return ''; @@ -1004,10 +1044,10 @@ describe('Azure DevOps Commit Validator', () => { expect(mockSetFailed).not.toHaveBeenCalled(); expect(mockValidateWorkItemExists).toHaveBeenCalled(); expect(mockGetWorkItemTitle).toHaveBeenCalledWith('my-org', 'azdo-token', '12345'); - expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith( - expect.objectContaining({ - body: 'This PR implements AB#12345 - Fix login bug' - }) + const updateCall = mockOctokit.rest.pulls.update.mock.calls[0][0]; + expect(updateCall.body).toContain('This PR implements AB#12345'); + expect(updateCall.body).toContain( + '| [12345](https://dev.azure.com/my-org/_workitems/edit/12345) | Bug | Fix login bug |' ); }); }); diff --git a/action.yml b/action.yml index 8bbe8ca..d5eb19d 100644 --- a/action.yml +++ b/action.yml @@ -45,7 +45,12 @@ inputs: required: false default: 'true' append-work-item-title: - 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.' + description: 'Deprecated: Use add-work-item-table instead. This input will be removed in a future major version.' + deprecationMessage: 'The append-work-item-title input is deprecated. Use add-work-item-table instead.' + required: false + default: 'false' + add-work-item-table: + 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' diff --git a/badges/coverage.svg b/badges/coverage.svg index 63742a8..0b1b507 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 85.12%Coverage85.12% \ No newline at end of file +Coverage: 85.06%Coverage85.06% \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ce57268..0ec54f8 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.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "azure-devops-work-item-link-enforcer-and-linker", - "version": "3.2.0", + "version": "3.2.1", "license": "MIT", "dependencies": { "@actions/core": "^3.0.0", diff --git a/package.json b/package.json index 5076e75..a77afb8 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.2.1", "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..80683b7 100644 --- a/src/index.js +++ b/src/index.js @@ -43,7 +43,10 @@ export async function run() { const githubToken = core.getInput('github-token'); 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 addWorkItemTableRaw = core.getInput('add-work-item-table'); + const appendWorkItemTitleRaw = core.getInput('append-work-item-title'); + const addWorkItemTable = + addWorkItemTableRaw === 'true' || (addWorkItemTableRaw !== 'true' && appendWorkItemTitleRaw === 'true'); // Warn if an invalid scope value was provided if (checkPullRequest && pullRequestCheckScopeRaw && !validScopes.includes(pullRequestCheckScopeRaw)) { @@ -70,7 +73,7 @@ export async function run() { } // Validate Azure DevOps configuration if linking, work item validation, or title appending is enabled - if (linkCommitsToPullRequest || validateWorkItemExistsFlag || appendWorkItemTitle) { + if (linkCommitsToPullRequest || validateWorkItemExistsFlag || addWorkItemTable) { const missingConfig = []; if (!azureDevopsOrganization) missingConfig.push('azure-devops-organization'); if (!azureDevopsToken) missingConfig.push('azure-devops-token'); @@ -79,7 +82,7 @@ export async function run() { const features = []; if (linkCommitsToPullRequest) features.push('link-commits-to-pull-request'); if (validateWorkItemExistsFlag) features.push('validate-work-item-exists'); - if (appendWorkItemTitle) features.push('append-work-item-title'); + if (addWorkItemTable) features.push('add-work-item-table'); core.setFailed( `The following input${missingConfig.length === 1 ? ' is' : 's are'} required when ${features.join(' or ')} ${features.length === 1 ? 'is' : 'are'} enabled: ${missingConfig.join(', ')}` ); @@ -123,7 +126,7 @@ export async function run() { azureDevopsOrganization, azureDevopsToken, workItemToCommitMap, - appendWorkItemTitle, + addWorkItemTable, pullRequestCheckScope ); } @@ -431,7 +434,7 @@ async function checkCommitsForWorkItems( * @param {string} azureDevopsOrganization - Azure DevOps organization name * @param {string} azureDevopsToken - Azure DevOps PAT token * @param {Map} workItemToCommitMap - Map of work item IDs to commit info from checkCommitsForWorkItems - * @param {boolean} appendWorkItemTitle - Whether to append work item titles to AB# references in PR body + * @param {boolean} addWorkItemTable - Whether to add a work item titles table to the PR body * @param {string} pullRequestCheckScope - Where to look for AB# in the PR: 'title-or-body', 'body-only', or 'title-only' * @returns {Array} Returns array of invalid work item IDs found in the PR based on pullRequestCheckScope */ @@ -444,7 +447,7 @@ async function checkPullRequestForWorkItems( azureDevopsOrganization, azureDevopsToken, workItemToCommitMap, - appendWorkItemTitle = false, + addWorkItemTable = false, pullRequestCheckScope = 'title-or-body' ) { const { owner, repo } = context.repo; @@ -586,7 +589,7 @@ async function checkPullRequestForWorkItems( } // Append work item titles to PR body if enabled - if (appendWorkItemTitle && azureDevopsOrganization && azureDevopsToken) { + if (addWorkItemTable && azureDevopsOrganization && azureDevopsToken) { await appendWorkItemTitlesToPRBody( octokit, context, @@ -606,10 +609,15 @@ async function checkPullRequestForWorkItems( return []; } +/** HTML comment markers for identifying the work item titles section */ +const WORK_ITEM_SECTION_START = ''; +const WORK_ITEM_SECTION_END = ''; + /** - * Append work item titles to AB# references in the PR body - * Only updates references that don't already have a title appended. - * Uses the pattern: AB#123 -> AB#123 - Work Item Title + * Append work item titles to the PR body as a separate section. + * Adds a "Linked Work Items" table at the bottom of the PR body, + * keeping the original AB# references intact so the Azure DevOps + * GitHub integration continues to detect them for the Development section. * * @param {Object} octokit - GitHub API client * @param {Object} context - GitHub Actions context @@ -629,38 +637,60 @@ async function appendWorkItemTitlesToPRBody( azureDevopsToken ) { const { owner, repo } = context.repo; - let updatedBody = pullBody; - let hasChanges = false; + // Collect work item info + const workItemInfos = []; for (const workItem of workItems) { const workItemNumber = workItem.substring(3); // Remove "AB#" prefix - - // Skip if this AB# reference already has a title appended (AB#123 - ...) - const alreadyAnnotatedPattern = new RegExp(`AB#${workItemNumber}(?!\\d)\\s+-\\s+\\S`, 'i'); - if (alreadyAnnotatedPattern.test(updatedBody)) { - core.info(`Work item AB#${workItemNumber} already has title appended in PR body, skipping`); - continue; - } - const workItemInfo = await getWorkItemTitle(azureDevopsOrganization, azureDevopsToken, workItemNumber); if (workItemInfo && workItemInfo.title) { - // Replace bare AB#123 with AB#123 - Title (only where not already annotated) - const barePattern = new RegExp(`AB#${workItemNumber}(?!\\d)(?!\\s+-\\s+\\S)`, 'gi'); - const replacement = `AB#${workItemNumber} - ${workItemInfo.title}`; - const newBody = updatedBody.replace(barePattern, () => replacement); - - if (newBody !== updatedBody) { - updatedBody = newBody; - hasChanges = true; - core.info(`Appended title to AB#${workItemNumber}: "${workItemInfo.title}"`); - core.summary.addRaw( - `- 📝 **Annotated:** AB#${workItemNumber} - ${workItemInfo.title} (${workItemInfo.type})\n` - ); - } + workItemInfos.push({ id: workItemNumber, title: workItemInfo.title, type: workItemInfo.type }); + core.summary.addRaw( + `- 📝 **Linked work item:** ${workItemNumber} - ${workItemInfo.title} (${workItemInfo.type})\n` + ); } } - if (hasChanges) { + if (workItemInfos.length === 0) { + core.info('No work item titles found to append'); + return; + } + + // Build the work items section + // Avoid using AB# in the table text -- the azure-boards bot detects AB# + // references even inside markdown links and adds duplicate Development + // section entries. Use just the work item number as the link text. + const devOpsBaseUrl = `https://dev.azure.com/${azureDevopsOrganization}`; + const sanitizeCell = value => String(value).replace(/\\/g, '\\\\').replace(/\r?\n/g, ' ').replace(/\|/g, '\\|'); + const tableRows = workItemInfos + .map(info => { + const workItemUrl = `${devOpsBaseUrl}/_workitems/edit/${info.id}`; + return `| [${info.id}](${workItemUrl}) | ${sanitizeCell(info.type)} | ${sanitizeCell(info.title)} |`; + }) + .join('\n'); + const section = [ + WORK_ITEM_SECTION_START, + '### Linked Work Items', + '| Work Item | Type | Title |', + '|---|---|---|', + tableRows, + WORK_ITEM_SECTION_END + ].join('\n'); + + // Strip any existing work item titles section from the body + const bodyWithoutSection = pullBody + .replace( + new RegExp(`\\n*---\\n${escapeRegExp(WORK_ITEM_SECTION_START)}[\\s\\S]*?${escapeRegExp(WORK_ITEM_SECTION_END)}`), + '' + ) + .replace( + new RegExp(`\\n*${escapeRegExp(WORK_ITEM_SECTION_START)}[\\s\\S]*?${escapeRegExp(WORK_ITEM_SECTION_END)}`), + '' + ); + + const updatedBody = `${bodyWithoutSection}\n\n---\n${section}`; + + if (updatedBody !== pullBody) { core.info('Updating PR body with work item titles...'); await octokit.rest.pulls.update({ owner, @@ -670,10 +700,20 @@ async function appendWorkItemTitlesToPRBody( }); core.info('... PR body updated successfully'); } else { - core.info('No changes needed for PR body (all work items already annotated or no titles found)'); + core.info('No changes needed for PR body (work item titles section already up to date)'); } } +/** + * Escape special regex characters in a string + * + * @param {string} str - String to escape + * @returns {string} Escaped string safe for use in RegExp + */ +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Add or update a comment on the pull request *