From 2a0412d04b46e50d44b876fecd648f71a60b57c6 Mon Sep 17 00:00:00 2001 From: Pierre Quinton Date: Sun, 22 Feb 2026 22:01:49 +0100 Subject: [PATCH 1/6] ci: Reverse title-checker logic to format title from labels Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/title-checker.yml | 113 +++++++++++----------------- 1 file changed, 46 insertions(+), 67 deletions(-) diff --git a/.github/workflows/title-checker.yml b/.github/workflows/title-checker.yml index ea4e36d5..82bf6487 100644 --- a/.github/workflows/title-checker.yml +++ b/.github/workflows/title-checker.yml @@ -1,99 +1,78 @@ -name: PR Title Checker +name: PR Title Formatter on: pull_request_target: - types: [opened, edited, reopened] + types: [opened, edited, reopened, labeled, unlabeled] jobs: - check-title: + format-title: runs-on: ubuntu-latest permissions: pull-requests: write steps: - - name: Validate PR Title and Apply Labels + - name: Format PR Title from Labels uses: actions/github-script@v7 with: script: | - const title = context.payload.pull_request.title; const { owner, repo } = context.repo; const pr_number = context.payload.pull_request.number; + const rawTitle = context.payload.pull_request.title; - const regex = /^([a-z-]+)(?:\(([^)]+)\))?(!?): ([A-Z].+)$/; - const match = title.match(regex); + // Step 1: Strip any existing CC prefix to get the bare description. + const ccRegex = /^[a-z-]+(?:\([^)]+\))?!?: (.+)$/; + const ccMatch = rawTitle.match(ccRegex); + let description = ccMatch ? ccMatch[1] : rawTitle; - if (!match) { - core.setFailed("PR title does not follow any of the accepted formats:\n 'type(scope)!: Description'\n 'type(scope): Description'\n 'type!: Description'\n 'type: Description'\n See https://www.conventionalcommits.org/ for more details"); - return; - } - - const [_, type, scope, breaking, __] = match; - - const displayTitle = `${title} (#${pr_number})`; - const maxLength = 72; - - if (displayTitle.length > maxLength) { - core.setFailed( - `PR title is too long by ${displayTitle.length-maxLength} characters.` - ); - return; - } + // Step 2: Capitalize first letter. + description = description.charAt(0).toUpperCase() + description.slice(1); - const repoLabels = await github.rest.issues.listLabelsForRepo({ + // Step 3: Read PR labels and require exactly one "cc: " label. + const prLabels = await github.rest.issues.listLabelsOnIssue({ owner, repo, + issue_number: pr_number, per_page: 100, }); + const labelNames = prLabels.data.map(l => l.name); - const labelNames = repoLabels.data.map(l => l.name); - const labelsToAdd = []; - - const typeLabel = `cc: ${type}`; - if (!labelNames.includes(typeLabel)) { - core.setFailed(`Invalid type: "${type}". No label "${typeLabel}" found in repo.`); + const ccLabels = labelNames.filter(n => n.startsWith('cc: ')); + if (ccLabels.length === 0) { + core.setFailed('No "cc: " label found on this PR. Please add exactly one.'); return; } - labelsToAdd.push(typeLabel); - - if (scope) { - const scopeLabel = `package: ${scope}`; - if (!labelNames.includes(scopeLabel)) { - core.setFailed(`Invalid scope: "${scope}". No label "${scopeLabel}" found in repo.`); - return; - } - labelsToAdd.push(scopeLabel); + if (ccLabels.length > 1) { + core.setFailed(`Multiple "cc: " labels found: ${ccLabels.join(', ')}. Please keep exactly one.`); + return; } + const type = ccLabels[0].slice(4); - if (breaking === '!') { - if (!labelNames.includes('breaking-change')) { - core.setFailed('No "breaking-change" label found in repo.'); - return; - } - labelsToAdd.push('breaking-change'); - } + // Step 4: Use scope from "package: " label if exactly one is present. + const packageLabels = labelNames.filter(n => n.startsWith('package: ')); + const scope = packageLabels.length === 1 ? packageLabels[0].slice(9) : null; - const prLabels = await github.rest.issues.listLabelsOnIssue({ - owner, - repo, - issue_number: pr_number, - per_page: 100, - }); - const managedPrefixes = ['cc: ', 'package: ']; - const labelsToRemove = prLabels.data - .map(l => l.name) - .filter(n => managedPrefixes.some(p => n.startsWith(p)) || n === 'breaking-change'); - for (const label of labelsToRemove) { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: pr_number, - name: label - }); + // Step 5: Check for breaking-change label. + const isBreaking = labelNames.includes('breaking-change'); + + // Step 6: Build new title. + let newTitle = type; + if (scope) newTitle += `(${scope})`; + if (isBreaking) newTitle += '!'; + newTitle += `: ${description}`; + + // Step 7: Enforce 72-character limit (counting the trailing " (#NNN)"). + const displayTitle = `${newTitle} (#${pr_number})`; + if (displayTitle.length > 72) { + core.setFailed( + `Formatted title is too long by ${displayTitle.length - 72} characters: "${displayTitle}"` + ); + return; } - await github.rest.issues.addLabels({ + // Step 8: Update the PR title. + await github.rest.pulls.update({ owner, repo, - issue_number: pr_number, - labels: labelsToAdd + pull_number: pr_number, + title: newTitle, }); - console.log(`Successfully added labels: ${labelsToAdd.join(', ')}`); + console.log(`PR title updated to: "${newTitle}"`); From a323aaec7f971f7d5357251764948429312cd021 Mon Sep 17 00:00:00 2001 From: Pierre Quinton Date: Sun, 22 Feb 2026 22:04:00 +0100 Subject: [PATCH 2/6] Renamed title-checker to title-formatter --- .github/workflows/{title-checker.yml => title-formatter.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{title-checker.yml => title-formatter.yml} (100%) diff --git a/.github/workflows/title-checker.yml b/.github/workflows/title-formatter.yml similarity index 100% rename from .github/workflows/title-checker.yml rename to .github/workflows/title-formatter.yml From 4cd344d26090bfa1c0421ac2a74941f835fb0dae Mon Sep 17 00:00:00 2001 From: Pierre Quinton Date: Sun, 22 Feb 2026 22:12:21 +0100 Subject: [PATCH 3/6] changed on to test, need to revert --- .github/workflows/title-formatter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/title-formatter.yml b/.github/workflows/title-formatter.yml index 82bf6487..698458ec 100644 --- a/.github/workflows/title-formatter.yml +++ b/.github/workflows/title-formatter.yml @@ -1,7 +1,7 @@ name: PR Title Formatter on: - pull_request_target: + pull_request: types: [opened, edited, reopened, labeled, unlabeled] jobs: From 53cd473b1f996f02fbf1997a567fb4b82a0f0fec Mon Sep 17 00:00:00 2001 From: Pierre Quinton Date: Sun, 22 Feb 2026 22:14:29 +0100 Subject: [PATCH 4/6] change action from file for testing --- .github/workflows/test.yml | 78 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..698458ec --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,78 @@ +name: PR Title Formatter + +on: + pull_request: + types: [opened, edited, reopened, labeled, unlabeled] + +jobs: + format-title: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Format PR Title from Labels + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const pr_number = context.payload.pull_request.number; + const rawTitle = context.payload.pull_request.title; + + // Step 1: Strip any existing CC prefix to get the bare description. + const ccRegex = /^[a-z-]+(?:\([^)]+\))?!?: (.+)$/; + const ccMatch = rawTitle.match(ccRegex); + let description = ccMatch ? ccMatch[1] : rawTitle; + + // Step 2: Capitalize first letter. + description = description.charAt(0).toUpperCase() + description.slice(1); + + // Step 3: Read PR labels and require exactly one "cc: " label. + const prLabels = await github.rest.issues.listLabelsOnIssue({ + owner, + repo, + issue_number: pr_number, + per_page: 100, + }); + const labelNames = prLabels.data.map(l => l.name); + + const ccLabels = labelNames.filter(n => n.startsWith('cc: ')); + if (ccLabels.length === 0) { + core.setFailed('No "cc: " label found on this PR. Please add exactly one.'); + return; + } + if (ccLabels.length > 1) { + core.setFailed(`Multiple "cc: " labels found: ${ccLabels.join(', ')}. Please keep exactly one.`); + return; + } + const type = ccLabels[0].slice(4); + + // Step 4: Use scope from "package: " label if exactly one is present. + const packageLabels = labelNames.filter(n => n.startsWith('package: ')); + const scope = packageLabels.length === 1 ? packageLabels[0].slice(9) : null; + + // Step 5: Check for breaking-change label. + const isBreaking = labelNames.includes('breaking-change'); + + // Step 6: Build new title. + let newTitle = type; + if (scope) newTitle += `(${scope})`; + if (isBreaking) newTitle += '!'; + newTitle += `: ${description}`; + + // Step 7: Enforce 72-character limit (counting the trailing " (#NNN)"). + const displayTitle = `${newTitle} (#${pr_number})`; + if (displayTitle.length > 72) { + core.setFailed( + `Formatted title is too long by ${displayTitle.length - 72} characters: "${displayTitle}"` + ); + return; + } + + // Step 8: Update the PR title. + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr_number, + title: newTitle, + }); + console.log(`PR title updated to: "${newTitle}"`); From ad2d124d6737921dd7999e19f55b668a841cad6a Mon Sep 17 00:00:00 2001 From: Pierre Quinton Date: Sun, 22 Feb 2026 22:17:41 +0100 Subject: [PATCH 5/6] Revert "change action from file for testing" This reverts commit 53cd473b1f996f02fbf1997a567fb4b82a0f0fec. --- .github/workflows/test.yml | 78 -------------------------------------- 1 file changed, 78 deletions(-) delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 698458ec..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: PR Title Formatter - -on: - pull_request: - types: [opened, edited, reopened, labeled, unlabeled] - -jobs: - format-title: - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - name: Format PR Title from Labels - uses: actions/github-script@v7 - with: - script: | - const { owner, repo } = context.repo; - const pr_number = context.payload.pull_request.number; - const rawTitle = context.payload.pull_request.title; - - // Step 1: Strip any existing CC prefix to get the bare description. - const ccRegex = /^[a-z-]+(?:\([^)]+\))?!?: (.+)$/; - const ccMatch = rawTitle.match(ccRegex); - let description = ccMatch ? ccMatch[1] : rawTitle; - - // Step 2: Capitalize first letter. - description = description.charAt(0).toUpperCase() + description.slice(1); - - // Step 3: Read PR labels and require exactly one "cc: " label. - const prLabels = await github.rest.issues.listLabelsOnIssue({ - owner, - repo, - issue_number: pr_number, - per_page: 100, - }); - const labelNames = prLabels.data.map(l => l.name); - - const ccLabels = labelNames.filter(n => n.startsWith('cc: ')); - if (ccLabels.length === 0) { - core.setFailed('No "cc: " label found on this PR. Please add exactly one.'); - return; - } - if (ccLabels.length > 1) { - core.setFailed(`Multiple "cc: " labels found: ${ccLabels.join(', ')}. Please keep exactly one.`); - return; - } - const type = ccLabels[0].slice(4); - - // Step 4: Use scope from "package: " label if exactly one is present. - const packageLabels = labelNames.filter(n => n.startsWith('package: ')); - const scope = packageLabels.length === 1 ? packageLabels[0].slice(9) : null; - - // Step 5: Check for breaking-change label. - const isBreaking = labelNames.includes('breaking-change'); - - // Step 6: Build new title. - let newTitle = type; - if (scope) newTitle += `(${scope})`; - if (isBreaking) newTitle += '!'; - newTitle += `: ${description}`; - - // Step 7: Enforce 72-character limit (counting the trailing " (#NNN)"). - const displayTitle = `${newTitle} (#${pr_number})`; - if (displayTitle.length > 72) { - core.setFailed( - `Formatted title is too long by ${displayTitle.length - 72} characters: "${displayTitle}"` - ); - return; - } - - // Step 8: Update the PR title. - await github.rest.pulls.update({ - owner, - repo, - pull_number: pr_number, - title: newTitle, - }); - console.log(`PR title updated to: "${newTitle}"`); From 1b3e68f8305fabbae29b75358217229412d37ea8 Mon Sep 17 00:00:00 2001 From: Pierre Quinton Date: Sun, 22 Feb 2026 22:18:02 +0100 Subject: [PATCH 6/6] Revert "changed on to test, need to revert" This reverts commit 4cd344d26090bfa1c0421ac2a74941f835fb0dae. --- .github/workflows/title-formatter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/title-formatter.yml b/.github/workflows/title-formatter.yml index 698458ec..82bf6487 100644 --- a/.github/workflows/title-formatter.yml +++ b/.github/workflows/title-formatter.yml @@ -1,7 +1,7 @@ name: PR Title Formatter on: - pull_request: + pull_request_target: types: [opened, edited, reopened, labeled, unlabeled] jobs: