diff --git a/.github/workflows/publish-release-check.yaml b/.github/workflows/publish-release-check.yaml new file mode 100644 index 00000000..a4129ea2 --- /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/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) + 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/nhs-notify-release-check run typecheck && pnpm --filter @nhsdigital/nhs-notify-release-check run test:unit + + - name: Publish package + run: pnpm --filter @nhsdigital/nhs-notify-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..fd4920d5 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/nhs-notify-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/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 new file mode 100644 index 00000000..8bf3203d --- /dev/null +++ b/tools/release-check/README.md @@ -0,0 +1,125 @@ +# release-check + +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 for the selected release ranges +- Jira issues assigned to the selected release versions +- 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/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` + +## Optional environment + +- `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/-.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 + +The package is configured for GitHub Packages as `@nhsdigital/nhs-notify-release-check`. + +```bash +pnpm --filter @nhsdigital/nhs-notify-release-check pack +pnpm --filter @nhsdigital/nhs-notify-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/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/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..e7e3708a --- /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/nhs-notify-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/cli.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..2f74beb3 --- /dev/null +++ b/tools/release-check/src/__tests__/args.test.ts @@ -0,0 +1,247 @@ +import { parseCliArgs } from '../args'; + +describe('parseCliArgs', () => { + it('parses required and optional arguments', () => { + expect( + parseCliArgs([ + '--fix', + 'fix-version', + '--fix-component', + 'Platform', + '--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({ + fixAction: 'fix-version', + fixComponent: 'Platform', + repo: '../repo', + gitTagSelectors: ['0.1.0'], + jiraVersionSelectors: ['71260'], + jiraProject: 'ABC', + jiraBaseUrl: 'https://jira.example.com', + previousTag: '0.0.9', + output: 'out.txt', + releaseNotesSource: 'tag', + yes: false, + }); + }); + + 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({ + fixAction: undefined, + fixComponent: undefined, + 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', + yes: false, + }); + }); + + it('uses defaults for optional arguments', () => { + expect( + parseCliArgs([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + ]), + ).toEqual({ + fixAction: undefined, + fixComponent: undefined, + repo: '../repo', + gitTagSelectors: ['0.1.0'], + jiraVersionSelectors: ['71260'], + jiraProject: 'CCM', + jiraBaseUrl: 'https://nhsd-jira.digital.nhs.uk', + 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, + }); + }); + + it('throws for missing required arguments', () => { + expect(() => parseCliArgs([])).toThrow('Missing required option --repo'); + }); + + it('throws when the git selector is missing', () => { + expect(() => + parseCliArgs(['--repo', '../repo', '--jira-version', '71260']), + ).toThrow('Missing required option --git-tag or --git-tags'); + }); + + it('throws when the Jira selector is missing', () => { + expect(() => + parseCliArgs(['--repo', '../repo', '--git-tag', '0.1.0']), + ).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', () => { + 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', + ); + }); + + 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__/compare.test.ts b/tools/release-check/src/__tests__/compare.test.ts new file mode 100644 index 00000000..b963983c --- /dev/null +++ b/tools/release-check/src/__tests__/compare.test.ts @@ -0,0 +1,151 @@ +import { compareRelease } from '../compare'; + +import type { GitCommit, JiraIssue } from '../types'; + +const issues: JiraIssue[] = [ + { + clinicalLead: '', + clinicalReviewStatus: 'Review required', + key: 'CCM-100', + medicalClinicalSafetyCategory: '', + issueType: 'Story', + summary: 'First ticket', + status: 'Done', + components: ['Platform'], + }, + { + clinicalLead: 'Dr Test', + clinicalReviewStatus: 'In review', + key: 'CCM-101', + medicalClinicalSafetyCategory: 'Cat 1', + issueType: 'Story', + summary: 'Exact summary fallback', + status: 'In Progress', + components: ['Platform'], + }, + { + clinicalLead: '', + clinicalReviewStatus: 'Review not needed', + key: 'CCM-102', + medicalClinicalSafetyCategory: '', + issueType: 'Story', + 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('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( + [ + { + 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..2939885c --- /dev/null +++ b/tools/release-check/src/__tests__/git.test.ts @@ -0,0 +1,386 @@ +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +import { + collectCommits, + collectCommitsForTags, + ensureCommitishExists, + getOriginRemoteUrl, + getPreviousTag, + getRepoName, + getRepoRoot, + listTags, + readTagAnnotation, + resolveGitTags, + 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('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(); + }); + + 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', + 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', + }, + ]); + }); + + 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', + 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)', + }, + ]); + }); +}); + +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..c43a7b6b --- /dev/null +++ b/tools/release-check/src/__tests__/github-release.test.ts @@ -0,0 +1,285 @@ +import { + parseGitHubRepositoryFromRemote, + readReleaseNotes, + readReleaseNotesForTags, +} 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.', + ); + }); + + 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 new file mode 100644 index 00000000..dbbbc9e7 --- /dev/null +++ b/tools/release-check/src/__tests__/index.test.ts @@ -0,0 +1,654 @@ +import { run } from '..'; + +jest.mock('node:fs/promises', () => ({ + mkdir: jest.fn().mockResolvedValue(undefined), + writeFile: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../git', () => ({ + collectCommitsForTags: jest.fn(), + getPreviousTag: jest.fn(), + getRepoName: jest.fn(), + getRepoRoot: jest.fn(), + listTags: jest.fn(), + resolveGitTags: jest.fn(), + resolveRepoPath: jest.fn(), +})); + +jest.mock('../jira', () => ({ + fetchJiraIssues: jest.fn(), + fetchJiraIssuesByKeys: jest.fn(), + listJiraVersions: jest.fn(), + resolveJiraVersions: jest.fn(), + updateJiraIssueClinicalReviewStatus: jest.fn(), + updateJiraIssueFixVersions: jest.fn(), +})); + +jest.mock('../github-release', () => ({ + readReleaseNotesForTags: jest.fn(), +})); + +jest.mock('../compare', () => ({ + compareRelease: jest.fn(), +})); + +jest.mock('../report', () => ({ + defaultReportPath: jest.fn(), + renderFixProposalSection: jest.fn(), + renderFixProposalTerminalSection: 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 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 +>; +const mockedCollectCommitsForTags = + git.collectCommitsForTags as jest.MockedFunction< + typeof git.collectCommitsForTags + >; +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 +>; +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 + >; +const mockedCompareRelease = compare.compareRelease as jest.MockedFunction< + typeof compare.compareRelease +>; +const mockedDefaultReportPath = report.defaultReportPath as jest.MockedFunction< + typeof report.defaultReportPath +>; +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 +>; + +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'); + 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', + name: 'release', + releaseDate: '2026-07-08', + released: true, + }, + ]); + mockedFetchJiraIssues.mockResolvedValue([]); + mockedFetchJiraIssuesByKeys.mockResolvedValue([]); + mockedUpdateJiraIssueClinicalReviewStatus.mockResolvedValue(undefined); + mockedUpdateJiraIssueFixVersions.mockResolvedValue(undefined); + mockedReadReleaseNotesForTags.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'); + mockedRenderFixProposalSection.mockReturnValue('fix proposal section'); + mockedRenderFixProposalTerminalSection.mockReturnValue( + 'fix proposal terminal section', + ); + mockedRenderReport.mockReturnValue('report'); + }); + + afterEach(() => { + process.stdout.write = originalStdoutWrite; + }); + + it('runs the end-to-end comparison and writes the report', async () => { + await run([ + '--repo', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + ]); + + expect(mockedResolveRepoPath).toHaveBeenCalledWith('../repo'); + expect(mockedResolveGitTags).toHaveBeenCalledWith('/repo', ['0.1.0']); + expect(mockedResolveJiraVersions).toHaveBeenCalledWith( + 'https://nhsd-jira.digital.nhs.uk', + 'CCM', + ['71260'], + ); + expect(mockedListJiraVersions).toHaveBeenCalledWith( + 'https://nhsd-jira.digital.nhs.uk', + 'CCM', + ); + expect(mockedReadReleaseNotesForTags).toHaveBeenCalledWith( + '/repo', + ['0.1.0'], + 'auto', + ); + expect(mockedFetchJiraIssuesByKeys).toHaveBeenCalledWith( + 'https://nhsd-jira.digital.nhs.uk', + 'CCM', + [], + ); + expect(fsPromises.mkdir).toHaveBeenCalledWith('/workspace', { + recursive: true, + }); + expect(fsPromises.writeFile).toHaveBeenCalledWith( + '/workspace/report.txt', + 'report', + 'utf8', + ); + expect(mockedRenderReport).toHaveBeenCalledWith( + expect.objectContaining({ + fixAction: undefined, + fixComponent: undefined, + fixProposals: undefined, + jiraBaseUrl: 'https://nhsd-jira.digital.nhs.uk', + }), + ); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining('Report written to /workspace/report.txt\n'), + ); + }); + + 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), + 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: [], + issueType: 'Story', + medicalClinicalSafetyCategory: '', + status: 'Done', + summary: 'one', + }, + ]) + .mockResolvedValueOnce([ + { + key: 'CCM-1', + clinicalLead: '', + clinicalReviewStatus: '', + components: [], + issueType: 'Story', + 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, rangeEndTag: '0.1.0' }, + { + gitTag: 'v0.2.0', + previousTag: '0.1.0', + rangeEndTag: 'v0.2.0', + }, + ], + jiraVersions: [ + { + id: '71260', + name: 'release-a', + releaseDate: '2026-07-08', + released: true, + }, + { + id: '71261', + name: 'release-b', + releaseDate: null, + released: false, + }, + ], + totalJiraIssues: 1, + }), + ); + }); + + 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([ + { + 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(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', + [{ id: '71260', name: 'release' }], + ); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining('Applied fixVersion updates to 1 issue(s).\n'), + ); + }); + + 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([ + [ + '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', + '../repo', + '--git-tag', + '0.1.0', + '--jira-version', + '71260', + '--fix', + 'clinical-review-not-needed', + '--fix-component', + 'Platform', + '--yes', + ]); + + expect(mockedUpdateJiraIssueClinicalReviewStatus).toHaveBeenCalledWith( + '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 new file mode 100644 index 00000000..75fd9927 --- /dev/null +++ b/tools/release-check/src/__tests__/jira.test.ts @@ -0,0 +1,299 @@ +import { + fetchJiraIssues, + fetchJiraIssuesByKeys, + resolveJiraVersion, + resolveJiraVersions, + updateJiraIssueClinicalReviewStatus, + updateJiraIssueFixVersions, +} 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('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, + }, + ], + }); + + 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('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('jira issue operations', () => { + 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 including fix versions', 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' }, + fixVersions: [{ id: 71_260, name: 'client-config-0.1.0' }], + issuetype: { name: 'Story' }, + 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', + fixVersions: [], + issuetype: { name: 'Bug' }, + 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([ + { + issueType: 'Story', + 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', + components: ['Platform'], + }, + { + issueType: 'Bug', + key: 'CCM-2', + clinicalLead: '', + clinicalReviewStatus: 'Review not needed', + fixVersions: [], + summary: 'Second', + medicalClinicalSafetyCategory: 'Cat 2|Cat 3', + status: 'In Progress', + 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 () => { + mockFetch.mockResolvedValue({ + 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( + 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', + }), + ); + }); +}); 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..7d23706d --- /dev/null +++ b/tools/release-check/src/__tests__/report.test.ts @@ -0,0 +1,879 @@ +import { + defaultReportPath, + renderFixProposalSection, + renderFixProposalTerminalSection, + renderReport, +} from '../report'; + +import type { + ComparisonResult, + FixProposal, + 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.'], +}; + +const fixProposals: FixProposal[] = [ + { + currentValueSummary: 'none', + issue: { + issueType: 'Story', + key: 'CCM-555', + summary: 'Needs fix version', + status: 'Done', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + fixVersions: [], + }, + proposedUpdateSummary: 'client-config-0.1.0', + 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.md'); + }); + + 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.md', + ); + }); +}); + +describe('renderReport', () => { + 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('## Warnings'); + expect(report).toContain( + '- No GitHub release body found for tag v0.0.1; falling back.', + ); + }); + + it('renders populated issue and fix sections as markdown tables', () => { + 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'], + }, + ], + ], + [ + '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: [ + { + commit: { + hash: 'b'.repeat(40), + shortHash: 'bbbbbbbb', + subject: 'CCM-999: outside', + body: '', + explicitIssueKeys: ['CCM-999'], + matchedIssueKeys: ['CCM-999'], + }, + 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: 'e'.repeat(40), + shortHash: 'eeeeeeee', + subject: 'maintenance', + body: '', + explicitIssueKeys: [], + matchedIssueKeys: [], + }, + ], + gitReferencedIssueKeys: ['CCM-100'], + jiraIssuesMissingClinicalLead: [ + { + issueType: 'Story', + key: 'CCM-104', + summary: 'Missing clinical lead', + status: 'Done', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: 'Review required', + medicalClinicalSafetyCategory: 'Cat 1', + }, + ], + jiraIssuesMissingClinicalSafetyCategory: [ + { + issueType: 'Story', + key: 'CCM-103', + summary: 'Missing clinical safety category', + status: 'Done', + components: ['Platform'], + clinicalLead: 'Dr Test', + clinicalReviewStatus: 'Review required', + medicalClinicalSafetyCategory: '', + }, + ], + jiraIssuesMissingFromGit: [ + { + issueType: 'Story', + key: 'CCM-101', + summary: 'Missing from git', + status: 'Done', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + }, + ], + jiraIssuesMissingFromReleaseNotes: [ + { + issueType: 'Story', + key: 'CCM-102', + summary: 'Missing from notes', + status: 'Done', + components: [], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + }, + ], + notesReferencedIssueKeys: ['CCM-100'], + releaseReferencedIssuesNotDone: [ + { + issueType: 'Story', + key: 'CCM-100', + summary: 'Referenced and not done', + status: 'In Progress', + components: ['Platform'], + clinicalLead: '', + clinicalReviewStatus: '', + medicalClinicalSafetyCategory: '', + }, + ], + releaseNotesIssueKeysOutsideRelease: ['CCM-200'], + }, + fixAction: 'fixVersion', + fixComponent: 'Platform', + fixProposals, + gitTags: [ + { gitTag: '0.1.0', previousTag: '0.0.9', rangeEndTag: '0.1.0' }, + ], + 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', + text: 'CCM-100 release note entry', + warnings: [], + }, + repoName: 'nhs-notify-client-config', + repoRoot: '/repos/nhs-notify-client-config', + totalJiraIssues: 3, + }); + + expect(populatedReport).toContain( + '## Jira issues in the release with no matching git reference', + ); + expect(populatedReport).toContain('| Issue | Commit |'); + expect(populatedReport).toContain( + '| [CCM-101](https://nhsd-jira.digital.nhs.uk/browse/CCM-101): [Platform] Missing from git (Done) | No matching commit |', + ); + expect(populatedReport).toContain( + '| [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](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](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', + ); + 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, rangeEndTag: '0.1.0' }, + { gitTag: 'v0.2.0', previousTag: '0.1.0', rangeEndTag: 'v0.2.0' }, + ], + jiraProject: 'CCM', + jiraVersions: [ + jiraVersion, + { + id: '71261', + name: 'client-config-0.2.0', + releaseDate: null, + released: false, + }, + ], + outsideReleaseIssuesByKey: new Map(), + releaseNotes: { + issueKeys: ['CCM-100'], + source: 'mixed', + text: null, + warnings: [], + }, + 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'); + 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', () => { + const report = renderReport({ + comparison, + fixAction: undefined, + fixComponent: undefined, + fixProposals: undefined, + gitTags: [{ gitTag: '0.1.0', previousTag: null }], + jiraProject: 'CCM', + jiraVersions: [ + { + id: '71260', + name: 'client-config-0.1.0', + releaseDate: null, + released: false, + }, + ], + 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('- **Jira release date:** unknown'); + 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: { + ...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](https://nhsd-jira.digital.nhs.uk/browse/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](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/args.ts b/tools/release-check/src/args.ts new file mode 100644 index 00000000..a1411059 --- /dev/null +++ b/tools/release-check/src/args.ts @@ -0,0 +1,118 @@ +import { parseArgs } from 'node:util'; + +import { parseSelectorList } from './selectors'; +import type { CliOptions, FixAction, 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'; + +const isFixAction = (value: string | undefined): value is FixAction => + value === 'fix-version' || value === 'clinical-review-not-needed'; + +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: { + fix: { type: 'string' }, + 'fix-component': { type: 'string' }, + 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' }, + 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'); + } + if (!isReleaseNotesSource(values['release-notes-source'])) { + throw new Error( + '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'], + 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, + ), + previousTag: values['previous-tag'], + output: values.output, + releaseNotesSource: values['release-notes-source'], + yes: values.yes ?? false, + }; +}; 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..41c9477b --- /dev/null +++ b/tools/release-check/src/compare.ts @@ -0,0 +1,144 @@ +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 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) { + 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 === '' && + shouldCheckClinicalReview(issue), + ); + const jiraIssuesMissingClinicalLead = issues.filter( + (issue) => issue.clinicalLead === '' && shouldCheckClinicalReview(issue), + ); + 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..014ecfe9 --- /dev/null +++ b/tools/release-check/src/git.ts @@ -0,0 +1,217 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +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; + +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 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, +): void => { + 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, + 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, + rangeEndTag: string = gitTag, +): GitCommit[] => { + 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', + '--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, + releaseRange, + releaseTag: gitTag, + shortHash, + subject, + }; + }); +}; + +export const collectCommitsForTags = ( + repoRoot: string, + gitTags: SelectedGitTag[], +): GitCommit[] => { + const commitsByHash = new Map(); + + 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); + } + } + } + + return [...commitsByHash.values()]; +}; + +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..a9fad778 --- /dev/null +++ b/tools/release-check/src/github-release.ts @@ -0,0 +1,236 @@ +import { getOriginRemoteUrl, readTagAnnotation } from './git'; + +import type { + ReleaseNotes, + ReleaseNotesLookupSource, + 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; +}; + +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, + 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 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 new file mode 100644 index 00000000..5f1991e1 --- /dev/null +++ b/tools/release-check/src/index.ts @@ -0,0 +1,693 @@ +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'; +import { + collectCommitsForTags, + getPreviousTag, + getRepoName, + getRepoRoot, + listTags, + resolveGitTags, + resolveRepoPath, +} from './git'; +import { readReleaseNotesForTags } from './github-release'; +import { + fetchJiraIssues, + fetchJiraIssuesByKeys, + listJiraVersions, + resolveJiraVersions, + updateJiraIssueClinicalReviewStatus, + updateJiraIssueFixVersions, +} from './jira'; +import { + defaultReportPath, + renderFixProposalTerminalSection, + renderReport, +} from './report'; +import type { + FixProposal, + JiraIssue, + JiraIssueFixDetails, + JiraVersion, + SelectedGitTag, +} from './types'; + +const writeReport = async ( + outputPath: string, + report: string, +): Promise => { + await mkdir(path.dirname(outputPath), { recursive: true }); + 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 formatSelectedGitTagLabel = ({ + gitTag, + rangeEndTag, +}: SelectedGitTag): string => + rangeEndTag && rangeEndTag !== gitTag + ? `${gitTag} (+ patches through ${rangeEndTag})` + : gitTag; + +const formatGitTagSummary = (gitTags: SelectedGitTag[]): string => + gitTags.map((gitTag) => formatSelectedGitTagLabel(gitTag)).join(', '); + +const formatComparisonBaseSummary = (gitTags: SelectedGitTag[]): string => + gitTags + .map( + (gitTag) => + `${formatSelectedGitTagLabel(gitTag)} <- ${gitTag.previousTag ?? 'repository start'}`, + ) + .join('; '); + +const formatJiraVersionSummary = (jiraVersions: JiraVersion[]): string => + jiraVersions.map((version) => `${version.name} (${version.id})`).join(', '); + +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 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 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, +): 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, +): 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, + proposedUpdateSummary: formatProposedFixVersionUpdate( + targetVersion, + issue.fixVersions, + ), + targetValueSummary: formatFixVersions( + appendFixVersion(issue.fixVersions, targetVersion), + ), + })); +}; + +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, + }, + ]); +}; + +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 = await resolveSelectedGitTags( + repoRoot, + options.gitTagSelectors, + options.jiraBaseUrl, + options.jiraProject, + options.previousTag, + ); + const commits = collectCommitsForTags(repoRoot, selectedGitTags); + + const jiraVersions = await resolveJiraVersions( + options.jiraBaseUrl, + options.jiraProject, + options.jiraVersionSelectors, + ); + const jiraIssuesByVersion = await Promise.all( + jiraVersions.map((jiraVersion) => + fetchJiraIssues(options.jiraBaseUrl, options.jiraProject, jiraVersion), + ), + ); + const jiraIssues = dedupeBy( + jiraIssuesByVersion.flat(), + (issue: JiraIssue) => issue.key, + ); + const releaseNotes = await readReleaseNotesForTags( + repoRoot, + selectedGitTags.map(({ gitTag }) => gitTag), + options.releaseNotesSource, + ); + const comparison = compareRelease( + commits, + 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 = 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, + totalJiraIssues: jiraIssues.length, + }); + + 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: ${formatSelectedGitTagLabel(selectedGitTags[0])}` + : `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}`, + ...(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 new file mode 100644 index 00000000..a0350fb8 --- /dev/null +++ b/tools/release-check/src/jira.ts @@ -0,0 +1,417 @@ +import { hasGlobPattern, matchesGlobPattern } from './selectors'; +import type { + JiraFixVersion, + JiraIssue, + JiraIssueFixDetails, + 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 JIRA_SEARCH_FIELDS = [ + 'summary', + 'status', + 'issuetype', + 'components', + 'fixVersions', + CLINICAL_LEAD_FIELD_ID, + MEDICAL_CLINICAL_SAFETY_CATEGORY_FIELD_ID, + CLINICAL_REVIEW_STATUS_FIELD_ID, +]; + +type JiraVersionResponse = { + id: string | number; + name: string; + releaseDate?: string; + 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, + releaseDate: version.releaseDate ?? null, + released: Boolean(version.released), +}); + +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 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 } => { + 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 }; +}; + +const fetchProjectVersions = async ( + jiraBaseUrl: string, + jiraProject: string, +): Promise => { + const versions = await fetchJiraJson( + `${jiraBaseUrl}/rest/api/2/project/${encodeURIComponent(jiraProject)}/versions`, + ); + + 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 versions matching "${reference}" in project ${jiraProject}.`, + ); + } + + 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 listJiraVersions = async ( + jiraBaseUrl: string, + jiraProject: string, +): Promise => fetchProjectVersions(jiraBaseUrl, jiraProject); + +export const fetchJiraIssues = async ( + jiraBaseUrl: string, + jiraProject: string, + jiraVersion: JiraVersion, +): Promise => { + 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); +}; + +export const fetchJiraIssuesByKeys = async ( + jiraBaseUrl: string, + jiraProject: string, + issueKeys: string[], +): Promise => { + const uniqueIssueKeys = [...new Set(issueKeys)]; + + if (uniqueIssueKeys.length === 0) { + return []; + } + + 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 new file mode 100644 index 00000000..4d9fc30b --- /dev/null +++ b/tools/release-check/src/report.ts @@ -0,0 +1,872 @@ +import path from 'node:path'; + +import type { + ComparisonResult, + FixProposal, + JiraFixVersion, + JiraIssue, + JiraVersion, + MatchedCommit, + ReleaseNotes, + 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 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`; + } + + const components = + issue.components.length > 0 ? `[${issue.components.join(', ')}] ` : ''; + 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', +): 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 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[] => + [ + ...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\n- none\n`; + } + + const renderedLines = lines.map((line) => `- ${line}`).join('\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 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 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 row; + }); + + return `## ${title}\n\n${renderTable(headers, rows)}\n`; +}; + +const renderFixProposalSection = ( + fixAction: string, + 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 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(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 [ + 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, '-'); + +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) => formatSelectedGitTagLabel(gitTag)).join(', '); + +const formatComparisonBaseSummary = (gitTags: SelectedGitTag[]): string => + gitTags + .map( + (gitTag) => + `${formatSelectedGitTagLabel(gitTag)} <- ${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, + gitTags: string[], + cwd: string = process.cwd(), +): string => + path.join( + cwd, + '.tmp', + 'release-check', + `${sanitizeFileSegment(repoName)}-${summarizeGitTagsForPath(gitTags)}.md`, + ); + +export const renderReport = ({ + comparison, + fixAction, + fixComponent, + fixProposals, + gitTags, + jiraBaseUrl = DEFAULT_JIRA_BASE_URL, + jiraProject, + jiraVersions, + outsideReleaseIssuesByKey, + releaseNotes, + repoName, + repoRoot, + totalJiraIssues, +}: { + comparison: ComparisonResult; + fixAction?: string; + fixComponent?: string; + fixProposals?: FixProposal[]; + gitTags: SelectedGitTag[]; + jiraBaseUrl?: string; + jiraProject: string; + jiraVersions: JiraVersion[]; + outsideReleaseIssuesByKey: Map; + 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 outsideReleaseIssueKeys = groupOutsideReleaseReferences( + comparison.commitsWithIssueKeysOutsideRelease, + ); + const showReleaseRangeComparison = gitTags.length > 1; + const sections = [ + '# Release check report', + '', + `- **Repository:** ${repoName}`, + `- **Repository root:** ${repoRoot}`, + singleGitTag + ? `- **Git tag:** ${formatSelectedGitTagLabel(gitTags[0])}` + : `- **Git tags selected (${gitTags.length}):** ${formatGitTagSummary(gitTags)}`, + singleGitTag + ? `- **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(', ')}`, + ...(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 ${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-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}`, + `- Jira issues missing clinical lead: ${comparison.jiraIssuesMissingClinicalLead.length}`, + `- Commits without Jira matches: ${comparison.commitsWithoutMatches.length}`, + ...renderGitRangeMappings(gitTags, jiraVersions), + '', + ]; + + if (warnings.length > 0) { + 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( + renderIssueSection( + `Jira issues in ${jiraScopeLabel} with no matching git reference`, + 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', + comparison.jiraIssuesMissingClinicalSafetyCategory.map( + (issue) => issue.key, + ), + 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 + ? [ + renderFixProposalSection( + fixAction, + fixComponent, + fixProposals, + comparison.commitsByIssueKey, + showReleaseRangeComparison, + jiraBaseUrl, + ), + ] + : []), + renderSimpleSection( + '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'); +}; + +export { renderFixProposalSection, renderFixProposalTerminalSection }; 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 new file mode 100644 index 00000000..cdbd200b --- /dev/null +++ b/tools/release-check/src/types.ts @@ -0,0 +1,99 @@ +export type JiraIssue = { + clinicalLead: string; + clinicalReviewStatus: string; + components: string[]; + fixVersions?: JiraFixVersion[]; + issueType: string; + key: string; + medicalClinicalSafetyCategory: string; + status: string; + summary: string; +}; + +export type JiraFixVersion = { + id: string; + name: string; +}; + +export type JiraIssueFixDetails = JiraIssue & { + fixVersions: JiraFixVersion[]; +}; + +export type GitCommit = { + body: string; + explicitIssueKeys: string[]; + hash: string; + releaseRange?: string; + releaseTag?: string; + shortHash: string; + subject: string; +}; + +export type ReleaseNotesSource = 'auto' | 'github' | 'tag' | 'none'; + +export type ReleaseNotesLookupSource = + 'github-release' | 'mixed' | 'none' | 'tag-annotation'; + +export type ReleaseNotes = { + issueKeys: string[]; + source: ReleaseNotesLookupSource; + 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 = { + fixAction?: FixAction; + fixComponent?: string; + gitTagSelectors: string[]; + jiraBaseUrl: string; + jiraProject: string; + jiraVersionSelectors: string[]; + output?: string; + previousTag?: string; + releaseNotesSource: ReleaseNotesSource; + repo: string; + yes: boolean; +}; + +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; +}; 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" + ] +}