From d84426b0a9f92bb6cdd02015b93ba1989e2c8832 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Tue, 22 Sep 2026 17:30:18 +0100 Subject: [PATCH 1/6] CCM-14750: Package release-check tool Generalise the release-check logic into a publishable shared-modules package so other repositories can compare git releases against Jira versions and release notes. Include the publish workflow, package wiring, and the comms-mgr clinical review checks in the packaged CLI output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-release-check.yaml | 85 ++++++ eslint.config.mjs | 7 + package.json | 1 + pnpm-lock.yaml | 31 +++ pnpm-workspace.yaml | 1 + tools/release-check/README.md | 59 +++++ tools/release-check/jest.config.ts | 28 ++ tools/release-check/package.json | 49 ++++ .../release-check/src/__tests__/args.test.ts | 90 +++++++ .../src/__tests__/compare.test.ts | 121 +++++++++ tools/release-check/src/__tests__/git.test.ts | 242 ++++++++++++++++++ .../src/__tests__/github-release.test.ts | 214 ++++++++++++++++ .../release-check/src/__tests__/index.test.ts | 219 ++++++++++++++++ .../release-check/src/__tests__/jira.test.ts | 238 +++++++++++++++++ .../src/__tests__/report.test.ts | 228 +++++++++++++++++ tools/release-check/src/args.ts | 64 +++++ tools/release-check/src/cli.ts | 9 + tools/release-check/src/compare.ts | 142 ++++++++++ tools/release-check/src/git.ts | 147 +++++++++++ tools/release-check/src/github-release.ts | 190 ++++++++++++++ tools/release-check/src/index.ts | 95 +++++++ tools/release-check/src/jira.ts | 198 ++++++++++++++ tools/release-check/src/report.ts | 155 +++++++++++ tools/release-check/src/types.ts | 65 +++++ tools/release-check/tsconfig.build.json | 29 +++ tools/release-check/tsconfig.json | 29 +++ 26 files changed, 2736 insertions(+) create mode 100644 .github/workflows/publish-release-check.yaml create mode 100644 tools/release-check/README.md create mode 100644 tools/release-check/jest.config.ts create mode 100644 tools/release-check/package.json create mode 100644 tools/release-check/src/__tests__/args.test.ts create mode 100644 tools/release-check/src/__tests__/compare.test.ts create mode 100644 tools/release-check/src/__tests__/git.test.ts create mode 100644 tools/release-check/src/__tests__/github-release.test.ts create mode 100644 tools/release-check/src/__tests__/index.test.ts create mode 100644 tools/release-check/src/__tests__/jira.test.ts create mode 100644 tools/release-check/src/__tests__/report.test.ts create mode 100644 tools/release-check/src/args.ts create mode 100644 tools/release-check/src/cli.ts create mode 100644 tools/release-check/src/compare.ts create mode 100644 tools/release-check/src/git.ts create mode 100644 tools/release-check/src/github-release.ts create mode 100644 tools/release-check/src/index.ts create mode 100644 tools/release-check/src/jira.ts create mode 100644 tools/release-check/src/report.ts create mode 100644 tools/release-check/src/types.ts create mode 100644 tools/release-check/tsconfig.build.json create mode 100644 tools/release-check/tsconfig.json diff --git a/.github/workflows/publish-release-check.yaml b/.github/workflows/publish-release-check.yaml new file mode 100644 index 00000000..1542fed6 --- /dev/null +++ b/.github/workflows/publish-release-check.yaml @@ -0,0 +1,85 @@ +name: Publish release-check package + +on: + release: + types: ["published"] + workflow_dispatch: + +jobs: + check-release-check-version-change: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + outputs: + version_changed: ${{ steps.check-version.outputs.version_changed }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Read tool versions + id: versions + shell: bash + run: | + echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: ${{ steps.versions.outputs.nodejs_version }} + registry-url: 'https://npm.pkg.github.com' + + - name: Check if local version differs from latest published version + id: check-version + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + published_version=$(npm view @nhsdigital/release-check --json 2>/dev/null | jq -r '.["dist-tags"].latest // "null"') + echo "Published version: $published_version" + + local_version=$(jq -r '.version' tools/release-check/package.json) + echo "Local version: $local_version" + + if [[ "$local_version" = "$published_version" ]]; then + echo "Local version matches the latest published version - skipping publish" + echo "version_changed=false" >> "$GITHUB_OUTPUT" + else + echo "Local version differs from the latest published version - publishing new version" + echo "version_changed=true" >> "$GITHUB_OUTPUT" + fi + + publish-release-check: + needs: check-release-check-version-change + if: needs.check-release-check-version-change.outputs.version_changed == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Read tool versions + id: versions + shell: bash + run: | + echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> "$GITHUB_OUTPUT" + echo "pnpm_version=$(grep "^pnpm\s" .tool-versions | cut -f2 -d' ')" >> "$GITHUB_OUTPUT" + + - name: Node install and setup + uses: ./.github/actions/node-install + with: + node-version: ${{ steps.versions.outputs.nodejs_version }} + pnpm-version: ${{ steps.versions.outputs.pnpm_version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate package + run: pnpm --filter @nhsdigital/release-check run typecheck && pnpm --filter @nhsdigital/release-check run test:unit + + - name: Publish package + run: pnpm --filter @nhsdigital/release-check publish --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/eslint.config.mjs b/eslint.config.mjs index 190464bb..975b2482 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -229,6 +229,13 @@ export default defineConfig([ 'no-relative-import-paths/no-relative-import-paths': 0, }, }, + { + files: ['tools/release-check/**'], + rules: { + 'no-relative-import-paths/no-relative-import-paths': 0, + 'import-x/no-relative-packages': 0, + }, + }, { files: ['scripts/**'], rules: { diff --git a/package.json b/package.json index 5488a124..24fa13cf 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "clean": "pnpm -r run --if-present clean", "lint": "turbo run lint", "lint:fix": "turbo run lint:fix", + "release-check": "pnpm --filter @nhsdigital/release-check run check", "test:unit": "turbo run test:unit", "typecheck": "turbo run typecheck" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34b46e09..cb9c5968 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -426,6 +426,36 @@ importers: specifier: ^8.60.1 version: 8.65.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + tools/release-check: + devDependencies: + '@tsconfig/node22': + specifier: ^22.0.5 + version: 22.0.5 + '@types/jest': + specifier: ^29.5.0 + version: 29.5.14 + '@types/node': + specifier: ^25.9.1 + version: 25.9.5 + globals: + specifier: ^17.6.0 + version: 17.9.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@25.9.5)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.11 + version: 29.4.12(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@30.4.1(supports-color@8.1.1))(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.28.1)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.5)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)))(typescript@6.0.3) + tsx: + specifier: ^4.22.0 + version: 4.23.5 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: ^8.60.1 + version: 8.65.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + packages: '@aws-sdk/checksums@3.1000.24': @@ -2431,6 +2461,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a020ced1..d1d2b7e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - "src/lambdas/apim-key-generator" - "src/utils" - "tools/check-overrides" + - "tools/release-check" - "infrastructure/terraform/modules/eventpub/lambda/eventpub" allowBuilds: diff --git a/tools/release-check/README.md b/tools/release-check/README.md new file mode 100644 index 00000000..066cc984 --- /dev/null +++ b/tools/release-check/README.md @@ -0,0 +1,59 @@ +# release-check + +Compares a local repository release tag against a Jira release version and reports mismatches across: + +- git commit history since the previous tag +- Jira issues assigned to the release version +- release notes, using the GitHub release body when available + +## Usage + +From the shared-modules repository root: + +```bash +pnpm release-check -- --repo ../nhs-notify-client-config --git-tag 0.1.0 --jira-version 71260 +``` + +Or directly: + +```bash +pnpm --filter @nhsdigital/release-check run check -- --repo ../nhs-notify-client-config --git-tag 0.1.0 --jira-version 71260 +``` + +## Required environment + +- `JIRA_API_TOKEN` or `JIRA_PERSONAL_TOKEN` or `JIRA_TOKEN` + +## Optional environment + +- `GITHUB_TOKEN` or `GH_TOKEN` for fetching GitHub release notes from private repositories + +## Notes + +- The tool auto-detects the previous tag using `git describe --tags --abbrev=0 ^`. +- When GitHub release notes are unavailable, auto mode falls back to annotated tag notes if the tag is annotated. +- Reports default to `.tmp/release-check/-.txt` in the current working directory. + +## Publishing + +The package is configured for GitHub Packages as `@nhsdigital/release-check`. + +```bash +pnpm --filter @nhsdigital/release-check pack +pnpm --filter @nhsdigital/release-check publish --no-git-checks +``` + +## Consuming from another repository + +Add this to the consuming repository's `.npmrc`: + +```ini +@nhsdigital:registry=https://npm.pkg.github.com +``` + +Then install and use the CLI: + +```bash +pnpm add -D @nhsdigital/release-check +pnpm release-check --repo ../nhs-notify-client-config --git-tag 0.1.0 --jira-version 71260 +``` diff --git a/tools/release-check/jest.config.ts b/tools/release-check/jest.config.ts new file mode 100644 index 00000000..4d3702b6 --- /dev/null +++ b/tools/release-check/jest.config.ts @@ -0,0 +1,28 @@ +import type { Config } from 'jest'; + +const jestConfig: Config = { + preset: 'ts-jest', + clearMocks: true, + silent: true, + collectCoverage: true, + coverageDirectory: './.reports/unit/coverage', + coverageProvider: 'v8', + coveragePathIgnorePatterns: ['/__tests__/', '/node_modules/'], + transform: { '^.+\\.ts$': 'ts-jest' }, + testPathIgnorePatterns: ['.build'], + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + testEnvironment: 'node', + moduleNameMapper: { + '^src/(.*)$': '/src/$1', + }, + coverageThreshold: { + global: { + branches: 95, + functions: 100, + lines: 99, + statements: 99, + }, + }, +}; + +export default jestConfig; diff --git a/tools/release-check/package.json b/tools/release-check/package.json new file mode 100644 index 00000000..f6c28643 --- /dev/null +++ b/tools/release-check/package.json @@ -0,0 +1,49 @@ +{ + "bin": { + "release-check": "./dist/cli.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "dist", + "README.md" + ], + "imports": { + "#src/*": "./dist/*.js" + }, + "main": "./dist/index.js", + "name": "@nhsdigital/release-check", + "publishConfig": { + "access": "restricted", + "registry": "https://npm.pkg.github.com" + }, + "version": "0.0.1", + "private": false, + "repository": { + "type": "git", + "url": "git+https://github.com/NHSDigital/nhs-notify-shared-modules.git", + "directory": "tools/release-check" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "check": "tsx ./src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "prebuild": "rm -rf dist", + "prepack": "npm run build", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/jest": "^29.5.0", + "@types/node": "^25.9.1", + "globals": "^17.6.0", + "jest": "^29.7.0", + "ts-jest": "^29.4.11", + "tsx": "^4.22.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1" + } +} diff --git a/tools/release-check/src/__tests__/args.test.ts b/tools/release-check/src/__tests__/args.test.ts new file mode 100644 index 00000000..18c653a5 --- /dev/null +++ b/tools/release-check/src/__tests__/args.test.ts @@ -0,0 +1,90 @@ +import { parseCliArgs } from '../args'; + +describe('parseCliArgs', () => { + it('parses required and optional arguments', () => { + expect( + parseCliArgs([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + '--jira-project', + 'ABC', + '--jira-base-url', + 'https://jira.example.com/', + '--previous-tag', + '0.0.9', + '--output', + 'out.txt', + '--release-notes-source', + 'tag', + ]), + ).toEqual({ + repo: '../repo', + gitTag: '0.1.0', + jiraVersion: '71260', + jiraProject: 'ABC', + jiraBaseUrl: 'https://jira.example.com', + previousTag: '0.0.9', + output: 'out.txt', + releaseNotesSource: 'tag', + }); + }); + + it('uses defaults for optional arguments', () => { + expect( + parseCliArgs([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + ]), + ).toEqual({ + repo: '../repo', + gitTag: '0.1.0', + jiraVersion: '71260', + jiraProject: 'CCM', + jiraBaseUrl: 'https://nhsd-jira.digital.nhs.uk', + previousTag: undefined, + output: undefined, + releaseNotesSource: 'auto', + }); + }); + + it('throws for missing required arguments', () => { + expect(() => parseCliArgs([])).toThrow('Missing required option --repo'); + }); + + it('throws when the git tag is missing', () => { + expect(() => + parseCliArgs(['--repo', '../repo', '--jira-version', '71260']), + ).toThrow('Missing required option --git-tag'); + }); + + it('throws when the Jira version is missing', () => { + expect(() => + parseCliArgs(['--repo', '../repo', '--git-tag', '0.1.0']), + ).toThrow('Missing required option --jira-version'); + }); + + it('throws for an invalid release notes source', () => { + expect(() => + parseCliArgs([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + '--release-notes-source', + 'weird', + ]), + ).toThrow( + 'Invalid --release-notes-source. Expected one of: auto, github, tag, none', + ); + }); +}); diff --git a/tools/release-check/src/__tests__/compare.test.ts b/tools/release-check/src/__tests__/compare.test.ts new file mode 100644 index 00000000..4c02c346 --- /dev/null +++ b/tools/release-check/src/__tests__/compare.test.ts @@ -0,0 +1,121 @@ +import { compareRelease } from '../compare'; + +import type { GitCommit, JiraIssue } from '../types'; + +const issues: JiraIssue[] = [ + { + clinicalLead: '', + clinicalReviewStatus: 'Review required', + key: 'CCM-100', + medicalClinicalSafetyCategory: '', + summary: 'First ticket', + status: 'Done', + components: ['Platform'], + }, + { + clinicalLead: 'Dr Test', + clinicalReviewStatus: 'In review', + key: 'CCM-101', + medicalClinicalSafetyCategory: 'Cat 1', + summary: 'Exact summary fallback', + status: 'In Progress', + components: ['Platform'], + }, + { + clinicalLead: '', + clinicalReviewStatus: 'Review not needed', + key: 'CCM-102', + medicalClinicalSafetyCategory: '', + summary: 'Release only ticket', + status: 'Done', + components: ['Platform'], + }, +]; + +const commits: GitCommit[] = [ + { + hash: 'a'.repeat(40), + shortHash: 'aaaaaaaa', + subject: 'CCM-100: implement first ticket', + body: '', + explicitIssueKeys: ['CCM-100'], + }, + { + hash: 'b'.repeat(40), + shortHash: 'bbbbbbbb', + subject: 'Exact summary fallback (#123)', + body: '', + explicitIssueKeys: [], + }, + { + hash: 'c'.repeat(40), + shortHash: 'cccccccc', + subject: 'Untracked maintenance change', + body: '', + explicitIssueKeys: [], + }, + { + hash: 'd'.repeat(40), + shortHash: 'dddddddd', + subject: 'CCM-999: outside release', + body: '', + explicitIssueKeys: ['CCM-999'], + }, +]; + +describe('compareRelease', () => { + it('compares git and release-note issue references against the Jira release', () => { + const result = compareRelease(commits, issues, ['CCM-100', 'CCM-200']); + + expect(result.gitReferencedIssueKeys).toEqual([ + 'CCM-100', + 'CCM-101', + 'CCM-999', + ]); + expect(result.notesReferencedIssueKeys).toEqual(['CCM-100', 'CCM-200']); + expect(result.jiraIssuesMissingFromGit.map((issue) => issue.key)).toEqual([ + 'CCM-102', + ]); + expect( + result.jiraIssuesMissingFromReleaseNotes.map((issue) => issue.key), + ).toEqual(['CCM-101', 'CCM-102']); + expect( + result.releaseReferencedIssuesNotDone.map((issue) => issue.key), + ).toEqual(['CCM-101']); + expect( + result.jiraIssuesMissingClinicalSafetyCategory.map((issue) => issue.key), + ).toEqual(['CCM-100']); + expect( + result.jiraIssuesMissingClinicalLead.map((issue) => issue.key), + ).toEqual(['CCM-100']); + expect(result.releaseNotesIssueKeysOutsideRelease).toEqual(['CCM-200']); + expect( + result.commitsWithIssueKeysOutsideRelease.map( + ({ missingKeys }) => missingKeys, + ), + ).toEqual([['CCM-999']]); + expect( + result.commitsWithoutMatches.map((commit) => commit.shortHash), + ).toEqual(['cccccccc']); + }); + + it('treats punctuation-only subjects as unmatched when no Jira key is present', () => { + const result = compareRelease( + [ + { + hash: 'e'.repeat(40), + shortHash: 'eeeeeeee', + subject: ' (#123) ', + body: '', + explicitIssueKeys: [], + }, + ], + issues, + [], + ); + + expect( + result.commitsWithoutMatches.map((commit) => commit.shortHash), + ).toEqual(['eeeeeeee']); + }); +}); diff --git a/tools/release-check/src/__tests__/git.test.ts b/tools/release-check/src/__tests__/git.test.ts new file mode 100644 index 00000000..aafa86bb --- /dev/null +++ b/tools/release-check/src/__tests__/git.test.ts @@ -0,0 +1,242 @@ +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +import { + collectCommits, + ensureCommitishExists, + getOriginRemoteUrl, + getPreviousTag, + getRepoName, + getRepoRoot, + readTagAnnotation, + resolveRepoPath, +} from '../git'; + +jest.mock('node:child_process', () => ({ + spawnSync: jest.fn(), +})); + +jest.mock('node:fs', () => ({ + existsSync: jest.fn(), +})); + +const mockedSpawnSync = spawnSync as jest.MockedFunction; +const mockedExistsSync = existsSync as jest.MockedFunction; + +describe('resolveRepoPath', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an existing absolute path', () => { + mockedExistsSync.mockImplementation((candidate) => candidate === '/repo'); + + expect(resolveRepoPath('/repo')).toBe('/repo'); + }); + + it('falls back to the sibling checkout path for relative input', () => { + const cwd = jest.spyOn(process, 'cwd').mockReturnValue('/workspace/shared'); + mockedExistsSync.mockImplementation( + (candidate) => candidate === '/workspace/tool-target', + ); + + expect(resolveRepoPath('tool-target')).toBe('/workspace/tool-target'); + + cwd.mockRestore(); + }); + + it('throws when no candidate exists', () => { + mockedExistsSync.mockReturnValue(false); + + expect(() => resolveRepoPath('missing-repo')).toThrow( + 'Could not resolve repository path for "missing-repo".', + ); + }); +}); + +describe('git command helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns repo metadata from git commands', () => { + mockedSpawnSync + .mockReturnValueOnce({ + status: 0, + stdout: '/repos/client-config\n', + stderr: '', + } as never) + .mockReturnValueOnce({ + status: 0, + stdout: '', + stderr: '', + } as never) + .mockReturnValueOnce({ + status: 0, + stdout: 'git@github.com:NHSDigital/nhs-notify-client-config.git\n', + stderr: '', + } as never); + + expect(getRepoRoot('/repos/client-config')).toBe('/repos/client-config'); + expect(() => + ensureCommitishExists('/repos/client-config', '0.1.0'), + ).not.toThrow(); + expect(getOriginRemoteUrl('/repos/client-config')).toBe( + 'git@github.com:NHSDigital/nhs-notify-client-config.git', + ); + expect(getRepoName('/repos/client-config')).toBe('client-config'); + }); + + it('surfaces git failures with stderr when available', () => { + mockedSpawnSync.mockReturnValue({ + status: 1, + stdout: '', + stderr: 'fatal: bad revision', + } as never); + + expect(() => getRepoRoot('/repos/client-config')).toThrow( + 'git rev-parse --show-toplevel failed: fatal: bad revision', + ); + }); + + it('falls back to stdout details when stderr is empty', () => { + mockedSpawnSync.mockReturnValue({ + status: 1, + stdout: 'fatal from stdout', + stderr: '', + } as never); + + expect(() => getRepoRoot('/repos/client-config')).toThrow( + 'git rev-parse --show-toplevel failed: fatal from stdout', + ); + }); +}); + +describe('getPreviousTag', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an explicit previous tag after verifying it exists', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: '', + stderr: '', + } as never); + + expect(getPreviousTag('/repos/client-config', '0.2.0', '0.1.0')).toBe( + '0.1.0', + ); + }); + + it('auto-detects the previous tag', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: '0.1.0\n', + stderr: '', + } as never); + + expect(getPreviousTag('/repos/client-config', '0.2.0')).toBe('0.1.0'); + }); + + it('returns null when no previous tag exists', () => { + mockedSpawnSync.mockReturnValue({ + status: 128, + stdout: '', + stderr: 'fatal', + } as never); + + expect(getPreviousTag('/repos/client-config', '0.1.0')).toBeNull(); + }); +}); + +describe('collectCommits', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an empty list when git log is empty', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: '', + stderr: '', + } as never); + + expect(collectCommits('/repos/client-config', '0.1.0', null)).toEqual([]); + }); + + it('parses git log output and extracts unique Jira keys', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: + `hash1\u001Fshort1\u001FCCM-100: Add feature\u001Fbody CCM-101 details\u001E` + + `hash2\u001Fshort2\u001FNo key commit\u001F\u001E`, + stderr: '', + } as never); + + expect(collectCommits('/repos/client-config', '0.2.0', '0.1.0')).toEqual([ + { + hash: 'hash1', + shortHash: 'short1', + subject: 'CCM-100: Add feature', + body: 'body CCM-101 details', + explicitIssueKeys: ['CCM-100', 'CCM-101'], + }, + { + hash: 'hash2', + shortHash: 'short2', + subject: 'No key commit', + body: '', + explicitIssueKeys: [], + }, + ]); + }); +}); + +describe('readTagAnnotation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null when the tag ref is absent', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: '', + stderr: '', + } as never); + + expect(readTagAnnotation('/repos/client-config', '0.1.0')).toBeNull(); + }); + + it('returns null when an annotated tag has only whitespace content', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: 'tag\u001F \n', + stderr: '', + } as never); + + expect(readTagAnnotation('/repos/client-config', '0.1.0')).toBeNull(); + }); + + it('returns null for lightweight tags', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: 'commit\u001FCCM-100 release text\n', + stderr: '', + } as never); + + expect(readTagAnnotation('/repos/client-config', '0.1.0')).toBeNull(); + }); + + it('returns trimmed annotation text for annotated tags', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: 'tag\u001F CCM-100 release text \n', + stderr: '', + } as never); + + expect(readTagAnnotation('/repos/client-config', '0.1.0')).toBe( + 'CCM-100 release text', + ); + }); +}); diff --git a/tools/release-check/src/__tests__/github-release.test.ts b/tools/release-check/src/__tests__/github-release.test.ts new file mode 100644 index 00000000..fe4139bd --- /dev/null +++ b/tools/release-check/src/__tests__/github-release.test.ts @@ -0,0 +1,214 @@ +import { + parseGitHubRepositoryFromRemote, + readReleaseNotes, +} from '../github-release'; + +jest.mock('../git', () => ({ + getOriginRemoteUrl: jest.fn(), + readTagAnnotation: jest.fn(), +})); + +const gitModule = jest.requireMock('../git'); +const mockFetch = jest.fn(); +const mockedGetOriginRemoteUrl = + gitModule.getOriginRemoteUrl as jest.MockedFunction< + typeof gitModule.getOriginRemoteUrl + >; +const mockedReadTagAnnotation = + gitModule.readTagAnnotation as jest.MockedFunction< + typeof gitModule.readTagAnnotation + >; + +Object.defineProperty(globalThis, 'fetch', { + value: mockFetch, + writable: true, +}); + +describe('parseGitHubRepositoryFromRemote', () => { + it('parses ssh remotes', () => { + expect( + parseGitHubRepositoryFromRemote( + 'git@github.com:NHSDigital/nhs-notify-client-config.git', + ), + ).toEqual({ + owner: 'NHSDigital', + repo: 'nhs-notify-client-config', + }); + }); + + it('parses https remotes', () => { + expect( + parseGitHubRepositoryFromRemote( + 'https://github.com/NHSDigital/nhs-notify-client-config.git', + ), + ).toEqual({ + owner: 'NHSDigital', + repo: 'nhs-notify-client-config', + }); + }); + + it('returns null for non-github remotes', () => { + expect( + parseGitHubRepositoryFromRemote('ssh://gitlab.example.com/repo.git'), + ).toBeNull(); + }); +}); + +describe('readReleaseNotes', () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + }); + + it('returns none when release notes are disabled', async () => { + await expect(readReleaseNotes('/repo', '0.1.0', 'none')).resolves.toEqual({ + issueKeys: [], + source: 'none', + text: null, + warnings: [], + }); + }); + + it('returns github release notes when a release body exists', async () => { + process.env.GITHUB_TOKEN = 'github-token'; + mockedGetOriginRemoteUrl.mockReturnValue( + 'git@github.com:NHSDigital/nhs-notify-client-config.git', + ); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ body: 'CCM-100 first\nCCM-101 second' }), + }); + + await expect(readReleaseNotes('/repo', '0.1.0', 'github')).resolves.toEqual( + { + issueKeys: ['CCM-100', 'CCM-101'], + source: 'github-release', + text: 'CCM-100 first\nCCM-101 second', + warnings: [], + }, + ); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.github.com/repos/NHSDigital/nhs-notify-client-config/releases/tags/0.1.0', + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: 'application/vnd.github+json', + Authorization: 'Bearer github-token', + }), + }), + ); + }); + + it('throws in github mode when the origin remote is not GitHub', async () => { + mockedGetOriginRemoteUrl.mockReturnValue( + 'ssh://gitlab.example.com/repo.git', + ); + + await expect(readReleaseNotes('/repo', '0.1.0', 'github')).rejects.toThrow( + 'Origin remote is not a supported GitHub URL.', + ); + }); + + it('falls back to tag annotation in auto mode with a warning', async () => { + mockedGetOriginRemoteUrl.mockReturnValue( + 'ssh://gitlab.example.com/repo.git', + ); + mockedReadTagAnnotation.mockReturnValue('CCM-200 annotated release'); + + await expect(readReleaseNotes('/repo', '0.1.0', 'auto')).resolves.toEqual({ + issueKeys: ['CCM-200'], + source: 'tag-annotation', + text: 'CCM-200 annotated release', + warnings: [ + 'Origin remote is not a supported GitHub URL; skipped GitHub release lookup.', + ], + }); + }); + + it('falls back to none in auto mode when GitHub lookup fails and no annotation exists', async () => { + mockedGetOriginRemoteUrl.mockReturnValue( + 'git@github.com:NHSDigital/nhs-notify-client-config.git', + ); + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'bad token', + }); + mockedReadTagAnnotation.mockReturnValue(null); + + await expect(readReleaseNotes('/repo', '0.1.0', 'auto')).resolves.toEqual({ + issueKeys: [], + source: 'none', + text: null, + warnings: [ + 'GitHub release lookup failed: GitHub release lookup failed (401 Unauthorized): bad token', + 'Tag 0.1.0 is not annotated; no tag release notes available.', + ], + }); + }); + + it('records non-Error exceptions from github lookup in auto mode', async () => { + mockedGetOriginRemoteUrl.mockReturnValue( + 'git@github.com:NHSDigital/nhs-notify-client-config.git', + ); + mockFetch.mockRejectedValue('network down'); + mockedReadTagAnnotation.mockReturnValue(null); + + await expect(readReleaseNotes('/repo', '0.1.0', 'auto')).resolves.toEqual({ + issueKeys: [], + source: 'none', + text: null, + warnings: [ + 'GitHub release lookup failed: network down', + 'Tag 0.1.0 is not annotated; no tag release notes available.', + ], + }); + }); + + it('throws in github mode when no release body is found', async () => { + mockedGetOriginRemoteUrl.mockReturnValue( + 'https://github.com/NHSDigital/nhs-notify-client-config.git', + ); + mockFetch.mockResolvedValue({ + status: 404, + ok: false, + statusText: 'Not Found', + text: async () => 'missing', + }); + + await expect(readReleaseNotes('/repo', '0.1.0', 'github')).rejects.toThrow( + 'No GitHub release body found for tag 0.1.0.', + ); + }); + + it('warns and falls back when the github release exists but has no body in auto mode', async () => { + mockedGetOriginRemoteUrl.mockReturnValue( + 'https://github.com/NHSDigital/nhs-notify-client-config.git', + ); + mockFetch.mockResolvedValue({ + status: 200, + ok: true, + statusText: 'OK', + json: async () => ({ body: '' }), + }); + mockedReadTagAnnotation.mockReturnValue('CCM-300 tag notes'); + + await expect(readReleaseNotes('/repo', '0.1.0', 'auto')).resolves.toEqual({ + issueKeys: ['CCM-300'], + source: 'tag-annotation', + text: 'CCM-300 tag notes', + warnings: ['No GitHub release body found for tag 0.1.0; falling back.'], + }); + }); + + it('throws in tag mode when the tag is not annotated', async () => { + mockedReadTagAnnotation.mockReturnValue(null); + + await expect(readReleaseNotes('/repo', '0.1.0', 'tag')).rejects.toThrow( + 'Tag 0.1.0 is not annotated, so no tag release notes are available.', + ); + }); +}); diff --git a/tools/release-check/src/__tests__/index.test.ts b/tools/release-check/src/__tests__/index.test.ts new file mode 100644 index 00000000..12ba4c98 --- /dev/null +++ b/tools/release-check/src/__tests__/index.test.ts @@ -0,0 +1,219 @@ +import { run } from '..'; + +jest.mock('node:fs/promises', () => ({ + mkdir: jest.fn().mockResolvedValue(undefined), + writeFile: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../git', () => ({ + collectCommits: jest.fn(), + ensureCommitishExists: jest.fn(), + getPreviousTag: jest.fn(), + getRepoName: jest.fn(), + getRepoRoot: jest.fn(), + resolveRepoPath: jest.fn(), +})); + +jest.mock('../jira', () => ({ + fetchJiraIssues: jest.fn(), + resolveJiraVersion: jest.fn(), +})); + +jest.mock('../github-release', () => ({ + readReleaseNotes: jest.fn(), +})); + +jest.mock('../compare', () => ({ + compareRelease: jest.fn(), +})); + +jest.mock('../report', () => ({ + defaultReportPath: jest.fn(), + renderReport: jest.fn(), +})); + +const fsPromises = + jest.requireMock('node:fs/promises'); +const git = jest.requireMock('../git'); +const jira = jest.requireMock('../jira'); +const notes = + jest.requireMock('../github-release'); +const compare = jest.requireMock('../compare'); +const report = jest.requireMock('../report'); +const mockedResolveRepoPath = git.resolveRepoPath as jest.MockedFunction< + typeof git.resolveRepoPath +>; +const mockedGetRepoRoot = git.getRepoRoot as jest.MockedFunction< + typeof git.getRepoRoot +>; +const mockedGetRepoName = git.getRepoName as jest.MockedFunction< + typeof git.getRepoName +>; +const mockedGetPreviousTag = git.getPreviousTag as jest.MockedFunction< + typeof git.getPreviousTag +>; +const mockedCollectCommits = git.collectCommits as jest.MockedFunction< + typeof git.collectCommits +>; +const mockedResolveJiraVersion = jira.resolveJiraVersion as jest.MockedFunction< + typeof jira.resolveJiraVersion +>; +const mockedFetchJiraIssues = jira.fetchJiraIssues as jest.MockedFunction< + typeof jira.fetchJiraIssues +>; +const mockedReadReleaseNotes = notes.readReleaseNotes as jest.MockedFunction< + typeof notes.readReleaseNotes +>; +const mockedCompareRelease = compare.compareRelease as jest.MockedFunction< + typeof compare.compareRelease +>; +const mockedDefaultReportPath = report.defaultReportPath as jest.MockedFunction< + typeof report.defaultReportPath +>; +const mockedRenderReport = report.renderReport as jest.MockedFunction< + typeof report.renderReport +>; + +describe('run', () => { + const originalStdoutWrite = process.stdout.write; + const stdoutWrite = jest.fn().mockReturnValue(true); + + beforeEach(() => { + jest.clearAllMocks(); + process.stdout.write = stdoutWrite as typeof process.stdout.write; + + mockedResolveRepoPath.mockReturnValue('/repo'); + mockedGetRepoRoot.mockReturnValue('/repo'); + mockedGetRepoName.mockReturnValue('repo'); + mockedGetPreviousTag.mockReturnValue('0.0.9'); + mockedCollectCommits.mockReturnValue([]); + mockedResolveJiraVersion.mockResolvedValue({ + id: '71260', + name: 'release', + releaseDate: '2026-07-08', + released: true, + }); + mockedFetchJiraIssues.mockResolvedValue([]); + mockedReadReleaseNotes.mockResolvedValue({ + issueKeys: [], + source: 'none', + text: null, + warnings: [], + }); + mockedCompareRelease.mockReturnValue({ + commitsByIssueKey: new Map(), + commitsWithIssueKeysOutsideRelease: [], + commitsWithoutMatches: [], + gitReferencedIssueKeys: [], + jiraIssuesMissingClinicalLead: [], + jiraIssuesMissingClinicalSafetyCategory: [], + jiraIssuesMissingFromGit: [], + jiraIssuesMissingFromReleaseNotes: [], + notesReferencedIssueKeys: [], + releaseReferencedIssuesNotDone: [], + releaseNotesIssueKeysOutsideRelease: [], + }); + mockedDefaultReportPath.mockReturnValue('/workspace/report.txt'); + mockedRenderReport.mockReturnValue('report'); + }); + + afterEach(() => { + process.stdout.write = originalStdoutWrite; + }); + + it('runs the end-to-end comparison and writes the report', async () => { + mockedCompareRelease.mockReturnValue({ + commitsByIssueKey: new Map(), + commitsWithIssueKeysOutsideRelease: [], + commitsWithoutMatches: [], + gitReferencedIssueKeys: [], + jiraIssuesMissingClinicalLead: [ + { + clinicalLead: '', + clinicalReviewStatus: 'Pending', + components: [], + key: 'CCM-2', + medicalClinicalSafetyCategory: '', + status: 'Done', + summary: 'lead missing', + }, + ], + jiraIssuesMissingClinicalSafetyCategory: [ + { + clinicalLead: '', + clinicalReviewStatus: 'Pending', + components: [], + key: 'CCM-1', + medicalClinicalSafetyCategory: '', + status: 'Done', + summary: 'category missing', + }, + ], + jiraIssuesMissingFromGit: [], + jiraIssuesMissingFromReleaseNotes: [], + notesReferencedIssueKeys: [], + releaseReferencedIssuesNotDone: [], + releaseNotesIssueKeysOutsideRelease: [], + }); + + await run([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + ]); + + expect(mockedResolveRepoPath).toHaveBeenCalledWith('../repo'); + expect(git.ensureCommitishExists).toHaveBeenCalledWith('/repo', '0.1.0'); + expect(mockedReadReleaseNotes).toHaveBeenCalledWith( + '/repo', + '0.1.0', + 'auto', + ); + expect(fsPromises.mkdir).toHaveBeenCalledWith('/workspace', { + recursive: true, + }); + expect(fsPromises.writeFile).toHaveBeenCalledWith( + '/workspace/report.txt', + 'report', + 'utf8', + ); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining( + 'Jira issues missing clinical safety category: 1\n', + ), + ); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining('Jira issues missing clinical lead: 1\n'), + ); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining('Report written to /workspace/report.txt\n'), + ); + }); + + it('respects an explicit output path and a missing previous tag', async () => { + mockedGetPreviousTag.mockReturnValue(null); + + await run([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + '--output', + 'reports/custom.txt', + ]); + + expect(mockedDefaultReportPath).not.toHaveBeenCalled(); + expect(fsPromises.mkdir).toHaveBeenCalledWith( + expect.stringContaining('/reports'), + { recursive: true }, + ); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining('Comparison base: repository start\n'), + ); + }); +}); diff --git a/tools/release-check/src/__tests__/jira.test.ts b/tools/release-check/src/__tests__/jira.test.ts new file mode 100644 index 00000000..ca292745 --- /dev/null +++ b/tools/release-check/src/__tests__/jira.test.ts @@ -0,0 +1,238 @@ +import { fetchJiraIssues, resolveJiraVersion } from '../jira'; + +const mockFetch = jest.fn(); + +Object.defineProperty(globalThis, 'fetch', { + value: mockFetch, + writable: true, +}); + +describe('resolveJiraVersion', () => { + const originalToken = process.env.JIRA_API_TOKEN; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.JIRA_API_TOKEN = 'token'; + }); + + afterAll(() => { + process.env.JIRA_API_TOKEN = originalToken; + }); + + it('resolves a numeric version id directly', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + id: 71_260, + name: 'client-config-0.1.0', + releaseDate: '2026-07-08', + released: true, + }), + }); + + await expect( + resolveJiraVersion('https://jira.example.com', 'CCM', '71260'), + ).resolves.toEqual({ + id: '71260', + name: 'client-config-0.1.0', + releaseDate: '2026-07-08', + released: true, + }); + }); + + it('extracts a version id from a Jira version URL', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + id: 71_260, + name: 'client-config-0.1.0', + releaseDate: '2026-07-08', + released: true, + }), + }); + + await expect( + resolveJiraVersion( + 'https://jira.example.com', + 'CCM', + 'https://jira.example.com/projects/CCM/versions/71260', + ), + ).resolves.toEqual({ + id: '71260', + name: 'client-config-0.1.0', + releaseDate: '2026-07-08', + released: true, + }); + }); + + it('resolves a version name from the project versions list', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => [ + { id: 1, name: 'older' }, + { + id: 71_260, + name: 'client-config-0.1.0', + releaseDate: '2026-07-08', + released: true, + }, + ], + }); + + await expect( + resolveJiraVersion( + 'https://jira.example.com', + 'CCM', + 'client-config-0.1.0', + ), + ).resolves.toEqual({ + id: '71260', + name: 'client-config-0.1.0', + releaseDate: '2026-07-08', + released: true, + }); + }); + + it('defaults missing release metadata from the version response', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + id: 71_260, + name: 'client-config-0.1.0', + }), + }); + + await expect( + resolveJiraVersion('https://jira.example.com', 'CCM', '71260'), + ).resolves.toEqual({ + id: '71260', + name: 'client-config-0.1.0', + releaseDate: null, + released: false, + }); + }); + + it('throws when the named version is not found', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => [{ id: 1, name: 'older' }], + }); + + await expect( + resolveJiraVersion('https://jira.example.com', 'CCM', 'missing'), + ).rejects.toThrow('Could not find Jira version "missing" in project CCM.'); + }); + + it('throws when no Jira token is configured', async () => { + delete process.env.JIRA_API_TOKEN; + + await expect( + resolveJiraVersion('https://jira.example.com', 'CCM', '71260'), + ).rejects.toThrow( + 'Missing Jira token. Set JIRA_API_TOKEN, JIRA_PERSONAL_TOKEN, or JIRA_TOKEN.', + ); + }); +}); + +describe('fetchJiraIssues', () => { + const originalToken = process.env.JIRA_API_TOKEN; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.JIRA_API_TOKEN = 'token'; + }); + + afterAll(() => { + process.env.JIRA_API_TOKEN = originalToken; + }); + + it('maps paged Jira issues', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + total: 2, + issues: [ + { + key: 'CCM-1', + fields: { + customfield_10523: { name: 'Dr Test' }, + customfield_15200: { value: 'Cat 1' }, + customfield_16657: { value: 'Review required' }, + summary: 'First', + status: { name: 'Done' }, + components: [{ name: 'Platform' }], + }, + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + total: 2, + issues: [ + { + key: 'CCM-2', + fields: { + customfield_10523: null, + customfield_15200: ['Cat 2', { value: 'Cat 3' }], + customfield_16657: 'Review not needed', + summary: 'Second', + status: { name: 'In Progress' }, + components: [], + }, + }, + ], + }), + }); + + await expect( + fetchJiraIssues('https://jira.example.com', 'CCM', { + id: '71260', + name: 'release', + releaseDate: null, + released: true, + }), + ).resolves.toEqual([ + { + key: 'CCM-1', + clinicalLead: 'Dr Test', + clinicalReviewStatus: 'Review required', + summary: 'First', + medicalClinicalSafetyCategory: 'Cat 1', + status: 'Done', + components: ['Platform'], + }, + { + key: 'CCM-2', + clinicalLead: '', + clinicalReviewStatus: 'Review not needed', + summary: 'Second', + medicalClinicalSafetyCategory: 'Cat 2|Cat 3', + status: 'In Progress', + components: [], + }, + ]); + }); + + it('throws when Jira responds with an error', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Server Error', + text: async () => 'boom', + }); + + await expect( + fetchJiraIssues('https://jira.example.com', 'CCM', { + id: '71260', + name: 'release', + releaseDate: null, + released: true, + }), + ).rejects.toThrow( + 'Jira request failed (500 Server Error) for https://jira.example.com/rest/api/2/search', + ); + }); +}); diff --git a/tools/release-check/src/__tests__/report.test.ts b/tools/release-check/src/__tests__/report.test.ts new file mode 100644 index 00000000..b5c2f1ae --- /dev/null +++ b/tools/release-check/src/__tests__/report.test.ts @@ -0,0 +1,228 @@ +import { defaultReportPath, renderReport } from '../report'; + +import type { ComparisonResult, JiraVersion, ReleaseNotes } from '../types'; + +const comparison: ComparisonResult = { + commitsByIssueKey: new Map(), + commitsWithIssueKeysOutsideRelease: [], + commitsWithoutMatches: [], + gitReferencedIssueKeys: ['CCM-100'], + jiraIssuesMissingClinicalLead: [], + jiraIssuesMissingClinicalSafetyCategory: [], + jiraIssuesMissingFromGit: [], + jiraIssuesMissingFromReleaseNotes: [], + notesReferencedIssueKeys: ['CCM-100'], + releaseReferencedIssuesNotDone: [], + releaseNotesIssueKeysOutsideRelease: [], +}; + +const jiraVersion: JiraVersion = { + id: '71260', + name: 'client-config-0.1.0', + releaseDate: '2026-07-08', + released: true, +}; + +const releaseNotes: ReleaseNotes = { + issueKeys: ['CCM-100'], + source: 'github-release', + text: 'CCM-100: release note entry', + warnings: ['No GitHub release body found for tag v0.0.1; falling back.'], +}; + +describe('defaultReportPath', () => { + it('writes reports under .tmp/release-check in the cwd', () => { + expect( + defaultReportPath('nhs-notify-client-config', '0.1.0', '/workspace'), + ).toBe('/workspace/.tmp/release-check/nhs-notify-client-config-0.1.0.txt'); + }); +}); + +describe('renderReport', () => { + it('renders summary metadata and warnings', () => { + const report = renderReport({ + comparison, + gitTag: '0.1.0', + jiraProject: 'CCM', + jiraVersion, + previousTag: null, + releaseNotes, + repoName: 'nhs-notify-client-config', + repoRoot: '/repos/nhs-notify-client-config', + totalJiraIssues: 16, + }); + + expect(report).toContain('Release check report'); + expect(report).toContain('Repository: nhs-notify-client-config'); + expect(report).toContain('Jira version: client-config-0.1.0 (71260)'); + expect(report).toContain('Release notes source: github-release'); + expect(report).toContain('Warnings'); + expect(report).toContain( + 'No GitHub release body found for tag v0.0.1; falling back.', + ); + }); + + it('renders populated issue and commit sections', () => { + const populatedReport = renderReport({ + comparison: { + commitsByIssueKey: new Map([ + [ + 'CCM-100', + [ + { + hash: 'a'.repeat(40), + shortHash: 'aaaaaaaa', + subject: 'CCM-100: ship it', + body: '', + explicitIssueKeys: ['CCM-100'], + matchedIssueKeys: ['CCM-100'], + }, + ], + ], + ]), + commitsWithIssueKeysOutsideRelease: [ + { + commit: { + hash: 'b'.repeat(40), + shortHash: 'bbbbbbbb', + subject: 'CCM-999: outside', + body: '', + explicitIssueKeys: ['CCM-999'], + matchedIssueKeys: ['CCM-999'], + }, + missingKeys: ['CCM-999'], + }, + ], + commitsWithoutMatches: [ + { + hash: 'c'.repeat(40), + shortHash: 'cccccccc', + subject: 'maintenance', + body: '', + explicitIssueKeys: [], + matchedIssueKeys: [], + }, + ], + gitReferencedIssueKeys: ['CCM-100'], + jiraIssuesMissingClinicalLead: [ + { + key: 'CCM-104', + summary: 'Missing clinical lead', + status: 'Done', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: 'Review required', + medicalClinicalSafetyCategory: 'Cat 1', + }, + ], + jiraIssuesMissingClinicalSafetyCategory: [ + { + key: 'CCM-103', + summary: 'Missing clinical safety category', + status: 'Done', + components: ['Platform'], + clinicalLead: 'Dr Test', + clinicalReviewStatus: 'Review required', + medicalClinicalSafetyCategory: '', + }, + ], + jiraIssuesMissingFromGit: [ + { + key: 'CCM-101', + summary: 'Missing from git', + status: 'Done', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + }, + ], + jiraIssuesMissingFromReleaseNotes: [ + { + key: 'CCM-102', + summary: 'Missing from notes', + status: 'Done', + components: [], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + }, + ], + notesReferencedIssueKeys: ['CCM-100'], + releaseReferencedIssuesNotDone: [ + { + key: 'CCM-100', + summary: 'Referenced and not done', + status: 'In Progress', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + }, + ], + releaseNotesIssueKeysOutsideRelease: ['CCM-200'], + }, + gitTag: '0.1.0', + jiraProject: 'CCM', + jiraVersion, + previousTag: '0.0.9', + releaseNotes: { + issueKeys: ['CCM-100'], + source: 'github-release', + text: 'CCM-100 release note entry', + warnings: [], + }, + repoName: 'nhs-notify-client-config', + repoRoot: '/repos/nhs-notify-client-config', + totalJiraIssues: 3, + }); + + expect(populatedReport).toContain( + 'CCM-101: [Platform] Missing from git (Done)', + ); + expect(populatedReport).toContain('CCM-102: Missing from notes (Done)'); + expect(populatedReport).toContain( + 'CCM-100: [Platform] Referenced and not done (In Progress) | commits: aaaaaaaa CCM-100: ship it', + ); + expect(populatedReport).toContain( + 'CCM-103: [Platform] Missing clinical safety category (Done)', + ); + expect(populatedReport).toContain( + 'CCM-104: [Platform] Missing clinical lead (Done)', + ); + expect(populatedReport).toContain( + 'bbbbbbbb CCM-999: outside | missing keys: CCM-999', + ); + expect(populatedReport).toContain( + 'Commits without a Jira key or exact Jira-summary match', + ); + expect(populatedReport).toContain('- cccccccc maintenance'); + }); + + it('renders unknown release metadata when Jira has not set it', () => { + const report = renderReport({ + comparison, + gitTag: '0.1.0', + jiraProject: 'CCM', + jiraVersion: { + id: '71260', + name: 'client-config-0.1.0', + releaseDate: null, + released: false, + }, + previousTag: null, + releaseNotes: { + issueKeys: [], + source: 'none', + text: null, + warnings: [], + }, + repoName: 'nhs-notify-client-config', + repoRoot: '/repos/nhs-notify-client-config', + totalJiraIssues: 0, + }); + + expect(report).toContain('Jira release date: unknown'); + expect(report).toContain('Jira version released: no'); + }); +}); diff --git a/tools/release-check/src/args.ts b/tools/release-check/src/args.ts new file mode 100644 index 00000000..5dbc4313 --- /dev/null +++ b/tools/release-check/src/args.ts @@ -0,0 +1,64 @@ +import { parseArgs } from 'node:util'; + +import type { CliOptions, ReleaseNotesSource } from './types'; + +const DEFAULT_JIRA_BASE_URL = 'https://nhsd-jira.digital.nhs.uk'; +const DEFAULT_JIRA_PROJECT = 'CCM'; + +const trimTrailingSlashes = (value: string): string => { + let end = value.length; + while (end > 0 && value[end - 1] === '/') { + end -= 1; + } + return value.slice(0, end); +}; + +const isReleaseNotesSource = ( + value: string | undefined, +): value is ReleaseNotesSource => + value === 'auto' || value === 'github' || value === 'tag' || value === 'none'; + +export const parseCliArgs = (argv: string[]): CliOptions => { + const { values } = parseArgs({ + args: argv, + options: { + repo: { type: 'string' }, + 'git-tag': { type: 'string' }, + 'jira-version': { type: 'string' }, + 'jira-project': { type: 'string', default: DEFAULT_JIRA_PROJECT }, + 'jira-base-url': { type: 'string', default: DEFAULT_JIRA_BASE_URL }, + 'previous-tag': { type: 'string' }, + output: { type: 'string' }, + 'release-notes-source': { type: 'string', default: 'auto' }, + }, + allowPositionals: false, + }); + + if (!values.repo) { + throw new Error('Missing required option --repo'); + } + if (!values['git-tag']) { + throw new Error('Missing required option --git-tag'); + } + if (!values['jira-version']) { + throw new Error('Missing required option --jira-version'); + } + if (!isReleaseNotesSource(values['release-notes-source'])) { + throw new Error( + 'Invalid --release-notes-source. Expected one of: auto, github, tag, none', + ); + } + + return { + repo: values.repo, + gitTag: values['git-tag'], + jiraVersion: values['jira-version'], + jiraProject: values['jira-project'] ?? DEFAULT_JIRA_PROJECT, + jiraBaseUrl: trimTrailingSlashes( + values['jira-base-url'] ?? DEFAULT_JIRA_BASE_URL, + ), + previousTag: values['previous-tag'], + output: values.output, + releaseNotesSource: values['release-notes-source'], + }; +}; diff --git a/tools/release-check/src/cli.ts b/tools/release-check/src/cli.ts new file mode 100644 index 00000000..0001e3aa --- /dev/null +++ b/tools/release-check/src/cli.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node +/* istanbul ignore file -- thin CLI bootstrap */ + +import { run } from '.'; + +run(process.argv.slice(2)).catch((error: unknown) => { + process.stderr.write(`${(error as Error).stack ?? String(error)}\n`); + process.exit(1); +}); diff --git a/tools/release-check/src/compare.ts b/tools/release-check/src/compare.ts new file mode 100644 index 00000000..782aeefd --- /dev/null +++ b/tools/release-check/src/compare.ts @@ -0,0 +1,142 @@ +import type { + ComparisonResult, + GitCommit, + JiraIssue, + MatchedCommit, +} from './types'; + +const DONE_STATUSES = new Set([ + 'Done', + 'Closed', + 'Resolved', + 'Live Services Consult', +]); +const compareIssueKeys = (left: string, right: string): number => + left.localeCompare(right); + +const stripPullRequestSuffix = (text: string): string => { + const trimmed = text.trimEnd(); + const suffixStart = trimmed.lastIndexOf(' (#'); + if (suffixStart === -1 || !trimmed.endsWith(')')) { + return trimmed; + } + + const candidate = trimmed.slice(suffixStart + 3, -1); + if ( + !candidate || + [...candidate].some((character) => character < '0' || character > '9') + ) { + return trimmed; + } + + return trimmed.slice(0, suffixStart).trimEnd(); +}; + +const normalise = (text: string): string => + stripPullRequestSuffix(text) + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, ' ') + .trim(); + +const findSummaryMatches = (subject: string, issues: JiraIssue[]): string[] => { + const normalisedSubject = normalise(subject); + if (!normalisedSubject) { + return []; + } + + return issues + .filter((issue) => normalise(issue.summary) === normalisedSubject) + .map((issue) => issue.key); +}; + +const matchCommit = ( + commit: GitCommit, + issues: JiraIssue[], +): MatchedCommit => ({ + ...commit, + matchedIssueKeys: + commit.explicitIssueKeys.length > 0 + ? commit.explicitIssueKeys + : findSummaryMatches(commit.subject, issues), +}); + +export const compareRelease = ( + commits: GitCommit[], + issues: JiraIssue[], + releaseNoteIssueKeys: string[], +): ComparisonResult => { + const issueMap = new Map(issues.map((issue) => [issue.key, issue])); + const matchedCommits = commits.map((commit) => matchCommit(commit, issues)); + const commitsByIssueKey = new Map(); + const commitsWithoutMatches: MatchedCommit[] = []; + const commitsWithIssueKeysOutsideRelease: { + commit: MatchedCommit; + missingKeys: string[]; + }[] = []; + + for (const commit of matchedCommits) { + const { matchedIssueKeys } = commit; + if (matchedIssueKeys.length === 0) { + commitsWithoutMatches.push(commit); + } else { + const missingKeys = matchedIssueKeys.filter((key) => !issueMap.has(key)); + if (missingKeys.length > 0) { + commitsWithIssueKeysOutsideRelease.push({ commit, missingKeys }); + } + + for (const key of matchedIssueKeys) { + const existing = commitsByIssueKey.get(key) ?? []; + existing.push(commit); + commitsByIssueKey.set(key, existing); + } + } + } + + const gitReferencedIssueKeys = [...commitsByIssueKey.keys()].toSorted( + compareIssueKeys, + ); + const notesReferencedIssueKeys = [...new Set(releaseNoteIssueKeys)].toSorted( + compareIssueKeys, + ); + + const jiraIssuesMissingFromGit = issues.filter( + (issue) => !commitsByIssueKey.has(issue.key), + ); + const jiraIssuesMissingFromReleaseNotes = issues.filter( + (issue) => !notesReferencedIssueKeys.includes(issue.key), + ); + const jiraIssuesMissingClinicalSafetyCategory = issues.filter( + (issue) => + issue.medicalClinicalSafetyCategory === '' && + issue.clinicalReviewStatus !== 'Review not needed', + ); + const jiraIssuesMissingClinicalLead = issues.filter( + (issue) => + issue.clinicalLead === '' && + issue.clinicalReviewStatus !== 'Review not needed', + ); + const releaseNotesIssueKeysOutsideRelease = notesReferencedIssueKeys.filter( + (key) => !issueMap.has(key), + ); + + const releaseReferencedIssuesNotDone = issues.filter( + (issue) => + (commitsByIssueKey.has(issue.key) || + notesReferencedIssueKeys.includes(issue.key)) && + !DONE_STATUSES.has(issue.status), + ); + + return { + commitsByIssueKey, + commitsWithIssueKeysOutsideRelease, + commitsWithoutMatches, + gitReferencedIssueKeys, + jiraIssuesMissingClinicalLead, + jiraIssuesMissingClinicalSafetyCategory, + jiraIssuesMissingFromGit, + jiraIssuesMissingFromReleaseNotes, + notesReferencedIssueKeys, + releaseReferencedIssuesNotDone, + releaseNotesIssueKeysOutsideRelease, + }; +}; diff --git a/tools/release-check/src/git.ts b/tools/release-check/src/git.ts new file mode 100644 index 00000000..d365a3d7 --- /dev/null +++ b/tools/release-check/src/git.ts @@ -0,0 +1,147 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import type { GitCommit } from './types'; + +const GIT_EXECUTABLE = '/usr/bin/git'; +const ISSUE_KEY_PATTERN = /\b[A-Z][A-Z0-9]+-\d+\b/g; + +const runGit = (repoPath: string, args: string[]): string => { + const result = spawnSync(GIT_EXECUTABLE, ['-C', repoPath, ...args], { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.status !== 0) { + const detail = + result.stderr.trim() || + result.stdout.trim() || + `git exited ${result.status}`; + throw new Error(`git ${args.join(' ')} failed: ${detail}`); + } + + return result.stdout.trimEnd(); +}; + +export const resolveRepoPath = (repoInput: string): string => { + const candidates = path.isAbsolute(repoInput) + ? [repoInput] + : [ + path.resolve(process.cwd(), repoInput), + path.resolve(process.cwd(), '..', repoInput), + ]; + + const resolved = candidates.find((candidate) => existsSync(candidate)); + if (!resolved) { + throw new Error(`Could not resolve repository path for "${repoInput}".`); + } + + return resolved; +}; + +export const getRepoRoot = (repoPath: string): string => + runGit(repoPath, ['rev-parse', '--show-toplevel']); + +export const getRepoName = (repoRoot: string): string => + path.basename(repoRoot); + +export const ensureCommitishExists = ( + repoRoot: string, + commitish: string, +): void => { + runGit(repoRoot, ['rev-parse', '--verify', `${commitish}^{commit}`]); +}; + +export const getPreviousTag = ( + repoRoot: string, + gitTag: string, + explicitPreviousTag?: string, +): string | null => { + if (explicitPreviousTag) { + ensureCommitishExists(repoRoot, explicitPreviousTag); + return explicitPreviousTag; + } + + const result = spawnSync( + GIT_EXECUTABLE, + ['-C', repoRoot, 'describe', '--tags', '--abbrev=0', `${gitTag}^`], + { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }, + ); + + if (result.status !== 0) { + return null; + } + + return result.stdout.trim() || null; +}; + +export const collectCommits = ( + repoRoot: string, + gitTag: string, + previousTag: string | null, +): GitCommit[] => { + const range = previousTag ? `${previousTag}..${gitTag}` : gitTag; + const raw = runGit(repoRoot, [ + 'log', + '--no-merges', + '--format=%H%x1f%h%x1f%s%x1f%b%x1e', + range, + ]); + + if (!raw) { + return []; + } + + return raw + .split('\u001E') + .map((record) => record.trim()) + .filter(Boolean) + .map((record) => { + const [hash, shortHash, subject, body = ''] = record.split('\u001F'); + const explicitIssueKeys = [ + ...new Set( + (`${subject}\n${body}`.match(ISSUE_KEY_PATTERN) ?? []).map((key) => + key.toUpperCase(), + ), + ), + ]; + + return { + body, + explicitIssueKeys, + hash, + shortHash, + subject, + }; + }); +}; + +export const getOriginRemoteUrl = (repoRoot: string): string => + runGit(repoRoot, ['remote', 'get-url', 'origin']); + +export const readTagAnnotation = ( + repoRoot: string, + gitTag: string, +): string | null => { + const output = runGit(repoRoot, [ + 'for-each-ref', + `refs/tags/${gitTag}`, + '--format=%(objecttype)%x1f%(contents)', + ]); + + if (!output) { + return null; + } + + const [objectType, contents = ''] = output.split('\u001F'); + if (objectType !== 'tag') { + return null; + } + + const text = contents.trim(); + return text || null; +}; diff --git a/tools/release-check/src/github-release.ts b/tools/release-check/src/github-release.ts new file mode 100644 index 00000000..eb9ff020 --- /dev/null +++ b/tools/release-check/src/github-release.ts @@ -0,0 +1,190 @@ +import { getOriginRemoteUrl, readTagAnnotation } from './git'; + +import type { ReleaseNotes, ReleaseNotesSource } from './types'; + +const GITHUB_REMOTE_SSH_PATTERN = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/; +const GITHUB_REMOTE_HTTPS_PATTERN = + /^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/; +const ISSUE_KEY_PATTERN = /\b[A-Z][A-Z0-9]+-\d+\b/g; + +const parseGitHubRepositoryFromRemote = ( + remoteUrl: string, +): { owner: string; repo: string } | null => { + const sshMatch = GITHUB_REMOTE_SSH_PATTERN.exec(remoteUrl); + if (sshMatch) { + return { owner: sshMatch[1], repo: sshMatch[2] }; + } + + const httpsMatch = GITHUB_REMOTE_HTTPS_PATTERN.exec(remoteUrl); + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2] }; + } + + return null; +}; + +const extractIssueKeys = (text: string): string[] => [ + ...new Set( + (text.match(ISSUE_KEY_PATTERN) ?? []).map((key) => key.toUpperCase()), + ), +]; + +const fetchGitHubReleaseBody = async ( + owner: string, + repo: string, + gitTag: string, +): Promise => { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + const headers: Record = { + Accept: 'application/vnd.github+json', + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(gitTag)}`, + { + headers, + }, + ); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + const detail = await response.text(); + throw new Error( + `GitHub release lookup failed (${response.status} ${response.statusText}): ${detail}`, + ); + } + + const release = (await response.json()) as { body?: string | null }; + const body = release.body?.trim(); + return body || null; +}; + +const formatLookupError = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const tryReadGitHubReleaseNotes = async ( + repoRoot: string, + gitTag: string, + source: ReleaseNotesSource, + warnings: string[], +): Promise => { + const remote = parseGitHubRepositoryFromRemote(getOriginRemoteUrl(repoRoot)); + if (!remote) { + if (source === 'github') { + throw new Error('Origin remote is not a supported GitHub URL.'); + } + warnings.push( + 'Origin remote is not a supported GitHub URL; skipped GitHub release lookup.', + ); + return null; + } + + const body = await fetchGitHubReleaseBody(remote.owner, remote.repo, gitTag); + if (body) { + return { + issueKeys: extractIssueKeys(body), + source: 'github-release', + text: body, + warnings, + }; + } + + if (source === 'github') { + throw new Error(`No GitHub release body found for tag ${gitTag}.`); + } + + warnings.push( + `No GitHub release body found for tag ${gitTag}; falling back.`, + ); + return null; +}; + +const readTagReleaseNotes = ( + repoRoot: string, + gitTag: string, + source: ReleaseNotesSource, + warnings: string[], +): ReleaseNotes | null => { + const annotation = readTagAnnotation(repoRoot, gitTag); + if (annotation) { + return { + issueKeys: extractIssueKeys(annotation), + source: 'tag-annotation', + text: annotation, + warnings, + }; + } + if (source === 'tag') { + throw new Error( + `Tag ${gitTag} is not annotated, so no tag release notes are available.`, + ); + } + warnings.push( + `Tag ${gitTag} is not annotated; no tag release notes available.`, + ); + return null; +}; + +export const readReleaseNotes = async ( + repoRoot: string, + gitTag: string, + source: ReleaseNotesSource, +): Promise => { + const warnings: string[] = []; + + if (source === 'none') { + return { + issueKeys: [], + source: 'none', + text: null, + warnings, + }; + } + + if (source === 'github' || source === 'auto') { + try { + const releaseNotes = await tryReadGitHubReleaseNotes( + repoRoot, + gitTag, + source, + warnings, + ); + if (releaseNotes) { + return releaseNotes; + } + } catch (error: unknown) { + if (source === 'github') { + throw error; + } + warnings.push( + `GitHub release lookup failed: ${formatLookupError(error)}`, + ); + } + } + + if (source === 'tag' || source === 'auto') { + const releaseNotes = readTagReleaseNotes( + repoRoot, + gitTag, + source, + warnings, + ); + if (releaseNotes) { + return releaseNotes; + } + } + + return { + issueKeys: [], + source: 'none', + text: null, + warnings, + }; +}; + +export { parseGitHubRepositoryFromRemote }; diff --git a/tools/release-check/src/index.ts b/tools/release-check/src/index.ts new file mode 100644 index 00000000..c9e57cdd --- /dev/null +++ b/tools/release-check/src/index.ts @@ -0,0 +1,95 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { parseCliArgs } from './args'; +import { compareRelease } from './compare'; +import { + collectCommits, + ensureCommitishExists, + getPreviousTag, + getRepoName, + getRepoRoot, + resolveRepoPath, +} from './git'; +import { readReleaseNotes } from './github-release'; +import { fetchJiraIssues, resolveJiraVersion } from './jira'; +import { defaultReportPath, renderReport } from './report'; + +const writeReport = async ( + outputPath: string, + report: string, +): Promise => { + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, report, 'utf8'); +}; + +export const run = async (argv: string[]): Promise => { + const options = parseCliArgs(argv); + const repoPath = resolveRepoPath(options.repo); + const repoRoot = getRepoRoot(repoPath); + ensureCommitishExists(repoRoot, options.gitTag); + + const repoName = getRepoName(repoRoot); + const previousTag = getPreviousTag( + repoRoot, + options.gitTag, + options.previousTag, + ); + const commits = collectCommits(repoRoot, options.gitTag, previousTag); + + const jiraVersion = await resolveJiraVersion( + options.jiraBaseUrl, + options.jiraProject, + options.jiraVersion, + ); + const jiraIssues = await fetchJiraIssues( + options.jiraBaseUrl, + options.jiraProject, + jiraVersion, + ); + const releaseNotes = await readReleaseNotes( + repoRoot, + options.gitTag, + options.releaseNotesSource, + ); + const comparison = compareRelease( + commits, + jiraIssues, + releaseNotes.issueKeys, + ); + + const outputPath = path.resolve( + options.output ?? defaultReportPath(repoName, options.gitTag), + ); + const report = renderReport({ + comparison, + gitTag: options.gitTag, + jiraProject: options.jiraProject, + jiraVersion, + previousTag, + releaseNotes, + repoName, + repoRoot, + totalJiraIssues: jiraIssues.length, + }); + + await writeReport(outputPath, report); + + process.stdout.write( + `${[ + `Repository: ${repoName}`, + `Git tag: ${options.gitTag}`, + `Comparison base: ${previousTag ?? 'repository start'}`, + `Commits inspected: ${commits.length}`, + `Jira version: ${jiraVersion.name} (${jiraVersion.id})`, + `Jira issues in release: ${jiraIssues.length}`, + `Jira issues missing from git: ${comparison.jiraIssuesMissingFromGit.length}`, + `Jira issues missing from release notes: ${comparison.jiraIssuesMissingFromReleaseNotes.length}`, + `Referenced Jira issues not done: ${comparison.releaseReferencedIssuesNotDone.length}`, + `Jira issues missing clinical safety category: ${comparison.jiraIssuesMissingClinicalSafetyCategory.length}`, + `Jira issues missing clinical lead: ${comparison.jiraIssuesMissingClinicalLead.length}`, + `Commits without Jira matches: ${comparison.commitsWithoutMatches.length}`, + `Report written to ${outputPath}`, + ].join('\n')}\n`, + ); +}; diff --git a/tools/release-check/src/jira.ts b/tools/release-check/src/jira.ts new file mode 100644 index 00000000..c0c20155 --- /dev/null +++ b/tools/release-check/src/jira.ts @@ -0,0 +1,198 @@ +import type { JiraIssue, JiraVersion } from './types'; + +const CLINICAL_LEAD_FIELD_ID = 'customfield_10523'; +const MEDICAL_CLINICAL_SAFETY_CATEGORY_FIELD_ID = 'customfield_15200'; +const CLINICAL_REVIEW_STATUS_FIELD_ID = 'customfield_16657'; + +const getJiraFieldString = (value: unknown): string => { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + return value + .map((entry) => getJiraFieldString(entry)) + .filter(Boolean) + .join('|'); + } + + if (value && typeof value === 'object') { + const { name, value: namedValue } = value as { + name?: unknown; + value?: unknown; + }; + if (typeof namedValue === 'string') { + return namedValue; + } + + if (typeof name === 'string') { + return name; + } + } + + return ''; +}; + +const getJiraToken = (): string => { + const token = + process.env.JIRA_API_TOKEN || + process.env.JIRA_PERSONAL_TOKEN || + process.env.JIRA_TOKEN; + + if (!token) { + throw new Error( + 'Missing Jira token. Set JIRA_API_TOKEN, JIRA_PERSONAL_TOKEN, or JIRA_TOKEN.', + ); + } + + return token; +}; + +const VERSION_PATH_PATTERN = /\/versions\/(\d+)/; + +const fetchJiraJson = async (url: string): Promise => { + const response = await fetch(url, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${getJiraToken()}`, + }, + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error( + `Jira request failed (${response.status} ${response.statusText}) for ${url}: ${detail}`, + ); + } + + return response.json() as Promise; +}; + +const parseVersionReference = ( + reference: string, +): { type: 'id'; value: string } | { type: 'name'; value: string } => { + const trimmed = reference.trim(); + + if (/^\d+$/.test(trimmed)) { + return { type: 'id', value: trimmed }; + } + + if (/^https?:\/\//i.test(trimmed)) { + const url = new URL(trimmed); + const match = VERSION_PATH_PATTERN.exec(url.pathname); + if (match) { + return { type: 'id', value: match[1] }; + } + } + + return { type: 'name', value: trimmed }; +}; + +export const resolveJiraVersion = async ( + jiraBaseUrl: string, + jiraProject: string, + reference: string, +): Promise => { + const parsed = parseVersionReference(reference); + + if (parsed.type === 'id') { + const version = await fetchJiraJson<{ + id: string | number; + name: string; + releaseDate?: string; + released?: boolean; + }>(`${jiraBaseUrl}/rest/api/2/version/${encodeURIComponent(parsed.value)}`); + + return { + id: String(version.id), + name: version.name, + releaseDate: version.releaseDate ?? null, + released: Boolean(version.released), + }; + } + + const versions = await fetchJiraJson< + { + id: string | number; + name: string; + releaseDate?: string; + released?: boolean; + }[] + >( + `${jiraBaseUrl}/rest/api/2/project/${encodeURIComponent(jiraProject)}/versions`, + ); + + const version = versions.find((candidate) => candidate.name === parsed.value); + if (!version) { + throw new Error( + `Could not find Jira version "${parsed.value}" in project ${jiraProject}.`, + ); + } + + return { + id: String(version.id), + name: version.name, + releaseDate: version.releaseDate ?? null, + released: Boolean(version.released), + }; +}; + +export const fetchJiraIssues = async ( + jiraBaseUrl: string, + jiraProject: string, + jiraVersion: JiraVersion, +): Promise => { + const issues: JiraIssue[] = []; + const maxResults = 100; + let startAt = 0; + const jql = `project = ${jiraProject} AND fixVersion = ${jiraVersion.id} AND issuetype not in (Epic) AND status != "Not Required" ORDER BY key ASC`; + + while (true) { + const search = await fetchJiraJson<{ + issues: { + key: string; + fields: { + customfield_10523?: unknown; + customfield_15200?: unknown; + customfield_16657?: unknown; + components: { name: string }[]; + status: { name: string }; + summary: string; + }; + }[]; + total: number; + }>( + `${jiraBaseUrl}/rest/api/2/search?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}&fields=summary,status,components,${CLINICAL_LEAD_FIELD_ID},${MEDICAL_CLINICAL_SAFETY_CATEGORY_FIELD_ID},${CLINICAL_REVIEW_STATUS_FIELD_ID}`, + ); + + for (const issue of search.issues) { + const { + components, + customfield_10523: clinicalLeadField, + customfield_15200: medicalClinicalSafetyCategoryField, + customfield_16657: clinicalReviewStatusField, + status, + summary, + } = issue.fields; + + issues.push({ + clinicalLead: getJiraFieldString(clinicalLeadField), + clinicalReviewStatus: getJiraFieldString(clinicalReviewStatusField), + components: components.map((component) => component.name), + key: issue.key, + medicalClinicalSafetyCategory: getJiraFieldString( + medicalClinicalSafetyCategoryField, + ), + status: status.name, + summary, + }); + } + + startAt += search.issues.length; + if (startAt >= search.total) { + break; + } + } + + return issues; +}; diff --git a/tools/release-check/src/report.ts b/tools/release-check/src/report.ts new file mode 100644 index 00000000..e6e1ceda --- /dev/null +++ b/tools/release-check/src/report.ts @@ -0,0 +1,155 @@ +import path from 'node:path'; + +import type { + ComparisonResult, + JiraIssue, + JiraVersion, + MatchedCommit, + ReleaseNotes, +} from './types'; + +const formatIssue = ( + issue: JiraIssue, + commitsByIssueKey: Map, +): string => { + const components = + issue.components.length > 0 ? `[${issue.components.join(', ')}] ` : ''; + const commits = commitsByIssueKey.get(issue.key) ?? []; + const commitSummary = commits + .map((commit) => `${commit.shortHash} ${commit.subject}`) + .join('; '); + const issueSummary = `${issue.key}: ${components}${issue.summary} (${issue.status})`; + return commitSummary + ? `${issueSummary} | commits: ${commitSummary}` + : issueSummary; +}; + +const formatCommit = (commit: MatchedCommit): string => + `${commit.shortHash} ${commit.subject}`; + +const renderSection = (title: string, lines: string[]): string => { + if (lines.length === 0) { + return `${title}\n- none\n`; + } + const renderedLines = lines.map((line) => `- ${line}`).join('\n'); + return `${title}\n${renderedLines}\n`; +}; + +const sanitizeFileSegment = (value: string): string => + value.replaceAll(/[^A-Za-z0-9._-]+/g, '-'); + +export const defaultReportPath = ( + repoName: string, + gitTag: string, + cwd: string = process.cwd(), +): string => + path.join( + cwd, + '.tmp', + 'release-check', + `${sanitizeFileSegment(repoName)}-${sanitizeFileSegment(gitTag)}.txt`, + ); + +export const renderReport = ({ + comparison, + gitTag, + jiraProject, + jiraVersion, + previousTag, + releaseNotes, + repoName, + repoRoot, + totalJiraIssues, +}: { + comparison: ComparisonResult; + gitTag: string; + jiraProject: string; + jiraVersion: JiraVersion; + previousTag: string | null; + releaseNotes: ReleaseNotes; + repoName: string; + repoRoot: string; + totalJiraIssues: number; +}): string => { + const { warnings } = releaseNotes; + const sections = [ + 'Release check report', + `Repository: ${repoName}`, + `Repository root: ${repoRoot}`, + `Git tag: ${gitTag}`, + `Comparison base: ${previousTag ?? 'repository start'}`, + `Jira project: ${jiraProject}`, + `Jira version: ${jiraVersion.name} (${jiraVersion.id})`, + `Jira release date: ${jiraVersion.releaseDate ?? 'unknown'}`, + `Jira version released: ${jiraVersion.released ? 'yes' : 'no'}`, + `Release notes source: ${releaseNotes.source}`, + '', + 'Summary', + `- Jira issues in release: ${totalJiraIssues}`, + `- Jira issues referenced in git: ${comparison.gitReferencedIssueKeys.length}`, + `- Jira issues referenced in release notes: ${comparison.notesReferencedIssueKeys.length}`, + `- Jira issues missing from git: ${comparison.jiraIssuesMissingFromGit.length}`, + `- Jira issues missing from release notes: ${comparison.jiraIssuesMissingFromReleaseNotes.length}`, + `- Git issue keys outside Jira release: ${comparison.commitsWithIssueKeysOutsideRelease.length}`, + `- Release-note issue keys outside Jira release: ${comparison.releaseNotesIssueKeysOutsideRelease.length}`, + `- Referenced Jira issues not done: ${comparison.releaseReferencedIssuesNotDone.length}`, + `- Jira issues missing clinical safety category: ${comparison.jiraIssuesMissingClinicalSafetyCategory.length}`, + `- Jira issues missing clinical lead: ${comparison.jiraIssuesMissingClinicalLead.length}`, + `- Commits without Jira matches: ${comparison.commitsWithoutMatches.length}`, + '', + ]; + + if (warnings.length > 0) { + sections.push(renderSection('Warnings', warnings)); + } + + sections.push( + renderSection( + 'Jira issues in the release with no matching git reference', + comparison.jiraIssuesMissingFromGit.map((issue) => + formatIssue(issue, comparison.commitsByIssueKey), + ), + ), + renderSection( + 'Jira issues in the release with no matching release-note reference', + comparison.jiraIssuesMissingFromReleaseNotes.map((issue) => + formatIssue(issue, comparison.commitsByIssueKey), + ), + ), + renderSection( + 'Jira issues referenced in git or release notes but not in a done status', + comparison.releaseReferencedIssuesNotDone.map((issue) => + formatIssue(issue, comparison.commitsByIssueKey), + ), + ), + renderSection( + 'Jira issues missing clinical safety category', + comparison.jiraIssuesMissingClinicalSafetyCategory.map((issue) => + formatIssue(issue, comparison.commitsByIssueKey), + ), + ), + renderSection( + 'Jira issues missing clinical lead', + comparison.jiraIssuesMissingClinicalLead.map((issue) => + formatIssue(issue, comparison.commitsByIssueKey), + ), + ), + renderSection( + 'Git-referenced Jira issue keys missing from the Jira release', + comparison.commitsWithIssueKeysOutsideRelease.map( + ({ commit, missingKeys }) => + `${formatCommit(commit)} | missing keys: ${missingKeys.join(', ')}`, + ), + ), + renderSection( + 'Release-note Jira issue keys missing from the Jira release', + comparison.releaseNotesIssueKeysOutsideRelease, + ), + renderSection( + 'Commits without a Jira key or exact Jira-summary match', + comparison.commitsWithoutMatches.map((commit) => formatCommit(commit)), + ), + ); + + return sections.join('\n').replaceAll(/\n{3,}/g, '\n\n'); +}; diff --git a/tools/release-check/src/types.ts b/tools/release-check/src/types.ts new file mode 100644 index 00000000..cdadb775 --- /dev/null +++ b/tools/release-check/src/types.ts @@ -0,0 +1,65 @@ +export type JiraIssue = { + clinicalLead: string; + clinicalReviewStatus: string; + components: string[]; + key: string; + medicalClinicalSafetyCategory: string; + status: string; + summary: string; +}; + +export type GitCommit = { + body: string; + explicitIssueKeys: string[]; + hash: string; + shortHash: string; + subject: string; +}; + +export type ReleaseNotesSource = 'auto' | 'github' | 'tag' | 'none'; + +export type ReleaseNotes = { + issueKeys: string[]; + source: 'github-release' | 'tag-annotation' | 'none'; + text: string | null; + warnings: string[]; +}; + +export type JiraVersion = { + id: string; + name: string; + releaseDate: string | null; + released: boolean; +}; + +export type MatchedCommit = GitCommit & { + matchedIssueKeys: string[]; +}; + +export type ComparisonResult = { + commitsByIssueKey: Map; + commitsWithIssueKeysOutsideRelease: { + commit: MatchedCommit; + missingKeys: string[]; + }[]; + commitsWithoutMatches: MatchedCommit[]; + gitReferencedIssueKeys: string[]; + jiraIssuesMissingClinicalLead: JiraIssue[]; + jiraIssuesMissingClinicalSafetyCategory: JiraIssue[]; + jiraIssuesMissingFromGit: JiraIssue[]; + jiraIssuesMissingFromReleaseNotes: JiraIssue[]; + notesReferencedIssueKeys: string[]; + releaseReferencedIssuesNotDone: JiraIssue[]; + releaseNotesIssueKeysOutsideRelease: string[]; +}; + +export type CliOptions = { + gitTag: string; + jiraBaseUrl: string; + jiraProject: string; + jiraVersion: string; + output?: string; + previousTag?: string; + releaseNotesSource: ReleaseNotesSource; + repo: string; +}; diff --git a/tools/release-check/tsconfig.build.json b/tools/release-check/tsconfig.build.json new file mode 100644 index 00000000..dbee8df8 --- /dev/null +++ b/tools/release-check/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "baseUrl": ".", + "declaration": true, + "ignoreDeprecations": "6.0", + "isolatedModules": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "paths": { + "src/*": [ + "./src/*" + ] + }, + "rootDir": "src", + "types": [ + "node" + ], + "verbatimModuleSyntax": false + }, + "exclude": [ + "src/**/__tests__/**" + ], + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "src/**/*.ts" + ] +} diff --git a/tools/release-check/tsconfig.json b/tools/release-check/tsconfig.json new file mode 100644 index 00000000..b14cfa57 --- /dev/null +++ b/tools/release-check/tsconfig.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "declaration": true, + "isolatedModules": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "paths": { + "src/*": [ + "./src/*" + ] + }, + "rootDir": ".", + "types": [ + "jest", + "node" + ], + "verbatimModuleSyntax": false + }, + "exclude": [ + "dist" + ], + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "src/**/*", + "./jest.config.ts" + ] +} From 7f3a74fcc194702616135dd36738b69eb10e6e84 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Tue, 22 Sep 2026 17:42:28 +0100 Subject: [PATCH 2/6] CCM-14750: Rename release-check package Align the published package name with the NHS Notify naming convention by switching the package metadata, workflow filters, and documentation to @nhsdigital/nhs-notify-release-check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-release-check.yaml | 6 +++--- package.json | 2 +- tools/release-check/README.md | 10 +++++----- tools/release-check/package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish-release-check.yaml b/.github/workflows/publish-release-check.yaml index 1542fed6..a4129ea2 100644 --- a/.github/workflows/publish-release-check.yaml +++ b/.github/workflows/publish-release-check.yaml @@ -34,7 +34,7 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - published_version=$(npm view @nhsdigital/release-check --json 2>/dev/null | jq -r '.["dist-tags"].latest // "null"') + published_version=$(npm view @nhsdigital/nhs-notify-release-check --json 2>/dev/null | jq -r '.["dist-tags"].latest // "null"') echo "Published version: $published_version" local_version=$(jq -r '.version' tools/release-check/package.json) @@ -77,9 +77,9 @@ jobs: run: pnpm install --frozen-lockfile - name: Validate package - run: pnpm --filter @nhsdigital/release-check run typecheck && pnpm --filter @nhsdigital/release-check run test:unit + run: pnpm --filter @nhsdigital/nhs-notify-release-check run typecheck && pnpm --filter @nhsdigital/nhs-notify-release-check run test:unit - name: Publish package - run: pnpm --filter @nhsdigital/release-check publish --no-git-checks + run: pnpm --filter @nhsdigital/nhs-notify-release-check publish --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/package.json b/package.json index 24fa13cf..fd4920d5 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "clean": "pnpm -r run --if-present clean", "lint": "turbo run lint", "lint:fix": "turbo run lint:fix", - "release-check": "pnpm --filter @nhsdigital/release-check run check", + "release-check": "pnpm --filter @nhsdigital/nhs-notify-release-check run check", "test:unit": "turbo run test:unit", "typecheck": "turbo run typecheck" }, diff --git a/tools/release-check/README.md b/tools/release-check/README.md index 066cc984..c18d9225 100644 --- a/tools/release-check/README.md +++ b/tools/release-check/README.md @@ -17,7 +17,7 @@ pnpm release-check -- --repo ../nhs-notify-client-config --git-tag 0.1.0 --jira- Or directly: ```bash -pnpm --filter @nhsdigital/release-check run check -- --repo ../nhs-notify-client-config --git-tag 0.1.0 --jira-version 71260 +pnpm --filter @nhsdigital/nhs-notify-release-check run check -- --repo ../nhs-notify-client-config --git-tag 0.1.0 --jira-version 71260 ``` ## Required environment @@ -36,11 +36,11 @@ pnpm --filter @nhsdigital/release-check run check -- --repo ../nhs-notify-client ## Publishing -The package is configured for GitHub Packages as `@nhsdigital/release-check`. +The package is configured for GitHub Packages as `@nhsdigital/nhs-notify-release-check`. ```bash -pnpm --filter @nhsdigital/release-check pack -pnpm --filter @nhsdigital/release-check publish --no-git-checks +pnpm --filter @nhsdigital/nhs-notify-release-check pack +pnpm --filter @nhsdigital/nhs-notify-release-check publish --no-git-checks ``` ## Consuming from another repository @@ -54,6 +54,6 @@ Add this to the consuming repository's `.npmrc`: Then install and use the CLI: ```bash -pnpm add -D @nhsdigital/release-check +pnpm add -D @nhsdigital/nhs-notify-release-check pnpm release-check --repo ../nhs-notify-client-config --git-tag 0.1.0 --jira-version 71260 ``` diff --git a/tools/release-check/package.json b/tools/release-check/package.json index f6c28643..208ca803 100644 --- a/tools/release-check/package.json +++ b/tools/release-check/package.json @@ -13,7 +13,7 @@ "#src/*": "./dist/*.js" }, "main": "./dist/index.js", - "name": "@nhsdigital/release-check", + "name": "@nhsdigital/nhs-notify-release-check", "publishConfig": { "access": "restricted", "registry": "https://npm.pkg.github.com" From 810ede5528862f22061be06d07388d4e02804379 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Wed, 23 Sep 2026 13:53:58 +0100 Subject: [PATCH 3/6] CCM-14750: Add multi-release support to release-check Extend the packaged release-check CLI so it can compare multiple git release tags against multiple Jira release versions in one run. Support comma-separated selectors, glob-style wildcards, aggregated release-note lookups, de-duplicated commit and issue sets, and reporting that explains the selected release scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/release-check/README.md | 37 +++- .../release-check/src/__tests__/args.test.ts | 83 ++++++++- tools/release-check/src/__tests__/git.test.ts | 106 +++++++++++ .../src/__tests__/github-release.test.ts | 71 ++++++++ .../release-check/src/__tests__/index.test.ts | 164 +++++++++++++++--- .../release-check/src/__tests__/jira.test.ts | 67 ++++++- .../src/__tests__/report.test.ts | 85 +++++++-- tools/release-check/src/args.ts | 48 ++++- tools/release-check/src/git.ts | 57 +++++- tools/release-check/src/github-release.ts | 48 ++++- tools/release-check/src/index.ts | 127 +++++++++----- tools/release-check/src/jira.ts | 133 ++++++++++---- tools/release-check/src/report.ts | 83 +++++++-- tools/release-check/src/selectors.ts | 35 ++++ tools/release-check/src/types.ts | 14 +- 15 files changed, 995 insertions(+), 163 deletions(-) create mode 100644 tools/release-check/src/selectors.ts diff --git a/tools/release-check/README.md b/tools/release-check/README.md index c18d9225..9b6fd14c 100644 --- a/tools/release-check/README.md +++ b/tools/release-check/README.md @@ -1,9 +1,9 @@ # release-check -Compares a local repository release tag against a Jira release version and reports mismatches across: +Compares a local repository release tag, or a selected set of release tags, against one or more Jira release versions and reports mismatches across: -- git commit history since the previous tag -- Jira issues assigned to the release version +- git commit history for the selected release ranges +- Jira issues assigned to the selected release versions - release notes, using the GitHub release body when available ## Usage @@ -20,6 +20,34 @@ Or directly: pnpm --filter @nhsdigital/nhs-notify-release-check run check -- --repo ../nhs-notify-client-config --git-tag 0.1.0 --jira-version 71260 ``` +## Multi-release usage + +Explicit list selection: + +```bash +pnpm release-check -- \ + --repo ../nhs-notify-client-config \ + --git-tags 0.1.0,v0.2.0,v0.3.0,v0.3.1 \ + --jira-versions client-config-0.1.0,client-config-0.2.0,client-config-0.3.0,client-config-0.3.1 +``` + +Wildcard selection against tag and Jira version names: + +```bash +pnpm release-check -- \ + --repo ../nhs-notify-client-config \ + --git-tags '0.1.0,v0.2.*,v0.3.*' \ + --jira-versions 'client-config-0.1.0,client-config-0.2.*,client-config-0.3.*' +``` + +Notes for multi-release mode: + +- `--git-tags` and `--jira-versions` accept comma-separated selectors. +- Selectors can be exact values or glob-style patterns using `*` and `?`. +- Multiple selected git tags are expanded in repository tag order. +- 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. + ## Required environment - `JIRA_API_TOKEN` or `JIRA_PERSONAL_TOKEN` or `JIRA_TOKEN` @@ -32,7 +60,8 @@ pnpm --filter @nhsdigital/nhs-notify-release-check run check -- --repo ../nhs-no - The tool auto-detects the previous tag using `git describe --tags --abbrev=0 ^`. - When GitHub release notes are unavailable, auto mode falls back to annotated tag notes if the tag is annotated. -- Reports default to `.tmp/release-check/-.txt` in the current working directory. +- Reports default to `.tmp/release-check/-.txt` for single-release checks. +- Multi-release reports default to `.tmp/release-check/--to---tags.txt`. ## Publishing diff --git a/tools/release-check/src/__tests__/args.test.ts b/tools/release-check/src/__tests__/args.test.ts index 18c653a5..4af1b69b 100644 --- a/tools/release-check/src/__tests__/args.test.ts +++ b/tools/release-check/src/__tests__/args.test.ts @@ -23,8 +23,8 @@ describe('parseCliArgs', () => { ]), ).toEqual({ repo: '../repo', - gitTag: '0.1.0', - jiraVersion: '71260', + gitTagSelectors: ['0.1.0'], + jiraVersionSelectors: ['71260'], jiraProject: 'ABC', jiraBaseUrl: 'https://jira.example.com', previousTag: '0.0.9', @@ -33,6 +33,28 @@ describe('parseCliArgs', () => { }); }); + it('parses comma-separated multi-release selectors', () => { + expect( + parseCliArgs([ + '--repo', + '../repo', + '--git-tags', + '0.1.0, v0.2.0 , v0.3.*', + '--jira-versions', + '71260, client-config-0.2.0 , client-config-*', + ]), + ).toEqual({ + repo: '../repo', + gitTagSelectors: ['0.1.0', 'v0.2.0', 'v0.3.*'], + jiraVersionSelectors: ['71260', 'client-config-0.2.0', 'client-config-*'], + jiraProject: 'CCM', + jiraBaseUrl: 'https://nhsd-jira.digital.nhs.uk', + previousTag: undefined, + output: undefined, + releaseNotesSource: 'auto', + }); + }); + it('uses defaults for optional arguments', () => { expect( parseCliArgs([ @@ -45,8 +67,8 @@ describe('parseCliArgs', () => { ]), ).toEqual({ repo: '../repo', - gitTag: '0.1.0', - jiraVersion: '71260', + gitTagSelectors: ['0.1.0'], + jiraVersionSelectors: ['71260'], jiraProject: 'CCM', jiraBaseUrl: 'https://nhsd-jira.digital.nhs.uk', previousTag: undefined, @@ -59,16 +81,61 @@ describe('parseCliArgs', () => { expect(() => parseCliArgs([])).toThrow('Missing required option --repo'); }); - it('throws when the git tag is missing', () => { + it('throws when the git selector is missing', () => { expect(() => parseCliArgs(['--repo', '../repo', '--jira-version', '71260']), - ).toThrow('Missing required option --git-tag'); + ).toThrow('Missing required option --git-tag or --git-tags'); }); - it('throws when the Jira version is missing', () => { + it('throws when the Jira selector is missing', () => { expect(() => parseCliArgs(['--repo', '../repo', '--git-tag', '0.1.0']), - ).toThrow('Missing required option --jira-version'); + ).toThrow('Missing required option --jira-version or --jira-versions'); + }); + + it('throws when both single and multiple git selectors are provided', () => { + expect(() => + parseCliArgs([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--git-tags', + '0.2.0', + '--jira-version', + '71260', + ]), + ).toThrow('Options --git-tag and --git-tags are mutually exclusive'); + }); + + it('throws when both single and multiple Jira selectors are provided', () => { + expect(() => + parseCliArgs([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + '--jira-versions', + '71261', + ]), + ).toThrow( + 'Options --jira-version and --jira-versions are mutually exclusive', + ); + }); + + it('throws when a selector list is empty after trimming', () => { + expect(() => + parseCliArgs([ + '--repo', + '../repo', + '--git-tags', + ' , ', + '--jira-version', + '71260', + ]), + ).toThrow('Selector list must not be empty.'); }); it('throws for an invalid release notes source', () => { diff --git a/tools/release-check/src/__tests__/git.test.ts b/tools/release-check/src/__tests__/git.test.ts index aafa86bb..6e1eaecb 100644 --- a/tools/release-check/src/__tests__/git.test.ts +++ b/tools/release-check/src/__tests__/git.test.ts @@ -3,12 +3,15 @@ import { spawnSync } from 'node:child_process'; import { collectCommits, + collectCommitsForTags, ensureCommitishExists, getOriginRemoteUrl, getPreviousTag, getRepoName, getRepoRoot, + listTags, readTagAnnotation, + resolveGitTags, resolveRepoPath, } from '../git'; @@ -112,6 +115,62 @@ describe('git command helpers', () => { }); }); +describe('listTags and resolveGitTags', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('lists tags in git sort order', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: '0.1.0\nv0.2.0\nv0.3.0\n', + stderr: '', + } as never); + + expect(listTags('/repos/client-config')).toEqual([ + '0.1.0', + 'v0.2.0', + 'v0.3.0', + ]); + }); + + it('resolves exact and wildcard tag selectors in tag order', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: '0.1.0\nv0.2.0\nv0.3.0\nv0.3.1\n', + stderr: '', + } as never); + + expect(resolveGitTags('/repos/client-config', ['v0.?.0', '0.1.0'])).toEqual( + ['0.1.0', 'v0.2.0', 'v0.3.0'], + ); + }); + + it('throws when an exact selector is missing', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: '0.1.0\n', + stderr: '', + } as never); + + expect(() => resolveGitTags('/repos/client-config', ['0.2.0'])).toThrow( + 'Could not find git tag "0.2.0".', + ); + }); + + it('throws when a wildcard selector matches no tags', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: '0.1.0\n', + stderr: '', + } as never); + + expect(() => resolveGitTags('/repos/client-config', ['v9.*'])).toThrow( + 'Could not find git tags matching "v9.*".', + ); + }); +}); + describe('getPreviousTag', () => { beforeEach(() => { jest.clearAllMocks(); @@ -191,6 +250,53 @@ describe('collectCommits', () => { }, ]); }); + + it('deduplicates commits across multiple selected tags', () => { + mockedSpawnSync + .mockReturnValueOnce({ + status: 0, + stdout: + `hash1\u001Fshort1\u001FCCM-100: Add feature\u001F\u001E` + + `hash2\u001Fshort2\u001FCCM-101: Add feature\u001F\u001E`, + stderr: '', + } as never) + .mockReturnValueOnce({ + status: 0, + stdout: + `hash2\u001Fshort2\u001FCCM-101: Add feature\u001F\u001E` + + `hash3\u001Fshort3\u001FCCM-102: Add feature\u001F\u001E`, + stderr: '', + } as never); + + expect( + collectCommitsForTags('/repos/client-config', [ + { gitTag: '0.2.0', previousTag: '0.1.0' }, + { gitTag: '0.3.0', previousTag: '0.2.0' }, + ]), + ).toEqual([ + { + hash: 'hash1', + shortHash: 'short1', + subject: 'CCM-100: Add feature', + body: '', + explicitIssueKeys: ['CCM-100'], + }, + { + hash: 'hash2', + shortHash: 'short2', + subject: 'CCM-101: Add feature', + body: '', + explicitIssueKeys: ['CCM-101'], + }, + { + hash: 'hash3', + shortHash: 'short3', + subject: 'CCM-102: Add feature', + body: '', + explicitIssueKeys: ['CCM-102'], + }, + ]); + }); }); describe('readTagAnnotation', () => { diff --git a/tools/release-check/src/__tests__/github-release.test.ts b/tools/release-check/src/__tests__/github-release.test.ts index fe4139bd..c43a7b6b 100644 --- a/tools/release-check/src/__tests__/github-release.test.ts +++ b/tools/release-check/src/__tests__/github-release.test.ts @@ -1,6 +1,7 @@ import { parseGitHubRepositoryFromRemote, readReleaseNotes, + readReleaseNotesForTags, } from '../github-release'; jest.mock('../git', () => ({ @@ -211,4 +212,74 @@ describe('readReleaseNotes', () => { 'Tag 0.1.0 is not annotated, so no tag release notes are available.', ); }); + + it('reuses single-tag release note lookup through the multi-tag helper', async () => { + await expect( + readReleaseNotesForTags('/repo', ['0.1.0'], 'none'), + ).resolves.toEqual({ + issueKeys: [], + source: 'none', + text: null, + warnings: [], + }); + }); + + it('keeps a single release-note source when all selected tags use the same one', async () => { + mockedGetOriginRemoteUrl.mockReturnValue( + 'https://github.com/NHSDigital/nhs-notify-client-config.git', + ); + mockFetch + .mockResolvedValueOnce({ + status: 200, + ok: true, + statusText: 'OK', + json: async () => ({ body: 'CCM-100 first' }), + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + statusText: 'OK', + json: async () => ({ body: 'CCM-101 second' }), + }); + + await expect( + readReleaseNotesForTags('/repo', ['0.1.0', '0.2.0'], 'github'), + ).resolves.toEqual({ + issueKeys: ['CCM-100', 'CCM-101'], + source: 'github-release', + text: null, + warnings: [], + }); + }); + + it('aggregates release notes across multiple selected tags', async () => { + mockedGetOriginRemoteUrl.mockReturnValue( + 'https://github.com/NHSDigital/nhs-notify-client-config.git', + ); + mockFetch + .mockResolvedValueOnce({ + status: 200, + ok: true, + statusText: 'OK', + json: async () => ({ body: 'CCM-100 first' }), + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + statusText: 'OK', + json: async () => ({ body: '' }), + }); + mockedReadTagAnnotation.mockReturnValue('CCM-200 fallback note'); + + await expect( + readReleaseNotesForTags('/repo', ['0.1.0', '0.2.0'], 'auto'), + ).resolves.toEqual({ + issueKeys: ['CCM-100', 'CCM-200'], + source: 'mixed', + text: null, + warnings: [ + '[0.2.0] No GitHub release body found for tag 0.2.0; falling back.', + ], + }); + }); }); diff --git a/tools/release-check/src/__tests__/index.test.ts b/tools/release-check/src/__tests__/index.test.ts index 12ba4c98..ec523081 100644 --- a/tools/release-check/src/__tests__/index.test.ts +++ b/tools/release-check/src/__tests__/index.test.ts @@ -6,21 +6,21 @@ jest.mock('node:fs/promises', () => ({ })); jest.mock('../git', () => ({ - collectCommits: jest.fn(), - ensureCommitishExists: jest.fn(), + collectCommitsForTags: jest.fn(), getPreviousTag: jest.fn(), getRepoName: jest.fn(), getRepoRoot: jest.fn(), + resolveGitTags: jest.fn(), resolveRepoPath: jest.fn(), })); jest.mock('../jira', () => ({ fetchJiraIssues: jest.fn(), - resolveJiraVersion: jest.fn(), + resolveJiraVersions: jest.fn(), })); jest.mock('../github-release', () => ({ - readReleaseNotes: jest.fn(), + readReleaseNotesForTags: jest.fn(), })); jest.mock('../compare', () => ({ @@ -49,21 +49,27 @@ const mockedGetRepoRoot = git.getRepoRoot as jest.MockedFunction< const mockedGetRepoName = git.getRepoName as jest.MockedFunction< typeof git.getRepoName >; +const mockedResolveGitTags = git.resolveGitTags as jest.MockedFunction< + typeof git.resolveGitTags +>; const mockedGetPreviousTag = git.getPreviousTag as jest.MockedFunction< typeof git.getPreviousTag >; -const mockedCollectCommits = git.collectCommits as jest.MockedFunction< - typeof git.collectCommits ->; -const mockedResolveJiraVersion = jira.resolveJiraVersion as jest.MockedFunction< - typeof jira.resolveJiraVersion ->; +const mockedCollectCommitsForTags = + git.collectCommitsForTags as jest.MockedFunction< + typeof git.collectCommitsForTags + >; +const mockedResolveJiraVersions = + jira.resolveJiraVersions as jest.MockedFunction< + typeof jira.resolveJiraVersions + >; const mockedFetchJiraIssues = jira.fetchJiraIssues as jest.MockedFunction< typeof jira.fetchJiraIssues >; -const mockedReadReleaseNotes = notes.readReleaseNotes as jest.MockedFunction< - typeof notes.readReleaseNotes ->; +const mockedReadReleaseNotesForTags = + notes.readReleaseNotesForTags as jest.MockedFunction< + typeof notes.readReleaseNotesForTags + >; const mockedCompareRelease = compare.compareRelease as jest.MockedFunction< typeof compare.compareRelease >; @@ -85,16 +91,19 @@ describe('run', () => { mockedResolveRepoPath.mockReturnValue('/repo'); mockedGetRepoRoot.mockReturnValue('/repo'); mockedGetRepoName.mockReturnValue('repo'); + mockedResolveGitTags.mockReturnValue(['0.1.0']); mockedGetPreviousTag.mockReturnValue('0.0.9'); - mockedCollectCommits.mockReturnValue([]); - mockedResolveJiraVersion.mockResolvedValue({ - id: '71260', - name: 'release', - releaseDate: '2026-07-08', - released: true, - }); + mockedCollectCommitsForTags.mockReturnValue([]); + mockedResolveJiraVersions.mockResolvedValue([ + { + id: '71260', + name: 'release', + releaseDate: '2026-07-08', + released: true, + }, + ]); mockedFetchJiraIssues.mockResolvedValue([]); - mockedReadReleaseNotes.mockResolvedValue({ + mockedReadReleaseNotesForTags.mockResolvedValue({ issueKeys: [], source: 'none', text: null, @@ -166,10 +175,15 @@ describe('run', () => { ]); expect(mockedResolveRepoPath).toHaveBeenCalledWith('../repo'); - expect(git.ensureCommitishExists).toHaveBeenCalledWith('/repo', '0.1.0'); - expect(mockedReadReleaseNotes).toHaveBeenCalledWith( + expect(mockedResolveGitTags).toHaveBeenCalledWith('/repo', ['0.1.0']); + expect(mockedResolveJiraVersions).toHaveBeenCalledWith( + 'https://nhsd-jira.digital.nhs.uk', + 'CCM', + ['71260'], + ); + expect(mockedReadReleaseNotesForTags).toHaveBeenCalledWith( '/repo', - '0.1.0', + ['0.1.0'], 'auto', ); expect(fsPromises.mkdir).toHaveBeenCalledWith('/workspace', { @@ -193,6 +207,108 @@ describe('run', () => { ); }); + it('aggregates multiple selected releases into one run', async () => { + mockedResolveGitTags.mockReturnValue(['0.1.0', 'v0.2.0']); + mockedGetPreviousTag.mockReturnValueOnce(null).mockReturnValueOnce('0.1.0'); + mockedCollectCommitsForTags.mockReturnValue([ + { + hash: 'a'.repeat(40), + shortHash: 'aaaaaaaa', + subject: 'CCM-100: ship it', + body: '', + explicitIssueKeys: ['CCM-100'], + }, + ]); + mockedResolveJiraVersions.mockResolvedValue([ + { + id: '71260', + name: 'release-a', + releaseDate: '2026-07-08', + released: true, + }, + { + id: '71261', + name: 'release-b', + releaseDate: null, + released: false, + }, + ]); + mockedFetchJiraIssues + .mockResolvedValueOnce([ + { + key: 'CCM-1', + clinicalLead: '', + clinicalReviewStatus: '', + components: [], + medicalClinicalSafetyCategory: '', + status: 'Done', + summary: 'one', + }, + ]) + .mockResolvedValueOnce([ + { + key: 'CCM-1', + clinicalLead: '', + clinicalReviewStatus: '', + components: [], + medicalClinicalSafetyCategory: '', + status: 'Done', + summary: 'duplicate one', + }, + ]); + + await run([ + '--repo', + '../repo', + '--git-tags', + '0.1.0,v0.2.0', + '--jira-versions', + 'release-a,release-b', + ]); + + expect(mockedDefaultReportPath).toHaveBeenCalledWith('repo', [ + '0.1.0', + 'v0.2.0', + ]); + expect(mockedFetchJiraIssues).toHaveBeenCalledTimes(2); + expect(mockedRenderReport).toHaveBeenCalledWith( + expect.objectContaining({ + gitTags: [ + { gitTag: '0.1.0', previousTag: null }, + { gitTag: 'v0.2.0', previousTag: '0.1.0' }, + ], + jiraVersions: [ + { + id: '71260', + name: 'release-a', + releaseDate: '2026-07-08', + released: true, + }, + { + id: '71261', + name: 'release-b', + releaseDate: null, + released: false, + }, + ], + totalJiraIssues: 1, + }), + ); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining('Git tags selected (2): 0.1.0, v0.2.0\n'), + ); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining( + 'Comparison bases: 0.1.0 <- repository start; v0.2.0 <- 0.1.0\n', + ), + ); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining( + 'Jira versions selected (2): release-a (71260), release-b (71261)\n', + ), + ); + }); + it('respects an explicit output path and a missing previous tag', async () => { mockedGetPreviousTag.mockReturnValue(null); diff --git a/tools/release-check/src/__tests__/jira.test.ts b/tools/release-check/src/__tests__/jira.test.ts index ca292745..61167790 100644 --- a/tools/release-check/src/__tests__/jira.test.ts +++ b/tools/release-check/src/__tests__/jira.test.ts @@ -1,4 +1,8 @@ -import { fetchJiraIssues, resolveJiraVersion } from '../jira'; +import { + fetchJiraIssues, + resolveJiraVersion, + resolveJiraVersions, +} from '../jira'; const mockFetch = jest.fn(); @@ -93,6 +97,52 @@ describe('resolveJiraVersion', () => { }); }); + it('resolves multiple versions from exact and wildcard selectors', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => [ + { + id: 1, + name: 'client-config-0.1.0', + releaseDate: '2026-07-08', + released: true, + }, + { + id: 2, + name: 'client-config-0.2.0', + releaseDate: '2026-08-08', + released: false, + }, + { + id: 3, + name: 'other-release', + releaseDate: '2026-09-01', + released: false, + }, + ], + }); + + await expect( + resolveJiraVersions('https://jira.example.com', 'CCM', [ + 'client-config-0.1.0', + 'client-config-*', + ]), + ).resolves.toEqual([ + { + id: '1', + name: 'client-config-0.1.0', + releaseDate: '2026-07-08', + released: true, + }, + { + id: '2', + name: 'client-config-0.2.0', + releaseDate: '2026-08-08', + released: false, + }, + ]); + }); + it('defaults missing release metadata from the version response', async () => { mockFetch.mockResolvedValue({ ok: true, @@ -123,6 +173,21 @@ describe('resolveJiraVersion', () => { ).rejects.toThrow('Could not find Jira version "missing" in project CCM.'); }); + it('throws when the wildcard version selector matches nothing', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => [{ id: 1, name: 'older' }], + }); + + await expect( + resolveJiraVersions('https://jira.example.com', 'CCM', [ + 'client-config-*', + ]), + ).rejects.toThrow( + 'Could not find Jira versions matching "client-config-*" in project CCM.', + ); + }); + it('throws when no Jira token is configured', async () => { delete process.env.JIRA_API_TOKEN; diff --git a/tools/release-check/src/__tests__/report.test.ts b/tools/release-check/src/__tests__/report.test.ts index b5c2f1ae..6773f5c5 100644 --- a/tools/release-check/src/__tests__/report.test.ts +++ b/tools/release-check/src/__tests__/report.test.ts @@ -31,21 +31,32 @@ const releaseNotes: ReleaseNotes = { }; describe('defaultReportPath', () => { - it('writes reports under .tmp/release-check in the cwd', () => { + it('writes single-release reports under .tmp/release-check in the cwd', () => { expect( - defaultReportPath('nhs-notify-client-config', '0.1.0', '/workspace'), + defaultReportPath('nhs-notify-client-config', ['0.1.0'], '/workspace'), ).toBe('/workspace/.tmp/release-check/nhs-notify-client-config-0.1.0.txt'); }); + + it('summarises multiple selected tags in the report filename', () => { + expect( + defaultReportPath( + 'nhs-notify-client-config', + ['0.1.0', 'v0.2.0', 'v0.3.1'], + '/workspace', + ), + ).toBe( + '/workspace/.tmp/release-check/nhs-notify-client-config-0.1.0-to-v0.3.1-3-tags.txt', + ); + }); }); describe('renderReport', () => { it('renders summary metadata and warnings', () => { const report = renderReport({ comparison, - gitTag: '0.1.0', + gitTags: [{ gitTag: '0.1.0', previousTag: null }], jiraProject: 'CCM', - jiraVersion, - previousTag: null, + jiraVersions: [jiraVersion], releaseNotes, repoName: 'nhs-notify-client-config', repoRoot: '/repos/nhs-notify-client-config', @@ -162,10 +173,9 @@ describe('renderReport', () => { ], releaseNotesIssueKeysOutsideRelease: ['CCM-200'], }, - gitTag: '0.1.0', + gitTags: [{ gitTag: '0.1.0', previousTag: '0.0.9' }], jiraProject: 'CCM', - jiraVersion, - previousTag: '0.0.9', + jiraVersions: [jiraVersion], releaseNotes: { issueKeys: ['CCM-100'], source: 'github-release', @@ -199,18 +209,61 @@ describe('renderReport', () => { expect(populatedReport).toContain('- cccccccc maintenance'); }); - it('renders unknown release metadata when Jira has not set it', () => { + it('renders multi-release metadata when multiple tags and Jira versions are selected', () => { const report = renderReport({ comparison, - gitTag: '0.1.0', + gitTags: [ + { gitTag: '0.1.0', previousTag: null }, + { gitTag: 'v0.2.0', previousTag: '0.1.0' }, + ], jiraProject: 'CCM', - jiraVersion: { - id: '71260', - name: 'client-config-0.1.0', - releaseDate: null, - released: false, + jiraVersions: [ + jiraVersion, + { + id: '71261', + name: 'client-config-0.2.0', + releaseDate: null, + released: false, + }, + ], + releaseNotes: { + issueKeys: ['CCM-100'], + source: 'mixed', + text: null, + warnings: [], }, - previousTag: null, + repoName: 'nhs-notify-client-config', + repoRoot: '/repos/nhs-notify-client-config', + totalJiraIssues: 20, + }); + + expect(report).toContain('Git tags selected (2): 0.1.0, v0.2.0'); + expect(report).toContain( + 'Comparison bases: 0.1.0 <- repository start; v0.2.0 <- 0.1.0', + ); + expect(report).toContain( + 'Jira versions selected (2): client-config-0.1.0 (71260), client-config-0.2.0 (71261)', + ); + expect(report).toContain( + 'Jira release dates: client-config-0.1.0: 2026-07-08; client-config-0.2.0: unknown', + ); + expect(report).toContain('Jira versions released: 1/2'); + expect(report).toContain('Release notes source: mixed'); + }); + + it('renders unknown release metadata when Jira has not set it', () => { + const report = renderReport({ + comparison, + gitTags: [{ gitTag: '0.1.0', previousTag: null }], + jiraProject: 'CCM', + jiraVersions: [ + { + id: '71260', + name: 'client-config-0.1.0', + releaseDate: null, + released: false, + }, + ], releaseNotes: { issueKeys: [], source: 'none', diff --git a/tools/release-check/src/args.ts b/tools/release-check/src/args.ts index 5dbc4313..d924ae94 100644 --- a/tools/release-check/src/args.ts +++ b/tools/release-check/src/args.ts @@ -1,5 +1,6 @@ import { parseArgs } from 'node:util'; +import { parseSelectorList } from './selectors'; import type { CliOptions, ReleaseNotesSource } from './types'; const DEFAULT_JIRA_BASE_URL = 'https://nhsd-jira.digital.nhs.uk'; @@ -18,13 +19,40 @@ const isReleaseNotesSource = ( ): value is ReleaseNotesSource => value === 'auto' || value === 'github' || value === 'tag' || value === 'none'; +const parseSelectors = ( + single: string | undefined, + multiple: string | undefined, + singleOption: string, + multipleOption: string, +): string[] => { + if (single && multiple) { + throw new Error( + `Options --${singleOption} and --${multipleOption} are mutually exclusive`, + ); + } + + if (multiple) { + return parseSelectorList(multiple); + } + + if (single) { + return [single.trim()].filter(Boolean); + } + + throw new Error( + `Missing required option --${singleOption} or --${multipleOption}`, + ); +}; + export const parseCliArgs = (argv: string[]): CliOptions => { const { values } = parseArgs({ args: argv, options: { repo: { type: 'string' }, 'git-tag': { type: 'string' }, + 'git-tags': { type: 'string' }, 'jira-version': { type: 'string' }, + 'jira-versions': { type: 'string' }, 'jira-project': { type: 'string', default: DEFAULT_JIRA_PROJECT }, 'jira-base-url': { type: 'string', default: DEFAULT_JIRA_BASE_URL }, 'previous-tag': { type: 'string' }, @@ -37,12 +65,6 @@ export const parseCliArgs = (argv: string[]): CliOptions => { if (!values.repo) { throw new Error('Missing required option --repo'); } - if (!values['git-tag']) { - throw new Error('Missing required option --git-tag'); - } - if (!values['jira-version']) { - throw new Error('Missing required option --jira-version'); - } if (!isReleaseNotesSource(values['release-notes-source'])) { throw new Error( 'Invalid --release-notes-source. Expected one of: auto, github, tag, none', @@ -51,8 +73,18 @@ export const parseCliArgs = (argv: string[]): CliOptions => { return { repo: values.repo, - gitTag: values['git-tag'], - jiraVersion: values['jira-version'], + gitTagSelectors: parseSelectors( + values['git-tag'], + values['git-tags'], + 'git-tag', + 'git-tags', + ), + jiraVersionSelectors: parseSelectors( + values['jira-version'], + values['jira-versions'], + 'jira-version', + 'jira-versions', + ), jiraProject: values['jira-project'] ?? DEFAULT_JIRA_PROJECT, jiraBaseUrl: trimTrailingSlashes( values['jira-base-url'] ?? DEFAULT_JIRA_BASE_URL, diff --git a/tools/release-check/src/git.ts b/tools/release-check/src/git.ts index d365a3d7..a2f52685 100644 --- a/tools/release-check/src/git.ts +++ b/tools/release-check/src/git.ts @@ -2,7 +2,8 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import type { GitCommit } from './types'; +import { hasGlobPattern, matchesGlobPattern } from './selectors'; +import type { GitCommit, SelectedGitTag } from './types'; const GIT_EXECUTABLE = '/usr/bin/git'; const ISSUE_KEY_PATTERN = /\b[A-Z][A-Z0-9]+-\d+\b/g; @@ -46,6 +47,14 @@ export const getRepoRoot = (repoPath: string): string => export const getRepoName = (repoRoot: string): string => path.basename(repoRoot); +export const listTags = (repoRoot: string): string[] => { + const raw = runGit(repoRoot, ['tag', '--list', '--sort=version:refname']); + return raw + .split('\n') + .map((entry) => entry.trim()) + .filter(Boolean); +}; + export const ensureCommitishExists = ( repoRoot: string, commitish: string, @@ -53,6 +62,35 @@ export const ensureCommitishExists = ( runGit(repoRoot, ['rev-parse', '--verify', `${commitish}^{commit}`]); }; +export const resolveGitTags = ( + repoRoot: string, + selectors: string[], +): string[] => { + const availableTags = listTags(repoRoot); + const selectedTags = new Set(); + + for (const selector of selectors) { + if (hasGlobPattern(selector)) { + const matches = availableTags.filter((tag) => + matchesGlobPattern(tag, selector), + ); + if (matches.length === 0) { + throw new Error(`Could not find git tags matching "${selector}".`); + } + for (const match of matches) { + selectedTags.add(match); + } + } else { + if (!availableTags.includes(selector)) { + throw new Error(`Could not find git tag "${selector}".`); + } + selectedTags.add(selector); + } + } + + return availableTags.filter((tag) => selectedTags.has(tag)); +}; + export const getPreviousTag = ( repoRoot: string, gitTag: string, @@ -120,6 +158,23 @@ export const collectCommits = ( }); }; +export const collectCommitsForTags = ( + repoRoot: string, + gitTags: SelectedGitTag[], +): GitCommit[] => { + const commitsByHash = new Map(); + + for (const { gitTag, previousTag } of gitTags) { + for (const commit of collectCommits(repoRoot, gitTag, previousTag)) { + if (!commitsByHash.has(commit.hash)) { + commitsByHash.set(commit.hash, commit); + } + } + } + + return [...commitsByHash.values()]; +}; + export const getOriginRemoteUrl = (repoRoot: string): string => runGit(repoRoot, ['remote', 'get-url', 'origin']); diff --git a/tools/release-check/src/github-release.ts b/tools/release-check/src/github-release.ts index eb9ff020..a9fad778 100644 --- a/tools/release-check/src/github-release.ts +++ b/tools/release-check/src/github-release.ts @@ -1,6 +1,10 @@ import { getOriginRemoteUrl, readTagAnnotation } from './git'; -import type { ReleaseNotes, ReleaseNotesSource } from './types'; +import type { + ReleaseNotes, + ReleaseNotesLookupSource, + ReleaseNotesSource, +} from './types'; const GITHUB_REMOTE_SSH_PATTERN = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/; const GITHUB_REMOTE_HTTPS_PATTERN = @@ -130,6 +134,16 @@ const readTagReleaseNotes = ( return null; }; +const mergeReleaseNoteSources = ( + sources: ReleaseNotesLookupSource[], +): ReleaseNotesLookupSource => { + const uniqueSources = [...new Set(sources)]; + if (uniqueSources.length === 1) { + return uniqueSources[0]; + } + return 'mixed'; +}; + export const readReleaseNotes = async ( repoRoot: string, gitTag: string, @@ -187,4 +201,36 @@ export const readReleaseNotes = async ( }; }; +export const readReleaseNotesForTags = async ( + repoRoot: string, + gitTags: string[], + source: ReleaseNotesSource, +): Promise => { + if (gitTags.length === 1) { + return readReleaseNotes(repoRoot, gitTags[0], source); + } + + const notesByTag = await Promise.all( + gitTags.map(async (gitTag) => ({ + gitTag, + releaseNotes: await readReleaseNotes(repoRoot, gitTag, source), + })), + ); + + return { + issueKeys: [ + ...new Set( + notesByTag.flatMap(({ releaseNotes }) => releaseNotes.issueKeys), + ), + ], + source: mergeReleaseNoteSources( + notesByTag.map(({ releaseNotes }) => releaseNotes.source), + ), + text: null, + warnings: notesByTag.flatMap(({ gitTag, releaseNotes }) => + releaseNotes.warnings.map((warning) => `[${gitTag}] ${warning}`), + ), + }; +}; + export { parseGitHubRepositoryFromRemote }; diff --git a/tools/release-check/src/index.ts b/tools/release-check/src/index.ts index c9e57cdd..184e6255 100644 --- a/tools/release-check/src/index.ts +++ b/tools/release-check/src/index.ts @@ -4,16 +4,17 @@ import path from 'node:path'; import { parseCliArgs } from './args'; import { compareRelease } from './compare'; import { - collectCommits, - ensureCommitishExists, + collectCommitsForTags, getPreviousTag, getRepoName, getRepoRoot, + resolveGitTags, resolveRepoPath, } from './git'; -import { readReleaseNotes } from './github-release'; -import { fetchJiraIssues, resolveJiraVersion } from './jira'; +import { readReleaseNotesForTags } from './github-release'; +import { fetchJiraIssues, resolveJiraVersions } from './jira'; import { defaultReportPath, renderReport } from './report'; +import type { JiraIssue, JiraVersion, SelectedGitTag } from './types'; const writeReport = async ( outputPath: string, @@ -23,33 +24,68 @@ const writeReport = async ( await writeFile(outputPath, report, 'utf8'); }; +const dedupeBy = (items: T[], getKey: (item: T) => string): T[] => { + const seenKeys = new Set(); + const dedupedItems: T[] = []; + + for (const item of items) { + const key = getKey(item); + if (!seenKeys.has(key)) { + seenKeys.add(key); + dedupedItems.push(item); + } + } + + return dedupedItems; +}; + +const formatGitTagSummary = (gitTags: SelectedGitTag[]): string => + gitTags.map(({ gitTag }) => gitTag).join(', '); + +const formatComparisonBaseSummary = (gitTags: SelectedGitTag[]): string => + gitTags + .map( + ({ gitTag, previousTag }) => + `${gitTag} <- ${previousTag ?? 'repository start'}`, + ) + .join('; '); + +const formatJiraVersionSummary = (jiraVersions: JiraVersion[]): string => + jiraVersions.map((version) => `${version.name} (${version.id})`).join(', '); + export const run = async (argv: string[]): Promise => { const options = parseCliArgs(argv); const repoPath = resolveRepoPath(options.repo); const repoRoot = getRepoRoot(repoPath); - ensureCommitishExists(repoRoot, options.gitTag); - const repoName = getRepoName(repoRoot); - const previousTag = getPreviousTag( - repoRoot, - options.gitTag, - options.previousTag, - ); - const commits = collectCommits(repoRoot, options.gitTag, previousTag); + const selectedGitTagNames = resolveGitTags(repoRoot, options.gitTagSelectors); + const selectedGitTags = selectedGitTagNames.map((gitTag, index) => ({ + gitTag, + previousTag: getPreviousTag( + repoRoot, + gitTag, + index === 0 ? options.previousTag : undefined, + ), + })); + const commits = collectCommitsForTags(repoRoot, selectedGitTags); - const jiraVersion = await resolveJiraVersion( + const jiraVersions = await resolveJiraVersions( options.jiraBaseUrl, options.jiraProject, - options.jiraVersion, + options.jiraVersionSelectors, ); - const jiraIssues = await fetchJiraIssues( - options.jiraBaseUrl, - options.jiraProject, - jiraVersion, + const jiraIssuesByVersion = await Promise.all( + jiraVersions.map((jiraVersion) => + fetchJiraIssues(options.jiraBaseUrl, options.jiraProject, jiraVersion), + ), ); - const releaseNotes = await readReleaseNotes( + const jiraIssues = dedupeBy( + jiraIssuesByVersion.flat(), + (issue: JiraIssue) => issue.key, + ); + const releaseNotes = await readReleaseNotesForTags( repoRoot, - options.gitTag, + selectedGitTags.map(({ gitTag }) => gitTag), options.releaseNotesSource, ); const comparison = compareRelease( @@ -59,14 +95,17 @@ export const run = async (argv: string[]): Promise => { ); const outputPath = path.resolve( - options.output ?? defaultReportPath(repoName, options.gitTag), + options.output ?? + defaultReportPath( + repoName, + selectedGitTags.map(({ gitTag }) => gitTag), + ), ); const report = renderReport({ comparison, - gitTag: options.gitTag, + gitTags: selectedGitTags, jiraProject: options.jiraProject, - jiraVersion, - previousTag, + jiraVersions, releaseNotes, repoName, repoRoot, @@ -75,21 +114,27 @@ export const run = async (argv: string[]): Promise => { await writeReport(outputPath, report); - process.stdout.write( - `${[ - `Repository: ${repoName}`, - `Git tag: ${options.gitTag}`, - `Comparison base: ${previousTag ?? 'repository start'}`, - `Commits inspected: ${commits.length}`, - `Jira version: ${jiraVersion.name} (${jiraVersion.id})`, - `Jira issues in release: ${jiraIssues.length}`, - `Jira issues missing from git: ${comparison.jiraIssuesMissingFromGit.length}`, - `Jira issues missing from release notes: ${comparison.jiraIssuesMissingFromReleaseNotes.length}`, - `Referenced Jira issues not done: ${comparison.releaseReferencedIssuesNotDone.length}`, - `Jira issues missing clinical safety category: ${comparison.jiraIssuesMissingClinicalSafetyCategory.length}`, - `Jira issues missing clinical lead: ${comparison.jiraIssuesMissingClinicalLead.length}`, - `Commits without Jira matches: ${comparison.commitsWithoutMatches.length}`, - `Report written to ${outputPath}`, - ].join('\n')}\n`, - ); + const summaryLines = [ + `Repository: ${repoName}`, + selectedGitTags.length === 1 + ? `Git tag: ${selectedGitTags[0].gitTag}` + : `Git tags selected (${selectedGitTags.length}): ${formatGitTagSummary(selectedGitTags)}`, + selectedGitTags.length === 1 + ? `Comparison base: ${selectedGitTags[0].previousTag ?? 'repository start'}` + : `Comparison bases: ${formatComparisonBaseSummary(selectedGitTags)}`, + `Commits inspected: ${commits.length}`, + jiraVersions.length === 1 + ? `Jira version: ${jiraVersions[0].name} (${jiraVersions[0].id})` + : `Jira versions selected (${jiraVersions.length}): ${formatJiraVersionSummary(jiraVersions)}`, + `Jira issues in ${jiraVersions.length === 1 ? 'release' : 'selected releases'}: ${jiraIssues.length}`, + `Jira issues missing from git: ${comparison.jiraIssuesMissingFromGit.length}`, + `Jira issues missing from release notes: ${comparison.jiraIssuesMissingFromReleaseNotes.length}`, + `Referenced Jira issues not done: ${comparison.releaseReferencedIssuesNotDone.length}`, + `Jira issues missing clinical safety category: ${comparison.jiraIssuesMissingClinicalSafetyCategory.length}`, + `Jira issues missing clinical lead: ${comparison.jiraIssuesMissingClinicalLead.length}`, + `Commits without Jira matches: ${comparison.commitsWithoutMatches.length}`, + `Report written to ${outputPath}`, + ]; + + process.stdout.write(`${summaryLines.join('\n')}\n`); }; diff --git a/tools/release-check/src/jira.ts b/tools/release-check/src/jira.ts index c0c20155..e02c6fc0 100644 --- a/tools/release-check/src/jira.ts +++ b/tools/release-check/src/jira.ts @@ -1,3 +1,4 @@ +import { hasGlobPattern, matchesGlobPattern } from './selectors'; import type { JiraIssue, JiraVersion } from './types'; const CLINICAL_LEAD_FIELD_ID = 'customfield_10523'; @@ -50,6 +51,20 @@ const getJiraToken = (): string => { const VERSION_PATH_PATTERN = /\/versions\/(\d+)/; +type JiraVersionResponse = { + id: string | number; + name: string; + releaseDate?: string; + released?: boolean; +}; + +const toJiraVersion = (version: JiraVersionResponse): JiraVersion => ({ + id: String(version.id), + name: version.name, + releaseDate: version.releaseDate ?? null, + released: Boolean(version.released), +}); + const fetchJiraJson = async (url: string): Promise => { const response = await fetch(url, { headers: { @@ -88,53 +103,95 @@ const parseVersionReference = ( return { type: 'name', value: trimmed }; }; -export const resolveJiraVersion = async ( +const fetchProjectVersions = async ( jiraBaseUrl: string, jiraProject: string, - reference: string, -): Promise => { - const parsed = parseVersionReference(reference); - - if (parsed.type === 'id') { - const version = await fetchJiraJson<{ - id: string | number; - name: string; - releaseDate?: string; - released?: boolean; - }>(`${jiraBaseUrl}/rest/api/2/version/${encodeURIComponent(parsed.value)}`); - - return { - id: String(version.id), - name: version.name, - releaseDate: version.releaseDate ?? null, - released: Boolean(version.released), - }; - } - - const versions = await fetchJiraJson< - { - id: string | number; - name: string; - releaseDate?: string; - released?: boolean; - }[] - >( +): Promise => { + const versions = await fetchJiraJson( `${jiraBaseUrl}/rest/api/2/project/${encodeURIComponent(jiraProject)}/versions`, ); - const version = versions.find((candidate) => candidate.name === parsed.value); - if (!version) { + return versions.map((version) => toJiraVersion(version)); +}; + +const fetchVersionById = async ( + jiraBaseUrl: string, + versionId: string, +): Promise => { + const version = await fetchJiraJson( + `${jiraBaseUrl}/rest/api/2/version/${encodeURIComponent(versionId)}`, + ); + + return toJiraVersion(version); +}; + +const resolveNamedJiraVersions = ( + jiraProject: string, + projectVersions: JiraVersion[], + reference: string, +): JiraVersion[] => { + const matches = hasGlobPattern(reference) + ? projectVersions.filter((version) => + matchesGlobPattern(version.name, reference), + ) + : projectVersions.filter((version) => version.name === reference); + + if (matches.length > 0) { + return matches; + } + + if (hasGlobPattern(reference)) { throw new Error( - `Could not find Jira version "${parsed.value}" in project ${jiraProject}.`, + `Could not find Jira versions matching "${reference}" in project ${jiraProject}.`, ); } - return { - id: String(version.id), - name: version.name, - releaseDate: version.releaseDate ?? null, - released: Boolean(version.released), - }; + throw new Error( + `Could not find Jira version "${reference}" in project ${jiraProject}.`, + ); +}; + +export const resolveJiraVersions = async ( + jiraBaseUrl: string, + jiraProject: string, + references: string[], +): Promise => { + const selectedVersions: JiraVersion[] = []; + const selectedVersionIds = new Set(); + const needsProjectVersions = references.some( + (reference) => parseVersionReference(reference).type === 'name', + ); + const projectVersions = needsProjectVersions + ? await fetchProjectVersions(jiraBaseUrl, jiraProject) + : []; + + for (const reference of references) { + const parsed = parseVersionReference(reference); + const matches = + parsed.type === 'id' + ? [await fetchVersionById(jiraBaseUrl, parsed.value)] + : resolveNamedJiraVersions(jiraProject, projectVersions, parsed.value); + + for (const match of matches) { + if (!selectedVersionIds.has(match.id)) { + selectedVersions.push(match); + selectedVersionIds.add(match.id); + } + } + } + + return selectedVersions; +}; + +export const resolveJiraVersion = async ( + jiraBaseUrl: string, + jiraProject: string, + reference: string, +): Promise => { + const [version] = await resolveJiraVersions(jiraBaseUrl, jiraProject, [ + reference, + ]); + return version; }; export const fetchJiraIssues = async ( diff --git a/tools/release-check/src/report.ts b/tools/release-check/src/report.ts index e6e1ceda..b129091e 100644 --- a/tools/release-check/src/report.ts +++ b/tools/release-check/src/report.ts @@ -6,6 +6,7 @@ import type { JiraVersion, MatchedCommit, ReleaseNotes, + SelectedGitTag, } from './types'; const formatIssue = ( @@ -38,54 +39,100 @@ const renderSection = (title: string, lines: string[]): string => { const sanitizeFileSegment = (value: string): string => value.replaceAll(/[^A-Za-z0-9._-]+/g, '-'); +const summarizeGitTagsForPath = (gitTags: string[]): string => + gitTags.length === 1 + ? sanitizeFileSegment(gitTags[0]) + : `${sanitizeFileSegment(gitTags[0])}-to-${sanitizeFileSegment(gitTags.at(-1) ?? gitTags[0])}-${gitTags.length}-tags`; + +const formatGitTagSummary = (gitTags: SelectedGitTag[]): string => + gitTags.map(({ gitTag }) => gitTag).join(', '); + +const formatComparisonBaseSummary = (gitTags: SelectedGitTag[]): string => + gitTags + .map( + ({ gitTag, previousTag }) => + `${gitTag} <- ${previousTag ?? 'repository start'}`, + ) + .join('; '); + +const formatJiraVersion = (jiraVersion: JiraVersion): string => + `${jiraVersion.name} (${jiraVersion.id})`; + +const formatJiraReleaseDates = (jiraVersions: JiraVersion[]): string => + jiraVersions + .map((version) => { + const releaseDate = version.releaseDate ?? 'unknown'; + return `${version.name}: ${releaseDate}`; + }) + .join('; '); + export const defaultReportPath = ( repoName: string, - gitTag: string, + gitTags: string[], cwd: string = process.cwd(), ): string => path.join( cwd, '.tmp', 'release-check', - `${sanitizeFileSegment(repoName)}-${sanitizeFileSegment(gitTag)}.txt`, + `${sanitizeFileSegment(repoName)}-${summarizeGitTagsForPath(gitTags)}.txt`, ); export const renderReport = ({ comparison, - gitTag, + gitTags, jiraProject, - jiraVersion, - previousTag, + jiraVersions, releaseNotes, repoName, repoRoot, totalJiraIssues, }: { comparison: ComparisonResult; - gitTag: string; + gitTags: SelectedGitTag[]; jiraProject: string; - jiraVersion: JiraVersion; - previousTag: string | null; + jiraVersions: JiraVersion[]; releaseNotes: ReleaseNotes; repoName: string; repoRoot: string; totalJiraIssues: number; }): string => { const { warnings } = releaseNotes; + const singleGitTag = gitTags.length === 1; + const singleJiraVersion = jiraVersions.length === 1; + const jiraScopeLabel = singleJiraVersion + ? 'the release' + : 'the selected releases'; + const jiraVersionScopeLabel = singleJiraVersion + ? 'the Jira release' + : 'the selected Jira versions'; const sections = [ 'Release check report', `Repository: ${repoName}`, `Repository root: ${repoRoot}`, - `Git tag: ${gitTag}`, - `Comparison base: ${previousTag ?? 'repository start'}`, + singleGitTag + ? `Git tag: ${gitTags[0].gitTag}` + : `Git tags selected (${gitTags.length}): ${formatGitTagSummary(gitTags)}`, + singleGitTag + ? `Comparison base: ${gitTags[0].previousTag ?? 'repository start'}` + : `Comparison bases: ${formatComparisonBaseSummary(gitTags)}`, `Jira project: ${jiraProject}`, - `Jira version: ${jiraVersion.name} (${jiraVersion.id})`, - `Jira release date: ${jiraVersion.releaseDate ?? 'unknown'}`, - `Jira version released: ${jiraVersion.released ? 'yes' : 'no'}`, + singleJiraVersion + ? `Jira version: ${formatJiraVersion(jiraVersions[0])}` + : `Jira versions selected (${jiraVersions.length}): ${jiraVersions.map((jiraVersion) => formatJiraVersion(jiraVersion)).join(', ')}`, + ...(singleJiraVersion + ? [ + `Jira release date: ${jiraVersions[0].releaseDate ?? 'unknown'}`, + `Jira version released: ${jiraVersions[0].released ? 'yes' : 'no'}`, + ] + : [ + `Jira release dates: ${formatJiraReleaseDates(jiraVersions)}`, + `Jira versions released: ${jiraVersions.filter((version) => version.released).length}/${jiraVersions.length}`, + ]), `Release notes source: ${releaseNotes.source}`, '', 'Summary', - `- Jira issues in release: ${totalJiraIssues}`, + `- Jira issues in ${singleJiraVersion ? 'release' : 'selected releases'}: ${totalJiraIssues}`, `- Jira issues referenced in git: ${comparison.gitReferencedIssueKeys.length}`, `- Jira issues referenced in release notes: ${comparison.notesReferencedIssueKeys.length}`, `- Jira issues missing from git: ${comparison.jiraIssuesMissingFromGit.length}`, @@ -105,13 +152,13 @@ export const renderReport = ({ sections.push( renderSection( - 'Jira issues in the release with no matching git reference', + `Jira issues in ${jiraScopeLabel} with no matching git reference`, comparison.jiraIssuesMissingFromGit.map((issue) => formatIssue(issue, comparison.commitsByIssueKey), ), ), renderSection( - 'Jira issues in the release with no matching release-note reference', + `Jira issues in ${jiraScopeLabel} with no matching release-note reference`, comparison.jiraIssuesMissingFromReleaseNotes.map((issue) => formatIssue(issue, comparison.commitsByIssueKey), ), @@ -135,14 +182,14 @@ export const renderReport = ({ ), ), renderSection( - 'Git-referenced Jira issue keys missing from the Jira release', + `Git-referenced Jira issue keys missing from ${jiraVersionScopeLabel}`, comparison.commitsWithIssueKeysOutsideRelease.map( ({ commit, missingKeys }) => `${formatCommit(commit)} | missing keys: ${missingKeys.join(', ')}`, ), ), renderSection( - 'Release-note Jira issue keys missing from the Jira release', + `Release-note Jira issue keys missing from ${jiraVersionScopeLabel}`, comparison.releaseNotesIssueKeysOutsideRelease, ), renderSection( diff --git a/tools/release-check/src/selectors.ts b/tools/release-check/src/selectors.ts new file mode 100644 index 00000000..55bb1d3c --- /dev/null +++ b/tools/release-check/src/selectors.ts @@ -0,0 +1,35 @@ +const escapeRegex = (value: string): string => + value.replaceAll(/[|\\{}()[\]^$+?.]/g, String.raw`\$&`); + +export const hasGlobPattern = (value: string): boolean => + value.includes('*') || value.includes('?'); + +export const matchesGlobPattern = (value: string, pattern: string): boolean => { + const regex = new RegExp( + `^${[...pattern] + .map((character) => { + if (character === '*') { + return '.*'; + } + if (character === '?') { + return '.'; + } + return escapeRegex(character); + }) + .join('')}$`, + ); + return regex.test(value); +}; + +export const parseSelectorList = (value: string): string[] => { + const selectors = value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + + if (selectors.length === 0) { + throw new Error('Selector list must not be empty.'); + } + + return selectors; +}; diff --git a/tools/release-check/src/types.ts b/tools/release-check/src/types.ts index cdadb775..121360a3 100644 --- a/tools/release-check/src/types.ts +++ b/tools/release-check/src/types.ts @@ -18,9 +18,12 @@ export type GitCommit = { export type ReleaseNotesSource = 'auto' | 'github' | 'tag' | 'none'; +export type ReleaseNotesLookupSource = + 'github-release' | 'mixed' | 'none' | 'tag-annotation'; + export type ReleaseNotes = { issueKeys: string[]; - source: 'github-release' | 'tag-annotation' | 'none'; + source: ReleaseNotesLookupSource; text: string | null; warnings: string[]; }; @@ -54,12 +57,17 @@ export type ComparisonResult = { }; export type CliOptions = { - gitTag: string; + gitTagSelectors: string[]; jiraBaseUrl: string; jiraProject: string; - jiraVersion: string; + jiraVersionSelectors: string[]; output?: string; previousTag?: string; releaseNotesSource: ReleaseNotesSource; repo: string; }; + +export type SelectedGitTag = { + gitTag: string; + previousTag: string | null; +}; From 4cb95b5fe330cfd3a7bf9266029215aa6b75e01b Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Wed, 23 Sep 2026 17:51:51 +0100 Subject: [PATCH 4/6] CCM-14750: Exclude bugs from clinical review checks Skip the missing clinical lead and safety category checks for Jira issues whose issue type is Bug, while keeping the rest of the release comparison logic unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/__tests__/compare.test.ts | 30 +++++++++++++++++++ .../release-check/src/__tests__/index.test.ts | 4 +++ .../release-check/src/__tests__/jira.test.ts | 4 +++ .../src/__tests__/report.test.ts | 5 ++++ tools/release-check/src/compare.ts | 10 ++++--- tools/release-check/src/jira.ts | 5 +++- tools/release-check/src/types.ts | 1 + 7 files changed, 54 insertions(+), 5 deletions(-) diff --git a/tools/release-check/src/__tests__/compare.test.ts b/tools/release-check/src/__tests__/compare.test.ts index 4c02c346..b963983c 100644 --- a/tools/release-check/src/__tests__/compare.test.ts +++ b/tools/release-check/src/__tests__/compare.test.ts @@ -8,6 +8,7 @@ const issues: JiraIssue[] = [ clinicalReviewStatus: 'Review required', key: 'CCM-100', medicalClinicalSafetyCategory: '', + issueType: 'Story', summary: 'First ticket', status: 'Done', components: ['Platform'], @@ -17,6 +18,7 @@ const issues: JiraIssue[] = [ clinicalReviewStatus: 'In review', key: 'CCM-101', medicalClinicalSafetyCategory: 'Cat 1', + issueType: 'Story', summary: 'Exact summary fallback', status: 'In Progress', components: ['Platform'], @@ -26,6 +28,7 @@ const issues: JiraIssue[] = [ clinicalReviewStatus: 'Review not needed', key: 'CCM-102', medicalClinicalSafetyCategory: '', + issueType: 'Story', summary: 'Release only ticket', status: 'Done', components: ['Platform'], @@ -99,6 +102,33 @@ describe('compareRelease', () => { ).toEqual(['cccccccc']); }); + it('excludes bugs from clinical review checks', () => { + const result = compareRelease( + commits, + [ + ...issues, + { + clinicalLead: '', + clinicalReviewStatus: 'Review required', + components: ['Platform'], + issueType: 'Bug', + key: 'CCM-103', + medicalClinicalSafetyCategory: '', + status: 'Done', + summary: 'Bug fix', + }, + ], + [], + ); + + expect( + result.jiraIssuesMissingClinicalSafetyCategory.map((issue) => issue.key), + ).not.toContain('CCM-103'); + expect( + result.jiraIssuesMissingClinicalLead.map((issue) => issue.key), + ).not.toContain('CCM-103'); + }); + it('treats punctuation-only subjects as unmatched when no Jira key is present', () => { const result = compareRelease( [ diff --git a/tools/release-check/src/__tests__/index.test.ts b/tools/release-check/src/__tests__/index.test.ts index ec523081..daa2fce7 100644 --- a/tools/release-check/src/__tests__/index.test.ts +++ b/tools/release-check/src/__tests__/index.test.ts @@ -141,6 +141,7 @@ describe('run', () => { clinicalLead: '', clinicalReviewStatus: 'Pending', components: [], + issueType: 'Story', key: 'CCM-2', medicalClinicalSafetyCategory: '', status: 'Done', @@ -152,6 +153,7 @@ describe('run', () => { clinicalLead: '', clinicalReviewStatus: 'Pending', components: [], + issueType: 'Story', key: 'CCM-1', medicalClinicalSafetyCategory: '', status: 'Done', @@ -240,6 +242,7 @@ describe('run', () => { clinicalLead: '', clinicalReviewStatus: '', components: [], + issueType: 'Story', medicalClinicalSafetyCategory: '', status: 'Done', summary: 'one', @@ -251,6 +254,7 @@ describe('run', () => { clinicalLead: '', clinicalReviewStatus: '', components: [], + issueType: 'Story', medicalClinicalSafetyCategory: '', status: 'Done', summary: 'duplicate one', diff --git a/tools/release-check/src/__tests__/jira.test.ts b/tools/release-check/src/__tests__/jira.test.ts index 61167790..9fed11c3 100644 --- a/tools/release-check/src/__tests__/jira.test.ts +++ b/tools/release-check/src/__tests__/jira.test.ts @@ -224,6 +224,7 @@ describe('fetchJiraIssues', () => { customfield_10523: { name: 'Dr Test' }, customfield_15200: { value: 'Cat 1' }, customfield_16657: { value: 'Review required' }, + issuetype: { name: 'Story' }, summary: 'First', status: { name: 'Done' }, components: [{ name: 'Platform' }], @@ -243,6 +244,7 @@ describe('fetchJiraIssues', () => { customfield_10523: null, customfield_15200: ['Cat 2', { value: 'Cat 3' }], customfield_16657: 'Review not needed', + issuetype: { name: 'Bug' }, summary: 'Second', status: { name: 'In Progress' }, components: [], @@ -261,6 +263,7 @@ describe('fetchJiraIssues', () => { }), ).resolves.toEqual([ { + issueType: 'Story', key: 'CCM-1', clinicalLead: 'Dr Test', clinicalReviewStatus: 'Review required', @@ -270,6 +273,7 @@ describe('fetchJiraIssues', () => { components: ['Platform'], }, { + issueType: 'Bug', key: 'CCM-2', clinicalLead: '', clinicalReviewStatus: 'Review not needed', diff --git a/tools/release-check/src/__tests__/report.test.ts b/tools/release-check/src/__tests__/report.test.ts index 6773f5c5..eb3d9413 100644 --- a/tools/release-check/src/__tests__/report.test.ts +++ b/tools/release-check/src/__tests__/report.test.ts @@ -117,6 +117,7 @@ describe('renderReport', () => { gitReferencedIssueKeys: ['CCM-100'], jiraIssuesMissingClinicalLead: [ { + issueType: 'Story', key: 'CCM-104', summary: 'Missing clinical lead', status: 'Done', @@ -128,6 +129,7 @@ describe('renderReport', () => { ], jiraIssuesMissingClinicalSafetyCategory: [ { + issueType: 'Story', key: 'CCM-103', summary: 'Missing clinical safety category', status: 'Done', @@ -139,6 +141,7 @@ describe('renderReport', () => { ], jiraIssuesMissingFromGit: [ { + issueType: 'Story', key: 'CCM-101', summary: 'Missing from git', status: 'Done', @@ -150,6 +153,7 @@ describe('renderReport', () => { ], jiraIssuesMissingFromReleaseNotes: [ { + issueType: 'Story', key: 'CCM-102', summary: 'Missing from notes', status: 'Done', @@ -162,6 +166,7 @@ describe('renderReport', () => { notesReferencedIssueKeys: ['CCM-100'], releaseReferencedIssuesNotDone: [ { + issueType: 'Story', key: 'CCM-100', summary: 'Referenced and not done', status: 'In Progress', diff --git a/tools/release-check/src/compare.ts b/tools/release-check/src/compare.ts index 782aeefd..41c9477b 100644 --- a/tools/release-check/src/compare.ts +++ b/tools/release-check/src/compare.ts @@ -38,6 +38,10 @@ const normalise = (text: string): string => .replaceAll(/[^a-z0-9]+/g, ' ') .trim(); +const shouldCheckClinicalReview = (issue: JiraIssue): boolean => + issue.issueType !== 'Bug' && + issue.clinicalReviewStatus !== 'Review not needed'; + const findSummaryMatches = (subject: string, issues: JiraIssue[]): string[] => { const normalisedSubject = normalise(subject); if (!normalisedSubject) { @@ -108,12 +112,10 @@ export const compareRelease = ( const jiraIssuesMissingClinicalSafetyCategory = issues.filter( (issue) => issue.medicalClinicalSafetyCategory === '' && - issue.clinicalReviewStatus !== 'Review not needed', + shouldCheckClinicalReview(issue), ); const jiraIssuesMissingClinicalLead = issues.filter( - (issue) => - issue.clinicalLead === '' && - issue.clinicalReviewStatus !== 'Review not needed', + (issue) => issue.clinicalLead === '' && shouldCheckClinicalReview(issue), ); const releaseNotesIssueKeysOutsideRelease = notesReferencedIssueKeys.filter( (key) => !issueMap.has(key), diff --git a/tools/release-check/src/jira.ts b/tools/release-check/src/jira.ts index e02c6fc0..6a60ec05 100644 --- a/tools/release-check/src/jira.ts +++ b/tools/release-check/src/jira.ts @@ -213,13 +213,14 @@ export const fetchJiraIssues = async ( customfield_15200?: unknown; customfield_16657?: unknown; components: { name: string }[]; + issuetype: { name: string }; status: { name: string }; summary: string; }; }[]; total: number; }>( - `${jiraBaseUrl}/rest/api/2/search?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}&fields=summary,status,components,${CLINICAL_LEAD_FIELD_ID},${MEDICAL_CLINICAL_SAFETY_CATEGORY_FIELD_ID},${CLINICAL_REVIEW_STATUS_FIELD_ID}`, + `${jiraBaseUrl}/rest/api/2/search?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}&fields=summary,status,issuetype,components,${CLINICAL_LEAD_FIELD_ID},${MEDICAL_CLINICAL_SAFETY_CATEGORY_FIELD_ID},${CLINICAL_REVIEW_STATUS_FIELD_ID}`, ); for (const issue of search.issues) { @@ -228,6 +229,7 @@ export const fetchJiraIssues = async ( customfield_10523: clinicalLeadField, customfield_15200: medicalClinicalSafetyCategoryField, customfield_16657: clinicalReviewStatusField, + issuetype, status, summary, } = issue.fields; @@ -236,6 +238,7 @@ export const fetchJiraIssues = async ( clinicalLead: getJiraFieldString(clinicalLeadField), clinicalReviewStatus: getJiraFieldString(clinicalReviewStatusField), components: components.map((component) => component.name), + issueType: issuetype.name, key: issue.key, medicalClinicalSafetyCategory: getJiraFieldString( medicalClinicalSafetyCategoryField, diff --git a/tools/release-check/src/types.ts b/tools/release-check/src/types.ts index 121360a3..acf74882 100644 --- a/tools/release-check/src/types.ts +++ b/tools/release-check/src/types.ts @@ -2,6 +2,7 @@ export type JiraIssue = { clinicalLead: string; clinicalReviewStatus: string; components: string[]; + issueType: string; key: string; medicalClinicalSafetyCategory: string; status: string; From bc3f685a438d5debdc23d158c801253b034e12d6 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Thu, 24 Sep 2026 10:14:53 +0100 Subject: [PATCH 5/6] CCM-14750: Add fix workflows to release-check reports Improve the release-check package so release audits are easier to review and act on in Jira. - render Markdown reports with Jira-enriched issue tables - add component-scoped fixVersion and clinical review fix workflows - document the reporting structure and correct the check entrypoint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/release-check/DESIGN.md | 75 +++++ tools/release-check/README.md | 41 ++- tools/release-check/package.json | 2 +- .../release-check/src/__tests__/args.test.ts | 90 ++++++ .../release-check/src/__tests__/index.test.ts | 233 +++++++++++---- .../release-check/src/__tests__/jira.test.ts | 207 ++++++-------- .../src/__tests__/report.test.ts | 267 ++++++++++++++++-- tools/release-check/src/args.ts | 24 +- tools/release-check/src/index.ts | 261 ++++++++++++++++- tools/release-check/src/jira.ts | 253 +++++++++++++---- tools/release-check/src/report.ts | 241 ++++++++++++---- tools/release-check/src/types.ts | 20 ++ 12 files changed, 1377 insertions(+), 337 deletions(-) create mode 100644 tools/release-check/DESIGN.md diff --git a/tools/release-check/DESIGN.md b/tools/release-check/DESIGN.md new file mode 100644 index 00000000..f005c18b --- /dev/null +++ b/tools/release-check/DESIGN.md @@ -0,0 +1,75 @@ +# release-check design + +## Purpose + +`release-check` compares git release history, Jira release membership, and release +notes for a repository so that release managers can spot: + +- Jira tickets that are in a release but not represented in git +- git-referenced tickets that are outside the selected Jira release scope +- tickets that are referenced but not done +- clinical review metadata gaps + +It also supports carefully scoped Jira fix-up actions for a single release pair. + +## High-level structure + +The package is split into a small set of focused modules: + +- [`src/args.ts`](./src/args.ts) parses and validates the CLI contract +- [`src/git.ts`](./src/git.ts) resolves tags and collects commit history +- [`src/jira.ts`](./src/jira.ts) resolves Jira versions, fetches issues, and applies Jira updates +- [`src/github-release.ts`](./src/github-release.ts) reads GitHub or annotated-tag release notes +- [`src/compare.ts`](./src/compare.ts) computes the comparison model from commits, issues, and notes +- [`src/report.ts`](./src/report.ts) renders Markdown reports and fix proposal summaries +- [`src/index.ts`](./src/index.ts) orchestrates the end-to-end flow +- [`src/cli.ts`](./src/cli.ts) is the thin executable entrypoint + +## Execution flow + +1. Parse CLI options. +2. Resolve the target repository and selected git tags. +3. Collect commits across the selected release ranges. +4. Resolve the selected Jira versions and fetch the issues assigned to them. +5. Read release notes from GitHub releases or annotated tags. +6. Compare commits, Jira issues, and release-note references. +7. Enrich any outside-release issue keys with Jira lookup results where possible. +8. Render a Markdown report. +9. Optionally show a confirmation summary and apply scoped Jira updates. + +## Reporting model + +The report is Markdown-first so it works well in editor preview panes. + +Issue-based sections are rendered as tables rather than nested bullets: + +- `Issue` column: Jira key, components, title, and status +- `Commit` column: the first representative commit plus the total commit count + +For issue keys that cannot be resolved in Jira, the report keeps the commit +evidence and labels the issue as `not found in Jira`. + +## Fix workflows + +Two fix actions are currently supported: + +- `fix-version` +- `clinical-review-not-needed` + +Both actions are intentionally constrained: + +- component-scoped via `--fix-component` +- single release pair only +- confirmation shown before changes are applied +- `--yes` required for non-interactive automation + +This keeps the first version conservative and easy to audit. + +## Extension points + +Likely future enhancements: + +- richer filtering beyond component-only matching +- multi-release fix inference +- additional Markdown sections or machine-readable exports +- safer dry-run or diff views for Jira mutations diff --git a/tools/release-check/README.md b/tools/release-check/README.md index 9b6fd14c..8bf3203d 100644 --- a/tools/release-check/README.md +++ b/tools/release-check/README.md @@ -56,12 +56,49 @@ Notes for multi-release mode: - `GITHUB_TOKEN` or `GH_TOKEN` for fetching GitHub release notes from private repositories +## Fix workflows + +The CLI can also prepare and optionally apply targeted Jira updates for a single +resolved release pair. + +### Add the selected Jira fix version to git-referenced issues outside the release + +```bash +pnpm release-check -- \ + --repo ../nhs-notify-client-config \ + --git-tag v0.2.0 \ + --jira-version client-config-0.2.0 \ + --fix fix-version \ + --fix-component onboarding-journey-improvements +``` + +### Mark clinical review as not needed for a component-scoped subset + +```bash +pnpm release-check -- \ + --repo ../nhs-notify-client-config \ + --git-tag v0.3.0 \ + --jira-version client-config-0.3.0 \ + --fix clinical-review-not-needed \ + --fix-component onboarding-journey-improvements +``` + +Notes for fix mode: + +- `--fix` accepts `fix-version` or `clinical-review-not-needed`. +- `--fix-component` is required and scopes the proposed Jira updates. +- Fix mode currently requires exactly one resolved git tag and one resolved Jira version. +- The CLI prints the full issue and representative commit list before applying updates. +- By default the CLI asks for confirmation before changing Jira. +- Use `--yes` to skip the confirmation prompt in non-interactive automation. + ## Notes - The tool auto-detects the previous tag using `git describe --tags --abbrev=0 ^`. - When GitHub release notes are unavailable, auto mode falls back to annotated tag notes if the tag is annotated. -- Reports default to `.tmp/release-check/-.txt` for single-release checks. -- Multi-release reports default to `.tmp/release-check/--to---tags.txt`. +- Reports default to `.tmp/release-check/-.md` for single-release checks. +- Multi-release reports default to `.tmp/release-check/--to---tags.md`. +- Reports are emitted as Markdown so they can be inspected in a Markdown preview. ## Publishing diff --git a/tools/release-check/package.json b/tools/release-check/package.json index 208ca803..e7e3708a 100644 --- a/tools/release-check/package.json +++ b/tools/release-check/package.json @@ -27,7 +27,7 @@ }, "scripts": { "build": "tsc -p tsconfig.build.json", - "check": "tsx ./src/index.ts", + "check": "tsx ./src/cli.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "prebuild": "rm -rf dist", diff --git a/tools/release-check/src/__tests__/args.test.ts b/tools/release-check/src/__tests__/args.test.ts index 4af1b69b..2f74beb3 100644 --- a/tools/release-check/src/__tests__/args.test.ts +++ b/tools/release-check/src/__tests__/args.test.ts @@ -4,6 +4,10 @@ describe('parseCliArgs', () => { it('parses required and optional arguments', () => { expect( parseCliArgs([ + '--fix', + 'fix-version', + '--fix-component', + 'Platform', '--repo', '../repo', '--git-tag', @@ -22,6 +26,8 @@ describe('parseCliArgs', () => { 'tag', ]), ).toEqual({ + fixAction: 'fix-version', + fixComponent: 'Platform', repo: '../repo', gitTagSelectors: ['0.1.0'], jiraVersionSelectors: ['71260'], @@ -30,6 +36,7 @@ describe('parseCliArgs', () => { previousTag: '0.0.9', output: 'out.txt', releaseNotesSource: 'tag', + yes: false, }); }); @@ -44,6 +51,8 @@ describe('parseCliArgs', () => { '71260, client-config-0.2.0 , client-config-*', ]), ).toEqual({ + fixAction: undefined, + fixComponent: undefined, repo: '../repo', gitTagSelectors: ['0.1.0', 'v0.2.0', 'v0.3.*'], jiraVersionSelectors: ['71260', 'client-config-0.2.0', 'client-config-*'], @@ -52,6 +61,7 @@ describe('parseCliArgs', () => { previousTag: undefined, output: undefined, releaseNotesSource: 'auto', + yes: false, }); }); @@ -66,6 +76,8 @@ describe('parseCliArgs', () => { '71260', ]), ).toEqual({ + fixAction: undefined, + fixComponent: undefined, repo: '../repo', gitTagSelectors: ['0.1.0'], jiraVersionSelectors: ['71260'], @@ -74,6 +86,37 @@ describe('parseCliArgs', () => { previousTag: undefined, output: undefined, releaseNotesSource: 'auto', + yes: false, + }); + }); + + it('parses fix confirmation flags', () => { + expect( + parseCliArgs([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + '--fix', + 'clinical-review-not-needed', + '--fix-component', + 'Platform', + '--yes', + ]), + ).toEqual({ + fixAction: 'clinical-review-not-needed', + fixComponent: 'Platform', + repo: '../repo', + gitTagSelectors: ['0.1.0'], + jiraVersionSelectors: ['71260'], + jiraProject: 'CCM', + jiraBaseUrl: 'https://nhsd-jira.digital.nhs.uk', + previousTag: undefined, + output: undefined, + releaseNotesSource: 'auto', + yes: true, }); }); @@ -154,4 +197,51 @@ describe('parseCliArgs', () => { 'Invalid --release-notes-source. Expected one of: auto, github, tag, none', ); }); + + it('throws for an invalid fix action', () => { + expect(() => + parseCliArgs([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + '--fix', + 'weird', + ]), + ).toThrow( + 'Invalid --fix. Expected one of: fix-version, clinical-review-not-needed', + ); + }); + + it('throws when --fix is missing its component', () => { + expect(() => + parseCliArgs([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + '--fix', + 'fix-version', + ]), + ).toThrow('Option --fix requires --fix-component'); + }); + + it('throws when --fix-component is provided without --fix', () => { + expect(() => + parseCliArgs([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + '--fix-component', + 'Platform', + ]), + ).toThrow('Option --fix-component requires --fix'); + }); }); diff --git a/tools/release-check/src/__tests__/index.test.ts b/tools/release-check/src/__tests__/index.test.ts index daa2fce7..348d00f6 100644 --- a/tools/release-check/src/__tests__/index.test.ts +++ b/tools/release-check/src/__tests__/index.test.ts @@ -16,7 +16,10 @@ jest.mock('../git', () => ({ jest.mock('../jira', () => ({ fetchJiraIssues: jest.fn(), + fetchJiraIssuesByKeys: jest.fn(), resolveJiraVersions: jest.fn(), + updateJiraIssueClinicalReviewStatus: jest.fn(), + updateJiraIssueFixVersions: jest.fn(), })); jest.mock('../github-release', () => ({ @@ -29,6 +32,7 @@ jest.mock('../compare', () => ({ jest.mock('../report', () => ({ defaultReportPath: jest.fn(), + renderFixProposalSection: jest.fn(), renderReport: jest.fn(), })); @@ -40,6 +44,7 @@ const notes = jest.requireMock('../github-release'); const compare = jest.requireMock('../compare'); const report = jest.requireMock('../report'); + const mockedResolveRepoPath = git.resolveRepoPath as jest.MockedFunction< typeof git.resolveRepoPath >; @@ -66,6 +71,18 @@ const mockedResolveJiraVersions = const mockedFetchJiraIssues = jira.fetchJiraIssues as jest.MockedFunction< typeof jira.fetchJiraIssues >; +const mockedFetchJiraIssuesByKeys = + jira.fetchJiraIssuesByKeys as jest.MockedFunction< + typeof jira.fetchJiraIssuesByKeys + >; +const mockedUpdateJiraIssueClinicalReviewStatus = + jira.updateJiraIssueClinicalReviewStatus as jest.MockedFunction< + typeof jira.updateJiraIssueClinicalReviewStatus + >; +const mockedUpdateJiraIssueFixVersions = + jira.updateJiraIssueFixVersions as jest.MockedFunction< + typeof jira.updateJiraIssueFixVersions + >; const mockedReadReleaseNotesForTags = notes.readReleaseNotesForTags as jest.MockedFunction< typeof notes.readReleaseNotesForTags @@ -76,6 +93,10 @@ const mockedCompareRelease = compare.compareRelease as jest.MockedFunction< const mockedDefaultReportPath = report.defaultReportPath as jest.MockedFunction< typeof report.defaultReportPath >; +const mockedRenderFixProposalSection = + report.renderFixProposalSection as jest.MockedFunction< + typeof report.renderFixProposalSection + >; const mockedRenderReport = report.renderReport as jest.MockedFunction< typeof report.renderReport >; @@ -103,6 +124,9 @@ describe('run', () => { }, ]); mockedFetchJiraIssues.mockResolvedValue([]); + mockedFetchJiraIssuesByKeys.mockResolvedValue([]); + mockedUpdateJiraIssueClinicalReviewStatus.mockResolvedValue(undefined); + mockedUpdateJiraIssueFixVersions.mockResolvedValue(undefined); mockedReadReleaseNotesForTags.mockResolvedValue({ issueKeys: [], source: 'none', @@ -123,6 +147,7 @@ describe('run', () => { releaseNotesIssueKeysOutsideRelease: [], }); mockedDefaultReportPath.mockReturnValue('/workspace/report.txt'); + mockedRenderFixProposalSection.mockReturnValue('fix proposal section'); mockedRenderReport.mockReturnValue('report'); }); @@ -131,42 +156,6 @@ describe('run', () => { }); it('runs the end-to-end comparison and writes the report', async () => { - mockedCompareRelease.mockReturnValue({ - commitsByIssueKey: new Map(), - commitsWithIssueKeysOutsideRelease: [], - commitsWithoutMatches: [], - gitReferencedIssueKeys: [], - jiraIssuesMissingClinicalLead: [ - { - clinicalLead: '', - clinicalReviewStatus: 'Pending', - components: [], - issueType: 'Story', - key: 'CCM-2', - medicalClinicalSafetyCategory: '', - status: 'Done', - summary: 'lead missing', - }, - ], - jiraIssuesMissingClinicalSafetyCategory: [ - { - clinicalLead: '', - clinicalReviewStatus: 'Pending', - components: [], - issueType: 'Story', - key: 'CCM-1', - medicalClinicalSafetyCategory: '', - status: 'Done', - summary: 'category missing', - }, - ], - jiraIssuesMissingFromGit: [], - jiraIssuesMissingFromReleaseNotes: [], - notesReferencedIssueKeys: [], - releaseReferencedIssuesNotDone: [], - releaseNotesIssueKeysOutsideRelease: [], - }); - await run([ '--repo', '../repo', @@ -188,6 +177,11 @@ describe('run', () => { ['0.1.0'], 'auto', ); + expect(mockedFetchJiraIssuesByKeys).toHaveBeenCalledWith( + 'https://nhsd-jira.digital.nhs.uk', + 'CCM', + [], + ); expect(fsPromises.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true, }); @@ -196,13 +190,12 @@ describe('run', () => { 'report', 'utf8', ); - expect(stdoutWrite).toHaveBeenCalledWith( - expect.stringContaining( - 'Jira issues missing clinical safety category: 1\n', - ), - ); - expect(stdoutWrite).toHaveBeenCalledWith( - expect.stringContaining('Jira issues missing clinical lead: 1\n'), + expect(mockedRenderReport).toHaveBeenCalledWith( + expect.objectContaining({ + fixAction: undefined, + fixComponent: undefined, + fixProposals: undefined, + }), ); expect(stdoutWrite).toHaveBeenCalledWith( expect.stringContaining('Report written to /workspace/report.txt\n'), @@ -298,23 +291,135 @@ describe('run', () => { totalJiraIssues: 1, }), ); - expect(stdoutWrite).toHaveBeenCalledWith( - expect.stringContaining('Git tags selected (2): 0.1.0, v0.2.0\n'), - ); - expect(stdoutWrite).toHaveBeenCalledWith( - expect.stringContaining( - 'Comparison bases: 0.1.0 <- repository start; v0.2.0 <- 0.1.0\n', - ), + }); + + it('proposes and applies component-filtered fix versions in fix mode', async () => { + mockedCollectCommitsForTags.mockReturnValue([ + { + hash: 'a'.repeat(40), + shortHash: 'aaaaaaaa', + subject: 'CCM-100: ship it', + body: '', + explicitIssueKeys: ['CCM-100'], + }, + ]); + mockedCompareRelease.mockReturnValue({ + commitsByIssueKey: new Map([ + [ + 'CCM-100', + [ + { + hash: 'a'.repeat(40), + shortHash: 'aaaaaaaa', + subject: 'CCM-100: ship it', + body: '', + explicitIssueKeys: ['CCM-100'], + matchedIssueKeys: ['CCM-100'], + }, + ], + ], + ]), + commitsWithIssueKeysOutsideRelease: [ + { + commit: { + hash: 'a'.repeat(40), + shortHash: 'aaaaaaaa', + subject: 'CCM-100: ship it', + body: '', + explicitIssueKeys: ['CCM-100'], + matchedIssueKeys: ['CCM-100'], + }, + missingKeys: ['CCM-100'], + }, + ], + commitsWithoutMatches: [], + gitReferencedIssueKeys: ['CCM-100'], + jiraIssuesMissingClinicalLead: [], + jiraIssuesMissingClinicalSafetyCategory: [], + jiraIssuesMissingFromGit: [], + jiraIssuesMissingFromReleaseNotes: [], + notesReferencedIssueKeys: [], + releaseReferencedIssuesNotDone: [], + releaseNotesIssueKeysOutsideRelease: [], + }); + mockedFetchJiraIssuesByKeys.mockResolvedValue([ + { + key: 'CCM-100', + clinicalLead: '', + clinicalReviewStatus: '', + components: ['Platform'], + fixVersions: [], + issueType: 'Story', + medicalClinicalSafetyCategory: '', + status: 'Done', + summary: 'outside', + }, + ]); + + await run([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + '--fix', + 'fix-version', + '--fix-component', + 'Platform', + '--yes', + ]); + + expect(mockedRenderFixProposalSection).toHaveBeenCalled(); + expect(mockedUpdateJiraIssueFixVersions).toHaveBeenCalledWith( + 'https://nhsd-jira.digital.nhs.uk', + 'CCM-100', + [{ id: '71260', name: 'release' }], ); expect(stdoutWrite).toHaveBeenCalledWith( - expect.stringContaining( - 'Jira versions selected (2): release-a (71260), release-b (71261)\n', - ), + expect.stringContaining('Applied fixVersion updates to 1 issue(s).\n'), ); }); - it('respects an explicit output path and a missing previous tag', async () => { - mockedGetPreviousTag.mockReturnValue(null); + it('applies component-filtered clinical review status updates', async () => { + mockedCompareRelease.mockReturnValue({ + commitsByIssueKey: new Map([ + [ + 'CCM-100', + [ + { + hash: 'a'.repeat(40), + shortHash: 'aaaaaaaa', + subject: 'CCM-100: ship it', + body: '', + explicitIssueKeys: ['CCM-100'], + matchedIssueKeys: ['CCM-100'], + }, + ], + ], + ]), + commitsWithIssueKeysOutsideRelease: [], + commitsWithoutMatches: [], + gitReferencedIssueKeys: [], + jiraIssuesMissingClinicalLead: [ + { + key: 'CCM-100', + clinicalLead: '', + clinicalReviewStatus: 'Review required', + components: ['Platform'], + issueType: 'Story', + medicalClinicalSafetyCategory: '', + status: 'Done', + summary: 'needs review update', + }, + ], + jiraIssuesMissingClinicalSafetyCategory: [], + jiraIssuesMissingFromGit: [], + jiraIssuesMissingFromReleaseNotes: [], + notesReferencedIssueKeys: [], + releaseReferencedIssuesNotDone: [], + releaseNotesIssueKeysOutsideRelease: [], + }); await run([ '--repo', @@ -323,17 +428,21 @@ describe('run', () => { '0.1.0', '--jira-version', '71260', - '--output', - 'reports/custom.txt', + '--fix', + 'clinical-review-not-needed', + '--fix-component', + 'Platform', + '--yes', ]); - expect(mockedDefaultReportPath).not.toHaveBeenCalled(); - expect(fsPromises.mkdir).toHaveBeenCalledWith( - expect.stringContaining('/reports'), - { recursive: true }, + expect(mockedUpdateJiraIssueClinicalReviewStatus).toHaveBeenCalledWith( + 'https://nhsd-jira.digital.nhs.uk', + 'CCM-100', ); expect(stdoutWrite).toHaveBeenCalledWith( - expect.stringContaining('Comparison base: repository start\n'), + expect.stringContaining( + 'Applied clinical review status updates to 1 issue(s).\n', + ), ); }); }); diff --git a/tools/release-check/src/__tests__/jira.test.ts b/tools/release-check/src/__tests__/jira.test.ts index 9fed11c3..0ce884dc 100644 --- a/tools/release-check/src/__tests__/jira.test.ts +++ b/tools/release-check/src/__tests__/jira.test.ts @@ -1,7 +1,10 @@ import { fetchJiraIssues, + fetchJiraIssuesByKeys, resolveJiraVersion, resolveJiraVersions, + updateJiraIssueClinicalReviewStatus, + updateJiraIssueFixVersions, } from '../jira'; const mockFetch = jest.fn(); @@ -44,59 +47,6 @@ describe('resolveJiraVersion', () => { }); }); - it('extracts a version id from a Jira version URL', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - id: 71_260, - name: 'client-config-0.1.0', - releaseDate: '2026-07-08', - released: true, - }), - }); - - await expect( - resolveJiraVersion( - 'https://jira.example.com', - 'CCM', - 'https://jira.example.com/projects/CCM/versions/71260', - ), - ).resolves.toEqual({ - id: '71260', - name: 'client-config-0.1.0', - releaseDate: '2026-07-08', - released: true, - }); - }); - - it('resolves a version name from the project versions list', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => [ - { id: 1, name: 'older' }, - { - id: 71_260, - name: 'client-config-0.1.0', - releaseDate: '2026-07-08', - released: true, - }, - ], - }); - - await expect( - resolveJiraVersion( - 'https://jira.example.com', - 'CCM', - 'client-config-0.1.0', - ), - ).resolves.toEqual({ - id: '71260', - name: 'client-config-0.1.0', - releaseDate: '2026-07-08', - released: true, - }); - }); - it('resolves multiple versions from exact and wildcard selectors', async () => { mockFetch.mockResolvedValue({ ok: true, @@ -113,12 +63,6 @@ describe('resolveJiraVersion', () => { releaseDate: '2026-08-08', released: false, }, - { - id: 3, - name: 'other-release', - releaseDate: '2026-09-01', - released: false, - }, ], }); @@ -143,51 +87,6 @@ describe('resolveJiraVersion', () => { ]); }); - it('defaults missing release metadata from the version response', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - id: 71_260, - name: 'client-config-0.1.0', - }), - }); - - await expect( - resolveJiraVersion('https://jira.example.com', 'CCM', '71260'), - ).resolves.toEqual({ - id: '71260', - name: 'client-config-0.1.0', - releaseDate: null, - released: false, - }); - }); - - it('throws when the named version is not found', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => [{ id: 1, name: 'older' }], - }); - - await expect( - resolveJiraVersion('https://jira.example.com', 'CCM', 'missing'), - ).rejects.toThrow('Could not find Jira version "missing" in project CCM.'); - }); - - it('throws when the wildcard version selector matches nothing', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => [{ id: 1, name: 'older' }], - }); - - await expect( - resolveJiraVersions('https://jira.example.com', 'CCM', [ - 'client-config-*', - ]), - ).rejects.toThrow( - 'Could not find Jira versions matching "client-config-*" in project CCM.', - ); - }); - it('throws when no Jira token is configured', async () => { delete process.env.JIRA_API_TOKEN; @@ -199,7 +98,7 @@ describe('resolveJiraVersion', () => { }); }); -describe('fetchJiraIssues', () => { +describe('jira issue operations', () => { const originalToken = process.env.JIRA_API_TOKEN; beforeEach(() => { @@ -211,7 +110,7 @@ describe('fetchJiraIssues', () => { process.env.JIRA_API_TOKEN = originalToken; }); - it('maps paged Jira issues', async () => { + it('maps paged Jira issues including fix versions', async () => { mockFetch .mockResolvedValueOnce({ ok: true, @@ -224,6 +123,7 @@ describe('fetchJiraIssues', () => { customfield_10523: { name: 'Dr Test' }, customfield_15200: { value: 'Cat 1' }, customfield_16657: { value: 'Review required' }, + fixVersions: [{ id: 71_260, name: 'client-config-0.1.0' }], issuetype: { name: 'Story' }, summary: 'First', status: { name: 'Done' }, @@ -244,6 +144,7 @@ describe('fetchJiraIssues', () => { customfield_10523: null, customfield_15200: ['Cat 2', { value: 'Cat 3' }], customfield_16657: 'Review not needed', + fixVersions: [], issuetype: { name: 'Bug' }, summary: 'Second', status: { name: 'In Progress' }, @@ -267,6 +168,7 @@ describe('fetchJiraIssues', () => { key: 'CCM-1', clinicalLead: 'Dr Test', clinicalReviewStatus: 'Review required', + fixVersions: [{ id: '71260', name: 'client-config-0.1.0' }], summary: 'First', medicalClinicalSafetyCategory: 'Cat 1', status: 'Done', @@ -277,6 +179,7 @@ describe('fetchJiraIssues', () => { key: 'CCM-2', clinicalLead: '', clinicalReviewStatus: 'Review not needed', + fixVersions: [], summary: 'Second', medicalClinicalSafetyCategory: 'Cat 2|Cat 3', status: 'In Progress', @@ -285,23 +188,91 @@ describe('fetchJiraIssues', () => { ]); }); - it('throws when Jira responds with an error', async () => { + it('fetches issues by key', async () => { mockFetch.mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Server Error', - text: async () => 'boom', + ok: true, + json: async () => ({ + total: 1, + issues: [ + { + key: 'CCM-42', + fields: { + customfield_10523: { name: 'Dr Test' }, + customfield_15200: { value: 'Cat 1' }, + customfield_16657: { value: 'Review required' }, + fixVersions: [], + issuetype: { name: 'Story' }, + summary: 'Outside selected versions', + status: { name: 'Done' }, + components: [{ name: 'Platform' }], + }, + }, + ], + }), }); await expect( - fetchJiraIssues('https://jira.example.com', 'CCM', { - id: '71260', - name: 'release', - releaseDate: null, - released: true, + fetchJiraIssuesByKeys('https://jira.example.com', 'CCM', ['CCM-42']), + ).resolves.toEqual([ + { + issueType: 'Story', + key: 'CCM-42', + clinicalLead: 'Dr Test', + clinicalReviewStatus: 'Review required', + fixVersions: [], + summary: 'Outside selected versions', + medicalClinicalSafetyCategory: 'Cat 1', + status: 'Done', + components: ['Platform'], + }, + ]); + }); + + it('updates issue fix versions', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: async () => '', + }); + + await expect( + updateJiraIssueFixVersions('https://jira.example.com', 'CCM-42', [ + { id: '71260', name: 'client-config-0.1.0' }, + ]), + ).resolves.toBeUndefined(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/issue/CCM-42', + expect.objectContaining({ + body: JSON.stringify({ + fields: { + fixVersions: [{ id: '71260' }], + }, + }), + method: 'PUT', + }), + ); + }); + + it('updates clinical review status', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: async () => '', + }); + + await expect( + updateJiraIssueClinicalReviewStatus('https://jira.example.com', 'CCM-42'), + ).resolves.toBeUndefined(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/issue/CCM-42', + expect.objectContaining({ + body: JSON.stringify({ + fields: { + customfield_16657: { value: 'Review not needed' }, + }, + }), + method: 'PUT', }), - ).rejects.toThrow( - 'Jira request failed (500 Server Error) for https://jira.example.com/rest/api/2/search', ); }); }); diff --git a/tools/release-check/src/__tests__/report.test.ts b/tools/release-check/src/__tests__/report.test.ts index eb3d9413..dee53556 100644 --- a/tools/release-check/src/__tests__/report.test.ts +++ b/tools/release-check/src/__tests__/report.test.ts @@ -1,6 +1,15 @@ -import { defaultReportPath, renderReport } from '../report'; +import { + defaultReportPath, + renderFixProposalSection, + renderReport, +} from '../report'; -import type { ComparisonResult, JiraVersion, ReleaseNotes } from '../types'; +import type { + ComparisonResult, + FixProposal, + JiraVersion, + ReleaseNotes, +} from '../types'; const comparison: ComparisonResult = { commitsByIssueKey: new Map(), @@ -30,11 +39,29 @@ const releaseNotes: ReleaseNotes = { warnings: ['No GitHub release body found for tag v0.0.1; falling back.'], }; +const fixProposals: FixProposal[] = [ + { + currentValueSummary: 'none', + issue: { + issueType: 'Story', + key: 'CCM-555', + summary: 'Needs fix version', + status: 'Done', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + fixVersions: [], + }, + targetValueSummary: 'client-config-0.1.0', + }, +]; + describe('defaultReportPath', () => { it('writes single-release reports under .tmp/release-check in the cwd', () => { expect( defaultReportPath('nhs-notify-client-config', ['0.1.0'], '/workspace'), - ).toBe('/workspace/.tmp/release-check/nhs-notify-client-config-0.1.0.txt'); + ).toBe('/workspace/.tmp/release-check/nhs-notify-client-config-0.1.0.md'); }); it('summarises multiple selected tags in the report filename', () => { @@ -45,35 +72,38 @@ describe('defaultReportPath', () => { '/workspace', ), ).toBe( - '/workspace/.tmp/release-check/nhs-notify-client-config-0.1.0-to-v0.3.1-3-tags.txt', + '/workspace/.tmp/release-check/nhs-notify-client-config-0.1.0-to-v0.3.1-3-tags.md', ); }); }); describe('renderReport', () => { - it('renders summary metadata and warnings', () => { + it('renders markdown summary metadata and warnings', () => { const report = renderReport({ comparison, + fixAction: undefined, + fixComponent: undefined, + fixProposals: undefined, gitTags: [{ gitTag: '0.1.0', previousTag: null }], jiraProject: 'CCM', jiraVersions: [jiraVersion], + outsideReleaseIssuesByKey: new Map(), releaseNotes, repoName: 'nhs-notify-client-config', repoRoot: '/repos/nhs-notify-client-config', totalJiraIssues: 16, }); - expect(report).toContain('Release check report'); - expect(report).toContain('Repository: nhs-notify-client-config'); - expect(report).toContain('Jira version: client-config-0.1.0 (71260)'); - expect(report).toContain('Release notes source: github-release'); - expect(report).toContain('Warnings'); + expect(report).toContain('# Release check report'); + expect(report).toContain('- **Repository:** nhs-notify-client-config'); + expect(report).toContain('- **Jira version:** client-config-0.1.0 (71260)'); + expect(report).toContain('## Warnings'); expect(report).toContain( - 'No GitHub release body found for tag v0.0.1; falling back.', + '- No GitHub release body found for tag v0.0.1; falling back.', ); }); - it('renders populated issue and commit sections', () => { + it('renders populated issue and fix sections as markdown tables', () => { const populatedReport = renderReport({ comparison: { commitsByIssueKey: new Map([ @@ -90,6 +120,40 @@ describe('renderReport', () => { }, ], ], + [ + 'CCM-999', + [ + { + hash: 'b'.repeat(40), + shortHash: 'bbbbbbbb', + subject: 'CCM-999: outside', + body: '', + explicitIssueKeys: ['CCM-999'], + matchedIssueKeys: ['CCM-999'], + }, + { + hash: 'c'.repeat(40), + shortHash: 'cccccccc', + subject: 'CCM-999: outside follow-up', + body: '', + explicitIssueKeys: ['CCM-999'], + matchedIssueKeys: ['CCM-999'], + }, + ], + ], + [ + 'CCM-555', + [ + { + hash: 'd'.repeat(40), + shortHash: 'dddddddd', + subject: 'CCM-555: proposed fix', + body: '', + explicitIssueKeys: ['CCM-555'], + matchedIssueKeys: ['CCM-555'], + }, + ], + ], ]), commitsWithIssueKeysOutsideRelease: [ { @@ -103,11 +167,22 @@ describe('renderReport', () => { }, missingKeys: ['CCM-999'], }, + { + commit: { + hash: 'c'.repeat(40), + shortHash: 'cccccccc', + subject: 'CCM-999: outside follow-up', + body: '', + explicitIssueKeys: ['CCM-999'], + matchedIssueKeys: ['CCM-999'], + }, + missingKeys: ['CCM-999'], + }, ], commitsWithoutMatches: [ { - hash: 'c'.repeat(40), - shortHash: 'cccccccc', + hash: 'e'.repeat(40), + shortHash: 'eeeeeeee', subject: 'maintenance', body: '', explicitIssueKeys: [], @@ -178,9 +253,40 @@ describe('renderReport', () => { ], releaseNotesIssueKeysOutsideRelease: ['CCM-200'], }, + fixAction: 'fixVersion', + fixComponent: 'Platform', + fixProposals, gitTags: [{ gitTag: '0.1.0', previousTag: '0.0.9' }], jiraProject: 'CCM', jiraVersions: [jiraVersion], + outsideReleaseIssuesByKey: new Map([ + [ + 'CCM-999', + { + issueType: 'Story', + key: 'CCM-999', + summary: 'Outside selected versions', + status: 'Done', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + }, + ], + [ + 'CCM-200', + { + issueType: 'Story', + key: 'CCM-200', + summary: 'Outside release notes', + status: 'Done', + components: [], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + }, + ], + ]), releaseNotes: { issueKeys: ['CCM-100'], source: 'github-release', @@ -193,30 +299,37 @@ describe('renderReport', () => { }); expect(populatedReport).toContain( - 'CCM-101: [Platform] Missing from git (Done)', + '## Jira issues in the release with no matching git reference', + ); + expect(populatedReport).toContain('| Issue | Commit |'); + expect(populatedReport).toContain( + '| CCM-101: [Platform] Missing from git (Done) | No matching commit |', ); - expect(populatedReport).toContain('CCM-102: Missing from notes (Done)'); expect(populatedReport).toContain( - 'CCM-100: [Platform] Referenced and not done (In Progress) | commits: aaaaaaaa CCM-100: ship it', + '| CCM-100: [Platform] Referenced and not done (In Progress) | `aaaaaaaa CCM-100: ship it` _(1 commit total)_ |', ); expect(populatedReport).toContain( - 'CCM-103: [Platform] Missing clinical safety category (Done)', + '| CCM-999: [Platform] Outside selected versions (Done) | `bbbbbbbb CCM-999: outside` _(2 commits total)_ |', ); expect(populatedReport).toContain( - 'CCM-104: [Platform] Missing clinical lead (Done)', + '## Proposed fixVersion updates for component Platform', ); + expect(populatedReport).toContain('| Issue | Commit | Proposed update |'); expect(populatedReport).toContain( - 'bbbbbbbb CCM-999: outside | missing keys: CCM-999', + '| CCM-555: [Platform] Needs fix version (Done) | `dddddddd CCM-555: proposed fix` _(1 commit total)_ | none -> client-config-0.1.0 |', ); expect(populatedReport).toContain( - 'Commits without a Jira key or exact Jira-summary match', + '## Commits without a Jira key or exact Jira-summary match', ); - expect(populatedReport).toContain('- cccccccc maintenance'); + expect(populatedReport).toContain('- eeeeeeee maintenance'); }); it('renders multi-release metadata when multiple tags and Jira versions are selected', () => { const report = renderReport({ comparison, + fixAction: undefined, + fixComponent: undefined, + fixProposals: undefined, gitTags: [ { gitTag: '0.1.0', previousTag: null }, { gitTag: 'v0.2.0', previousTag: '0.1.0' }, @@ -231,6 +344,7 @@ describe('renderReport', () => { released: false, }, ], + outsideReleaseIssuesByKey: new Map(), releaseNotes: { issueKeys: ['CCM-100'], source: 'mixed', @@ -242,23 +356,26 @@ describe('renderReport', () => { totalJiraIssues: 20, }); - expect(report).toContain('Git tags selected (2): 0.1.0, v0.2.0'); + expect(report).toContain('- **Git tags selected (2):** 0.1.0, v0.2.0'); expect(report).toContain( - 'Comparison bases: 0.1.0 <- repository start; v0.2.0 <- 0.1.0', + '- **Comparison bases:** 0.1.0 <- repository start; v0.2.0 <- 0.1.0', ); expect(report).toContain( - 'Jira versions selected (2): client-config-0.1.0 (71260), client-config-0.2.0 (71261)', + '- **Jira versions selected (2):** client-config-0.1.0 (71260), client-config-0.2.0 (71261)', ); expect(report).toContain( - 'Jira release dates: client-config-0.1.0: 2026-07-08; client-config-0.2.0: unknown', + '- **Jira release dates:** client-config-0.1.0: 2026-07-08; client-config-0.2.0: unknown', ); - expect(report).toContain('Jira versions released: 1/2'); - expect(report).toContain('Release notes source: mixed'); + expect(report).toContain('- **Jira versions released:** 1/2'); + expect(report).toContain('- **Release notes source:** mixed'); }); it('renders unknown release metadata when Jira has not set it', () => { const report = renderReport({ comparison, + fixAction: undefined, + fixComponent: undefined, + fixProposals: undefined, gitTags: [{ gitTag: '0.1.0', previousTag: null }], jiraProject: 'CCM', jiraVersions: [ @@ -269,6 +386,7 @@ describe('renderReport', () => { released: false, }, ], + outsideReleaseIssuesByKey: new Map(), releaseNotes: { issueKeys: [], source: 'none', @@ -280,7 +398,96 @@ describe('renderReport', () => { totalJiraIssues: 0, }); - expect(report).toContain('Jira release date: unknown'); - expect(report).toContain('Jira version released: no'); + expect(report).toContain('- **Jira release date:** unknown'); + expect(report).toContain('- **Jira version released:** no'); + }); + + it('labels missing Jira issues as not found while retaining commits', () => { + const report = renderReport({ + comparison: { + ...comparison, + commitsByIssueKey: new Map([ + [ + 'CCM-404', + [ + { + hash: 'e'.repeat(40), + shortHash: 'eeeeeeee', + subject: 'CCM-404: missing issue', + body: '', + explicitIssueKeys: ['CCM-404'], + matchedIssueKeys: ['CCM-404'], + }, + ], + ], + ]), + commitsWithIssueKeysOutsideRelease: [ + { + commit: { + hash: 'e'.repeat(40), + shortHash: 'eeeeeeee', + subject: 'CCM-404: missing issue', + body: '', + explicitIssueKeys: ['CCM-404'], + matchedIssueKeys: ['CCM-404'], + }, + missingKeys: ['CCM-404'], + }, + ], + }, + fixAction: undefined, + fixComponent: undefined, + fixProposals: undefined, + gitTags: [{ gitTag: '0.1.0', previousTag: null }], + jiraProject: 'CCM', + jiraVersions: [jiraVersion], + outsideReleaseIssuesByKey: new Map(), + releaseNotes: { + issueKeys: [], + source: 'none', + text: null, + warnings: [], + }, + repoName: 'nhs-notify-client-config', + repoRoot: '/repos/nhs-notify-client-config', + totalJiraIssues: 0, + }); + + expect(report).toContain( + '| CCM-404: not found in Jira | `eeeeeeee CCM-404: missing issue` _(1 commit total)_ |', + ); + }); +}); + +describe('renderFixProposalSection', () => { + it('renders markdown table rows for proposed fixes', () => { + const section = renderFixProposalSection( + 'fixVersion', + 'Platform', + fixProposals, + new Map([ + [ + 'CCM-555', + [ + { + hash: 'd'.repeat(40), + shortHash: 'dddddddd', + subject: 'CCM-555: proposed fix', + body: '', + explicitIssueKeys: ['CCM-555'], + matchedIssueKeys: ['CCM-555'], + }, + ], + ], + ]), + ); + + expect(section).toContain( + '## Proposed fixVersion updates for component Platform', + ); + expect(section).toContain('| Issue | Commit | Proposed update |'); + expect(section).toContain( + '| CCM-555: [Platform] Needs fix version (Done) | `dddddddd CCM-555: proposed fix` _(1 commit total)_ | none -> client-config-0.1.0 |', + ); }); }); diff --git a/tools/release-check/src/args.ts b/tools/release-check/src/args.ts index d924ae94..a1411059 100644 --- a/tools/release-check/src/args.ts +++ b/tools/release-check/src/args.ts @@ -1,7 +1,7 @@ import { parseArgs } from 'node:util'; import { parseSelectorList } from './selectors'; -import type { CliOptions, ReleaseNotesSource } from './types'; +import type { CliOptions, FixAction, ReleaseNotesSource } from './types'; const DEFAULT_JIRA_BASE_URL = 'https://nhsd-jira.digital.nhs.uk'; const DEFAULT_JIRA_PROJECT = 'CCM'; @@ -19,6 +19,9 @@ const isReleaseNotesSource = ( ): value is ReleaseNotesSource => value === 'auto' || value === 'github' || value === 'tag' || value === 'none'; +const isFixAction = (value: string | undefined): value is FixAction => + value === 'fix-version' || value === 'clinical-review-not-needed'; + const parseSelectors = ( single: string | undefined, multiple: string | undefined, @@ -48,6 +51,8 @@ export const parseCliArgs = (argv: string[]): CliOptions => { const { values } = parseArgs({ args: argv, options: { + fix: { type: 'string' }, + 'fix-component': { type: 'string' }, repo: { type: 'string' }, 'git-tag': { type: 'string' }, 'git-tags': { type: 'string' }, @@ -58,10 +63,13 @@ export const parseCliArgs = (argv: string[]): CliOptions => { 'previous-tag': { type: 'string' }, output: { type: 'string' }, 'release-notes-source': { type: 'string', default: 'auto' }, + yes: { type: 'boolean', default: false }, }, allowPositionals: false, }); + const fixAction = values.fix; + if (!values.repo) { throw new Error('Missing required option --repo'); } @@ -70,8 +78,21 @@ export const parseCliArgs = (argv: string[]): CliOptions => { 'Invalid --release-notes-source. Expected one of: auto, github, tag, none', ); } + if (fixAction && !isFixAction(fixAction)) { + throw new Error( + 'Invalid --fix. Expected one of: fix-version, clinical-review-not-needed', + ); + } + if (fixAction && !values['fix-component']) { + throw new Error('Option --fix requires --fix-component'); + } + if (!fixAction && values['fix-component']) { + throw new Error('Option --fix-component requires --fix'); + } return { + fixAction: isFixAction(fixAction) ? fixAction : undefined, + fixComponent: values['fix-component'], repo: values.repo, gitTagSelectors: parseSelectors( values['git-tag'], @@ -92,5 +113,6 @@ export const parseCliArgs = (argv: string[]): CliOptions => { previousTag: values['previous-tag'], output: values.output, releaseNotesSource: values['release-notes-source'], + yes: values.yes ?? false, }; }; diff --git a/tools/release-check/src/index.ts b/tools/release-check/src/index.ts index 184e6255..18c1042c 100644 --- a/tools/release-check/src/index.ts +++ b/tools/release-check/src/index.ts @@ -1,5 +1,6 @@ import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { createInterface } from 'node:readline/promises'; import { parseCliArgs } from './args'; import { compareRelease } from './compare'; @@ -12,9 +13,25 @@ import { resolveRepoPath, } from './git'; import { readReleaseNotesForTags } from './github-release'; -import { fetchJiraIssues, resolveJiraVersions } from './jira'; -import { defaultReportPath, renderReport } from './report'; -import type { JiraIssue, JiraVersion, SelectedGitTag } from './types'; +import { + fetchJiraIssues, + fetchJiraIssuesByKeys, + resolveJiraVersions, + updateJiraIssueClinicalReviewStatus, + updateJiraIssueFixVersions, +} from './jira'; +import { + defaultReportPath, + renderFixProposalSection, + renderReport, +} from './report'; +import type { + FixProposal, + JiraIssue, + JiraIssueFixDetails, + JiraVersion, + SelectedGitTag, +} from './types'; const writeReport = async ( outputPath: string, @@ -53,20 +70,172 @@ const formatComparisonBaseSummary = (gitTags: SelectedGitTag[]): string => const formatJiraVersionSummary = (jiraVersions: JiraVersion[]): string => jiraVersions.map((version) => `${version.name} (${version.id})`).join(', '); -export const run = async (argv: string[]): Promise => { - const options = parseCliArgs(argv); - const repoPath = resolveRepoPath(options.repo); - const repoRoot = getRepoRoot(repoPath); - const repoName = getRepoName(repoRoot); - const selectedGitTagNames = resolveGitTags(repoRoot, options.gitTagSelectors); - const selectedGitTags = selectedGitTagNames.map((gitTag, index) => ({ +const getFixActionLabel = (fixAction: string): string => + fixAction === 'clinical-review-not-needed' + ? 'clinical review status' + : 'fixVersion'; + +const formatFixVersions = ( + fixVersions: JiraIssueFixDetails['fixVersions'], +): string => + fixVersions.length > 0 + ? fixVersions.map(({ name }) => name).join(', ') + : 'none'; + +const dedupeIssuesByKey = (issues: T[]): T[] => + dedupeBy(issues, (issue) => issue.key); + +const resolveSelectedGitTags = ( + repoRoot: string, + gitTagSelectors: string[], + previousTag?: string, +): SelectedGitTag[] => + resolveGitTags(repoRoot, gitTagSelectors).map((gitTag, index) => ({ gitTag, previousTag: getPreviousTag( repoRoot, gitTag, - index === 0 ? options.previousTag : undefined, + index === 0 ? previousTag : undefined, ), })); + +const getOutsideReleaseIssueKeys = ( + comparison: ReturnType, +): string[] => + dedupeBy( + [ + ...comparison.commitsWithIssueKeysOutsideRelease.flatMap( + ({ missingKeys }) => missingKeys, + ), + ...comparison.releaseNotesIssueKeysOutsideRelease, + ], + (issueKey) => issueKey, + ); + +const buildFixProposals = ( + fixAction: NonNullable['fixAction']>, + issues: JiraIssueFixDetails[], + component: string, + targetVersion: JiraVersion, +): FixProposal[] => { + const componentIssues = issues + .filter((issue) => issue.components.includes(component)) + .toSorted((left, right) => left.key.localeCompare(right.key)); + + if (fixAction === 'clinical-review-not-needed') { + return componentIssues + .filter((issue) => issue.clinicalReviewStatus !== 'Review not needed') + .map((issue) => ({ + currentValueSummary: issue.clinicalReviewStatus || 'empty', + issue, + targetValueSummary: 'Review not needed', + })); + } + + return componentIssues + .filter( + (issue) => + !issue.fixVersions.some( + (fixVersion) => fixVersion.id === targetVersion.id, + ), + ) + .map((issue) => ({ + currentValueSummary: formatFixVersions(issue.fixVersions), + issue, + targetValueSummary: targetVersion.name, + })); +}; + +const confirmFixApplication = async ( + section: string, + fixActionLabel: string, + autoConfirm: boolean, +): Promise => { + process.stdout.write(`${section}\n`); + + if (autoConfirm) { + return true; + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error( + `Applying ${fixActionLabel} updates requires an interactive terminal unless --yes is provided.`, + ); + } + + const readline = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const answer = await readline.question( + `Apply ${fixActionLabel} updates? [y/N] `, + ); + return /^(y|yes)$/i.test(answer.trim()); + } finally { + readline.close(); + } +}; + +const buildFixCandidateIssues = async ( + fixAction: NonNullable['fixAction']>, + comparison: ReturnType, + jiraBaseUrl: string, + jiraProject: string, +): Promise => { + if (fixAction === 'clinical-review-not-needed') { + return dedupeIssuesByKey([ + ...comparison.jiraIssuesMissingClinicalSafetyCategory, + ...comparison.jiraIssuesMissingClinicalLead, + ]).map((issue) => ({ + ...issue, + fixVersions: [], + })); + } + + return fetchJiraIssuesByKeys( + jiraBaseUrl, + jiraProject, + dedupeBy( + comparison.commitsWithIssueKeysOutsideRelease.flatMap( + ({ missingKeys }) => missingKeys, + ), + (issueKey) => issueKey, + ), + ); +}; + +const applyFixProposal = async ( + jiraBaseUrl: string, + fixAction: NonNullable['fixAction']>, + proposal: FixProposal, + jiraVersion: JiraVersion, +): Promise => { + if (fixAction === 'clinical-review-not-needed') { + await updateJiraIssueClinicalReviewStatus(jiraBaseUrl, proposal.issue.key); + return; + } + + await updateJiraIssueFixVersions(jiraBaseUrl, proposal.issue.key, [ + ...proposal.issue.fixVersions, + { + id: jiraVersion.id, + name: jiraVersion.name, + }, + ]); +}; + +export const run = async (argv: string[]): Promise => { + const options = parseCliArgs(argv); + const repoPath = resolveRepoPath(options.repo); + const repoRoot = getRepoRoot(repoPath); + const repoName = getRepoName(repoRoot); + const selectedGitTags = resolveSelectedGitTags( + repoRoot, + options.gitTagSelectors, + options.previousTag, + ); const commits = collectCommitsForTags(repoRoot, selectedGitTags); const jiraVersions = await resolveJiraVersions( @@ -93,6 +262,38 @@ export const run = async (argv: string[]): Promise => { jiraIssues, releaseNotes.issueKeys, ); + const outsideReleaseIssueKeys = getOutsideReleaseIssueKeys(comparison); + const outsideReleaseIssues = await fetchJiraIssuesByKeys( + options.jiraBaseUrl, + options.jiraProject, + outsideReleaseIssueKeys, + ); + const outsideReleaseIssuesByKey = new Map( + outsideReleaseIssues.map((issue) => [issue.key, issue]), + ); + let fixProposals: FixProposal[] | undefined; + + if (options.fixAction) { + if (selectedGitTags.length !== 1 || jiraVersions.length !== 1) { + throw new Error( + 'Option --fix currently requires exactly one resolved git tag and one resolved Jira version.', + ); + } + + const fixCandidateIssues = await buildFixCandidateIssues( + options.fixAction, + comparison, + options.jiraBaseUrl, + options.jiraProject, + ); + + fixProposals = buildFixProposals( + options.fixAction, + fixCandidateIssues, + options.fixComponent!, + jiraVersions[0], + ); + } const outputPath = path.resolve( options.output ?? @@ -103,17 +304,55 @@ export const run = async (argv: string[]): Promise => { ); const report = renderReport({ comparison, + fixAction: options.fixAction, + fixComponent: options.fixComponent, + fixProposals, gitTags: selectedGitTags, jiraProject: options.jiraProject, jiraVersions, releaseNotes, repoName, repoRoot, + outsideReleaseIssuesByKey, totalJiraIssues: jiraIssues.length, }); await writeReport(outputPath, report); + if (options.fixAction && fixProposals) { + const fixActionLabel = getFixActionLabel(options.fixAction); + const proposalSection = renderFixProposalSection( + fixActionLabel, + options.fixComponent!, + fixProposals, + comparison.commitsByIssueKey, + ); + const confirmed = await confirmFixApplication( + proposalSection, + fixActionLabel, + options.yes, + ); + + if (confirmed) { + for (const proposal of fixProposals) { + await applyFixProposal( + options.jiraBaseUrl, + options.fixAction, + proposal, + jiraVersions[0], + ); + } + + process.stdout.write( + `Applied ${fixActionLabel} updates to ${fixProposals.length} issue(s).\n`, + ); + } else { + process.stdout.write( + `Aborted without applying ${fixActionLabel} updates.\n`, + ); + } + } + const summaryLines = [ `Repository: ${repoName}`, selectedGitTags.length === 1 diff --git a/tools/release-check/src/jira.ts b/tools/release-check/src/jira.ts index 6a60ec05..33529c80 100644 --- a/tools/release-check/src/jira.ts +++ b/tools/release-check/src/jira.ts @@ -1,5 +1,10 @@ import { hasGlobPattern, matchesGlobPattern } from './selectors'; -import type { JiraIssue, JiraVersion } from './types'; +import type { + JiraFixVersion, + JiraIssue, + JiraIssueFixDetails, + JiraVersion, +} from './types'; const CLINICAL_LEAD_FIELD_ID = 'customfield_10523'; const MEDICAL_CLINICAL_SAFETY_CATEGORY_FIELD_ID = 'customfield_15200'; @@ -50,6 +55,15 @@ const getJiraToken = (): string => { }; const VERSION_PATH_PATTERN = /\/versions\/(\d+)/; +const JIRA_SEARCH_FIELDS = [ + 'summary', + 'status', + 'issuetype', + 'components', + CLINICAL_LEAD_FIELD_ID, + MEDICAL_CLINICAL_SAFETY_CATEGORY_FIELD_ID, + CLINICAL_REVIEW_STATUS_FIELD_ID, +]; type JiraVersionResponse = { id: string | number; @@ -58,6 +72,20 @@ type JiraVersionResponse = { released?: boolean; }; +type JiraSearchIssueResponse = { + key: string; + fields: { + customfield_10523?: unknown; + customfield_15200?: unknown; + customfield_16657?: unknown; + components: { name: string }[]; + fixVersions?: { id: string | number; name: string }[]; + issuetype: { name: string }; + status: { name: string }; + summary: string; + }; +}; + const toJiraVersion = (version: JiraVersionResponse): JiraVersion => ({ id: String(version.id), name: version.name, @@ -83,6 +111,99 @@ const fetchJiraJson = async (url: string): Promise => { return response.json() as Promise; }; +const fetchJiraSearchPage = async ( + jiraBaseUrl: string, + jql: string, + startAt: number, + maxResults: number, +): Promise<{ issues: JiraSearchIssueResponse[]; total: number }> => { + const response = await fetch(`${jiraBaseUrl}/rest/api/2/search`, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${getJiraToken()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fields: JIRA_SEARCH_FIELDS, + jql, + maxResults, + startAt, + }), + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error( + `Jira request failed (${response.status} ${response.statusText}) for ${jiraBaseUrl}/rest/api/2/search: ${detail}`, + ); + } + + return response.json() as Promise<{ + issues: JiraSearchIssueResponse[]; + total: number; + }>; +}; + +const toJiraIssueFixDetails = ( + issue: JiraSearchIssueResponse, +): JiraIssueFixDetails => { + const { + components, + customfield_10523: clinicalLeadField, + customfield_15200: medicalClinicalSafetyCategoryField, + customfield_16657: clinicalReviewStatusField, + fixVersions, + issuetype, + status, + summary, + } = issue.fields; + + return { + clinicalLead: getJiraFieldString(clinicalLeadField), + clinicalReviewStatus: getJiraFieldString(clinicalReviewStatusField), + components: components.map((component) => component.name), + fixVersions: (fixVersions ?? []).map((fixVersion) => ({ + id: String(fixVersion.id), + name: fixVersion.name, + })), + issueType: issuetype.name, + key: issue.key, + medicalClinicalSafetyCategory: getJiraFieldString( + medicalClinicalSafetyCategoryField, + ), + status: status.name, + summary, + }; +}; + +const searchJiraIssues = async ( + jiraBaseUrl: string, + jql: string, +): Promise => { + const issues: JiraIssueFixDetails[] = []; + const maxResults = 100; + let startAt = 0; + + while (true) { + const search = await fetchJiraSearchPage( + jiraBaseUrl, + jql, + startAt, + maxResults, + ); + + issues.push(...search.issues.map((issue) => toJiraIssueFixDetails(issue))); + + startAt += search.issues.length; + if (startAt >= search.total) { + break; + } + } + + return issues; +}; + const parseVersionReference = ( reference: string, ): { type: 'id'; value: string } | { type: 'name'; value: string } => { @@ -199,60 +320,92 @@ export const fetchJiraIssues = async ( jiraProject: string, jiraVersion: JiraVersion, ): Promise => { - const issues: JiraIssue[] = []; - const maxResults = 100; - let startAt = 0; const jql = `project = ${jiraProject} AND fixVersion = ${jiraVersion.id} AND issuetype not in (Epic) AND status != "Not Required" ORDER BY key ASC`; + return searchJiraIssues(jiraBaseUrl, jql); +}; - while (true) { - const search = await fetchJiraJson<{ - issues: { - key: string; - fields: { - customfield_10523?: unknown; - customfield_15200?: unknown; - customfield_16657?: unknown; - components: { name: string }[]; - issuetype: { name: string }; - status: { name: string }; - summary: string; - }; - }[]; - total: number; - }>( - `${jiraBaseUrl}/rest/api/2/search?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}&fields=summary,status,issuetype,components,${CLINICAL_LEAD_FIELD_ID},${MEDICAL_CLINICAL_SAFETY_CATEGORY_FIELD_ID},${CLINICAL_REVIEW_STATUS_FIELD_ID}`, - ); +export const fetchJiraIssuesByKeys = async ( + jiraBaseUrl: string, + jiraProject: string, + issueKeys: string[], +): Promise => { + const uniqueIssueKeys = [...new Set(issueKeys)]; - for (const issue of search.issues) { - const { - components, - customfield_10523: clinicalLeadField, - customfield_15200: medicalClinicalSafetyCategoryField, - customfield_16657: clinicalReviewStatusField, - issuetype, - status, - summary, - } = issue.fields; - - issues.push({ - clinicalLead: getJiraFieldString(clinicalLeadField), - clinicalReviewStatus: getJiraFieldString(clinicalReviewStatusField), - components: components.map((component) => component.name), - issueType: issuetype.name, - key: issue.key, - medicalClinicalSafetyCategory: getJiraFieldString( - medicalClinicalSafetyCategoryField, - ), - status: status.name, - summary, - }); - } + if (uniqueIssueKeys.length === 0) { + return []; + } - startAt += search.issues.length; - if (startAt >= search.total) { - break; - } + const issues: JiraIssueFixDetails[] = []; + + for (let index = 0; index < uniqueIssueKeys.length; index += 100) { + const batch = uniqueIssueKeys + .slice(index, index + 100) + .map((issueKey) => `"${issueKey}"`) + .join(', '); + const jql = `project = ${jiraProject} AND key in (${batch}) ORDER BY key ASC`; + issues.push(...(await searchJiraIssues(jiraBaseUrl, jql))); } return issues; }; + +export const updateJiraIssueFixVersions = async ( + jiraBaseUrl: string, + issueKey: string, + fixVersions: JiraFixVersion[], +): Promise => { + const response = await fetch( + `${jiraBaseUrl}/rest/api/2/issue/${encodeURIComponent(issueKey)}`, + { + method: 'PUT', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${getJiraToken()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fields: { + fixVersions: fixVersions.map((fixVersion) => ({ + id: fixVersion.id, + })), + }, + }), + }, + ); + + if (!response.ok) { + const detail = await response.text(); + throw new Error( + `Jira request failed (${response.status} ${response.statusText}) for ${jiraBaseUrl}/rest/api/2/issue/${encodeURIComponent(issueKey)}: ${detail}`, + ); + } +}; + +export const updateJiraIssueClinicalReviewStatus = async ( + jiraBaseUrl: string, + issueKey: string, +): Promise => { + const response = await fetch( + `${jiraBaseUrl}/rest/api/2/issue/${encodeURIComponent(issueKey)}`, + { + method: 'PUT', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${getJiraToken()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fields: { + [CLINICAL_REVIEW_STATUS_FIELD_ID]: { value: 'Review not needed' }, + }, + }), + }, + ); + + if (!response.ok) { + const detail = await response.text(); + throw new Error( + `Jira request failed (${response.status} ${response.statusText}) for ${jiraBaseUrl}/rest/api/2/issue/${encodeURIComponent(issueKey)}: ${detail}`, + ); + } +}; diff --git a/tools/release-check/src/report.ts b/tools/release-check/src/report.ts index b129091e..e0e80e6a 100644 --- a/tools/release-check/src/report.ts +++ b/tools/release-check/src/report.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import type { ComparisonResult, + FixProposal, JiraIssue, JiraVersion, MatchedCommit, @@ -9,31 +10,109 @@ import type { SelectedGitTag, } from './types'; -const formatIssue = ( - issue: JiraIssue, - commitsByIssueKey: Map, -): string => { +const formatCommit = (commit: MatchedCommit): string => + `${commit.shortHash} ${commit.subject}`; + +const escapeMarkdownCell = (value: string): string => + value.replaceAll('|', String.raw`\|`).replaceAll('\n', '
'); + +const formatIssueHeading = (key: string, issue?: JiraIssue): string => { + if (!issue) { + return `${key}: not found in Jira`; + } + const components = issue.components.length > 0 ? `[${issue.components.join(', ')}] ` : ''; - const commits = commitsByIssueKey.get(issue.key) ?? []; - const commitSummary = commits - .map((commit) => `${commit.shortHash} ${commit.subject}`) - .join('; '); - const issueSummary = `${issue.key}: ${components}${issue.summary} (${issue.status})`; - return commitSummary - ? `${issueSummary} | commits: ${commitSummary}` - : issueSummary; + return `${key}: ${components}${issue.summary} (${issue.status})`; }; -const formatCommit = (commit: MatchedCommit): string => - `${commit.shortHash} ${commit.subject}`; +const formatRepresentativeCommit = ( + commits: MatchedCommit[], + emptyLabel = 'No matching commit', +): string => { + if (commits.length === 0) { + return emptyLabel; + } -const renderSection = (title: string, lines: string[]): string => { + const totalLabel = + commits.length === 1 ? '1 commit total' : `${commits.length} commits total`; + return `\`${formatCommit(commits[0])}\` _(${totalLabel})_`; +}; + +const groupOutsideReleaseReferences = ( + commitsWithIssueKeysOutsideRelease: ComparisonResult['commitsWithIssueKeysOutsideRelease'], +): string[] => + [ + ...new Set( + commitsWithIssueKeysOutsideRelease.flatMap( + ({ missingKeys }) => missingKeys, + ), + ), + ].toSorted((left, right) => left.localeCompare(right)); + +const renderSimpleSection = (title: string, lines: string[]): string => { if (lines.length === 0) { - return `${title}\n- none\n`; + return `## ${title}\n\n- none\n`; } + const renderedLines = lines.map((line) => `- ${line}`).join('\n'); - return `${title}\n${renderedLines}\n`; + return `## ${title}\n\n${renderedLines}\n`; +}; + +const renderTable = (headers: string[], rows: string[][]): string => { + const headerRow = `| ${headers.join(' | ')} |`; + const separatorRow = `| ${headers.map(() => '---').join(' | ')} |`; + const bodyRows = rows.map((row) => `| ${row.join(' | ')} |`).join('\n'); + return `${headerRow}\n${separatorRow}\n${bodyRows}`; +}; + +const renderIssueSection = ( + title: string, + issueKeys: string[], + issueByKey: Map, + commitsByIssueKey: Map, +): string => { + if (issueKeys.length === 0) { + return `## ${title}\n\n- none\n`; + } + + const rows = issueKeys.map((key) => [ + escapeMarkdownCell(formatIssueHeading(key, issueByKey.get(key))), + escapeMarkdownCell( + formatRepresentativeCommit(commitsByIssueKey.get(key) ?? []), + ), + ]); + + return `## ${title}\n\n${renderTable(['Issue', 'Commit'], rows)}\n`; +}; + +const renderFixProposalSection = ( + fixAction: string, + fixComponent: string, + proposals: FixProposal[], + commitsByIssueKey: Map, +): string => { + const title = `Proposed ${fixAction} updates for component ${fixComponent}`; + if (proposals.length === 0) { + return `## ${title}\n\n- none\n`; + } + + const rows = proposals.map((proposal) => [ + escapeMarkdownCell(formatIssueHeading(proposal.issue.key, proposal.issue)), + escapeMarkdownCell( + formatRepresentativeCommit( + commitsByIssueKey.get(proposal.issue.key) ?? [], + ), + ), + escapeMarkdownCell( + `${proposal.currentValueSummary} -> ${proposal.targetValueSummary}`, + ), + ]); + + return `## ${title}\n\n${renderTable( + ['Issue', 'Commit', 'Proposed update'], + rows, + )}\n`; }; const sanitizeFileSegment = (value: string): string => @@ -75,23 +154,31 @@ export const defaultReportPath = ( cwd, '.tmp', 'release-check', - `${sanitizeFileSegment(repoName)}-${summarizeGitTagsForPath(gitTags)}.txt`, + `${sanitizeFileSegment(repoName)}-${summarizeGitTagsForPath(gitTags)}.md`, ); export const renderReport = ({ comparison, + fixAction, + fixComponent, + fixProposals, gitTags, jiraProject, jiraVersions, + outsideReleaseIssuesByKey, releaseNotes, repoName, repoRoot, totalJiraIssues, }: { comparison: ComparisonResult; + fixAction?: string; + fixComponent?: string; + fixProposals?: FixProposal[]; gitTags: SelectedGitTag[]; jiraProject: string; jiraVersions: JiraVersion[]; + outsideReleaseIssuesByKey: Map; releaseNotes: ReleaseNotes; repoName: string; repoRoot: string; @@ -106,38 +193,43 @@ export const renderReport = ({ const jiraVersionScopeLabel = singleJiraVersion ? 'the Jira release' : 'the selected Jira versions'; + const outsideReleaseIssueKeys = groupOutsideReleaseReferences( + comparison.commitsWithIssueKeysOutsideRelease, + ); const sections = [ - 'Release check report', - `Repository: ${repoName}`, - `Repository root: ${repoRoot}`, + '# Release check report', + '', + `- **Repository:** ${repoName}`, + `- **Repository root:** ${repoRoot}`, singleGitTag - ? `Git tag: ${gitTags[0].gitTag}` - : `Git tags selected (${gitTags.length}): ${formatGitTagSummary(gitTags)}`, + ? `- **Git tag:** ${gitTags[0].gitTag}` + : `- **Git tags selected (${gitTags.length}):** ${formatGitTagSummary(gitTags)}`, singleGitTag - ? `Comparison base: ${gitTags[0].previousTag ?? 'repository start'}` - : `Comparison bases: ${formatComparisonBaseSummary(gitTags)}`, - `Jira project: ${jiraProject}`, + ? `- **Comparison base:** ${gitTags[0].previousTag ?? 'repository start'}` + : `- **Comparison bases:** ${formatComparisonBaseSummary(gitTags)}`, + `- **Jira project:** ${jiraProject}`, singleJiraVersion - ? `Jira version: ${formatJiraVersion(jiraVersions[0])}` - : `Jira versions selected (${jiraVersions.length}): ${jiraVersions.map((jiraVersion) => formatJiraVersion(jiraVersion)).join(', ')}`, + ? `- **Jira version:** ${formatJiraVersion(jiraVersions[0])}` + : `- **Jira versions selected (${jiraVersions.length}):** ${jiraVersions.map((jiraVersion) => formatJiraVersion(jiraVersion)).join(', ')}`, ...(singleJiraVersion ? [ - `Jira release date: ${jiraVersions[0].releaseDate ?? 'unknown'}`, - `Jira version released: ${jiraVersions[0].released ? 'yes' : 'no'}`, + `- **Jira release date:** ${jiraVersions[0].releaseDate ?? 'unknown'}`, + `- **Jira version released:** ${jiraVersions[0].released ? 'yes' : 'no'}`, ] : [ - `Jira release dates: ${formatJiraReleaseDates(jiraVersions)}`, - `Jira versions released: ${jiraVersions.filter((version) => version.released).length}/${jiraVersions.length}`, + `- **Jira release dates:** ${formatJiraReleaseDates(jiraVersions)}`, + `- **Jira versions released:** ${jiraVersions.filter((version) => version.released).length}/${jiraVersions.length}`, ]), - `Release notes source: ${releaseNotes.source}`, + `- **Release notes source:** ${releaseNotes.source}`, + '', + '## Summary', '', - 'Summary', `- Jira issues in ${singleJiraVersion ? 'release' : 'selected releases'}: ${totalJiraIssues}`, `- Jira issues referenced in git: ${comparison.gitReferencedIssueKeys.length}`, `- Jira issues referenced in release notes: ${comparison.notesReferencedIssueKeys.length}`, `- Jira issues missing from git: ${comparison.jiraIssuesMissingFromGit.length}`, `- Jira issues missing from release notes: ${comparison.jiraIssuesMissingFromReleaseNotes.length}`, - `- Git issue keys outside Jira release: ${comparison.commitsWithIssueKeysOutsideRelease.length}`, + `- Git-referenced Jira issues outside Jira release: ${outsideReleaseIssueKeys.length}`, `- Release-note issue keys outside Jira release: ${comparison.releaseNotesIssueKeysOutsideRelease.length}`, `- Referenced Jira issues not done: ${comparison.releaseReferencedIssuesNotDone.length}`, `- Jira issues missing clinical safety category: ${comparison.jiraIssuesMissingClinicalSafetyCategory.length}`, @@ -147,52 +239,75 @@ export const renderReport = ({ ]; if (warnings.length > 0) { - sections.push(renderSection('Warnings', warnings)); + sections.push(renderSimpleSection('Warnings', warnings)); } + const selectedIssuesByKey = new Map( + [ + ...comparison.jiraIssuesMissingFromGit, + ...comparison.jiraIssuesMissingFromReleaseNotes, + ...comparison.releaseReferencedIssuesNotDone, + ...comparison.jiraIssuesMissingClinicalSafetyCategory, + ...comparison.jiraIssuesMissingClinicalLead, + ].map((issue) => [issue.key, issue]), + ); + sections.push( - renderSection( + renderIssueSection( `Jira issues in ${jiraScopeLabel} with no matching git reference`, - comparison.jiraIssuesMissingFromGit.map((issue) => - formatIssue(issue, comparison.commitsByIssueKey), - ), + comparison.jiraIssuesMissingFromGit.map((issue) => issue.key), + selectedIssuesByKey, + comparison.commitsByIssueKey, ), - renderSection( + renderIssueSection( `Jira issues in ${jiraScopeLabel} with no matching release-note reference`, - comparison.jiraIssuesMissingFromReleaseNotes.map((issue) => - formatIssue(issue, comparison.commitsByIssueKey), - ), + comparison.jiraIssuesMissingFromReleaseNotes.map((issue) => issue.key), + selectedIssuesByKey, + comparison.commitsByIssueKey, ), - renderSection( + renderIssueSection( 'Jira issues referenced in git or release notes but not in a done status', - comparison.releaseReferencedIssuesNotDone.map((issue) => - formatIssue(issue, comparison.commitsByIssueKey), - ), + comparison.releaseReferencedIssuesNotDone.map((issue) => issue.key), + selectedIssuesByKey, + comparison.commitsByIssueKey, ), - renderSection( + renderIssueSection( 'Jira issues missing clinical safety category', - comparison.jiraIssuesMissingClinicalSafetyCategory.map((issue) => - formatIssue(issue, comparison.commitsByIssueKey), + comparison.jiraIssuesMissingClinicalSafetyCategory.map( + (issue) => issue.key, ), + selectedIssuesByKey, + comparison.commitsByIssueKey, ), - renderSection( + renderIssueSection( 'Jira issues missing clinical lead', - comparison.jiraIssuesMissingClinicalLead.map((issue) => - formatIssue(issue, comparison.commitsByIssueKey), - ), + comparison.jiraIssuesMissingClinicalLead.map((issue) => issue.key), + selectedIssuesByKey, + comparison.commitsByIssueKey, ), - renderSection( + renderIssueSection( `Git-referenced Jira issue keys missing from ${jiraVersionScopeLabel}`, - comparison.commitsWithIssueKeysOutsideRelease.map( - ({ commit, missingKeys }) => - `${formatCommit(commit)} | missing keys: ${missingKeys.join(', ')}`, - ), + outsideReleaseIssueKeys, + outsideReleaseIssuesByKey, + comparison.commitsByIssueKey, ), - renderSection( + renderIssueSection( `Release-note Jira issue keys missing from ${jiraVersionScopeLabel}`, comparison.releaseNotesIssueKeysOutsideRelease, + outsideReleaseIssuesByKey, + comparison.commitsByIssueKey, ), - renderSection( + ...(fixAction && fixComponent && fixProposals + ? [ + renderFixProposalSection( + fixAction, + fixComponent, + fixProposals, + comparison.commitsByIssueKey, + ), + ] + : []), + renderSimpleSection( 'Commits without a Jira key or exact Jira-summary match', comparison.commitsWithoutMatches.map((commit) => formatCommit(commit)), ), @@ -200,3 +315,5 @@ export const renderReport = ({ return sections.join('\n').replaceAll(/\n{3,}/g, '\n\n'); }; + +export { renderFixProposalSection }; diff --git a/tools/release-check/src/types.ts b/tools/release-check/src/types.ts index acf74882..d7565711 100644 --- a/tools/release-check/src/types.ts +++ b/tools/release-check/src/types.ts @@ -9,6 +9,15 @@ export type JiraIssue = { summary: string; }; +export type JiraFixVersion = { + id: string; + name: string; +}; + +export type JiraIssueFixDetails = JiraIssue & { + fixVersions: JiraFixVersion[]; +}; + export type GitCommit = { body: string; explicitIssueKeys: string[]; @@ -58,6 +67,8 @@ export type ComparisonResult = { }; export type CliOptions = { + fixAction?: FixAction; + fixComponent?: string; gitTagSelectors: string[]; jiraBaseUrl: string; jiraProject: string; @@ -66,6 +77,15 @@ export type CliOptions = { previousTag?: string; releaseNotesSource: ReleaseNotesSource; repo: string; + yes: boolean; +}; + +export type FixAction = 'fix-version' | 'clinical-review-not-needed'; + +export type FixProposal = { + currentValueSummary: string; + issue: JiraIssueFixDetails; + targetValueSummary: string; }; export type SelectedGitTag = { From 3679cde5321b94bc88bcedfe22887771f1ec2f17 Mon Sep 17 00:00:00 2001 From: Mike Houston Date: Thu, 24 Sep 2026 13:51:32 +0100 Subject: [PATCH 6/6] CCM-14750: Refine release-check reporting and fixes Extend release-check reporting so Jira and git comparisons are easier to act on. Add patch release rollups, Jira-aware command suggestions, and safer fix proposal output that preserves existing fix versions while keeping terminal previews readable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/release-check/src/__tests__/git.test.ts | 38 ++ .../release-check/src/__tests__/index.test.ts | 212 +++++- .../release-check/src/__tests__/jira.test.ts | 21 + .../src/__tests__/report.test.ts | 404 +++++++++++- tools/release-check/src/git.ts | 21 +- tools/release-check/src/index.ts | 438 +++++++++++-- tools/release-check/src/jira.ts | 6 + tools/release-check/src/report.ts | 601 +++++++++++++++++- tools/release-check/src/types.ts | 5 + 9 files changed, 1645 insertions(+), 101 deletions(-) diff --git a/tools/release-check/src/__tests__/git.test.ts b/tools/release-check/src/__tests__/git.test.ts index 6e1eaecb..2939885c 100644 --- a/tools/release-check/src/__tests__/git.test.ts +++ b/tools/release-check/src/__tests__/git.test.ts @@ -236,17 +236,21 @@ describe('collectCommits', () => { expect(collectCommits('/repos/client-config', '0.2.0', '0.1.0')).toEqual([ { hash: 'hash1', + releaseTag: '0.2.0', shortHash: 'short1', subject: 'CCM-100: Add feature', body: 'body CCM-101 details', explicitIssueKeys: ['CCM-100', 'CCM-101'], + releaseRange: '0.1.0..0.2.0', }, { hash: 'hash2', + releaseTag: '0.2.0', shortHash: 'short2', subject: 'No key commit', body: '', explicitIssueKeys: [], + releaseRange: '0.1.0..0.2.0', }, ]); }); @@ -276,24 +280,58 @@ describe('collectCommits', () => { ).toEqual([ { hash: 'hash1', + releaseTag: '0.2.0', shortHash: 'short1', subject: 'CCM-100: Add feature', body: '', explicitIssueKeys: ['CCM-100'], + releaseRange: '0.1.0..0.2.0', }, { hash: 'hash2', + releaseTag: '0.2.0', shortHash: 'short2', subject: 'CCM-101: Add feature', body: '', explicitIssueKeys: ['CCM-101'], + releaseRange: '0.1.0..0.2.0', }, { hash: 'hash3', + releaseTag: '0.3.0', shortHash: 'short3', subject: 'CCM-102: Add feature', body: '', explicitIssueKeys: ['CCM-102'], + releaseRange: '0.2.0..0.3.0', + }, + ]); + }); + + it('collects commits through the rolled-up patch end tag', () => { + mockedSpawnSync.mockReturnValue({ + status: 0, + stdout: `hash1\u001Fshort1\u001FCCM-100: Add feature\u001F\u001E`, + stderr: '', + } as never); + + expect( + collectCommitsForTags('/repos/client-config', [ + { + gitTag: '0.3.0', + previousTag: '0.2.0', + rangeEndTag: 'v0.3.1', + }, + ]), + ).toEqual([ + { + hash: 'hash1', + releaseTag: '0.3.0', + shortHash: 'short1', + subject: 'CCM-100: Add feature', + body: '', + explicitIssueKeys: ['CCM-100'], + releaseRange: '0.2.0..0.3.0 (+ patches through v0.3.1)', }, ]); }); diff --git a/tools/release-check/src/__tests__/index.test.ts b/tools/release-check/src/__tests__/index.test.ts index 348d00f6..dbbbc9e7 100644 --- a/tools/release-check/src/__tests__/index.test.ts +++ b/tools/release-check/src/__tests__/index.test.ts @@ -10,6 +10,7 @@ jest.mock('../git', () => ({ getPreviousTag: jest.fn(), getRepoName: jest.fn(), getRepoRoot: jest.fn(), + listTags: jest.fn(), resolveGitTags: jest.fn(), resolveRepoPath: jest.fn(), })); @@ -17,6 +18,7 @@ jest.mock('../git', () => ({ jest.mock('../jira', () => ({ fetchJiraIssues: jest.fn(), fetchJiraIssuesByKeys: jest.fn(), + listJiraVersions: jest.fn(), resolveJiraVersions: jest.fn(), updateJiraIssueClinicalReviewStatus: jest.fn(), updateJiraIssueFixVersions: jest.fn(), @@ -33,6 +35,7 @@ jest.mock('../compare', () => ({ jest.mock('../report', () => ({ defaultReportPath: jest.fn(), renderFixProposalSection: jest.fn(), + renderFixProposalTerminalSection: jest.fn(), renderReport: jest.fn(), })); @@ -57,6 +60,7 @@ const mockedGetRepoName = git.getRepoName as jest.MockedFunction< const mockedResolveGitTags = git.resolveGitTags as jest.MockedFunction< typeof git.resolveGitTags >; +const mockedListTags = git.listTags as jest.MockedFunction; const mockedGetPreviousTag = git.getPreviousTag as jest.MockedFunction< typeof git.getPreviousTag >; @@ -68,6 +72,9 @@ const mockedResolveJiraVersions = jira.resolveJiraVersions as jest.MockedFunction< typeof jira.resolveJiraVersions >; +const mockedListJiraVersions = jira.listJiraVersions as jest.MockedFunction< + typeof jira.listJiraVersions +>; const mockedFetchJiraIssues = jira.fetchJiraIssues as jest.MockedFunction< typeof jira.fetchJiraIssues >; @@ -97,6 +104,10 @@ const mockedRenderFixProposalSection = report.renderFixProposalSection as jest.MockedFunction< typeof report.renderFixProposalSection >; +const mockedRenderFixProposalTerminalSection = + report.renderFixProposalTerminalSection as jest.MockedFunction< + typeof report.renderFixProposalTerminalSection + >; const mockedRenderReport = report.renderReport as jest.MockedFunction< typeof report.renderReport >; @@ -112,9 +123,18 @@ describe('run', () => { mockedResolveRepoPath.mockReturnValue('/repo'); mockedGetRepoRoot.mockReturnValue('/repo'); mockedGetRepoName.mockReturnValue('repo'); + mockedListTags.mockReturnValue(['0.1.0']); mockedResolveGitTags.mockReturnValue(['0.1.0']); mockedGetPreviousTag.mockReturnValue('0.0.9'); mockedCollectCommitsForTags.mockReturnValue([]); + mockedListJiraVersions.mockResolvedValue([ + { + id: '71260', + name: 'release', + releaseDate: '2026-07-08', + released: true, + }, + ]); mockedResolveJiraVersions.mockResolvedValue([ { id: '71260', @@ -148,6 +168,9 @@ describe('run', () => { }); mockedDefaultReportPath.mockReturnValue('/workspace/report.txt'); mockedRenderFixProposalSection.mockReturnValue('fix proposal section'); + mockedRenderFixProposalTerminalSection.mockReturnValue( + 'fix proposal terminal section', + ); mockedRenderReport.mockReturnValue('report'); }); @@ -172,6 +195,10 @@ describe('run', () => { 'CCM', ['71260'], ); + expect(mockedListJiraVersions).toHaveBeenCalledWith( + 'https://nhsd-jira.digital.nhs.uk', + 'CCM', + ); expect(mockedReadReleaseNotesForTags).toHaveBeenCalledWith( '/repo', ['0.1.0'], @@ -195,6 +222,7 @@ describe('run', () => { fixAction: undefined, fixComponent: undefined, fixProposals: undefined, + jiraBaseUrl: 'https://nhsd-jira.digital.nhs.uk', }), ); expect(stdoutWrite).toHaveBeenCalledWith( @@ -203,8 +231,23 @@ describe('run', () => { }); it('aggregates multiple selected releases into one run', async () => { + mockedListTags.mockReturnValue(['0.1.0', 'v0.2.0']); mockedResolveGitTags.mockReturnValue(['0.1.0', 'v0.2.0']); mockedGetPreviousTag.mockReturnValueOnce(null).mockReturnValueOnce('0.1.0'); + mockedListJiraVersions.mockResolvedValue([ + { + id: '71260', + name: 'release-a', + releaseDate: '2026-07-08', + released: true, + }, + { + id: '71261', + name: 'release-b', + releaseDate: null, + released: false, + }, + ]); mockedCollectCommitsForTags.mockReturnValue([ { hash: 'a'.repeat(40), @@ -271,8 +314,12 @@ describe('run', () => { expect(mockedRenderReport).toHaveBeenCalledWith( expect.objectContaining({ gitTags: [ - { gitTag: '0.1.0', previousTag: null }, - { gitTag: 'v0.2.0', previousTag: '0.1.0' }, + { gitTag: '0.1.0', previousTag: null, rangeEndTag: '0.1.0' }, + { + gitTag: 'v0.2.0', + previousTag: '0.1.0', + rangeEndTag: 'v0.2.0', + }, ], jiraVersions: [ { @@ -293,6 +340,59 @@ describe('run', () => { ); }); + it('rolls patch tags into the base release when Jira has no patch version', async () => { + mockedListTags.mockReturnValue(['0.3.0', 'v0.3.0', 'v0.3.1']); + mockedResolveGitTags.mockReturnValue(['0.3.0', 'v0.3.0', 'v0.3.1']); + mockedListJiraVersions.mockResolvedValue([ + { + id: '73218', + name: 'client-config-0.3.0', + releaseDate: null, + released: true, + }, + ]); + mockedResolveJiraVersions.mockResolvedValue([ + { + id: '73218', + name: 'client-config-0.3.0', + releaseDate: null, + released: true, + }, + ]); + mockedGetPreviousTag.mockImplementation((_, gitTag) => { + if (gitTag === '0.3.0') { + return 'v0.2.0'; + } + + if (gitTag === 'v0.3.0') { + return '0.3.0'; + } + + if (gitTag === 'v0.3.1') { + return 'v0.3.0'; + } + + return 'v0.2.0'; + }); + + await run([ + '--repo', + '../repo', + '--git-tags', + '0.3.0,v0.3.0,v0.3.1', + '--jira-version', + 'client-config-0.3.0', + ]); + + expect(mockedCollectCommitsForTags).toHaveBeenCalledWith('/repo', [ + { + gitTag: '0.3.0', + previousTag: 'v0.2.0', + rangeEndTag: 'v0.3.1', + }, + ]); + }); + it('proposes and applies component-filtered fix versions in fix mode', async () => { mockedCollectCommitsForTags.mockReturnValue([ { @@ -370,7 +470,21 @@ describe('run', () => { '--yes', ]); - expect(mockedRenderFixProposalSection).toHaveBeenCalled(); + expect(mockedRenderFixProposalTerminalSection).toHaveBeenCalled(); + expect(mockedRenderFixProposalTerminalSection).toHaveBeenCalledWith( + 'fixVersion', + 'Platform', + [ + expect.objectContaining({ + currentValueSummary: 'none', + proposedUpdateSummary: 'release', + targetValueSummary: 'release', + }), + ], + expect.any(Map), + ); + expect(mockedRenderReport).not.toHaveBeenCalled(); + expect(fsPromises.writeFile).not.toHaveBeenCalled(); expect(mockedUpdateJiraIssueFixVersions).toHaveBeenCalledWith( 'https://nhsd-jira.digital.nhs.uk', 'CCM-100', @@ -381,6 +495,96 @@ describe('run', () => { ); }); + it('shows additive fix-version proposals when issues already have other fix versions', async () => { + mockedCompareRelease.mockReturnValue({ + commitsByIssueKey: new Map([ + [ + 'CCM-100', + [ + { + hash: 'a'.repeat(40), + shortHash: 'aaaaaaaa', + subject: 'CCM-100: ship it', + body: '', + explicitIssueKeys: ['CCM-100'], + matchedIssueKeys: ['CCM-100'], + }, + ], + ], + ]), + commitsWithIssueKeysOutsideRelease: [ + { + commit: { + hash: 'a'.repeat(40), + shortHash: 'aaaaaaaa', + subject: 'CCM-100: ship it', + body: '', + explicitIssueKeys: ['CCM-100'], + matchedIssueKeys: ['CCM-100'], + }, + missingKeys: ['CCM-100'], + }, + ], + commitsWithoutMatches: [], + gitReferencedIssueKeys: ['CCM-100'], + jiraIssuesMissingClinicalLead: [], + jiraIssuesMissingClinicalSafetyCategory: [], + jiraIssuesMissingFromGit: [], + jiraIssuesMissingFromReleaseNotes: [], + notesReferencedIssueKeys: [], + releaseReferencedIssuesNotDone: [], + releaseNotesIssueKeysOutsideRelease: [], + }); + mockedFetchJiraIssuesByKeys.mockResolvedValue([ + { + key: 'CCM-100', + clinicalLead: '', + clinicalReviewStatus: '', + components: ['Platform'], + fixVersions: [{ id: '70000', name: 'other-release' }], + issueType: 'Story', + medicalClinicalSafetyCategory: '', + status: 'Done', + summary: 'outside', + }, + ]); + + await run([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + '--fix', + 'fix-version', + '--fix-component', + 'Platform', + '--yes', + ]); + + expect(mockedRenderFixProposalTerminalSection).toHaveBeenCalledWith( + 'fixVersion', + 'Platform', + [ + expect.objectContaining({ + currentValueSummary: 'other-release', + proposedUpdateSummary: 'release + 1 (other-release)', + targetValueSummary: 'other-release, release', + }), + ], + expect.any(Map), + ); + expect(mockedUpdateJiraIssueFixVersions).toHaveBeenCalledWith( + 'https://nhsd-jira.digital.nhs.uk', + 'CCM-100', + [ + { id: '70000', name: 'other-release' }, + { id: '71260', name: 'release' }, + ], + ); + }); + it('applies component-filtered clinical review status updates', async () => { mockedCompareRelease.mockReturnValue({ commitsByIssueKey: new Map([ @@ -439,6 +643,8 @@ describe('run', () => { 'https://nhsd-jira.digital.nhs.uk', 'CCM-100', ); + expect(mockedRenderReport).not.toHaveBeenCalled(); + expect(fsPromises.writeFile).not.toHaveBeenCalled(); expect(stdoutWrite).toHaveBeenCalledWith( expect.stringContaining( 'Applied clinical review status updates to 1 issue(s).\n', diff --git a/tools/release-check/src/__tests__/jira.test.ts b/tools/release-check/src/__tests__/jira.test.ts index 0ce884dc..75fd9927 100644 --- a/tools/release-check/src/__tests__/jira.test.ts +++ b/tools/release-check/src/__tests__/jira.test.ts @@ -186,6 +186,27 @@ describe('jira issue operations', () => { components: [], }, ]); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/search', + expect.objectContaining({ + body: JSON.stringify({ + fields: [ + 'summary', + 'status', + 'issuetype', + 'components', + 'fixVersions', + 'customfield_10523', + 'customfield_15200', + 'customfield_16657', + ], + jql: 'project = CCM AND fixVersion = 71260 AND issuetype not in (Epic) AND status != "Not Required" ORDER BY key ASC', + maxResults: 100, + startAt: 0, + }), + }), + ); }); it('fetches issues by key', async () => { diff --git a/tools/release-check/src/__tests__/report.test.ts b/tools/release-check/src/__tests__/report.test.ts index dee53556..7d23706d 100644 --- a/tools/release-check/src/__tests__/report.test.ts +++ b/tools/release-check/src/__tests__/report.test.ts @@ -1,6 +1,7 @@ import { defaultReportPath, renderFixProposalSection, + renderFixProposalTerminalSection, renderReport, } from '../report'; @@ -53,6 +54,7 @@ const fixProposals: FixProposal[] = [ medicalClinicalSafetyCategory: '', fixVersions: [], }, + proposedUpdateSummary: 'client-config-0.1.0', targetValueSummary: 'client-config-0.1.0', }, ]; @@ -256,7 +258,9 @@ describe('renderReport', () => { fixAction: 'fixVersion', fixComponent: 'Platform', fixProposals, - gitTags: [{ gitTag: '0.1.0', previousTag: '0.0.9' }], + gitTags: [ + { gitTag: '0.1.0', previousTag: '0.0.9', rangeEndTag: '0.1.0' }, + ], jiraProject: 'CCM', jiraVersions: [jiraVersion], outsideReleaseIssuesByKey: new Map([ @@ -303,20 +307,26 @@ describe('renderReport', () => { ); expect(populatedReport).toContain('| Issue | Commit |'); expect(populatedReport).toContain( - '| CCM-101: [Platform] Missing from git (Done) | No matching commit |', + '| [CCM-101](https://nhsd-jira.digital.nhs.uk/browse/CCM-101): [Platform] Missing from git (Done) | No matching commit |', ); expect(populatedReport).toContain( - '| CCM-100: [Platform] Referenced and not done (In Progress) | `aaaaaaaa CCM-100: ship it` _(1 commit total)_ |', + '| [CCM-100](https://nhsd-jira.digital.nhs.uk/browse/CCM-100): [Platform] Referenced and not done (In Progress) | `aaaaaaaa CCM-100: ship it` _(1 commit total)_ |', ); expect(populatedReport).toContain( - '| CCM-999: [Platform] Outside selected versions (Done) | `bbbbbbbb CCM-999: outside` _(2 commits total)_ |', + '| [CCM-999](https://nhsd-jira.digital.nhs.uk/browse/CCM-999): [Platform] Outside selected versions (Done) | `bbbbbbbb CCM-999: outside` _(2 commits total)_ |', ); expect(populatedReport).toContain( '## Proposed fixVersion updates for component Platform', ); expect(populatedReport).toContain('| Issue | Commit | Proposed update |'); expect(populatedReport).toContain( - '| CCM-555: [Platform] Needs fix version (Done) | `dddddddd CCM-555: proposed fix` _(1 commit total)_ | none -> client-config-0.1.0 |', + '| [CCM-555](https://nhsd-jira.digital.nhs.uk/browse/CCM-555): [Platform] Needs fix version (Done) | `dddddddd CCM-555: proposed fix` _(1 commit total)_ | client-config-0.1.0 |', + ); + expect(populatedReport).toContain( + '- Git commit ranges mapped to Jira versions:', + ); + expect(populatedReport).toContain( + ' - `0.0.9..0.1.0` -> client-config-0.1.0', ); expect(populatedReport).toContain( '## Commits without a Jira key or exact Jira-summary match', @@ -331,8 +341,8 @@ describe('renderReport', () => { fixComponent: undefined, fixProposals: undefined, gitTags: [ - { gitTag: '0.1.0', previousTag: null }, - { gitTag: 'v0.2.0', previousTag: '0.1.0' }, + { gitTag: '0.1.0', previousTag: null, rangeEndTag: '0.1.0' }, + { gitTag: 'v0.2.0', previousTag: '0.1.0', rangeEndTag: 'v0.2.0' }, ], jiraProject: 'CCM', jiraVersions: [ @@ -368,6 +378,143 @@ describe('renderReport', () => { ); expect(report).toContain('- **Jira versions released:** 1/2'); expect(report).toContain('- **Release notes source:** mixed'); + expect(report).toContain( + ' - `repository start..0.1.0` -> client-config-0.1.0', + ); + expect(report).toContain(' - `0.1.0..v0.2.0` -> client-config-0.2.0'); + }); + + it('adds separate release range and fix version columns to multi-release reports', () => { + const report = renderReport({ + comparison: { + ...comparison, + jiraIssuesMissingClinicalSafetyCategory: [ + { + issueType: 'Story', + key: 'CCM-998', + summary: 'Missing clinical safety category', + status: 'Done', + components: ['Platform'], + clinicalLead: 'Dr Test', + clinicalReviewStatus: 'Review required', + medicalClinicalSafetyCategory: '', + fixVersions: [{ id: '71261', name: 'client-config-0.2.0' }], + }, + ], + commitsByIssueKey: new Map([ + [ + 'CCM-999', + [ + { + hash: 'b'.repeat(40), + shortHash: 'bbbbbbbb', + subject: 'CCM-999: outside', + body: '', + explicitIssueKeys: ['CCM-999'], + matchedIssueKeys: ['CCM-999'], + releaseRange: '0.1.0..v0.2.0', + releaseTag: 'v0.2.0', + }, + { + hash: 'c'.repeat(40), + shortHash: 'cccccccc', + subject: 'CCM-999: outside follow-up', + body: '', + explicitIssueKeys: ['CCM-999'], + matchedIssueKeys: ['CCM-999'], + releaseRange: 'v0.2.0..v0.3.0', + releaseTag: 'v0.3.0', + }, + ], + ], + ]), + commitsWithIssueKeysOutsideRelease: [ + { + commit: { + hash: 'b'.repeat(40), + shortHash: 'bbbbbbbb', + subject: 'CCM-999: outside', + body: '', + explicitIssueKeys: ['CCM-999'], + matchedIssueKeys: ['CCM-999'], + releaseRange: '0.1.0..v0.2.0', + releaseTag: 'v0.2.0', + }, + missingKeys: ['CCM-999'], + }, + ], + }, + fixAction: undefined, + fixComponent: undefined, + fixProposals: undefined, + gitTags: [ + { gitTag: 'v0.2.0', previousTag: '0.1.0', rangeEndTag: 'v0.2.0' }, + { gitTag: 'v0.3.0', previousTag: 'v0.2.0', rangeEndTag: 'v0.3.0' }, + ], + jiraProject: 'CCM', + jiraVersions: [ + { + id: '71261', + name: 'client-config-0.2.0', + releaseDate: null, + released: false, + }, + { + id: '71262', + name: 'client-config-0.3.0', + releaseDate: null, + released: false, + }, + ], + outsideReleaseIssuesByKey: new Map([ + [ + 'CCM-999', + { + issueType: 'Story', + key: 'CCM-999', + summary: 'Outside selected versions', + status: 'Done', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + fixVersions: [ + { id: '71260', name: 'client-config-0.1.0' }, + { id: '80000', name: 'another-release' }, + ], + }, + ], + ]), + releaseNotes: { + issueKeys: [], + source: 'none', + text: null, + warnings: [], + }, + repoName: 'nhs-notify-client-config', + repoRoot: '/repos/nhs-notify-client-config', + totalJiraIssues: 0, + }); + + expect(report).toContain( + '| Issue | Commit | Release range | Fix versions |', + ); + expect(report).toContain( + '| [CCM-999](https://nhsd-jira.digital.nhs.uk/browse/CCM-999): [Platform] Outside selected versions (Done) | `bbbbbbbb CCM-999: outside` _(2 commits total)_ | 0.1.0..v0.2.0; v0.2.0..v0.3.0 | client-config-0.1.0, another-release |', + ); + expect(report).toContain('### Example fix-version commands by component'); + expect(report).toContain( + "npm run check -- --repo '/repos/nhs-notify-client-config' --git-tag 'v0.2.0' --jira-version 'client-config-0.2.0' --fix fix-version --fix-component 'Platform'", + ); + expect(report).toContain( + "npm run check -- --repo '/repos/nhs-notify-client-config' --git-tag 'v0.3.0' --jira-version 'client-config-0.3.0' --fix fix-version --fix-component 'Platform'", + ); + expect(report).toContain( + '### Example clinical-review-not-needed commands by component', + ); + expect(report).toContain( + "npm run check -- --repo '/repos/nhs-notify-client-config' --git-tag 'v0.2.0' --jira-version 'client-config-0.2.0' --fix clinical-review-not-needed --fix-component 'Platform'", + ); }); it('renders unknown release metadata when Jira has not set it', () => { @@ -402,6 +549,163 @@ describe('renderReport', () => { expect(report).toContain('- **Jira version released:** no'); }); + it('scopes clinical review example commands to the issue fix versions', () => { + const report = renderReport({ + comparison: { + ...comparison, + commitsByIssueKey: new Map([ + [ + 'CCM-12081', + [ + { + hash: 'a'.repeat(40), + shortHash: 'aaaaaaaa', + subject: 'CCM-12081: first release work', + body: '', + explicitIssueKeys: ['CCM-12081'], + matchedIssueKeys: ['CCM-12081'], + releaseRange: 'repository start..0.1.0', + releaseTag: '0.1.0', + }, + ], + ], + [ + 'CCM-22822', + [ + { + hash: 'b'.repeat(40), + shortHash: 'bbbbbbbb', + subject: 'CCM-22822: third release work', + body: '', + explicitIssueKeys: ['CCM-22822'], + matchedIssueKeys: ['CCM-22822'], + releaseRange: 'v0.2.0..v0.3.1', + releaseTag: '0.3.0', + }, + ], + ], + ]), + jiraIssuesMissingClinicalSafetyCategory: [ + { + issueType: 'Story', + key: 'CCM-12081', + summary: 'First release issue', + status: 'Done', + components: ['Onboarding-Improvements'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + fixVersions: [{ id: '71260', name: 'client-config-0.1.0' }], + }, + { + issueType: 'Story', + key: 'CCM-22822', + summary: 'Third release issue', + status: 'Done', + components: ['onboarding-journey-improvements'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + fixVersions: [{ id: '71262', name: 'client-config-0.3.0' }], + }, + ], + }, + fixAction: undefined, + fixComponent: undefined, + fixProposals: undefined, + gitTags: [ + { gitTag: '0.1.0', previousTag: null, rangeEndTag: '0.1.0' }, + { gitTag: '0.3.0', previousTag: 'v0.2.0', rangeEndTag: 'v0.3.1' }, + ], + jiraProject: 'CCM', + jiraVersions: [ + { + id: '71260', + name: 'client-config-0.1.0', + releaseDate: '2026-07-08', + released: true, + }, + { + id: '71262', + name: 'client-config-0.3.0', + releaseDate: '2026-09-15', + released: true, + }, + ], + outsideReleaseIssuesByKey: new Map(), + releaseNotes: { + issueKeys: [], + source: 'none', + text: null, + warnings: [], + }, + repoName: 'nhs-notify-client-config', + repoRoot: '/repos/nhs-notify-client-config', + totalJiraIssues: 2, + }); + + expect(report).toContain( + "npm run check -- --repo '/repos/nhs-notify-client-config' --git-tag '0.1.0' --jira-version 'client-config-0.1.0' --fix clinical-review-not-needed --fix-component 'Onboarding-Improvements'", + ); + expect(report).toContain( + "npm run check -- --repo '/repos/nhs-notify-client-config' --git-tag '0.3.0' --jira-version 'client-config-0.3.0' --fix clinical-review-not-needed --fix-component 'onboarding-journey-improvements'", + ); + expect(report).not.toContain( + "npm run check -- --repo '/repos/nhs-notify-client-config' --git-tag '0.3.0' --jira-version 'client-config-0.3.0' --fix clinical-review-not-needed --fix-component 'Onboarding-Improvements'", + ); + }); + + it('does not invent clinical review commands for issues without a selected fix version', () => { + const report = renderReport({ + comparison: { + ...comparison, + jiraIssuesMissingClinicalSafetyCategory: [ + { + issueType: 'Story', + key: 'CCM-999', + summary: 'No selected fix version', + status: 'Done', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + fixVersions: [{ id: '80000', name: 'some-other-release' }], + }, + ], + }, + fixAction: undefined, + fixComponent: undefined, + fixProposals: undefined, + gitTags: [ + { gitTag: '0.3.0', previousTag: 'v0.2.0', rangeEndTag: 'v0.3.1' }, + ], + jiraProject: 'CCM', + jiraVersions: [ + { + id: '71262', + name: 'client-config-0.3.0', + releaseDate: '2026-09-15', + released: true, + }, + ], + outsideReleaseIssuesByKey: new Map(), + releaseNotes: { + issueKeys: [], + source: 'none', + text: null, + warnings: [], + }, + repoName: 'nhs-notify-client-config', + repoRoot: '/repos/nhs-notify-client-config', + totalJiraIssues: 1, + }); + + expect(report).not.toContain('--fix clinical-review-not-needed'); + expect(report).toContain( + '# No single-release clinical review command generated for CCM-999', + ); + }); + it('labels missing Jira issues as not found while retaining commits', () => { const report = renderReport({ comparison: { @@ -454,7 +758,7 @@ describe('renderReport', () => { }); expect(report).toContain( - '| CCM-404: not found in Jira | `eeeeeeee CCM-404: missing issue` _(1 commit total)_ |', + '| [CCM-404](https://nhsd-jira.digital.nhs.uk/browse/CCM-404): not found in Jira | `eeeeeeee CCM-404: missing issue` _(1 commit total)_ |', ); }); }); @@ -487,7 +791,89 @@ describe('renderFixProposalSection', () => { ); expect(section).toContain('| Issue | Commit | Proposed update |'); expect(section).toContain( - '| CCM-555: [Platform] Needs fix version (Done) | `dddddddd CCM-555: proposed fix` _(1 commit total)_ | none -> client-config-0.1.0 |', + '| [CCM-555](https://nhsd-jira.digital.nhs.uk/browse/CCM-555): [Platform] Needs fix version (Done) | `dddddddd CCM-555: proposed fix` _(1 commit total)_ | client-config-0.1.0 |', ); }); + + it('renders terminal-friendly proposed fixes without markdown formatting', () => { + const section = renderFixProposalTerminalSection( + 'fixVersion', + 'Platform', + fixProposals, + new Map([ + [ + 'CCM-555', + [ + { + hash: 'd'.repeat(40), + shortHash: 'dddddddd', + subject: 'CCM-555: proposed fix', + body: '', + explicitIssueKeys: ['CCM-555'], + matchedIssueKeys: ['CCM-555'], + }, + ], + ], + ]), + ); + + expect(section).toContain( + 'Proposed fixVersion updates for component Platform', + ); + expect(section).toContain('CCM-555: [Platform] Needs fix version (Done)'); + expect(section).toContain( + 'dddddddd CCM-555: proposed fix (1 commit total)', + ); + expect(section).not.toContain('## Proposed'); + expect(section).not.toContain('| Issue | Commit | Proposed update |'); + expect(section).not.toContain('[CCM-555]('); + }); + + it('truncates wide issue and commit cells in terminal output', () => { + const longSummary = `Needs fix version ${'summary '.repeat(20)}`.trim(); + const longCommit = `CCM-777: ${'proposed fix '.repeat(20)}`.trim(); + + const section = renderFixProposalTerminalSection( + 'fixVersion', + 'Platform', + [ + { + currentValueSummary: 'other-release', + issue: { + issueType: 'Story', + key: 'CCM-777', + summary: longSummary, + status: 'Done', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + fixVersions: [{ id: '70000', name: 'other-release' }], + }, + proposedUpdateSummary: 'client-config-0.1.0 + 1 (other-release)', + targetValueSummary: 'other-release, client-config-0.1.0', + }, + ], + new Map([ + [ + 'CCM-777', + [ + { + hash: 'd'.repeat(40), + shortHash: 'dddddddd', + subject: longCommit, + body: '', + explicitIssueKeys: ['CCM-777'], + matchedIssueKeys: ['CCM-777'], + }, + ], + ], + ]), + ); + + expect(section).toContain('...'); + expect(section).not.toContain(longSummary); + expect(section).not.toContain(`dddddddd ${longCommit} (1 commit total)`); + expect(section).toContain('client-config-0.1.0 + 1 (other-release)'); + }); }); diff --git a/tools/release-check/src/git.ts b/tools/release-check/src/git.ts index a2f52685..014ecfe9 100644 --- a/tools/release-check/src/git.ts +++ b/tools/release-check/src/git.ts @@ -121,8 +121,16 @@ export const collectCommits = ( repoRoot: string, gitTag: string, previousTag: string | null, + rangeEndTag: string = gitTag, ): GitCommit[] => { - const range = previousTag ? `${previousTag}..${gitTag}` : gitTag; + const range = previousTag ? `${previousTag}..${rangeEndTag}` : rangeEndTag; + const baseReleaseRange = previousTag + ? `${previousTag}..${gitTag}` + : `repository start..${gitTag}`; + const releaseRange = + rangeEndTag === gitTag + ? baseReleaseRange + : `${baseReleaseRange} (+ patches through ${rangeEndTag})`; const raw = runGit(repoRoot, [ 'log', '--no-merges', @@ -152,6 +160,8 @@ export const collectCommits = ( body, explicitIssueKeys, hash, + releaseRange, + releaseTag: gitTag, shortHash, subject, }; @@ -164,8 +174,13 @@ export const collectCommitsForTags = ( ): GitCommit[] => { const commitsByHash = new Map(); - for (const { gitTag, previousTag } of gitTags) { - for (const commit of collectCommits(repoRoot, gitTag, previousTag)) { + for (const { gitTag, previousTag, rangeEndTag } of gitTags) { + for (const commit of collectCommits( + repoRoot, + gitTag, + previousTag, + rangeEndTag ?? gitTag, + )) { if (!commitsByHash.has(commit.hash)) { commitsByHash.set(commit.hash, commit); } diff --git a/tools/release-check/src/index.ts b/tools/release-check/src/index.ts index 18c1042c..5f1991e1 100644 --- a/tools/release-check/src/index.ts +++ b/tools/release-check/src/index.ts @@ -9,6 +9,7 @@ import { getPreviousTag, getRepoName, getRepoRoot, + listTags, resolveGitTags, resolveRepoPath, } from './git'; @@ -16,13 +17,14 @@ import { readReleaseNotesForTags } from './github-release'; import { fetchJiraIssues, fetchJiraIssuesByKeys, + listJiraVersions, resolveJiraVersions, updateJiraIssueClinicalReviewStatus, updateJiraIssueFixVersions, } from './jira'; import { defaultReportPath, - renderFixProposalSection, + renderFixProposalTerminalSection, renderReport, } from './report'; import type { @@ -56,14 +58,22 @@ const dedupeBy = (items: T[], getKey: (item: T) => string): T[] => { return dedupedItems; }; +const formatSelectedGitTagLabel = ({ + gitTag, + rangeEndTag, +}: SelectedGitTag): string => + rangeEndTag && rangeEndTag !== gitTag + ? `${gitTag} (+ patches through ${rangeEndTag})` + : gitTag; + const formatGitTagSummary = (gitTags: SelectedGitTag[]): string => - gitTags.map(({ gitTag }) => gitTag).join(', '); + gitTags.map((gitTag) => formatSelectedGitTagLabel(gitTag)).join(', '); const formatComparisonBaseSummary = (gitTags: SelectedGitTag[]): string => gitTags .map( - ({ gitTag, previousTag }) => - `${gitTag} <- ${previousTag ?? 'repository start'}`, + (gitTag) => + `${formatSelectedGitTagLabel(gitTag)} <- ${gitTag.previousTag ?? 'repository start'}`, ) .join('; '); @@ -82,22 +92,237 @@ const formatFixVersions = ( ? fixVersions.map(({ name }) => name).join(', ') : 'none'; +const appendFixVersion = ( + fixVersions: JiraIssueFixDetails['fixVersions'], + targetVersion: JiraVersion, +): JiraIssueFixDetails['fixVersions'] => [ + ...fixVersions, + { + id: targetVersion.id, + name: targetVersion.name, + }, +]; + +const PROPOSED_FIX_UPDATE_MAX_LENGTH = 60; + +const truncateSummary = (value: string, maxLength: number): string => { + if (value.length <= maxLength) { + return value; + } + + if (maxLength <= 3) { + return value.slice(0, maxLength); + } + + return `${value.slice(0, maxLength - 3)}...`; +}; + +const formatProposedFixVersionUpdate = ( + targetVersion: JiraVersion, + existingFixVersions: JiraIssueFixDetails['fixVersions'], +): string => { + if (existingFixVersions.length === 0) { + return targetVersion.name; + } + + const summary = `${targetVersion.name} + ${existingFixVersions.length} (${existingFixVersions.map(({ name }) => name).join(', ')})`; + + return truncateSummary(summary, PROPOSED_FIX_UPDATE_MAX_LENGTH); +}; + const dedupeIssuesByKey = (issues: T[]): T[] => dedupeBy(issues, (issue) => issue.key); -const resolveSelectedGitTags = ( +const VERSION_TAG_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)$/; + +type ParsedVersionTag = { + major: number; + minor: number; + patch: number; +}; + +const parseVersionTag = (tag: string): ParsedVersionTag | undefined => { + const match = VERSION_TAG_PATTERN.exec(tag); + if (!match) { + return undefined; + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +}; + +const isSameMinorSeries = ( + left: ParsedVersionTag, + right: ParsedVersionTag, +): boolean => left.major === right.major && left.minor === right.minor; + +const compareVersionTags = (left: string, right: string): number => { + const parsedLeft = parseVersionTag(left); + const parsedRight = parseVersionTag(right); + + if (!parsedLeft || !parsedRight) { + return left.localeCompare(right); + } + + return ( + parsedLeft.major - parsedRight.major || + parsedLeft.minor - parsedRight.minor || + parsedLeft.patch - parsedRight.patch || + left.localeCompare(right) + ); +}; + +const hasSpecificJiraVersionForTag = ( + gitTag: string, + jiraVersions: JiraVersion[], +): boolean => { + const parsedTag = parseVersionTag(gitTag); + if (!parsedTag) { + return false; + } + + const versionSuffix = `${parsedTag.major}.${parsedTag.minor}.${parsedTag.patch}`; + return jiraVersions.some((jiraVersion) => + jiraVersion.name.endsWith(versionSuffix), + ); +}; + +const findCanonicalSeriesBaseTag = ( + gitTag: string, + availableTags: string[], +): string => { + const parsedTag = parseVersionTag(gitTag); + if (!parsedTag) { + return gitTag; + } + + return ( + availableTags.find((candidateTag) => { + const parsedCandidate = parseVersionTag(candidateTag); + return ( + parsedCandidate && + parsedCandidate.patch === 0 && + isSameMinorSeries(parsedCandidate, parsedTag) + ); + }) ?? gitTag + ); +}; + +const findPatchRollupEndTag = ( + repoRoot: string, + canonicalBaseTag: string, + availableTags: string[], + jiraVersions: JiraVersion[], +): string => { + const parsedBaseTag = parseVersionTag(canonicalBaseTag); + if (!parsedBaseTag) { + return canonicalBaseTag; + } + + const sameSeriesCandidates = availableTags + .filter((candidateTag) => { + const parsedCandidate = parseVersionTag(candidateTag); + return ( + candidateTag !== canonicalBaseTag && + parsedCandidate && + isSameMinorSeries(parsedCandidate, parsedBaseTag) + ); + }) + .toSorted(compareVersionTags); + let currentTag = canonicalBaseTag; + + while (true) { + let nextTag: string | undefined; + + for (const candidateTag of sameSeriesCandidates) { + if (getPreviousTag(repoRoot, candidateTag) === currentTag) { + nextTag = candidateTag; + break; + } + } + + if (!nextTag) { + break; + } + + const parsedNextTag = parseVersionTag(nextTag); + if ( + parsedNextTag && + parsedNextTag.patch > 0 && + hasSpecificJiraVersionForTag(nextTag, jiraVersions) + ) { + break; + } + + currentTag = nextTag; + } + + return currentTag; +}; + +const getCanonicalBaseTag = ( + resolvedTagName: string, + availableTags: string[], + projectJiraVersions: JiraVersion[], +): string => { + const parsedResolvedTag = parseVersionTag(resolvedTagName); + if (parsedResolvedTag?.patch === 0) { + return findCanonicalSeriesBaseTag(resolvedTagName, availableTags); + } + + if (hasSpecificJiraVersionForTag(resolvedTagName, projectJiraVersions)) { + return resolvedTagName; + } + + return findCanonicalSeriesBaseTag(resolvedTagName, availableTags); +}; + +const resolveSelectedGitTags = async ( repoRoot: string, gitTagSelectors: string[], + jiraBaseUrl: string, + jiraProject: string, previousTag?: string, -): SelectedGitTag[] => - resolveGitTags(repoRoot, gitTagSelectors).map((gitTag, index) => ({ - gitTag, - previousTag: getPreviousTag( - repoRoot, - gitTag, - index === 0 ? previousTag : undefined, - ), - })); +): Promise => { + const availableTags = listTags(repoRoot); + const projectJiraVersions = await listJiraVersions(jiraBaseUrl, jiraProject); + const resolvedSelectedTagNames = resolveGitTags(repoRoot, gitTagSelectors); + const seenBaseTags = new Set(); + const orderedBaseTags: string[] = []; + + for (const resolvedTagName of resolvedSelectedTagNames) { + const canonicalBaseTag = getCanonicalBaseTag( + resolvedTagName, + availableTags, + projectJiraVersions, + ); + + if (!seenBaseTags.has(canonicalBaseTag)) { + seenBaseTags.add(canonicalBaseTag); + orderedBaseTags.push(canonicalBaseTag); + } + } + + return orderedBaseTags + .toSorted(compareVersionTags) + .map((canonicalBaseTag, index) => ({ + gitTag: canonicalBaseTag, + previousTag: getPreviousTag( + repoRoot, + canonicalBaseTag, + index === 0 ? previousTag : undefined, + ), + rangeEndTag: findPatchRollupEndTag( + repoRoot, + canonicalBaseTag, + availableTags, + projectJiraVersions, + ), + })); +}; const getOutsideReleaseIssueKeys = ( comparison: ReturnType, @@ -142,7 +367,13 @@ const buildFixProposals = ( .map((issue) => ({ currentValueSummary: formatFixVersions(issue.fixVersions), issue, - targetValueSummary: targetVersion.name, + proposedUpdateSummary: formatProposedFixVersionUpdate( + targetVersion, + issue.fixVersions, + ), + targetValueSummary: formatFixVersions( + appendFixVersion(issue.fixVersions, targetVersion), + ), })); }; @@ -226,14 +457,128 @@ const applyFixProposal = async ( ]); }; +const maybeWriteComparisonReport = async ({ + comparison, + fixAction, + fixComponent, + fixProposals, + gitTags, + jiraBaseUrl, + jiraProject, + jiraVersions, + output, + outsideReleaseIssuesByKey, + releaseNotes, + repoName, + repoRoot, + totalJiraIssues, +}: { + comparison: ReturnType; + fixAction?: NonNullable['fixAction']>; + fixComponent?: string; + fixProposals?: FixProposal[]; + gitTags: SelectedGitTag[]; + jiraBaseUrl: string; + jiraProject: string; + jiraVersions: JiraVersion[]; + output?: string; + outsideReleaseIssuesByKey: Map; + releaseNotes: Awaited>; + repoName: string; + repoRoot: string; + totalJiraIssues: number; +}): Promise => { + if (fixAction) { + return undefined; + } + + const outputPath = path.resolve( + output ?? + defaultReportPath( + repoName, + gitTags.map(({ gitTag }) => gitTag), + ), + ); + const report = renderReport({ + comparison, + fixAction, + fixComponent, + fixProposals, + gitTags, + jiraBaseUrl, + jiraProject, + jiraVersions, + releaseNotes, + repoName, + repoRoot, + outsideReleaseIssuesByKey, + totalJiraIssues, + }); + + await writeReport(outputPath, report); + return outputPath; +}; + +const maybeApplyFixProposals = async ({ + commitsByIssueKey, + fixAction, + fixComponent, + fixProposals, + jiraBaseUrl, + jiraVersion, + yes, +}: { + commitsByIssueKey: ReturnType['commitsByIssueKey']; + fixAction?: NonNullable['fixAction']>; + fixComponent?: string; + fixProposals?: FixProposal[]; + jiraBaseUrl: string; + jiraVersion: JiraVersion; + yes: boolean; +}): Promise => { + if (!fixAction || !fixProposals) { + return; + } + + const fixActionLabel = getFixActionLabel(fixAction); + const proposalSection = renderFixProposalTerminalSection( + fixActionLabel, + fixComponent!, + fixProposals, + commitsByIssueKey, + ); + const confirmed = await confirmFixApplication( + proposalSection, + fixActionLabel, + yes, + ); + + if (!confirmed) { + process.stdout.write( + `Aborted without applying ${fixActionLabel} updates.\n`, + ); + return; + } + + for (const proposal of fixProposals) { + await applyFixProposal(jiraBaseUrl, fixAction, proposal, jiraVersion); + } + + process.stdout.write( + `Applied ${fixActionLabel} updates to ${fixProposals.length} issue(s).\n`, + ); +}; + export const run = async (argv: string[]): Promise => { const options = parseCliArgs(argv); const repoPath = resolveRepoPath(options.repo); const repoRoot = getRepoRoot(repoPath); const repoName = getRepoName(repoRoot); - const selectedGitTags = resolveSelectedGitTags( + const selectedGitTags = await resolveSelectedGitTags( repoRoot, options.gitTagSelectors, + options.jiraBaseUrl, + options.jiraProject, options.previousTag, ); const commits = collectCommitsForTags(repoRoot, selectedGitTags); @@ -295,68 +640,37 @@ export const run = async (argv: string[]): Promise => { ); } - const outputPath = path.resolve( - options.output ?? - defaultReportPath( - repoName, - selectedGitTags.map(({ gitTag }) => gitTag), - ), - ); - const report = renderReport({ + const outputPath = await maybeWriteComparisonReport({ comparison, fixAction: options.fixAction, fixComponent: options.fixComponent, fixProposals, gitTags: selectedGitTags, + jiraBaseUrl: options.jiraBaseUrl, jiraProject: options.jiraProject, jiraVersions, + output: options.output, + outsideReleaseIssuesByKey, releaseNotes, repoName, repoRoot, - outsideReleaseIssuesByKey, totalJiraIssues: jiraIssues.length, }); - await writeReport(outputPath, report); - - if (options.fixAction && fixProposals) { - const fixActionLabel = getFixActionLabel(options.fixAction); - const proposalSection = renderFixProposalSection( - fixActionLabel, - options.fixComponent!, - fixProposals, - comparison.commitsByIssueKey, - ); - const confirmed = await confirmFixApplication( - proposalSection, - fixActionLabel, - options.yes, - ); - - if (confirmed) { - for (const proposal of fixProposals) { - await applyFixProposal( - options.jiraBaseUrl, - options.fixAction, - proposal, - jiraVersions[0], - ); - } - - process.stdout.write( - `Applied ${fixActionLabel} updates to ${fixProposals.length} issue(s).\n`, - ); - } else { - process.stdout.write( - `Aborted without applying ${fixActionLabel} updates.\n`, - ); - } - } + await maybeApplyFixProposals({ + commitsByIssueKey: comparison.commitsByIssueKey, + fixAction: options.fixAction, + fixComponent: options.fixComponent, + fixProposals, + jiraBaseUrl: options.jiraBaseUrl, + jiraVersion: jiraVersions[0], + yes: options.yes, + }); const summaryLines = [ `Repository: ${repoName}`, selectedGitTags.length === 1 - ? `Git tag: ${selectedGitTags[0].gitTag}` + ? `Git tag: ${formatSelectedGitTagLabel(selectedGitTags[0])}` : `Git tags selected (${selectedGitTags.length}): ${formatGitTagSummary(selectedGitTags)}`, selectedGitTags.length === 1 ? `Comparison base: ${selectedGitTags[0].previousTag ?? 'repository start'}` @@ -372,7 +686,7 @@ export const run = async (argv: string[]): Promise => { `Jira issues missing clinical safety category: ${comparison.jiraIssuesMissingClinicalSafetyCategory.length}`, `Jira issues missing clinical lead: ${comparison.jiraIssuesMissingClinicalLead.length}`, `Commits without Jira matches: ${comparison.commitsWithoutMatches.length}`, - `Report written to ${outputPath}`, + ...(outputPath ? [`Report written to ${outputPath}`] : []), ]; process.stdout.write(`${summaryLines.join('\n')}\n`); diff --git a/tools/release-check/src/jira.ts b/tools/release-check/src/jira.ts index 33529c80..a0350fb8 100644 --- a/tools/release-check/src/jira.ts +++ b/tools/release-check/src/jira.ts @@ -60,6 +60,7 @@ const JIRA_SEARCH_FIELDS = [ 'status', 'issuetype', 'components', + 'fixVersions', CLINICAL_LEAD_FIELD_ID, MEDICAL_CLINICAL_SAFETY_CATEGORY_FIELD_ID, CLINICAL_REVIEW_STATUS_FIELD_ID, @@ -315,6 +316,11 @@ export const resolveJiraVersion = async ( return version; }; +export const listJiraVersions = async ( + jiraBaseUrl: string, + jiraProject: string, +): Promise => fetchProjectVersions(jiraBaseUrl, jiraProject); + export const fetchJiraIssues = async ( jiraBaseUrl: string, jiraProject: string, diff --git a/tools/release-check/src/report.ts b/tools/release-check/src/report.ts index e0e80e6a..4d9fc30b 100644 --- a/tools/release-check/src/report.ts +++ b/tools/release-check/src/report.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import type { ComparisonResult, FixProposal, + JiraFixVersion, JiraIssue, JiraVersion, MatchedCommit, @@ -10,13 +11,18 @@ import type { SelectedGitTag, } from './types'; +const DEFAULT_JIRA_BASE_URL = 'https://nhsd-jira.digital.nhs.uk'; + const formatCommit = (commit: MatchedCommit): string => `${commit.shortHash} ${commit.subject}`; const escapeMarkdownCell = (value: string): string => value.replaceAll('|', String.raw`\|`).replaceAll('\n', '
'); -const formatIssueHeading = (key: string, issue?: JiraIssue): string => { +const formatJiraIssueLink = (jiraBaseUrl: string, issueKey: string): string => + `[${issueKey}](${jiraBaseUrl}/browse/${encodeURIComponent(issueKey)})`; + +const formatPlainIssueHeading = (key: string, issue?: JiraIssue): string => { if (!issue) { return `${key}: not found in Jira`; } @@ -26,6 +32,22 @@ const formatIssueHeading = (key: string, issue?: JiraIssue): string => { return `${key}: ${components}${issue.summary} (${issue.status})`; }; +const formatIssueHeading = ( + jiraBaseUrl: string, + key: string, + issue?: JiraIssue, +): string => { + const linkedKey = formatJiraIssueLink(jiraBaseUrl, key); + + if (!issue) { + return `${linkedKey}: not found in Jira`; + } + + const components = + issue.components.length > 0 ? `[${issue.components.join(', ')}] ` : ''; + return `${linkedKey}: ${components}${issue.summary} (${issue.status})`; +}; + const formatRepresentativeCommit = ( commits: MatchedCommit[], emptyLabel = 'No matching commit', @@ -39,6 +61,182 @@ const formatRepresentativeCommit = ( return `\`${formatCommit(commits[0])}\` _(${totalLabel})_`; }; +const formatRepresentativeCommitText = ( + commits: MatchedCommit[], + emptyLabel = 'No matching commit', +): string => { + if (commits.length === 0) { + return emptyLabel; + } + + const totalLabel = + commits.length === 1 ? '1 commit total' : `${commits.length} commits total`; + return `${formatCommit(commits[0])} (${totalLabel})`; +}; + +const formatFixVersions = (fixVersions?: JiraFixVersion[]): string => { + if (!fixVersions || fixVersions.length === 0) { + return 'none'; + } + + return fixVersions.map(({ name }) => name).join(', '); +}; + +const formatReleaseRanges = ( + commits: MatchedCommit[], + emptyLabel = 'No matching commit', +): string => { + if (commits.length === 0) { + return emptyLabel; + } + + const countsByRange = new Map(); + + for (const commit of commits) { + const rangeLabel = commit.releaseRange ?? 'unknown range'; + countsByRange.set(rangeLabel, (countsByRange.get(rangeLabel) ?? 0) + 1); + } + + return [...countsByRange.entries()] + .map(([rangeLabel, count]) => + count === 1 ? rangeLabel : `${rangeLabel} (${count} commits)`, + ) + .join('; '); +}; + +const formatSelectedGitRange = ({ + gitTag, + previousTag, + rangeEndTag, +}: SelectedGitTag): string => + `${previousTag ?? 'repository start'}..${rangeEndTag ?? gitTag}`; + +function normalizeGitTagForVersionMatch(gitTag: string): string { + return gitTag.startsWith('v') ? gitTag.slice(1) : gitTag; +} + +function findJiraVersionForGitTag( + gitTag: string, + jiraVersions: JiraVersion[], +): JiraVersion | undefined { + const normalizedGitTag = normalizeGitTagForVersionMatch(gitTag); + + return jiraVersions.find((jiraVersion) => + jiraVersion.name.endsWith(normalizedGitTag), + ); +} + +function findGitTagForJiraVersion( + jiraVersion: JiraVersion, + gitTags: SelectedGitTag[], +): SelectedGitTag | undefined { + return gitTags.find((gitTag) => + findJiraVersionForGitTag(gitTag.gitTag, [jiraVersion]), + ); +} + +function renderGitRangeMappings( + gitTags: SelectedGitTag[], + jiraVersions: JiraVersion[], +): string[] { + const lines = ['- Git commit ranges mapped to Jira versions:']; + + for (const gitTag of gitTags) { + const jiraVersion = findJiraVersionForGitTag(gitTag.gitTag, jiraVersions); + lines.push( + ` - \`${formatSelectedGitRange(gitTag)}\` -> ${jiraVersion?.name ?? 'no matching selected Jira version'}`, + ); + } + + return lines; +} + +function quoteShellArg(value: string): string { + const escapedValue = value.replaceAll("'", `'"'"'`); + return `'${escapedValue}'`; +} + +const formatIssueFixVersions = (issue?: JiraIssue): string => + issue ? formatFixVersions(issue.fixVersions) : 'not found in Jira'; + +const compareStrings = (left: string, right: string): number => + left.localeCompare(right); + +const formatSelectedGitTagLabel = ({ + gitTag, + rangeEndTag, +}: SelectedGitTag): string => + rangeEndTag && rangeEndTag !== gitTag + ? `${gitTag} (+ patches through ${rangeEndTag})` + : gitTag; + +function formatFixCommand({ + action, + component, + gitTag, + jiraVersion, + repoRoot, +}: { + action: 'clinical-review-not-needed' | 'fix-version'; + component: string; + gitTag: string; + jiraVersion: string; + repoRoot: string; +}): string { + return `npm run check -- --repo ${quoteShellArg(repoRoot)} --git-tag ${quoteShellArg(gitTag)} --jira-version ${quoteShellArg(jiraVersion)} --fix ${action} --fix-component ${quoteShellArg(component)}`; +} + +const getIssueReleaseTags = ( + issueKey: string, + commitsByIssueKey: Map, +): Set => + new Set( + (commitsByIssueKey.get(issueKey) ?? []) + .map((commit) => commit.releaseTag) + .filter((releaseTag): releaseTag is string => releaseTag != null), + ); + +const addFixVersionCommandEntries = ({ + commandEntries, + gitTags, + issue, + jiraVersions, + releaseTags, + repoRoot, + unmappedRanges, +}: { + commandEntries: Set; + gitTags: SelectedGitTag[]; + issue: JiraIssue; + jiraVersions: JiraVersion[]; + releaseTags: Set; + repoRoot: string; + unmappedRanges: Set; +}): void => { + for (const releaseTag of releaseTags) { + const selectedGitTag = gitTags.find( + ({ gitTag }) => gitTag === releaseTag, + )?.gitTag; + const jiraVersion = findJiraVersionForGitTag(releaseTag, jiraVersions); + + if (selectedGitTag && jiraVersion) { + for (const component of issue.components) { + commandEntries.add( + `# ${component}\n${formatFixCommand({ + action: 'fix-version', + component, + gitTag: selectedGitTag, + jiraVersion: jiraVersion.name, + repoRoot, + })}`, + ); + } + } else { + unmappedRanges.add(releaseTag); + } + } +}; + const groupOutsideReleaseReferences = ( commitsWithIssueKeysOutsideRelease: ComparisonResult['commitsWithIssueKeysOutsideRelease'], ): string[] => @@ -66,24 +264,118 @@ const renderTable = (headers: string[], rows: string[][]): string => { return `${headerRow}\n${separatorRow}\n${bodyRows}`; }; +const DEFAULT_TERMINAL_COLUMN_WIDTH = 100; +const TERMINAL_TABLE_MIN_COLUMN_WIDTH = 20; + +const truncateTerminalCell = (value: string, maxWidth: number): string => { + if (value.length <= maxWidth) { + return value; + } + + if (maxWidth <= 3) { + return value.slice(0, maxWidth); + } + + return `${value.slice(0, maxWidth - 3)}...`; +}; + +const getTerminalColumnMaxWidths = (columnCount: number): number[] => { + if (!process.stdout.isTTY || !process.stdout.columns) { + return Array.from( + { length: columnCount }, + () => DEFAULT_TERMINAL_COLUMN_WIDTH, + ); + } + + const separatorWidth = (columnCount - 1) * 3; + const availableWidth = Math.max( + process.stdout.columns - separatorWidth, + columnCount * TERMINAL_TABLE_MIN_COLUMN_WIDTH, + ); + const equalShare = Math.max( + TERMINAL_TABLE_MIN_COLUMN_WIDTH, + Math.floor(availableWidth / columnCount), + ); + + return Array.from({ length: columnCount }, () => + Math.min(DEFAULT_TERMINAL_COLUMN_WIDTH, equalShare), + ); +}; + +const renderTerminalTable = ( + headers: string[], + rows: string[][], + maxColumnWidths = getTerminalColumnMaxWidths(headers.length), +): string => { + const truncatedHeaders = headers.map((header, index) => + truncateTerminalCell( + header, + maxColumnWidths[index] ?? DEFAULT_TERMINAL_COLUMN_WIDTH, + ), + ); + const truncatedRows = rows.map((row) => + row.map((cell, index) => + truncateTerminalCell( + cell ?? '', + maxColumnWidths[index] ?? DEFAULT_TERMINAL_COLUMN_WIDTH, + ), + ), + ); + const columnWidths = headers.map((header, index) => + Math.max( + truncatedHeaders[index]?.length ?? header.length, + ...truncatedRows.map((row) => (row[index] ?? '').length), + ), + ); + const formatRow = (row: string[]): string => + row + .map((cell, index) => (cell ?? '').padEnd(columnWidths[index])) + .join(' | '); + const separator = columnWidths.map((width) => '-'.repeat(width)).join('-+-'); + + return [ + formatRow(truncatedHeaders), + separator, + ...truncatedRows.map((row) => formatRow(row)), + ].join('\n'); +}; + const renderIssueSection = ( title: string, issueKeys: string[], issueByKey: Map, commitsByIssueKey: Map, + showReleaseRangeComparison: boolean, + jiraBaseUrl: string, ): string => { if (issueKeys.length === 0) { return `## ${title}\n\n- none\n`; } - const rows = issueKeys.map((key) => [ - escapeMarkdownCell(formatIssueHeading(key, issueByKey.get(key))), - escapeMarkdownCell( - formatRepresentativeCommit(commitsByIssueKey.get(key) ?? []), - ), - ]); + const headers = ['Issue', 'Commit']; + if (showReleaseRangeComparison) { + headers.push('Release range', 'Fix versions'); + } + + const rows = issueKeys.map((key) => { + const issue = issueByKey.get(key); + const commits = commitsByIssueKey.get(key) ?? []; + const row = [ + escapeMarkdownCell(formatIssueHeading(jiraBaseUrl, key, issue)), + escapeMarkdownCell(formatRepresentativeCommit(commits)), + ]; + + if (showReleaseRangeComparison) { + row.push( + escapeMarkdownCell(formatReleaseRanges(commits)), + escapeMarkdownCell(formatIssueFixVersions(issue)), + ); + } - return `## ${title}\n\n${renderTable(['Issue', 'Commit'], rows)}\n`; + return row; + }); + + return `## ${title}\n\n${renderTable(headers, rows)}\n`; }; const renderFixProposalSection = ( @@ -91,30 +383,257 @@ const renderFixProposalSection = ( fixComponent: string, proposals: FixProposal[], commitsByIssueKey: Map, + showReleaseRangeComparison = false, + jiraBaseUrl = DEFAULT_JIRA_BASE_URL, ): string => { const title = `Proposed ${fixAction} updates for component ${fixComponent}`; if (proposals.length === 0) { return `## ${title}\n\n- none\n`; } - const rows = proposals.map((proposal) => [ - escapeMarkdownCell(formatIssueHeading(proposal.issue.key, proposal.issue)), - escapeMarkdownCell( - formatRepresentativeCommit( - commitsByIssueKey.get(proposal.issue.key) ?? [], + const headers = ['Issue', 'Commit']; + if (showReleaseRangeComparison) { + headers.push('Release range', 'Fix versions'); + } + headers.push('Proposed update'); + + const rows = proposals.map((proposal) => { + const commits = commitsByIssueKey.get(proposal.issue.key) ?? []; + const row = [ + escapeMarkdownCell( + formatIssueHeading(jiraBaseUrl, proposal.issue.key, proposal.issue), ), - ), - escapeMarkdownCell( - `${proposal.currentValueSummary} -> ${proposal.targetValueSummary}`, - ), - ]); + escapeMarkdownCell(formatRepresentativeCommit(commits)), + ]; + + if (showReleaseRangeComparison) { + row.push( + escapeMarkdownCell(formatReleaseRanges(commits)), + escapeMarkdownCell(formatIssueFixVersions(proposal.issue)), + ); + } + + row.push( + escapeMarkdownCell( + proposal.proposedUpdateSummary ?? + `${proposal.currentValueSummary} -> ${proposal.targetValueSummary}`, + ), + ); + + return row; + }); + + return `## ${title}\n\n${renderTable(headers, rows)}\n`; +}; + +const renderFixProposalTerminalSection = ( + fixAction: string, + fixComponent: string, + proposals: FixProposal[], + commitsByIssueKey: Map, +): string => { + const title = `Proposed ${fixAction} updates for component ${fixComponent}`; + if (proposals.length === 0) { + return `${title}\n\nnone\n`; + } + + const rows = proposals.map((proposal) => { + const commits = commitsByIssueKey.get(proposal.issue.key) ?? []; - return `## ${title}\n\n${renderTable( + return [ + formatPlainIssueHeading(proposal.issue.key, proposal.issue), + formatRepresentativeCommitText(commits), + proposal.proposedUpdateSummary ?? + `${proposal.currentValueSummary} -> ${proposal.targetValueSummary}`, + ]; + }); + + return `${title}\n\n${renderTerminalTable( ['Issue', 'Commit', 'Proposed update'], rows, )}\n`; }; +const renderFixVersionCommandExamples = ({ + commitsByIssueKey, + gitTags, + issueKeys, + jiraVersions, + outsideReleaseIssuesByKey, + repoRoot, +}: { + commitsByIssueKey: Map; + gitTags: SelectedGitTag[]; + issueKeys: string[]; + jiraVersions: JiraVersion[]; + outsideReleaseIssuesByKey: Map; + repoRoot: string; +}): string => { + const commandEntries = new Set(); + const issuesWithoutComponents: string[] = []; + const unmappedRanges = new Set(); + + for (const issueKey of issueKeys) { + const issue = outsideReleaseIssuesByKey.get(issueKey); + if (issue && issue.components.length === 0) { + issuesWithoutComponents.push(issueKey); + } else if (issue) { + addFixVersionCommandEntries({ + commandEntries, + gitTags, + issue, + jiraVersions, + releaseTags: getIssueReleaseTags(issueKey, commitsByIssueKey), + repoRoot, + unmappedRanges, + }); + } + } + + const lines = [...commandEntries].toSorted(compareStrings); + + if (issuesWithoutComponents.length > 0) { + lines.push( + `# Manual review needed: no component set for ${issuesWithoutComponents.toSorted(compareStrings).join(', ')}`, + ); + } + + if (unmappedRanges.size > 0) { + lines.push( + `# No single-release fix command generated for ranges without a matching selected Jira version: ${[...unmappedRanges].toSorted(compareStrings).join(', ')}`, + ); + } + + if (lines.length === 0) { + return ''; + } + + return [ + '### Example fix-version commands by component', + '', + '```bash', + ...lines, + '```', + '', + ].join('\n'); +}; + +const getSelectedJiraVersionsForIssue = ( + issue: JiraIssue, + jiraVersions: JiraVersion[], +): JiraVersion[] => { + const issueFixVersions = issue.fixVersions ?? []; + + return jiraVersions.filter((jiraVersion) => + issueFixVersions.some( + ({ id, name }) => jiraVersion.id === id || jiraVersion.name === name, + ), + ); +}; + +const addClinicalReviewCommandsForIssue = ({ + commandEntries, + gitTags, + issue, + issuesWithoutSelectedVersion, + jiraVersions, + repoRoot, +}: { + commandEntries: Set; + gitTags: SelectedGitTag[]; + issue: JiraIssue; + issuesWithoutSelectedVersion: string[]; + jiraVersions: JiraVersion[]; + repoRoot: string; +}): void => { + const matchingJiraVersions = getSelectedJiraVersionsForIssue( + issue, + jiraVersions, + ); + + if (matchingJiraVersions.length === 0) { + issuesWithoutSelectedVersion.push(issue.key); + return; + } + + for (const jiraVersion of matchingJiraVersions) { + const gitTag = findGitTagForJiraVersion(jiraVersion, gitTags); + if (gitTag) { + for (const component of issue.components) { + commandEntries.add( + `# ${component}\n${formatFixCommand({ + action: 'clinical-review-not-needed', + component, + gitTag: gitTag.gitTag, + jiraVersion: jiraVersion.name, + repoRoot, + })}`, + ); + } + } else { + issuesWithoutSelectedVersion.push(issue.key); + } + } +}; + +const renderClinicalReviewCommandExamples = ({ + gitTags, + issues, + jiraVersions, + repoRoot, +}: { + gitTags: SelectedGitTag[]; + issues: JiraIssue[]; + jiraVersions: JiraVersion[]; + repoRoot: string; +}): string => { + const commandEntries = new Set(); + const issuesWithoutComponents: string[] = []; + const issuesWithoutSelectedVersion: string[] = []; + + for (const issue of issues) { + if (issue.components.length === 0) { + issuesWithoutComponents.push(issue.key); + } else { + addClinicalReviewCommandsForIssue({ + commandEntries, + gitTags, + issue, + issuesWithoutSelectedVersion, + jiraVersions, + repoRoot, + }); + } + } + + const lines = [...commandEntries].toSorted(compareStrings); + + if (issuesWithoutComponents.length > 0) { + lines.push( + `# Manual review needed: no component set for ${issuesWithoutComponents.toSorted(compareStrings).join(', ')}`, + ); + } + + if (issuesWithoutSelectedVersion.length > 0) { + lines.push( + `# No single-release clinical review command generated for ${[...new Set(issuesWithoutSelectedVersion)].toSorted(compareStrings).join(', ')}`, + ); + } + + if (lines.length === 0) { + return ''; + } + + return [ + '### Example clinical-review-not-needed commands by component', + '', + '```bash', + ...lines, + '```', + '', + ].join('\n'); +}; + const sanitizeFileSegment = (value: string): string => value.replaceAll(/[^A-Za-z0-9._-]+/g, '-'); @@ -124,13 +643,13 @@ const summarizeGitTagsForPath = (gitTags: string[]): string => : `${sanitizeFileSegment(gitTags[0])}-to-${sanitizeFileSegment(gitTags.at(-1) ?? gitTags[0])}-${gitTags.length}-tags`; const formatGitTagSummary = (gitTags: SelectedGitTag[]): string => - gitTags.map(({ gitTag }) => gitTag).join(', '); + gitTags.map((gitTag) => formatSelectedGitTagLabel(gitTag)).join(', '); const formatComparisonBaseSummary = (gitTags: SelectedGitTag[]): string => gitTags .map( - ({ gitTag, previousTag }) => - `${gitTag} <- ${previousTag ?? 'repository start'}`, + (gitTag) => + `${formatSelectedGitTagLabel(gitTag)} <- ${gitTag.previousTag ?? 'repository start'}`, ) .join('; '); @@ -163,6 +682,7 @@ export const renderReport = ({ fixComponent, fixProposals, gitTags, + jiraBaseUrl = DEFAULT_JIRA_BASE_URL, jiraProject, jiraVersions, outsideReleaseIssuesByKey, @@ -176,6 +696,7 @@ export const renderReport = ({ fixComponent?: string; fixProposals?: FixProposal[]; gitTags: SelectedGitTag[]; + jiraBaseUrl?: string; jiraProject: string; jiraVersions: JiraVersion[]; outsideReleaseIssuesByKey: Map; @@ -196,13 +717,14 @@ export const renderReport = ({ const outsideReleaseIssueKeys = groupOutsideReleaseReferences( comparison.commitsWithIssueKeysOutsideRelease, ); + const showReleaseRangeComparison = gitTags.length > 1; const sections = [ '# Release check report', '', `- **Repository:** ${repoName}`, `- **Repository root:** ${repoRoot}`, singleGitTag - ? `- **Git tag:** ${gitTags[0].gitTag}` + ? `- **Git tag:** ${formatSelectedGitTagLabel(gitTags[0])}` : `- **Git tags selected (${gitTags.length}):** ${formatGitTagSummary(gitTags)}`, singleGitTag ? `- **Comparison base:** ${gitTags[0].previousTag ?? 'repository start'}` @@ -235,6 +757,7 @@ export const renderReport = ({ `- Jira issues missing clinical safety category: ${comparison.jiraIssuesMissingClinicalSafetyCategory.length}`, `- Jira issues missing clinical lead: ${comparison.jiraIssuesMissingClinicalLead.length}`, `- Commits without Jira matches: ${comparison.commitsWithoutMatches.length}`, + ...renderGitRangeMappings(gitTags, jiraVersions), '', ]; @@ -258,18 +781,24 @@ export const renderReport = ({ comparison.jiraIssuesMissingFromGit.map((issue) => issue.key), selectedIssuesByKey, comparison.commitsByIssueKey, + showReleaseRangeComparison, + jiraBaseUrl, ), renderIssueSection( `Jira issues in ${jiraScopeLabel} with no matching release-note reference`, comparison.jiraIssuesMissingFromReleaseNotes.map((issue) => issue.key), selectedIssuesByKey, comparison.commitsByIssueKey, + showReleaseRangeComparison, + jiraBaseUrl, ), renderIssueSection( 'Jira issues referenced in git or release notes but not in a done status', comparison.releaseReferencedIssuesNotDone.map((issue) => issue.key), selectedIssuesByKey, comparison.commitsByIssueKey, + showReleaseRangeComparison, + jiraBaseUrl, ), renderIssueSection( 'Jira issues missing clinical safety category', @@ -278,24 +807,46 @@ export const renderReport = ({ ), selectedIssuesByKey, comparison.commitsByIssueKey, + showReleaseRangeComparison, + jiraBaseUrl, ), + renderClinicalReviewCommandExamples({ + gitTags, + issues: comparison.jiraIssuesMissingClinicalSafetyCategory, + jiraVersions, + repoRoot, + }), renderIssueSection( 'Jira issues missing clinical lead', comparison.jiraIssuesMissingClinicalLead.map((issue) => issue.key), selectedIssuesByKey, comparison.commitsByIssueKey, + showReleaseRangeComparison, + jiraBaseUrl, ), renderIssueSection( `Git-referenced Jira issue keys missing from ${jiraVersionScopeLabel}`, outsideReleaseIssueKeys, outsideReleaseIssuesByKey, comparison.commitsByIssueKey, + showReleaseRangeComparison, + jiraBaseUrl, ), + renderFixVersionCommandExamples({ + commitsByIssueKey: comparison.commitsByIssueKey, + gitTags, + issueKeys: outsideReleaseIssueKeys, + jiraVersions, + outsideReleaseIssuesByKey, + repoRoot, + }), renderIssueSection( `Release-note Jira issue keys missing from ${jiraVersionScopeLabel}`, comparison.releaseNotesIssueKeysOutsideRelease, outsideReleaseIssuesByKey, comparison.commitsByIssueKey, + showReleaseRangeComparison, + jiraBaseUrl, ), ...(fixAction && fixComponent && fixProposals ? [ @@ -304,6 +855,8 @@ export const renderReport = ({ fixComponent, fixProposals, comparison.commitsByIssueKey, + showReleaseRangeComparison, + jiraBaseUrl, ), ] : []), @@ -316,4 +869,4 @@ export const renderReport = ({ return sections.join('\n').replaceAll(/\n{3,}/g, '\n\n'); }; -export { renderFixProposalSection }; +export { renderFixProposalSection, renderFixProposalTerminalSection }; diff --git a/tools/release-check/src/types.ts b/tools/release-check/src/types.ts index d7565711..cdbd200b 100644 --- a/tools/release-check/src/types.ts +++ b/tools/release-check/src/types.ts @@ -2,6 +2,7 @@ export type JiraIssue = { clinicalLead: string; clinicalReviewStatus: string; components: string[]; + fixVersions?: JiraFixVersion[]; issueType: string; key: string; medicalClinicalSafetyCategory: string; @@ -22,6 +23,8 @@ export type GitCommit = { body: string; explicitIssueKeys: string[]; hash: string; + releaseRange?: string; + releaseTag?: string; shortHash: string; subject: string; }; @@ -85,10 +88,12 @@ export type FixAction = 'fix-version' | 'clinical-review-not-needed'; export type FixProposal = { currentValueSummary: string; issue: JiraIssueFixDetails; + proposedUpdateSummary?: string; targetValueSummary: string; }; export type SelectedGitTag = { gitTag: string; previousTag: string | null; + rangeEndTag?: string; };