Skip to content

Commit 597a3ee

Browse files
committed
feat: add automatic AB# tagging from branch name
Add new `add-ab-tag-from-branch` input that extracts work item IDs from the head branch name and appends AB#xxx to the PR body if not already present. Supports common branch formats like task/12345/description, task-12345, 12345-description, and more. closes #151
1 parent 0851f88 commit 597a3ee

7 files changed

Lines changed: 391 additions & 7 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ This action validates that pull requests and commits contain Azure DevOps work i
1414
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
1515
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
1616
- 🎯 **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!
17-
4. **Visibility & Tracking** - Work item linkages are added to the job summary for easy visibility
17+
4. **Auto-Tag from Branch** - Optionally extracts work item IDs from the head branch name (e.g. `task/12345/fix-bug`) and adds `AB#12345` to the PR body automatically
18+
5. **Visibility & Tracking** - Work item linkages are added to the job summary for easy visibility
1819

1920
## Action Output
2021

@@ -72,6 +73,7 @@ jobs:
7273
| `link-commits-to-pull-request` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `true` |
7374
| `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` |
7475
| `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` |
76+
| `add-ab-tag-from-branch` | Automatically extract work item ID(s) from the head branch name and add `AB#xxx` to the PR body if not already present | `false` | `false` |
7577
| `azure-devops-organization` | Only if `check-commits=true`, link the work items found in commits to the pull request | `false` | `''` |
7678
| `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` | `''` |
7779
| `github-token` | The GitHub token that has contents-read and pull_request-write access | `true` | `${{ github.token }}` |

__tests__/index.test.js

