diff --git a/README.md b/README.md
index ac3ceca..8db3389 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,16 @@ 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
+
+## Action Output
+
+The action provides visibility into work items through the **Job Summary**:
+
+- A summary of all work items found in commits and PR is added to the workflow run's job summary page
+- Includes clickable links to commits and displays associated work items
+- Shows which work items were **linked** to the PR (when `link-commits-to-pull-request` is enabled) vs. **verified** (when `validate-work-item-exists` is enabled)
+- Provides a quick reference of work items associated with the PR
## Usage
diff --git a/__tests__/index.test.js b/__tests__/index.test.js
index 34d38f9..de9bbf6 100644
--- a/__tests__/index.test.js
+++ b/__tests__/index.test.js
@@ -9,12 +9,17 @@ const mockGetInput = jest.fn();
const mockSetFailed = jest.fn();
const mockInfo = jest.fn();
const mockError = jest.fn();
+const mockSummary = {
+ addRaw: jest.fn().mockReturnThis(),
+ write: jest.fn().mockResolvedValue(undefined)
+};
jest.unstable_mockModule('@actions/core', () => ({
getInput: mockGetInput,
setFailed: mockSetFailed,
info: mockInfo,
- error: mockError
+ error: mockError,
+ summary: mockSummary
}));
// Mock @actions/github
@@ -64,6 +69,10 @@ describe('Azure DevOps Commit Validator', () => {
// Clear all mocks
jest.clearAllMocks();
+ // Reset summary mock
+ mockSummary.addRaw.mockClear().mockReturnThis();
+ mockSummary.write.mockClear().mockResolvedValue(undefined);
+
// Setup default mock implementations
mockGetInput.mockImplementation(name => {
const defaults = {
@@ -444,6 +453,9 @@ describe('Azure DevOps Commit Validator', () => {
expect(mockLinkWorkItem).toHaveBeenCalled();
expect(mockSetFailed).not.toHaveBeenCalled();
+ // Verify job summary was written with work item info
+ expect(mockSummary.addRaw).toHaveBeenCalledWith(expect.stringContaining('AB#12345'));
+ expect(mockSummary.write).toHaveBeenCalled();
});
it('should handle duplicate work items', async () => {
@@ -504,6 +516,9 @@ describe('Azure DevOps Commit Validator', () => {
await run();
expect(mockSetFailed).not.toHaveBeenCalled();
+ // Verify job summary was written with work item info
+ expect(mockSummary.addRaw).toHaveBeenCalledWith(expect.stringContaining('AB#12345'));
+ expect(mockSummary.write).toHaveBeenCalled();
});
it('should pass when PR has work item in body', async () => {
@@ -525,6 +540,9 @@ describe('Azure DevOps Commit Validator', () => {
await run();
expect(mockSetFailed).not.toHaveBeenCalled();
+ // Verify job summary was written with work item info
+ expect(mockSummary.addRaw).toHaveBeenCalledWith(expect.stringContaining('AB#12345'));
+ expect(mockSummary.write).toHaveBeenCalled();
});
it('should fail when PR has no work item link', async () => {
@@ -583,6 +601,37 @@ describe('Azure DevOps Commit Validator', () => {
})
);
});
+
+ it('should pass when valid work item appears in both commit and PR', async () => {
+ mockGetInput.mockImplementation(name => {
+ if (name === 'check-commits') return 'true';
+ if (name === 'check-pull-request') return 'true';
+ if (name === 'github-token') return 'github-token';
+ if (name === 'comment-on-failure') return 'true';
+ return 'false';
+ });
+
+ mockOctokit.rest.pulls.listCommits.mockResolvedValue({
+ data: [{ sha: 'abc123', commit: { message: 'fix: resolve issue AB#12345' } }]
+ });
+
+ mockOctokit.rest.pulls.get.mockResolvedValue({
+ data: {
+ title: 'fix: resolve issue AB#12345',
+ body: 'This PR fixes AB#12345'
+ }
+ });
+
+ await run();
+
+ expect(mockSetFailed).not.toHaveBeenCalled();
+ // Verify job summary was written and work item appears only once
+ expect(mockSummary.addRaw).toHaveBeenCalled();
+ expect(mockSummary.write).toHaveBeenCalled();
+ // Work item AB#12345 should be in the summary from commit (where it was found first)
+ const summaryCallArg = mockSummary.addRaw.mock.calls.find(call => call[0].includes('AB#12345'));
+ expect(summaryCallArg).toBeDefined();
+ });
});
describe('Comment management', () => {
diff --git a/badges/coverage.svg b/badges/coverage.svg
index cf5e8a2..2b72247 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 2a2cd60..607ce26 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "azure-devops-work-item-link-enforcer-and-linker",
- "version": "3.0.7",
+ "version": "3.0.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "azure-devops-work-item-link-enforcer-and-linker",
- "version": "3.0.7",
+ "version": "3.0.8",
"license": "MIT",
"dependencies": {
"@actions/core": "^2.0.1",
diff --git a/package.json b/package.json
index 07e805c..7489eb6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "azure-devops-work-item-link-enforcer-and-linker",
- "version": "3.0.7",
+ "version": "3.0.8",
"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 bd0ec15..c95f082 100644
--- a/src/index.js
+++ b/src/index.js
@@ -181,6 +181,9 @@ export async function run() {
core.info('... invalid work item comment updated to success');
}
}
+
+ // Write job summary once at the end (summary content was added throughout execution)
+ await core.summary.write();
} catch (error) {
core.setFailed(`Action failed with error: ${error}`);
}
@@ -357,25 +360,43 @@ async function checkCommitsForWorkItems(
// (Don't update success comment here - let caller handle it after checking PR too)
}
- // Link work items to PR if enabled (after deduplication)
- if (linkCommitsToPullRequest && allWorkItems.length > 0) {
+ // Process work items found in commits (after deduplication)
+ if (allWorkItems.length > 0) {
// Remove duplicates
const uniqueWorkItems = [...new Set(allWorkItems)];
for (const match of uniqueWorkItems) {
const workItemId = match.substring(3); // Remove "AB#" prefix
- core.info(`Linking work item ${workItemId} to pull request ${pullNumber}...`);
-
- // Set environment variables for main.js
- process.env.REPO_TOKEN = githubToken;
- process.env.AZURE_DEVOPS_ORG = azureDevopsOrganization;
- process.env.AZURE_DEVOPS_PAT = azureDevopsToken;
- process.env.WORKITEMID = workItemId;
- process.env.PULLREQUESTID = pullNumber.toString();
- process.env.REPO = `${context.repo.owner}/${context.repo.repo}`;
- process.env.GITHUB_SERVER_URL = process.env.GITHUB_SERVER_URL || 'https://github.com';
-
- await linkWorkItem();
+ const commitInfo = workItemToCommitMap.get(workItemId);
+
+ // Link work items to PR if enabled
+ if (linkCommitsToPullRequest) {
+ core.info(`Linking work item ${workItemId} to pull request ${pullNumber}...`);
+
+ // Set environment variables for main.js
+ process.env.REPO_TOKEN = githubToken;
+ process.env.AZURE_DEVOPS_ORG = azureDevopsOrganization;
+ process.env.AZURE_DEVOPS_PAT = azureDevopsToken;
+ process.env.WORKITEMID = workItemId;
+ process.env.PULLREQUESTID = pullNumber.toString();
+ process.env.REPO = `${context.repo.owner}/${context.repo.repo}`;
+ process.env.GITHUB_SERVER_URL = process.env.GITHUB_SERVER_URL || 'https://github.com';
+
+ await linkWorkItem();
+ }
+
+ // Add job summary for visibility (regardless of linking setting)
+ if (commitInfo) {
+ if (linkCommitsToPullRequest) {
+ core.summary.addRaw(
+ `- ✅ **Linked:** Work item AB#${workItemId} (from commit [\`${commitInfo.shortSha}\`](${context.payload.repository?.html_url}/commit/${commitInfo.sha})) linked to PR #${pullNumber}\n`
+ );
+ } else {
+ core.summary.addRaw(
+ `- ✔️ **Verified:** Work item AB#${workItemId} found in commit [\`${commitInfo.shortSha}\`](${context.payload.repository?.html_url}/commit/${commitInfo.sha})\n`
+ );
+ }
+ }
}
}
@@ -502,10 +523,30 @@ async function checkPullRequestForWorkItems(
return invalidWorkItems;
}
+ // All work items valid - add job summary for each (only if not already added from commits)
+ for (const workItem of uniqueWorkItems) {
+ const workItemNumber = workItem.substring(3); // Remove "AB#" prefix
+ // Only add to summary if this work item wasn't already added from a commit
+ if (!workItemToCommitMap.has(workItemNumber) || workItemToCommitMap.get(workItemNumber) === null) {
+ core.summary.addRaw(`- ✔️ **Verified:** Work item AB#${workItemNumber} found in PR title/body\n`);
+ }
+ }
+
// All work items valid - return empty array
return [];
}
+ // Validation disabled - add job summary for each work item (only if not already added from commits)
+ for (const workItem of uniqueWorkItems) {
+ const workItemNumber = workItem.substring(3); // Remove "AB#" prefix
+
+ // Only add to map and summary if this work item wasn't already added from a commit
+ if (!workItemToCommitMap.has(workItemNumber)) {
+ workItemToCommitMap.set(workItemNumber, null); // null indicates it's from PR title/body
+ core.summary.addRaw(`- ✔️ **Verified:** Work item AB#${workItemNumber} found in PR title/body\n`);
+ }
+ }
+
// Validation disabled - return empty array
return [];
}