Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3b80757
chore: empty commit to sync branch
joshjohanning Nov 7, 2025
42a4fac
feat: add work item linkage information to job summary and notices
Copilot Nov 7, 2025
6df8911
Merge branch 'main' into copilot/add-info-to-job-summary
joshjohanning Jan 6, 2026
0001d47
chore: bump version to 3.0.7
joshjohanning Jan 6, 2026
eacdf11
fix: ensure job summary is written only once after execution
joshjohanning Jan 6, 2026
5cb3b30
Merge branch 'main' into copilot/add-info-to-job-summary
joshjohanning Jan 6, 2026
50a09b3
refactor: simplify job summary annotations in commit and PR checks by…
joshjohanning Jan 6, 2026
ebf6244
test: verify job summary includes work item info in commit validation
joshjohanning Jan 6, 2026
e8264b1
docs: update README to clarify job summary visibility for linked work…
joshjohanning Jan 6, 2026
637a598
chore: bump version to 3.0.8 in package.json
joshjohanning Jan 6, 2026
64757da
fix: improve job summary handling to avoid duplicate work item entries
joshjohanning Jan 6, 2026
a83232d
refactor: remove unused mockNotice from core actions mock
joshjohanning Jan 6, 2026
cfcd71d
docs: update README to clarify job summary links and work item display
joshjohanning Jan 6, 2026
68bdeb9
feat: enhance job summary visibility for linked work items in PRs
joshjohanning Jan 6, 2026
12f6b01
fix: update coverage badge to reflect current coverage percentage
joshjohanning Jan 6, 2026
8b2110e
Merge branch 'main' into copilot/add-info-to-job-summary
joshjohanning Jan 6, 2026
10562e7
fix: update coverage badge to reflect new coverage percentage (81.08%)
joshjohanning Jan 6, 2026
168d505
docs: enhance job summary visibility for linked work items
joshjohanning Jan 6, 2026
1187687
test: add validation for work item presence in commit and PR
joshjohanning Jan 6, 2026
41e47c3
test: simplify mock data structure in commit validation tests
joshjohanning Jan 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 50 additions & 1 deletion __tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion badges/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 ",
Expand Down
69 changes: 55 additions & 14 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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`
);
}
}
}
}

Expand Down Expand Up @@ -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 [];
}
Expand Down