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 @@ -Coverage: 81.38%Coverage81.38% \ No newline at end of file +Coverage: 81.08%Coverage81.08% \ 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 []; }