From b93520bba5e1194945f1d2bd9023e165bbc8c981 Mon Sep 17 00:00:00 2001 From: Dhanya Thattil Date: Mon, 21 Sep 2026 11:26:19 +0200 Subject: [PATCH] Dev/pr automation 5 (#1564) * check the presence of release notes * release notes found should not include heading * debugging * typo * typo * trim out no release note checkbox as well * refactored to only read current section * minor * release note now triggered after detector labels, removed no relaes note checkbox, also checking if its developing detector label only, then also release note not required, adding a fake ai assisted releae note if found locally, removing adding release notelabel * minor * print * to run everytim * typo * ai assisted * into steps * coimment out release notes workflow * read permissions * copilot * removed ai assisted notes * refactored --- .github/workflows/pr_edit_trigger.yaml | 56 ++++- .github/workflows/pr_release_notes.yaml | 262 ++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/pr_release_notes.yaml diff --git a/.github/workflows/pr_edit_trigger.yaml b/.github/workflows/pr_edit_trigger.yaml index b43e7c3e4..8ff50c1f0 100644 --- a/.github/workflows/pr_edit_trigger.yaml +++ b/.github/workflows/pr_edit_trigger.yaml @@ -1,4 +1,4 @@ -# This workflow is triggered when opening/reopening PR, editing the PR description or changing labels. It ignores unrelated label changes. It runs PR type validation and if it succeeds, it runs PR milestone and detector labels workflow in parallel. +# This workflow is triggered when opening/reopening PR, editing the PR description or changing labels. It ignores unrelated label changes. It runs PR type validation and if it succeeds, it runs PR milestone, release notes, file version and detector labels workflow in parallel. name: PR Edit Trigger on: @@ -13,6 +13,7 @@ on: permissions: pull-requests: write contents: read + models: read jobs: check-pr-trigger: @@ -102,7 +103,17 @@ jobs: contents: read milestone: - needs: validate-pr-type + needs: + - check-pr-trigger + - validate-pr-type + if: >- + ${{ + always() && + ( + needs.check-pr-trigger.outputs.require_validation == 'false' || + needs.validate-pr-type.result == 'success' + ) + }} uses: ./.github/workflows/pr_milestone.yaml with: pr_number: ${{ github.event.pull_request.number }} @@ -111,7 +122,17 @@ jobs: contents: read detector-labels: - needs: validate-pr-type + needs: + - check-pr-trigger + - validate-pr-type + if: >- + ${{ + always() && + ( + needs.check-pr-trigger.outputs.require_validation == 'false' || + needs.validate-pr-type.result == 'success' + ) + }} uses: ./.github/workflows/pr_detector_labels.yaml with: pr_number: ${{ github.event.pull_request.number }} @@ -119,6 +140,25 @@ jobs: pull-requests: write issues: read + release-notes: + needs: + - check-pr-trigger + - detector-labels + if: >- + ${{ + always() && + ( + needs.check-pr-trigger.outputs.require_validation == 'false' || + needs.detector-labels.result == 'success' + ) + }} + uses: ./.github/workflows/pr_release_notes.yaml + with: + pr_number: ${{ github.event.pull_request.number }} + permissions: + pull-requests: read + contents: read + file-version: needs: - check-pr-trigger @@ -127,11 +167,11 @@ jobs: ${{ always() && ( - needs.check-pr-trigger.outputs.file_label_change == 'true' || - ( - needs.check-pr-trigger.outputs.pr_edited == 'true' && - needs.validate-pr-type.result == 'success' - ) + needs.check-pr-trigger.outputs.file_label_change == 'true' + ) || + ( + needs.check-pr-trigger.outputs.require_validation == 'false' || + needs.validate-pr-type.result == 'success' ) }} uses: ./.github/workflows/pr_file_version.yaml diff --git a/.github/workflows/pr_release_notes.yaml b/.github/workflows/pr_release_notes.yaml new file mode 100644 index 000000000..877835793 --- /dev/null +++ b/.github/workflows/pr_release_notes.yaml @@ -0,0 +1,262 @@ +# This reusable workflow validates the PR release notes. It ignores comments and +#
blocks, checks for notes under the "## Release notes" section. If +# it is not an Infrastructure PR and release notes are not found, it should find +# them in an equivalent PR or referenced PR, else validation fails. If it is an +# Infrastructure PR and release notes are found, validation fails. + +name: PR Release Notes + +on: + workflow_call: + inputs: + pr_number: + required: true + type: number + +permissions: + pull-requests: read + contents: read + +jobs: + check-release-notes: + name: Check PR release notes + runs-on: ubuntu-latest + + steps: + + - name: Validate with labels + id: validate-labels + uses: actions/github-script@v8 + with: + script: | + const prNumber = ${{ inputs.pr_number }}; + if (!prNumber) { + core.setFailed('Unable to determine PR number.'); + return; + } + + core.setOutput('is_main_branch', 'false'); + core.setOutput('require_release_notes', 'true'); + + // base branch: main => no validation needed + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + const baseBranch = pr.base.ref; + if (baseBranch === 'main') { + core.info('PR targets main branch. Release notes validation is not required.'); + core.setOutput('is_main_branch', 'true'); + core.setOutput('require_release_notes', 'false'); + return; + } + + // get current PR labels + const { data: labels } = + await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + const labelNames = labels.map(label => label.name); + + // infrastructure label + const hasInfrastructure = labelNames.includes('Infrastructure'); + core.info('Infrastructure label: ' + hasInfrastructure); + + // developer detector label + const developingDetectorLabels = ['Ctb', 'Xilinx Ctb', 'Matterhorn']; + const otherDetectorLabels = ['Eiger', 'Mythen3', 'Gotthard2', 'Jungfrau', 'Moench']; + const hasDevelopingDetectorLabel = labelNames.some( + label => developingDetectorLabels.includes(label) + ); + const hasOtherDetectorLabel = labelNames.some( + label => otherDetectorLabels.includes(label) + ); + const hasOnlyDevelopingDetectors = + hasDevelopingDetectorLabel && !hasOtherDetectorLabel; + core.info('Only developing detector labels: ' + hasOnlyDevelopingDetectors + '\n'); + + if (hasInfrastructure || hasOnlyDevelopingDetectors) { + core.setOutput('require_release_notes', 'false'); + core.info ('Release notes required: false'); + return; + } + + core.info('Release notes required: true'); + + + - name: Find release notes in current PR + id: find-current + if: steps.validate-labels.outputs.is_main_branch == 'false' + uses: actions/github-script@v8 + with: + script: | + const prNumber = ${{ inputs.pr_number }}; + core.setOutput('needs_further_action', 'false'); + + // some functions to help find release notes in current PR body + const stripIgnoredContent = (text = '') => { + return (text || '') + .replace(//gs, '\n') + .replace(//gi, '\n') + .trim(); + }; + const findReleaseNotesSection = (body = '') => { + const sanitized = stripIgnoredContent(body || ''); + if (!sanitized) return ''; + + const match = sanitized.match( + /(^|\n)#{1,6}\s*Release notes\s*\n([\s\S]*?)(?=\n#{1,6}\s+\S|\s*$)/i + ); + if (!match) return ''; + + let section = (match[2] || '') + .split('\n') + .filter(line => !/^#{1,6}\s+/.test(line.trim())) + .join('\n') + .trim(); + + return section; + }; + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + // find release notes in the current PR body + const releaseNotes = findReleaseNotesSection(pr.body || ''); + const hasReleaseNotesCurrent = Boolean(releaseNotes); + core.info('Release notes in current PR: ' + (hasReleaseNotesCurrent ? 'Found\n' : 'Not found\n')); + + // validate release notes requirement + const requireReleaseNotes = '${{ steps.validate-labels.outputs.require_release_notes }}' === 'true'; + if (!requireReleaseNotes) { + if (hasReleaseNotesCurrent) { + core.setFailed('Release notes cannot be included for this PR ' + + '(Infrastructure/developing detector). \n' + + 'To fix this, \n' + + '\t - remove release notes or\n' + + '\t - change PR type or\n' + + '\t - add/remove detector labels.\n'); + return; + } + core.info('No further action required (Infrastructure/developing detector).'); + return; + } + + if (hasReleaseNotesCurrent) { + core.info('No further action required.'); + return; + } + + core.setOutput('needs_further_action', 'true'); + core.info('Further action required.'); + + - name: Find release notes in linked PRs + if: >- + ${{ + steps.validate-labels.outputs.is_main_branch == 'false' && + steps.find-current.outputs.needs_further_action == 'true' + }} + uses: actions/github-script@v8 + with: + script: | + const prNumber = ${{ inputs.pr_number }}; + + // functions to find release notes in linked PRs + const stripIgnoredContent = (text = '') => { + return (text || '') + .replace(//gs, '\n') + .replace(//gi, '\n') + .trim(); + }; + const findReleaseNotesSection = (body = '') => { + const sanitized = stripIgnoredContent(body || ''); + if (!sanitized) return ''; + + const match = sanitized.match( + /(^|\n)#{1,6}\s*Release notes\s*\n([\s\S]*?)(?=\n#{1,6}\s+\S|\s*$)/i + ); + if (!match) return ''; + + return (match[2] || '') + .split('\n') + .filter(line => !/^#{1,6}\s+/.test(line.trim())) + .join('\n') + .trim(); + }; + const parseNumberList = (text) => { + if (!text) { + return []; + } + return Array.from( + new Set( + Array.from(text.matchAll(/#(\d+)/g), match => Number(match[1])) + ) + ); + }; + const findReferencedPrNumbers = (body = '') => { + if (!body) { + return []; + } + const relevantSections = [ + 'Equivalent PRs', + 'Referenced PRs' + ]; + const patterns = relevantSections.map(section => + new RegExp( + `(^|\\n)#{1,6}\\s*${section.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\s*\\n([\\s\\S]*?)(?=\\n#{1,6}\\s+\\S|\\s*$)`, + 'i' + ) + ); + const numbers = []; + for (const pattern of patterns) { + const match = body.match(pattern); + if (match && match[2]) { + numbers.push(...parseNumberList(match[2])); + } + } + return Array.from(new Set(numbers)); + }; + + // Loop thorough linked PRs to find release notes + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const equivalentAndReferencedPrNumbers = findReferencedPrNumbers(pr.body || ''); + for (const referencedPrNumber of equivalentAndReferencedPrNumbers) { + if (referencedPrNumber === prNumber) { + continue; + } + try { + const { data: referencedPr } = + await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: referencedPrNumber + }); + const referencedNotes = findReleaseNotesSection(referencedPr.body || ''); + if (referencedNotes) { + core.info( + `Release notes found: In equivalent/referenced PR ` + + `#${referencedPrNumber}. Using that as the source for this PR.` + ); + return; + } + } catch (error) { + core.warning( + `Unable to inspect referenced PR #${referencedPrNumber}: ${error.message}` + ); + } + } + + core.setFailed( + 'No release notes were found in the PR body, equivalent PRs, or referenced PRs.' + ); \ No newline at end of file