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 @@
-
\ No newline at end of file
+
\ 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
*