diff --git a/.github/workflows/title-checker.yml b/.github/workflows/title-checker.yml deleted file mode 100644 index ea4e36d5..00000000 --- a/.github/workflows/title-checker.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: PR Title Checker - -on: - pull_request_target: - types: [opened, edited, reopened] - -jobs: - check-title: - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - name: Validate PR Title and Apply 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 regex = /^([a-z-]+)(?:\(([^)]+)\))?(!?): ([A-Z].+)$/; - const match = title.match(regex); - - 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; - } - - const repoLabels = await github.rest.issues.listLabelsForRepo({ - owner, - repo, - per_page: 100, - }); - - 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.`); - 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 (breaking === '!') { - if (!labelNames.includes('breaking-change')) { - core.setFailed('No "breaking-change" label found in repo.'); - return; - } - labelsToAdd.push('breaking-change'); - } - - 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 - }); - } - - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: pr_number, - labels: labelsToAdd - }); - console.log(`Successfully added labels: ${labelsToAdd.join(', ')}`); diff --git a/.github/workflows/title-formatter.yml b/.github/workflows/title-formatter.yml new file mode 100644 index 00000000..82bf6487 --- /dev/null +++ b/.github/workflows/title-formatter.yml @@ -0,0 +1,78 @@ +name: PR Title Formatter + +on: + pull_request_target: + 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}"`);