Lines changed: 292 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ describe('Azure DevOps Commit Validator', () => {
5858
let mockOctokit;
5959
let run;
6060
let COMMENT_MARKERS;
61+
let extractWorkItemIdsFromBranch;
6162

6263
beforeAll(async () => {
6364
// Set NODE_ENV to test to prevent auto-execution
@@ -67,6 +68,7 @@ describe('Azure DevOps Commit Validator', () => {
6768
const indexModule = await import('../src/index.js');
6869
run = indexModule.run;
6970
COMMENT_MARKERS = indexModule.COMMENT_MARKERS;
71+
extractWorkItemIdsFromBranch = indexModule.extractWorkItemIdsFromBranch;
7072
});
7173

7274
beforeEach(() => {
@@ -90,7 +92,8 @@ describe('Azure DevOps Commit Validator', () => {
9092
'github-token': 'github-token',
9193
'comment-on-failure': 'true',
9294
'validate-work-item-exists': 'false',
93-
'append-work-item-title': 'false'
95+
'append-work-item-title': 'false',
96+
'add-ab-tag-from-branch': 'false'
9497
};
9598
return defaults[name] || '';
9699
});
@@ -125,7 +128,7 @@ describe('Azure DevOps Commit Validator', () => {
125128
};
126129

127130
mockGetOctokit.mockReturnValue(mockOctokit);
128-
mockContext.payload.pull_request = { number: 42 };
131+
mockContext.payload.pull_request = { number: 42, head: { ref: 'feature/test-branch' } };
129132

130133
// Default mock for validateWorkItemExists (returns true by default)
131134
mockValidateWorkItemExists.mockResolvedValue(true);
@@ -2190,4 +2193,291 @@ describe('Azure DevOps Commit Validator', () => {
21902193
);
21912194
});
21922195
});
2196+
2197+
describe('extractWorkItemIdsFromBranch', () => {
2198+
it('should extract work item ID from task/12345/make-it-better', () => {
2199+
expect(extractWorkItemIdsFromBranch('task/12345/make-it-better')).toEqual(['12345']);
2200+
});
2201+
2202+
it('should extract work item ID from task/12345-make-it-better', () => {
2203+
expect(extractWorkItemIdsFromBranch('task/12345-make-it-better')).toEqual(['12345']);
2204+
});
2205+
2206+
it('should extract work item ID from task/12345', () => {
2207+
expect(extractWorkItemIdsFromBranch('task/12345')).toEqual(['12345']);
2208+
});
2209+
2210+
it('should extract work item ID from task-12345', () => {
2211+
expect(extractWorkItemIdsFromBranch('task-12345')).toEqual(['12345']);
2212+
});
2213+
2214+
it('should extract work item ID from 12345-make-it-better', () => {
2215+
expect(extractWorkItemIdsFromBranch('12345-make-it-better')).toEqual(['12345']);
2216+
});
2217+
2218+
it('should extract work item ID from 12345make-it-better', () => {
2219+
expect(extractWorkItemIdsFromBranch('12345make-it-better')).toEqual(['12345']);
2220+
});
2221+
2222+
it('should extract work item ID from 12345', () => {
2223+
expect(extractWorkItemIdsFromBranch('12345')).toEqual(['12345']);
2224+
});
2225+
2226+
it('should extract work item ID from feature_12345_description', () => {
2227+
expect(extractWorkItemIdsFromBranch('feature_12345_description')).toEqual(['12345']);
2228+
});
2229+
2230+
it('should return unique IDs when branch contains duplicates', () => {
2231+
expect(extractWorkItemIdsFromBranch('fix/12345/12345-again')).toEqual(['12345']);
2232+
});
2233+
2234+
it('should extract multiple different work item IDs', () => {
2235+
expect(extractWorkItemIdsFromBranch('fix/12345/67890-combined')).toEqual(['12345', '67890']);
2236+
});
2237+
2238+
it('should return empty array for branch with no numbers', () => {
2239+
expect(extractWorkItemIdsFromBranch('feature/add-new-stuff')).toEqual([]);
2240+
});
2241+
2242+
it('should return empty array for null/empty input', () => {
2243+
expect(extractWorkItemIdsFromBranch('')).toEqual([]);
2244+
expect(extractWorkItemIdsFromBranch(null)).toEqual([]);
2245+
expect(extractWorkItemIdsFromBranch(undefined)).toEqual([]);
2246+
});
2247+
});
2248+
2249+
describe('Add AB# tag from branch', () => {
2250+
it('should add AB# tag to PR body when work item found in branch name', async () => {
2251+
mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/make-it-better' } };
2252+
2253+
mockGetInput.mockImplementation(name => {
2254+
const inputs = {
2255+
'check-commits': 'true',
2256+
'check-pull-request': 'false',
2257+
'fail-if-missing-workitem-commit-link': 'false',
2258+
'link-commits-to-pull-request': 'false',
2259+
'comment-on-failure': 'false',
2260+
'validate-work-item-exists': 'false',
2261+
'append-work-item-title': 'false',
2262+
'add-ab-tag-from-branch': 'true',
2263+
'github-token': 'github-token',
2264+
'azure-devops-token': '',
2265+
'azure-devops-organization': ''
2266+
};
2267+
return inputs[name] || '';
2268+
});
2269+
2270+
mockOctokit.rest.pulls.get.mockResolvedValue({
2271+
data: { title: 'My PR', body: 'Some description' }
2272+
});
2273+
2274+
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
2275+
2276+
await run();
2277+
2278+
expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
2279+
expect.objectContaining({
2280+
body: expect.stringContaining('AB#12345')
2281+
})
2282+
);
2283+
});
2284+
2285+
it('should not add AB# tag when it already exists in PR body', async () => {
2286+
mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/make-it-better' } };
2287+
2288+
mockGetInput.mockImplementation(name => {
2289+
const inputs = {
2290+
'check-commits': 'true',
2291+
'check-pull-request': 'false',
2292+
'fail-if-missing-workitem-commit-link': 'false',
2293+
'link-commits-to-pull-request': 'false',
2294+
'comment-on-failure': 'false',
2295+
'validate-work-item-exists': 'false',
2296+
'append-work-item-title': 'false',
2297+
'add-ab-tag-from-branch': 'true',
2298+
'github-token': 'github-token',
2299+
'azure-devops-token': '',
2300+
'azure-devops-organization': ''
2301+
};
2302+
return inputs[name] || '';
2303+
});
2304+
2305+
mockOctokit.rest.pulls.get.mockResolvedValue({
2306+
data: { title: 'My PR', body: 'Fix AB#12345 bug' }
2307+
});
2308+
2309+
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
2310+
2311+
await run();
2312+
2313+
// Should NOT call update since AB#12345 is already in the body
2314+
expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
2315+
});
2316+
2317+
it('should not update PR when no work item IDs found in branch', async () => {
2318+
mockContext.payload.pull_request = { number: 42, head: { ref: 'feature/add-new-stuff' } };
2319+
2320+
mockGetInput.mockImplementation(name => {
2321+
const inputs = {
2322+
'check-commits': 'true',
2323+
'check-pull-request': 'false',
2324+
'fail-if-missing-workitem-commit-link': 'false',
2325+
'link-commits-to-pull-request': 'false',
2326+
'comment-on-failure': 'false',
2327+
'validate-work-item-exists': 'false',
2328+
'append-work-item-title': 'false',
2329+
'add-ab-tag-from-branch': 'true',
2330+
'github-token': 'github-token',
2331+
'azure-devops-token': '',
2332+
'azure-devops-organization': ''
2333+
};
2334+
return inputs[name] || '';
2335+
});
2336+
2337+
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
2338+
2339+
await run();
2340+
2341+
// Should NOT call pulls.get or pulls.update since no IDs found
2342+
expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
2343+
});
2344+
2345+
it('should not run when add-ab-tag-from-branch is false', async () => {
2346+
mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/make-it-better' } };
2347+
2348+
mockGetInput.mockImplementation(name => {
2349+
const inputs = {
2350+
'check-commits': 'true',
2351+
'check-pull-request': 'false',
2352+
'fail-if-missing-workitem-commit-link': 'false',
2353+
'link-commits-to-pull-request': 'false',
2354+
'comment-on-failure': 'false',
2355+
'validate-work-item-exists': 'false',
2356+
'append-work-item-title': 'false',
2357+
'add-ab-tag-from-branch': 'false',
2358+
'github-token': 'github-token',
2359+
'azure-devops-token': '',
2360+
'azure-devops-organization': ''
2361+
};
2362+
return inputs[name] || '';
2363+
});
2364+
2365+
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
2366+
2367+
await run();
2368+
2369+
// Should NOT call pulls.update since feature is disabled
2370+
expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
2371+
});
2372+
2373+
it('should handle empty PR body when adding AB# tag', async () => {
2374+
mockContext.payload.pull_request = { number: 42, head: { ref: 'task/12345/fix' } };
2375+
2376+
mockGetInput.mockImplementation(name => {
2377+
const inputs = {
2378+
'check-commits': 'true',
2379+
'check-pull-request': 'false',
2380+
'fail-if-missing-workitem-commit-link': 'false',
2381+
'link-commits-to-pull-request': 'false',
2382+
'comment-on-failure': 'false',
2383+
'validate-work-item-exists': 'false',
2384+
'append-work-item-title': 'false',
2385+
'add-ab-tag-from-branch': 'true',
2386+
'github-token': 'github-token',
2387+
'azure-devops-token': '',
2388+
'azure-devops-organization': ''
2389+
};
2390+
return inputs[name] || '';
2391+
});
2392+
2393+
mockOctokit.rest.pulls.get.mockResolvedValue({
2394+
data: { title: 'My PR', body: '' }
2395+
});
2396+
2397+
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
2398+
2399+
await run();
2400+
2401+
// Should set body to just the AB# tag (no leading newlines)
2402+
expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
2403+
expect.objectContaining({
2404+
body: 'AB#12345'
2405+
})
2406+
);
2407+
});
2408+
2409+
it('should add multiple AB# tags from branch with multiple IDs', async () => {
2410+
mockContext.payload.pull_request = { number: 42, head: { ref: 'fix/12345/67890-combined' } };
2411+
2412+
mockGetInput.mockImplementation(name => {
2413+
const inputs = {
2414+
'check-commits': 'true',
2415+
'check-pull-request': 'false',
2416+
'fail-if-missing-workitem-commit-link': 'false',
2417+
'link-commits-to-pull-request': 'false',
2418+
'comment-on-failure': 'false',
2419+
'validate-work-item-exists': 'false',
2420+
'append-work-item-title': 'false',
2421+
'add-ab-tag-from-branch': 'true',
2422+
'github-token': 'github-token',
2423+
'azure-devops-token': '',
2424+
'azure-devops-organization': ''
2425+
};
2426+
return inputs[name] || '';
2427+
});
2428+
2429+
mockOctokit.rest.pulls.get.mockResolvedValue({
2430+
data: { title: 'My PR', body: 'Description' }
2431+
});
2432+
2433+
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
2434+
2435+
await run();
2436+
2437+
expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
2438+
expect.objectContaining({
2439+
body: expect.stringContaining('AB#12345')
2440+
})
2441+
);
2442+
expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith(
2443+
expect.objectContaining({
2444+
body: expect.stringContaining('AB#67890')
2445+
})
2446+
);
2447+
});
2448+
2449+
it('should only add missing AB# tags when some already exist in body', async () => {
2450+
mockContext.payload.pull_request = { number: 42, head: { ref: 'fix/12345/67890-combined' } };
2451+
2452+
mockGetInput.mockImplementation(name => {
2453+
const inputs = {
2454+
'check-commits': 'true',
2455+
'check-pull-request': 'false',
2456+
'fail-if-missing-workitem-commit-link': 'false',
2457+
'link-commits-to-pull-request': 'false',
2458+
'comment-on-failure': 'false',
2459+
'validate-work-item-exists': 'false',
2460+
'append-work-item-title': 'false',
2461+
'add-ab-tag-from-branch': 'true',
2462+
'github-token': 'github-token',
2463+
'azure-devops-token': '',
2464+
'azure-devops-organization': ''
2465+
};
2466+
return inputs[name] || '';
2467+
});
2468+
2469+
mockOctokit.rest.pulls.get.mockResolvedValue({
2470+
data: { title: 'My PR', body: 'Fixes AB#12345' }
2471+
});
2472+
2473+
mockOctokit.paginate.mockResolvedValueOnce([]); // commits
2474+
2475+
await run();
2476+
2477+
// Should only add AB#67890 since AB#12345 already exists
2478+
const updateCall = mockOctokit.rest.pulls.update.mock.calls[0][0];
2479+
expect(updateCall.body).toContain('AB#67890');
2480+
expect(updateCall.body).toContain('Fixes AB#12345'); // original body preserved
2481+
});
2482+
});
21932483
});

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ inputs:
4848
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.'
4949
required: false
5050
default: 'false'
51+
add-ab-tag-from-branch:
52+
description: 'Automatically extract work item ID(s) from the head branch name and add AB#xxx to the PR body if not already present (e.g. branch task/12345/fix-bug adds AB#12345 to the PR body)'
53+
required: false
54+
default: 'false'
5155

5256
runs:
5357
using: 'node20'

badges/coverage.svg

Lines changed: 1 addition & 1 deletion
Loading

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "azure-devops-work-item-link-enforcer-and-linker",
3-
"version": "3.2.0",
3+
"version": "3.3.0",
44
"private": true,
55
"type": "module",
66
"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 ",

0 commit comments

Comments
 (0)