Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions tools/release-check/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ pnpm --filter @nhsdigital/nhs-notify-release-check run check -- --repo ../nhs-no

## Multi-release usage

Multi-release mode is mainly intended for cases like an initial production release, where we want to validate the full set of tickets that make up the build being released to prod for the first time.

Explicit list selection:

```bash
Expand All @@ -48,6 +50,36 @@ Notes for multi-release mode:
- Commit history is aggregated by collecting each selected release range and de-duplicating overlapping commits.
- Multiple selected Jira versions are aggregated into one issue set before comparison.

## Commit ticket mappings

If a commit carries the wrong Jira issue number, you can provide a mapping file to substitute the release-check ticket detection for specific commits.

When present, the tool automatically looks for a file called `.jira-commits` in the root of the target repository. Use `--commit-mapping-file` if you want to point at a different file instead.

```bash
pnpm release-check -- \
--repo ../nhs-notify-client-config \
--git-tag v0.2.0 \
--jira-version client-config-0.2.0 \
--commit-mapping-file .release-check/commit-mappings.txt
```

Example mapping file format:

```text
# <commit-hash> <replacement-jira-key>
2f6c9d1 CCM-12081
4ab12cd CCM-22822
```

Notes for commit mappings:

- `.jira-commits` is auto-detected from the target repository root.
- Relative paths passed to `--commit-mapping-file` are resolved from the target repository root.
- Hashes can be 7-40 hexadecimal characters, as long as they uniquely identify one selected commit.
- The mapped Jira key replaces the detected key for reporting and fix workflows.
- Reports mark mapped commits explicitly so reviewers can see where a substitution was applied.

## Required environment

- `JIRA_API_TOKEN` or `JIRA_PERSONAL_TOKEN` or `JIRA_TOKEN`
Expand Down
7 changes: 7 additions & 0 deletions tools/release-check/src/__tests__/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ describe('parseCliArgs', () => {
'Platform',
'--repo',
'../repo',
'--commit-mapping-file',
'.release-check/commit-mappings.txt',
'--git-tag',
'0.1.0',
'--jira-version',
Expand All @@ -26,6 +28,7 @@ describe('parseCliArgs', () => {
'tag',
]),
).toEqual({
commitMappingFile: '.release-check/commit-mappings.txt',
fixAction: 'fix-version',
fixComponent: 'Platform',
repo: '../repo',
Expand All @@ -51,6 +54,7 @@ describe('parseCliArgs', () => {
'71260, client-config-0.2.0 , client-config-*',
]),
).toEqual({
commitMappingFile: undefined,
fixAction: undefined,
fixComponent: undefined,
repo: '../repo',
Expand All @@ -76,6 +80,7 @@ describe('parseCliArgs', () => {
'71260',
]),
).toEqual({
commitMappingFile: undefined,
fixAction: undefined,
fixComponent: undefined,
repo: '../repo',
Expand Down Expand Up @@ -106,6 +111,7 @@ describe('parseCliArgs', () => {
'--yes',
]),
).toEqual({
commitMappingFile: undefined,
fixAction: 'clinical-review-not-needed',
fixComponent: 'Platform',
repo: '../repo',
Expand Down Expand Up @@ -133,6 +139,7 @@ describe('parseCliArgs', () => {
'github',
]),
).toEqual({
commitMappingFile: undefined,
fixAction: undefined,
fixComponent: undefined,
repo: '../repo',
Expand Down
30 changes: 30 additions & 0 deletions tools/release-check/src/__tests__/compare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,34 @@ describe('compareRelease', () => {
result.commitsWithoutMatches.map((commit) => commit.shortHash),
).toEqual(['eeeeeeee']);
});

it('uses a commit ticket mapping instead of the detected key', () => {
const result = compareRelease(
[
{
hash: 'f'.repeat(40),
shortHash: 'ffffffff',
subject: 'CCM-999: wrong ticket on PR',
body: '',
explicitIssueKeys: ['CCM-999'],
issueKeyOverride: {
commitHash: 'fffffff',
issueKey: 'CCM-100',
},
},
],
issues,
[],
);

expect(result.gitReferencedIssueKeys).toEqual(['CCM-100']);
expect(result.commitsByIssueKey.get('CCM-100')).toEqual([
expect.objectContaining({
detectedIssueKeys: ['CCM-999'],
issueKeySource: 'mapped',
matchedIssueKeys: ['CCM-100'],
}),
]);
expect(result.commitsWithIssueKeysOutsideRelease).toEqual([]);
});
});
217 changes: 217 additions & 0 deletions tools/release-check/src/__tests__/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { existsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';

import {
applyCommitIssueKeyMappings,
collectCommits,
collectCommitsForTags,
ensureCommitishExists,
findDefaultCommitIssueMappingFile,
getOriginRemoteUrl,
getPreviousTag,
getRepoName,
getRepoRoot,
listTags,
readCommitIssueKeyMappings,
readTagAnnotation,
resolveGitTags,
resolveRepoPath,
Expand All @@ -22,9 +25,17 @@ jest.mock('node:child_process', () => ({
jest.mock('node:fs', () => ({
existsSync: jest.fn(),
}));
jest.mock('node:fs/promises', () => ({
readFile: jest.fn(),
}));

const mockedSpawnSync = spawnSync as jest.MockedFunction<typeof spawnSync>;
const mockedExistsSync = existsSync as jest.MockedFunction<typeof existsSync>;
const fsPromises =
jest.requireMock<typeof import('node:fs/promises')>('node:fs/promises');
const mockedReadFile = fsPromises.readFile as jest.MockedFunction<
typeof fsPromises.readFile
>;

describe('resolveRepoPath', () => {
beforeEach(() => {
Expand Down Expand Up @@ -57,6 +68,30 @@ describe('resolveRepoPath', () => {
});
});

describe('findDefaultCommitIssueMappingFile', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('returns the repo-root .jira-commits path when it exists', () => {
mockedExistsSync.mockImplementation(
(candidate) => candidate === '/repos/client-config/.jira-commits',
);

expect(findDefaultCommitIssueMappingFile('/repos/client-config')).toBe(
'/repos/client-config/.jira-commits',
);
});

it('returns undefined when the repo-root .jira-commits file does not exist', () => {
mockedExistsSync.mockReturnValue(false);

expect(
findDefaultCommitIssueMappingFile('/repos/client-config'),
).toBeUndefined();
});
});

describe('git command helpers', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -388,6 +423,188 @@ describe('collectCommits', () => {
});
});

describe('commit ticket mappings', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('reads whitespace-delimited commit-to-ticket mappings', async () => {
mockedReadFile.mockResolvedValue(
'# comment\n2f6c9d1 CCM-12081\n4ab12cd ccm-22822\n',
);

await expect(
readCommitIssueKeyMappings(
'/repos/client-config',
'.release-check/map.txt',
),
).resolves.toEqual(
new Map([
['2f6c9d1', 'CCM-12081'],
['4ab12cd', 'CCM-22822'],
]),
);
});

it('rejects malformed mapping lines', async () => {
mockedReadFile.mockResolvedValue('2f6c9d1 only-one-column extra\n');

await expect(
readCommitIssueKeyMappings(
'/repos/client-config',
'.release-check/map.txt',
),
).rejects.toThrow('expected "<commit-hash> <JIRA-KEY>"');
});

it('ignores blank and comment-only mapping files', async () => {
mockedReadFile.mockResolvedValue('\n# comment\n \n');

await expect(
readCommitIssueKeyMappings(
'/repos/client-config',
'.release-check/map.txt',
),
).resolves.toEqual(new Map());
});

it('rejects invalid commit hashes in mapping files', async () => {
mockedReadFile.mockResolvedValue('not-a-hash CCM-12081\n');

await expect(
readCommitIssueKeyMappings(
'/repos/client-config',
'.release-check/map.txt',
),
).rejects.toThrow('Expected 7-40 hexadecimal characters');
});

it('rejects invalid Jira issue keys in mapping files', async () => {
mockedReadFile.mockResolvedValue('2f6c9d1 NOT_A_KEY\n');

await expect(
readCommitIssueKeyMappings(
'/repos/client-config',
'.release-check/map.txt',
),
).rejects.toThrow('Invalid Jira issue key');
});

it('rejects duplicate commit hashes in mapping files', async () => {
mockedReadFile.mockResolvedValue('2f6c9d1 CCM-12081\n2f6c9d1 CCM-22822\n');

await expect(
readCommitIssueKeyMappings(
'/repos/client-config',
'.release-check/map.txt',
),
).rejects.toThrow('Duplicate commit mapping');
});

it('applies matching ticket overrides to selected commits', () => {
expect(
applyCommitIssueKeyMappings(
[
{
hash: '2f6c9d1abcdef00000000000000000000000000',
shortHash: '2f6c9d1',
subject: 'CCM-999: wrong ticket',
body: '',
explicitIssueKeys: ['CCM-999'],
},
],
new Map([['2f6c9d1', 'CCM-12081']]),
),
).toEqual([
{
hash: '2f6c9d1abcdef00000000000000000000000000',
shortHash: '2f6c9d1',
subject: 'CCM-999: wrong ticket',
body: '',
explicitIssueKeys: ['CCM-999'],
issueKeyOverride: {
commitHash: '2f6c9d1',
issueKey: 'CCM-12081',
},
},
]);
});

it('returns the original commits when there are no mappings', () => {
const commits = [
{
hash: '2f6c9d1abcdef00000000000000000000000000',
shortHash: '2f6c9d1',
subject: 'CCM-999: wrong ticket',
body: '',
explicitIssueKeys: ['CCM-999'],
},
];

expect(applyCommitIssueKeyMappings(commits, new Map())).toBe(commits);
});

it('ignores mappings that do not match the selected commits', () => {
const commits = [
{
hash: '2f6c9d1abcdef00000000000000000000000000',
shortHash: '2f6c9d1',
subject: 'CCM-999: wrong ticket',
body: '',
explicitIssueKeys: ['CCM-999'],
},
];

expect(
applyCommitIssueKeyMappings(commits, new Map([['deadbee', 'CCM-12081']])),
).toEqual(commits);
});

it('rejects ambiguous commit hash prefixes', () => {
expect(() =>
applyCommitIssueKeyMappings(
[
{
hash: '2f6c9d1abcdef00000000000000000000000000',
shortHash: '2f6c9d1',
subject: 'CCM-999: wrong ticket',
body: '',
explicitIssueKeys: ['CCM-999'],
},
{
hash: '2f6c9d1bbbbbb00000000000000000000000000',
shortHash: '2f6c9d1',
subject: 'CCM-998: another wrong ticket',
body: '',
explicitIssueKeys: ['CCM-998'],
},
],
new Map([['2f6c9d1', 'CCM-12081']]),
),
).toThrow('matched multiple selected commits');
});

it('rejects multiple mapping entries that resolve to the same commit', () => {
expect(() =>
applyCommitIssueKeyMappings(
[
{
hash: '2f6c9d1abcdef00000000000000000000000000',
shortHash: '2f6c9d1',
subject: 'CCM-999: wrong ticket',
body: '',
explicitIssueKeys: ['CCM-999'],
},
],
new Map([
['2f6c9d1', 'CCM-12081'],
['2f6c9d1abc', 'CCM-22822'],
]),
),
).toThrow('Multiple commit mappings matched commit');
});
});

describe('readTagAnnotation', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down
Loading
Loading