From 65d3b110da41c0cf2de56f2946b534e3fc04a1a6 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Fri, 14 Nov 2025 17:56:28 +0100 Subject: [PATCH 01/10] Stop filtering out PRs that were also backported to previous versions --- src/common/github-service.ts | 37 ++++++++++++++----- src/common/pr.tsx | 19 +++++++++- .../components/grouped-pr-list.tsx | 10 ++++- .../components/uncategorized-pr.tsx | 5 ++- .../release-notes/prepare-release-notes.tsx | 26 +++++++++---- src/pages/release-notes/release-notes.tsx | 2 +- 6 files changed, 75 insertions(+), 24 deletions(-) diff --git a/src/common/github-service.ts b/src/common/github-service.ts index 453b59a..d55d49b 100644 --- a/src/common/github-service.ts +++ b/src/common/github-service.ts @@ -50,16 +50,33 @@ function filterPrsForVersion( version: string, ignoredVersionLabels: readonly string[] = [] ): PrItem[] { - return prs.filter((pr) => { - const prVersions = pr.labels - .filter((label) => label.name?.match(SEMVER_REGEX)) - .filter((label) => label.name && !ignoredVersionLabels.includes(label.name)) - .map((label) => semver.clean(label.name ?? '') ?? ''); - // Check if there is any version label below the one we are looking for - // which would mean this PR has already been released (and blogged about) - // in an earlier dev documentation blog post. - return !prVersions.some((verLabel) => semver.lt(verLabel, version)); - }); + // No longer filtering out PRs with lower version labels + // Just return all PRs, the warning logic is handled separately + return prs; +} + +/** + * Checks if a PR has multiple patch version labels for the same major.minor version. + * This indicates the PR may have been documented in multiple patch releases. + */ +export function hasDuplicatePatchLabels(pr: PrItem, targetVersion: string): boolean { + const targetSemVer = semver.parse(targetVersion); + if (!targetSemVer) { + return false; + } + + const prVersions = pr.labels + .filter((label) => label.name?.match(SEMVER_REGEX)) + .map((label) => semver.parse(label.name ?? '')) + .filter((ver): ver is semver.SemVer => ver !== null); + + // Find all version labels that match the same major.minor as target + const sameMajorMinor = prVersions.filter( + (ver) => ver.major === targetSemVer.major && ver.minor === targetSemVer.minor + ); + + // If there are 2 or more patch versions for the same major.minor, return true + return sameMajorMinor.length >= 2; } export class GitHubService { diff --git a/src/common/pr.tsx b/src/common/pr.tsx index c85cab6..644dce1 100644 --- a/src/common/pr.tsx +++ b/src/common/pr.tsx @@ -1,6 +1,6 @@ import { EuiLink, EuiIconTip } from '@elastic/eui'; import { FC, memo } from 'react'; -import { PrItem } from './github-service'; +import { PrItem, hasDuplicatePatchLabels } from './github-service'; import { extractReleaseNotes, NormalizeOptions, ReleaseNoteDetails } from './pr-utils'; interface PrProps { @@ -8,13 +8,15 @@ interface PrProps { showAuthor?: boolean; showTransformedTitle?: boolean; normalizeOptions?: NormalizeOptions; + version?: string; } export const Pr: FC = memo( - ({ pr, showAuthor, showTransformedTitle, normalizeOptions }) => { + ({ pr, showAuthor, showTransformedTitle, normalizeOptions, version }) => { const title: ReleaseNoteDetails = showTransformedTitle ? extractReleaseNotes(pr, normalizeOptions) : { type: 'title', title: pr.title }; + const hasDuplicates = version ? hasDuplicatePatchLabels(pr, version) : false; return ( <> {title.title} ( @@ -27,6 +29,19 @@ export const Pr: FC = memo( )} ){' '} + {hasDuplicates && ( + + This PR has multiple patch version labels for the same major.minor version. It may + have already been documented in a previous patch release. + + } + /> + )} {title.type === 'releaseNoteTitle' && ( = memo(({ groupedPrs, groups, keyPrefix }) => { +export const GroupedPrList: FC = memo(({ groupedPrs, groups, keyPrefix, version }) => { const sortedGroups = useMemo( () => [...groups].sort((a, b) => a.title.localeCompare(b.title)), [groups] @@ -37,7 +38,12 @@ export const GroupedPrList: FC = memo(({ groupedPrs, groups, keyPrefix })
    {prs.map((pr) => (
  • - +
  • ))}
diff --git a/src/pages/release-notes/components/uncategorized-pr.tsx b/src/pages/release-notes/components/uncategorized-pr.tsx index 99c3d23..2265d67 100644 --- a/src/pages/release-notes/components/uncategorized-pr.tsx +++ b/src/pages/release-notes/components/uncategorized-pr.tsx @@ -17,6 +17,7 @@ import { setConfig, useActiveConfig } from '../../../config'; interface UncategorizedPrProps { pr: PrItem; + version: string; } const LabelBadge: FC<{ label: Label }> = memo(({ label }) => { @@ -83,7 +84,7 @@ const LabelBadge: FC<{ label: Label }> = memo(({ label }) => { ); }); -export const UncategorizedPr: FC = memo(({ pr }) => { +export const UncategorizedPr: FC = memo(({ pr, version }) => { // We only want to show non version non release_note labels in the UI const filteredLables = useMemo( () => @@ -95,7 +96,7 @@ export const UncategorizedPr: FC = memo(({ pr }) => { return ( - + {filteredLables.length > 0 && ( diff --git a/src/pages/release-notes/prepare-release-notes.tsx b/src/pages/release-notes/prepare-release-notes.tsx index 30ca234..fdc4bbd 100644 --- a/src/pages/release-notes/prepare-release-notes.tsx +++ b/src/pages/release-notes/prepare-release-notes.tsx @@ -7,9 +7,10 @@ import { GroupedPrList, UncategorizedPr } from './components'; interface Props { prs: PrItem[]; + version: string; } -export const PrepareReleaseNotes: FC = ({ prs }) => { +export const PrepareReleaseNotes: FC = ({ prs, version }) => { const config = useActiveConfig(); const groupedPrs = useMemo(() => groupPrs(prs), [prs]); @@ -49,7 +50,7 @@ export const PrepareReleaseNotes: FC = ({ prs }) => {
    {groupedPrs.missingLabel.map((pr) => (
  • - +
  • ))}
@@ -73,7 +74,7 @@ export const PrepareReleaseNotes: FC = ({ prs }) => { {unknownPrs.map((pr) => ( - + ))} @@ -87,7 +88,7 @@ export const PrepareReleaseNotes: FC = ({ prs }) => {
    {groupedPrs.breaking.map((pr) => (
  • - +
  • ))}
@@ -101,7 +102,7 @@ export const PrepareReleaseNotes: FC = ({ prs }) => {
    {groupedPrs.deprecation.map((pr) => (
  • - +
  • ))}
@@ -112,7 +113,12 @@ export const PrepareReleaseNotes: FC = ({ prs }) => {

Features (release_note:feature)

- + )} {Object.keys(enhancementPrs).length > 0 && ( @@ -124,6 +130,7 @@ export const PrepareReleaseNotes: FC = ({ prs }) => { groupedPrs={enhancementPrs} groups={config.areas} keyPrefix="enhancements" + version={version} /> )} @@ -132,7 +139,12 @@ export const PrepareReleaseNotes: FC = ({ prs }) => {

Fixes (release_note:fix)

- + )} diff --git a/src/pages/release-notes/release-notes.tsx b/src/pages/release-notes/release-notes.tsx index 4b7a7a5..6bea11f 100644 --- a/src/pages/release-notes/release-notes.tsx +++ b/src/pages/release-notes/release-notes.tsx @@ -160,7 +160,7 @@ export const ReleaseNotes: FC = ({ )} {step === 'prepare' ? ( - + ) : ( )} From bf21cd3e49f03585ab36979259f2bde5396ab0d9 Mon Sep 17 00:00:00 2001 From: Florent Le Borgne Date: Fri, 14 Nov 2025 19:07:13 +0100 Subject: [PATCH 02/10] remove step for selecting unreleased version numbers --- src/common/github-service.ts | 13 +- src/pages/release-notes/page.tsx | 5 +- src/pages/release-notes/release-notes.tsx | 15 +- src/pages/release-notes/wizard.tsx | 176 +--------------------- 4 files changed, 13 insertions(+), 196 deletions(-) diff --git a/src/common/github-service.ts b/src/common/github-service.ts index d55d49b..b9d2a56 100644 --- a/src/common/github-service.ts +++ b/src/common/github-service.ts @@ -45,11 +45,7 @@ interface ExtractDeployedShaParams extends ServerlessGitOpsParams { const SEMVER_REGEX = /^v(\d+)\.(\d+)\.(\d+)$/; -function filterPrsForVersion( - prs: PrItem[], - version: string, - ignoredVersionLabels: readonly string[] = [] -): PrItem[] { +function filterPrsForVersion(prs: PrItem[]): PrItem[] { // No longer filtering out PRs with lower version labels // Just return all PRs, the warning logic is handled separately return prs; @@ -249,14 +245,13 @@ export class GitHubService { q: `repo:${GITHUB_OWNER}/${this.repoName} label:release_note:plugin_api_changes label:${version}`, }); const items = await this.octokit.paginate(options); - return filterPrsForVersion(items, version); + return filterPrsForVersion(items); } public async getPrsForVersion( version: string, excludedLabels: readonly string[] = [], - includedLabels: readonly string[] = [], - ignoredVersionLabels: readonly string[] = [] + includedLabels: readonly string[] = [] ): Promise>> { const semVer = semver.parse(version); @@ -298,7 +293,7 @@ export class GitHubService { (async () => { const items: PrItem[] = []; for await (const response of this.octokit.paginate.iterator(options)) { - items.push(...filterPrsForVersion(response.data, version, ignoredVersionLabels)); + items.push(...filterPrsForVersion(response.data)); if (response.headers.link) { const links = parseLinkHeader(response.headers.link); if (links?.last?.page) { diff --git a/src/pages/release-notes/page.tsx b/src/pages/release-notes/page.tsx index 6981211..e7c5232 100644 --- a/src/pages/release-notes/page.tsx +++ b/src/pages/release-notes/page.tsx @@ -4,12 +4,10 @@ import { ReleaseNotesWizard } from './wizard'; export const ReleaseNotesPage: FC = () => { const [selectedVersion, setSelectedVersion] = useState(); - const [ignoredVersions, setIgnoredVersions] = useState([]); const [selectedServerlessSHAs, setSelectedServerlessSHAs] = useState>(new Set()); - const onVersionChange = useCallback((version: string, ignoreVersions: string[] = []) => { + const onVersionChange = useCallback((version: string) => { setSelectedVersion(version); - setIgnoredVersions(ignoreVersions); }, []); return ( @@ -24,7 +22,6 @@ export const ReleaseNotesPage: FC = () => { {selectedVersion && ( setSelectedVersion(undefined)} /> diff --git a/src/pages/release-notes/release-notes.tsx b/src/pages/release-notes/release-notes.tsx index 6bea11f..052226f 100644 --- a/src/pages/release-notes/release-notes.tsx +++ b/src/pages/release-notes/release-notes.tsx @@ -21,17 +21,11 @@ import { ConfigFlyout } from './components'; interface Props { version: string; - ignoredPriorReleases: string[]; selectedServerlessSHAs: Set; onVersionChange: () => void; } -export const ReleaseNotes: FC = ({ - version, - onVersionChange, - ignoredPriorReleases, - selectedServerlessSHAs, -}) => { +export const ReleaseNotes: FC = ({ version, onVersionChange, selectedServerlessSHAs }) => { const subscriptionRef = useRef(); const [github, errorHandler] = useGitHubService(); const config = useActiveConfig(); @@ -54,12 +48,7 @@ export const ReleaseNotes: FC = ({ try { subscriptionRef.current = ( - await github.getPrsForVersion( - version, - config.excludedLabels, - config.includedLabels, - ignoredPriorReleases - ) + await github.getPrsForVersion(version, config.excludedLabels, config.includedLabels) ).subscribe((status) => { if (status.type === 'complete') { setLoading(false); diff --git a/src/pages/release-notes/wizard.tsx b/src/pages/release-notes/wizard.tsx index 5d3e2bc..c3560d1 100644 --- a/src/pages/release-notes/wizard.tsx +++ b/src/pages/release-notes/wizard.tsx @@ -1,8 +1,6 @@ import { - EuiAccordion, EuiButton, EuiButtonEmpty, - EuiCallOut, EuiCheckableCard, EuiCheckboxGroup, EuiFieldText, @@ -15,10 +13,8 @@ import { EuiSpacer, EuiSteps, EuiStepsProps, - EuiSwitch, EuiText, EuiTextColor, - EuiTitle, } from '@elastic/eui'; import React, { FC, useEffect, useMemo, useState, useCallback } from 'react'; import { ServerlessRelease, useGitHubService } from '../../common'; @@ -28,7 +24,7 @@ import { ConfigFlyout } from './components'; const DEFAULT_SERVERLESS_SHAS = 2; interface Props { - onVersionSelected: (version: string, ignoreVersions?: string[]) => void; + onVersionSelected: (version: string) => void; selectedServerlessSHAs: Set; setSelectedServerlessSHAs: (sha: Set) => void; } @@ -43,9 +39,6 @@ export const ReleaseNotesWizard: FC = ({ const [manualLabel, setManualLabel] = useState(''); const [showConfigFlyout, setShowConfigFlyout] = useState(); const [templates, setTemplates] = useState(getTemplateInfos()); - const [validateVersion, setValidateVersion] = useState(); - const [isValidatingVersion, setIsValidatingVersion] = useState(false); - const [previousMissingReleases, setPreviousMissingReleases] = useState>(); const isServerless = getActiveTemplateId() === 'serverless'; const [serverlessReleases, setServerlessReleases] = useState([]); @@ -63,35 +56,13 @@ export const ReleaseNotesWizard: FC = ({ } }, [errorHandler, github, isServerless]); - const onValidateVersion = useCallback( - async (version: string): Promise => { - setValidateVersion(version); - setIsValidatingVersion(true); - try { - const missingReleases = await github.getUnreleasedPastLabels(version); - setIsValidatingVersion(false); - if (missingReleases.length === 0) { - onVersionSelected(version); - } else { - setPreviousMissingReleases( - Object.fromEntries(missingReleases.map((release) => [release, false])) - ); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - errorHandler(e); - } - }, - [errorHandler, github, onVersionSelected] - ); - const onSubmitManualLabel = useCallback( (ev: React.FormEvent): void => { ev.preventDefault(); - onValidateVersion(manualLabel); + onVersionSelected(manualLabel); setManualLabel(''); }, - [manualLabel, onValidateVersion] + [manualLabel, onVersionSelected] ); const onServerlessReleaseChange = useCallback( @@ -113,16 +84,7 @@ export const ReleaseNotesWizard: FC = ({ if (isServerless) { onVersionSelected('serverless'); } - - if (validateVersion) { - onVersionSelected( - validateVersion, - Object.entries(previousMissingReleases ?? {}) - .filter(([, plannedRelease]) => !plannedRelease) - .map(([release]) => release) - ); - } - }, [isServerless, onVersionSelected, previousMissingReleases, validateVersion]); + }, [isServerless, onVersionSelected]); const steps = useMemo(() => { const baseSteps: EuiStepsProps['steps'] = [ @@ -245,23 +207,9 @@ export const ReleaseNotesWizard: FC = ({ {labels?.map((label) => ( - onValidateVersion(label)} - iconType={validateVersion === label ? 'check' : undefined} - > - {label} - + onVersionSelected(label)}>{label} ))} - {validateVersion && !labels.includes(validateVersion) && ( - - - {validateVersion} - - - )}
@@ -295,131 +243,19 @@ export const ReleaseNotesWizard: FC = ({ ), }, - { - title: previousMissingReleases - ? 'Found version labels without release' - : 'Validate release version', - status: isValidatingVersion ? 'loading' : validateVersion ? 'warning' : 'incomplete', - children: ( - <> - {isValidatingVersion && Checking previous releases …} - {!isValidatingVersion && !validateVersion && ( - - Please select a version above to continue. - - )} - {!isValidatingVersion && validateVersion && ( - <> - - - The following older version labels exist in the repository, but actually have - not been released (yet). For the release note generation to work, please mark - versions that will still be released. Leave versions unmarked if there is no - release planned for this version. - - - -

- How does this tool determine PRs for a version? Version - labels on PRs determine in which version's release note a PR appears. The - way version labels are used in the repository, can cause a PR to have - multiple version labels attached. That PR should nevertheless only show up - in the earliest version it got released, e.g. a PR with the labels v7.10.2, - v7.11.0 and v8.0.0 should only appear in the v7.10.2 release notes. This - tool makes sure it will only be included in the release note of the earliest - version. -

-

- What is the problem with unreleased version labels? PRs - with a version label assigned for a version that never will be released are - causing problems. Scenario: a PR has the labels v7.10.3, v7.11.1, v7.12.0, - v8.0.0 with 7.10.3 never being an actual release. This tool would now assume - that this PR should show up in v7.10.3's release notes, which never existed, - and thus the PR will never be listed in any release notes. -

-

- Why do those unreleased labels exist? Sometimes the release - was originally planned and then canceled, sometimes some engineer created - that label while that release was never planned and then the label ended up - on more and more PRs. -

-

- What do I need to do here? This tool retrieves a list of - existing version labels from the two minor versions prior to the selected - version. It will check if any of them does not match an existing release. - For each of those version labels without a matching release you'll need to - specify how it should be treated: -

-
- null} - compressed - label="" - style={{ cursor: 'default' }} - />{' '} - The version will still be released, it just hasn't happened yet. No - special behavior will be applied. -
- -
- null} - compressed - label="" - style={{ cursor: 'default' }} - />{' '} - The version will never be released. The tool will ignore this label and - treat PRs, like they wouldn't have this label attached, i.e. they move into - the release notes for the next highest version label attached. -
-
-
-
- - -

Which versions will still be released?

-
- - {previousMissingReleases && - Object.entries(previousMissingReleases).map(([release, checked]) => ( - - - setPreviousMissingReleases((prev) => ({ ...prev, [release]: !checked })) - } - compressed - /> - - ))} - - - Generate release notes - - - )} - - ), - }, ]); }, [ githubLoading, serverlessReleases, isServerless, - isValidatingVersion, labels, manualLabel, onGenerateReleaseNotes, onServerlessReleaseChange, onSubmitManualLabel, - onValidateVersion, - previousMissingReleases, + onVersionSelected, selectedServerlessSHAs, templates, - validateVersion, ]); return ( From abec88e274019fd4e785812c8ed140e0d3dfba20 Mon Sep 17 00:00:00 2001 From: Brad White Date: Tue, 18 Nov 2025 09:03:32 +1000 Subject: [PATCH 03/10] remove filterPrsForVersion --- src/common/github-service.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/common/github-service.ts b/src/common/github-service.ts index b9d2a56..150d97b 100644 --- a/src/common/github-service.ts +++ b/src/common/github-service.ts @@ -45,12 +45,6 @@ interface ExtractDeployedShaParams extends ServerlessGitOpsParams { const SEMVER_REGEX = /^v(\d+)\.(\d+)\.(\d+)$/; -function filterPrsForVersion(prs: PrItem[]): PrItem[] { - // No longer filtering out PRs with lower version labels - // Just return all PRs, the warning logic is handled separately - return prs; -} - /** * Checks if a PR has multiple patch version labels for the same major.minor version. * This indicates the PR may have been documented in multiple patch releases. @@ -244,8 +238,7 @@ export class GitHubService { const options = this.octokit.search.issuesAndPullRequests.endpoint.merge({ q: `repo:${GITHUB_OWNER}/${this.repoName} label:release_note:plugin_api_changes label:${version}`, }); - const items = await this.octokit.paginate(options); - return filterPrsForVersion(items); + return await this.octokit.paginate(options); } public async getPrsForVersion( @@ -293,7 +286,7 @@ export class GitHubService { (async () => { const items: PrItem[] = []; for await (const response of this.octokit.paginate.iterator(options)) { - items.push(...filterPrsForVersion(response.data)); + items.push(...response.data); if (response.headers.link) { const links = parseLinkHeader(response.headers.link); if (links?.last?.page) { From f17e34a47bc8a83db1e0302cbc470019be78af9f Mon Sep 17 00:00:00 2001 From: Brad White Date: Tue, 18 Nov 2025 09:35:33 +1000 Subject: [PATCH 04/10] cleanup semver parsing --- src/common/github-service.ts | 7 ++----- src/pages/release-notes/wizard.tsx | 8 ++++++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/common/github-service.ts b/src/common/github-service.ts index 150d97b..c11c563 100644 --- a/src/common/github-service.ts +++ b/src/common/github-service.ts @@ -43,8 +43,6 @@ interface ExtractDeployedShaParams extends ServerlessGitOpsParams { gitOpsSha: string; } -const SEMVER_REGEX = /^v(\d+)\.(\d+)\.(\d+)$/; - /** * Checks if a PR has multiple patch version labels for the same major.minor version. * This indicates the PR may have been documented in multiple patch releases. @@ -56,9 +54,8 @@ export function hasDuplicatePatchLabels(pr: PrItem, targetVersion: string): bool } const prVersions = pr.labels - .filter((label) => label.name?.match(SEMVER_REGEX)) - .map((label) => semver.parse(label.name ?? '')) - .filter((ver): ver is semver.SemVer => ver !== null); + .map(({ name }) => semver.parse(name ?? '')) + .filter((ver): ver is SemVer => ver !== null); // Find all version labels that match the same major.minor as target const sameMajorMinor = prVersions.filter( diff --git a/src/pages/release-notes/wizard.tsx b/src/pages/release-notes/wizard.tsx index c3560d1..ae68c2e 100644 --- a/src/pages/release-notes/wizard.tsx +++ b/src/pages/release-notes/wizard.tsx @@ -20,6 +20,7 @@ import React, { FC, useEffect, useMemo, useState, useCallback } from 'react'; import { ServerlessRelease, useGitHubService } from '../../common'; import { getTemplateInfos, setActiveTemplate, TemplateId, getActiveTemplateId } from '../../config'; import { ConfigFlyout } from './components'; +import semver, { SemVer } from 'semver'; const DEFAULT_SERVERLESS_SHAS = 2; @@ -42,6 +43,13 @@ export const ReleaseNotesWizard: FC = ({ const isServerless = getActiveTemplateId() === 'serverless'; const [serverlessReleases, setServerlessReleases] = useState([]); + console.log(semver.parse('v1.2.3'), 'v1.2.3'); + console.log(semver.parse('1.2.3'), '1.2.3'); + console.log(semver.parse(null), 'null'); + console.log(semver.parse(undefined), 'undef'); + console.log(semver.parse(''), 'empty'); + console.log(semver.parse('a.b.c'), 'a.b.c'); + useEffect(() => { if (isServerless) { github.getServerlessReleases().then( From 5346d061550bc4d28f7adc5c2b8a414a746ed652 Mon Sep 17 00:00:00 2001 From: Brad White Date: Tue, 18 Nov 2025 09:36:38 +1000 Subject: [PATCH 05/10] cleanup debugging code --- src/pages/release-notes/wizard.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/pages/release-notes/wizard.tsx b/src/pages/release-notes/wizard.tsx index ae68c2e..c3560d1 100644 --- a/src/pages/release-notes/wizard.tsx +++ b/src/pages/release-notes/wizard.tsx @@ -20,7 +20,6 @@ import React, { FC, useEffect, useMemo, useState, useCallback } from 'react'; import { ServerlessRelease, useGitHubService } from '../../common'; import { getTemplateInfos, setActiveTemplate, TemplateId, getActiveTemplateId } from '../../config'; import { ConfigFlyout } from './components'; -import semver, { SemVer } from 'semver'; const DEFAULT_SERVERLESS_SHAS = 2; @@ -43,13 +42,6 @@ export const ReleaseNotesWizard: FC = ({ const isServerless = getActiveTemplateId() === 'serverless'; const [serverlessReleases, setServerlessReleases] = useState([]); - console.log(semver.parse('v1.2.3'), 'v1.2.3'); - console.log(semver.parse('1.2.3'), '1.2.3'); - console.log(semver.parse(null), 'null'); - console.log(semver.parse(undefined), 'undef'); - console.log(semver.parse(''), 'empty'); - console.log(semver.parse('a.b.c'), 'a.b.c'); - useEffect(() => { if (isServerless) { github.getServerlessReleases().then( From 20254974f2da01eab4db5cf5d6032a2c7ebb42b3 Mon Sep 17 00:00:00 2001 From: Brad White Date: Tue, 18 Nov 2025 09:44:05 +1000 Subject: [PATCH 06/10] cleanup manualLabel --- src/pages/release-notes/wizard.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/release-notes/wizard.tsx b/src/pages/release-notes/wizard.tsx index c3560d1..b9df53a 100644 --- a/src/pages/release-notes/wizard.tsx +++ b/src/pages/release-notes/wizard.tsx @@ -22,6 +22,7 @@ import { getTemplateInfos, setActiveTemplate, TemplateId, getActiveTemplateId } import { ConfigFlyout } from './components'; const DEFAULT_SERVERLESS_SHAS = 2; +const SEMVER_REGEX = /^v(\d+)\.(\d+)\.(\d+)$/; interface Props { onVersionSelected: (version: string) => void; @@ -218,20 +219,20 @@ export const ReleaseNotesWizard: FC = ({ setManualLabel(ev.target.value)} - isInvalid={!!manualLabel && !manualLabel.match(/^v\d+\.\d+\.\d+$/)} + isInvalid={!!manualLabel && !manualLabel.match(SEMVER_REGEX)} /> Apply From d11e0fd02f329f1141c99efe4aac244449265732 Mon Sep 17 00:00:00 2001 From: Brad White Date: Tue, 18 Nov 2025 10:24:18 +1000 Subject: [PATCH 07/10] use reducer --- src/common/github-service.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/common/github-service.ts b/src/common/github-service.ts index c11c563..e4f9e50 100644 --- a/src/common/github-service.ts +++ b/src/common/github-service.ts @@ -53,17 +53,23 @@ export function hasDuplicatePatchLabels(pr: PrItem, targetVersion: string): bool return false; } - const prVersions = pr.labels - .map(({ name }) => semver.parse(name ?? '')) - .filter((ver): ver is SemVer => ver !== null); - // Find all version labels that match the same major.minor as target - const sameMajorMinor = prVersions.filter( - (ver) => ver.major === targetSemVer.major && ver.minor === targetSemVer.minor - ); + const sameMajorMinor = pr.labels.reduce((acc, { name }) => { + const ver = semver.parse(name ?? ''); + + if (ver === null) { + return acc; + } + + // tilde matches exactly on major.minor but allows for any patch version + if (semver.intersects(`~${ver.version}`, `~${targetSemVer.version}`)) { + acc++; + } + + return acc; + }, 0); - // If there are 2 or more patch versions for the same major.minor, return true - return sameMajorMinor.length >= 2; + return sameMajorMinor >= 2; } export class GitHubService { From 57f81610e61d4189aad61f104736c6be7ec877a7 Mon Sep 17 00:00:00 2001 From: Brad White Date: Tue, 18 Nov 2025 10:40:38 +1000 Subject: [PATCH 08/10] use actual version in tooltip --- src/common/github-service.ts | 2 +- src/common/pr.tsx | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/common/github-service.ts b/src/common/github-service.ts index e4f9e50..82171f5 100644 --- a/src/common/github-service.ts +++ b/src/common/github-service.ts @@ -47,7 +47,7 @@ interface ExtractDeployedShaParams extends ServerlessGitOpsParams { * Checks if a PR has multiple patch version labels for the same major.minor version. * This indicates the PR may have been documented in multiple patch releases. */ -export function hasDuplicatePatchLabels(pr: PrItem, targetVersion: string): boolean { +export function hasDuplicatePatchLabels(pr: PrItem, targetVersion: string | undefined): boolean { const targetSemVer = semver.parse(targetVersion); if (!targetSemVer) { return false; diff --git a/src/common/pr.tsx b/src/common/pr.tsx index 644dce1..b4aea93 100644 --- a/src/common/pr.tsx +++ b/src/common/pr.tsx @@ -16,7 +16,10 @@ export const Pr: FC = memo( const title: ReleaseNoteDetails = showTransformedTitle ? extractReleaseNotes(pr, normalizeOptions) : { type: 'title', title: pr.title }; - const hasDuplicates = version ? hasDuplicatePatchLabels(pr, version) : false; + const hasDuplicates = hasDuplicatePatchLabels(pr, version); + const majorMinorVersion = + version?.substring(0, version?.lastIndexOf('.')) ?? 'this major and minor version'; + return ( <> {title.title} ( @@ -34,12 +37,7 @@ export const Pr: FC = memo( color="warning" type="alert" size="m" - content={ - <> - This PR has multiple patch version labels for the same major.minor version. It may - have already been documented in a previous patch release. - - } + content={`This PR has multiple patch version labels for ${majorMinorVersion} and may have already been documented in a previous patch release.`} /> )} {title.type === 'releaseNoteTitle' && ( From d4d74322c2fe85489f35b7310a0933f82f25c399 Mon Sep 17 00:00:00 2001 From: Brad White Date: Tue, 18 Nov 2025 10:48:54 +1000 Subject: [PATCH 09/10] move to utils. adjust type --- src/common/github-service.ts | 29 --------------------------- src/common/pr-utils/extracting.ts | 33 +++++++++++++++++++++++++++++++ src/common/pr-utils/index.ts | 2 +- src/common/pr.tsx | 11 ++++++++--- 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/src/common/github-service.ts b/src/common/github-service.ts index 82171f5..25c5207 100644 --- a/src/common/github-service.ts +++ b/src/common/github-service.ts @@ -43,35 +43,6 @@ interface ExtractDeployedShaParams extends ServerlessGitOpsParams { gitOpsSha: string; } -/** - * Checks if a PR has multiple patch version labels for the same major.minor version. - * This indicates the PR may have been documented in multiple patch releases. - */ -export function hasDuplicatePatchLabels(pr: PrItem, targetVersion: string | undefined): boolean { - const targetSemVer = semver.parse(targetVersion); - if (!targetSemVer) { - return false; - } - - // Find all version labels that match the same major.minor as target - const sameMajorMinor = pr.labels.reduce((acc, { name }) => { - const ver = semver.parse(name ?? ''); - - if (ver === null) { - return acc; - } - - // tilde matches exactly on major.minor but allows for any patch version - if (semver.intersects(`~${ver.version}`, `~${targetSemVer.version}`)) { - acc++; - } - - return acc; - }, 0); - - return sameMajorMinor >= 2; -} - export class GitHubService { private octokit: Octokit; private repoId: number | undefined; diff --git a/src/common/pr-utils/extracting.ts b/src/common/pr-utils/extracting.ts index e35f360..98be13a 100644 --- a/src/common/pr-utils/extracting.ts +++ b/src/common/pr-utils/extracting.ts @@ -1,5 +1,6 @@ import { Config } from '../../config'; import { PrItem } from '../github-service'; +import semver from 'semver'; export type NormalizeOptions = Config['areas'][number]['options']; @@ -117,3 +118,35 @@ export function extractReleaseNotes( originalTitle: pr.title, }; } + +/** + * Checks if a PR has multiple patch version labels for the same major.minor version. + * This indicates the PR may have been documented in multiple patch releases. + */ +export function hasDuplicatePatchLabels( + prLabels: PrItem['labels'], + targetVersion: string | undefined +): boolean { + const targetSemVer = semver.parse(targetVersion); + if (!targetSemVer) { + return false; + } + + // Find all version labels that match the same major.minor as target + const sameMajorMinor = prLabels.reduce((acc, { name }) => { + const ver = semver.parse(name ?? ''); + + if (ver === null) { + return acc; + } + + // tilde matches exactly on major.minor but allows for any patch version + if (semver.intersects(`~${ver.version}`, `~${targetSemVer.version}`)) { + acc++; + } + + return acc; + }, 0); + + return sameMajorMinor >= 2; +} diff --git a/src/common/pr-utils/index.ts b/src/common/pr-utils/index.ts index 1122c4f..e177989 100644 --- a/src/common/pr-utils/index.ts +++ b/src/common/pr-utils/index.ts @@ -1,3 +1,3 @@ export * from './grouping'; -export { extractReleaseNotes } from './extracting'; +export { extractReleaseNotes, hasDuplicatePatchLabels } from './extracting'; export type { NormalizeOptions, ReleaseNoteDetails } from './extracting'; diff --git a/src/common/pr.tsx b/src/common/pr.tsx index b4aea93..c409829 100644 --- a/src/common/pr.tsx +++ b/src/common/pr.tsx @@ -1,7 +1,12 @@ import { EuiLink, EuiIconTip } from '@elastic/eui'; import { FC, memo } from 'react'; -import { PrItem, hasDuplicatePatchLabels } from './github-service'; -import { extractReleaseNotes, NormalizeOptions, ReleaseNoteDetails } from './pr-utils'; +import { PrItem } from './github-service'; +import { + extractReleaseNotes, + NormalizeOptions, + ReleaseNoteDetails, + hasDuplicatePatchLabels, +} from './pr-utils'; interface PrProps { pr: PrItem; @@ -16,7 +21,7 @@ export const Pr: FC = memo( const title: ReleaseNoteDetails = showTransformedTitle ? extractReleaseNotes(pr, normalizeOptions) : { type: 'title', title: pr.title }; - const hasDuplicates = hasDuplicatePatchLabels(pr, version); + const hasDuplicates = hasDuplicatePatchLabels(pr.labels, version); const majorMinorVersion = version?.substring(0, version?.lastIndexOf('.')) ?? 'this major and minor version'; From cc2052f47ff7061772ff999caa38d58759e6ba5e Mon Sep 17 00:00:00 2001 From: Brad White Date: Tue, 18 Nov 2025 10:56:49 +1000 Subject: [PATCH 10/10] add unit tests --- src/common/pr-utils/extracting.test.ts | 53 +++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/common/pr-utils/extracting.test.ts b/src/common/pr-utils/extracting.test.ts index e857b33..12a2705 100644 --- a/src/common/pr-utils/extracting.test.ts +++ b/src/common/pr-utils/extracting.test.ts @@ -1,5 +1,10 @@ import { PrItem } from '../github-service'; -import { normalizeTitle, findReleaseNote, extractReleaseNotes } from './extracting'; +import { + normalizeTitle, + findReleaseNote, + extractReleaseNotes, + hasDuplicatePatchLabels, +} from './extracting'; describe('extraction tools', () => { describe('normalizeTitle', () => { @@ -142,4 +147,50 @@ Next paragraph expect(actual.title).toBe('Adds cool feature in *TSVB*'); }); }); + + describe('hasDuplicatePatchLabels', () => { + it('should return false for invalid or missing target versions', () => { + const labels = [{ name: '8.12.0' }, { name: '8.12.1' }]; + + expect(hasDuplicatePatchLabels(labels, undefined)).toBe(false); + expect(hasDuplicatePatchLabels(labels, 'invalid-version')).toBe(false); + }); + + it('should return false when there are no matching labels', () => { + expect(hasDuplicatePatchLabels([{ name: '7.11.0' }, { name: '8.11.1' }], '8.12.0')).toBe( + false + ); + }); + + it('should return false when there is only one matching label', () => { + expect(hasDuplicatePatchLabels([{ name: '8.12.0' }, { name: '7.11.1' }], '8.12.0')).toBe( + false + ); + }); + + it('should return true when there are multiple matching patch labels', () => { + expect(hasDuplicatePatchLabels([{ name: '8.12.5' }, { name: '8.12.9' }], '8.12.0')).toBe( + true + ); + }); + + it('should handle invalid and undefined label names correctly', () => { + expect( + hasDuplicatePatchLabels( + [ + { name: '8.12.0' }, + { name: 'bug' }, + { name: '8.12.1' }, + { name: 'feature' }, + { name: undefined }, + ], + '8.12.0' + ) + ).toBe(true); + }); + + it('should handle empty array of labels correctly', () => { + expect(hasDuplicatePatchLabels([], '8.12.0')).toBe(false); + }); + }); });