From 10fc85e2632988c55f61ba198b16bbecc018b191 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 24 May 2026 07:36:20 +0200 Subject: [PATCH] ci: add `tools/ci-tools.ts` for the PR labeler workflow (#37831) Backport `./tools/ci-tools.ts` to 1.26 which is needed for the `pr-title` flow to succeed on that branch. Co-authored-by: Claude (Opus 4.7) --- eslint.config.ts | 6 ++- tools/ci-tools.ts | 133 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 tools/ci-tools.ts diff --git a/eslint.config.ts b/eslint.config.ts index c75f88a5ca..92cd2bb056 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -570,8 +570,6 @@ export default defineConfig([ 'no-redeclare': [0], // must be disabled for typescript overloads 'no-regex-spaces': [2], 'no-restricted-exports': [0], - 'no-restricted-globals': [2, ...restrictedGlobals], - 'no-restricted-properties': [2, ...restrictedProperties], 'no-restricted-imports': [2, {paths: [ {name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true}, {name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true}, @@ -1024,5 +1022,9 @@ export default defineConfig([ { files: ['web_src/**/*'], languageOptions: {globals: {...globals.browser, ...globals.jquery, htmx: false}}, + rules: { + 'no-restricted-globals': [2, ...restrictedGlobals], + 'no-restricted-properties': [2, ...restrictedProperties], + }, }, ]); diff --git a/tools/ci-tools.ts b/tools/ci-tools.ts new file mode 100644 index 0000000000..4562643247 --- /dev/null +++ b/tools/ci-tools.ts @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import {argv, env, exit} from 'node:process'; + +const allowedTypes = [ + 'build', + 'chore', + 'ci', + 'docs', + 'enhance', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', +] as const; +type CommitType = typeof allowedTypes[number]; + +const allowedTypesList = allowedTypes.join(', '); +const titlePattern = new RegExp(`^(${allowedTypes.join('|')})(\\([\\w/.-]+\\))?(!)?: .+$`); + +function parsePrTitle(title: string): {type: CommitType; breaking: boolean} | null { + const match = titlePattern.exec(title); + return match ? {type: match[1] as CommitType, breaking: Boolean(match[3])} : null; +} + +const breakingLabel = 'pr/breaking'; + +// Mutually exclusive type labels, fully synced with the title type (added and removed). +const typeLabels: Partial> = { + feat: 'type/feature', + enhance: 'type/enhancement', + fix: 'type/bug', + docs: 'type/docs', + test: 'type/testing', +}; + +// Non-type labels, only added, never auto-removed, so manual labeling is not clobbered. +const extraLabels: Partial> = { + chore: 'skip-changelog', + ci: 'skip-changelog', + build: 'topic/build', +}; + +// Labels this tool may remove when the title no longer implies them. +const removableLabels = [...Object.values(typeLabels), breakingLabel]; + +function labelsForPrTitle(title: string): string[] { + const parsed = parsePrTitle(title); + if (!parsed) return []; + return [typeLabels[parsed.type], extraLabels[parsed.type], parsed.breaking ? breakingLabel : undefined] + .filter((label): label is string => label !== undefined); +} + +// Command: validate PR_TITLE against the allowed Conventional Commits format. +function lintPrTitle(): void { + if (!env.PR_TITLE) { + console.error('Missing PR_TITLE'); + exit(1); + } + if (!parsePrTitle(env.PR_TITLE)) { + console.error(`Invalid PR title: ${env.PR_TITLE}`); + console.error('Expected format: type(scope): subject (scope optional, append "!" for breaking changes)'); + console.error(`Allowed types: ${allowedTypesList}`); + exit(1); + } +} + +// Command: sync the title-derived labels onto the PR via the GitHub API. +async function setPrLabels(): Promise { + if (!env.PR_TITLE || !env.GITHUB_TOKEN || !env.GITHUB_REPOSITORY || !env.PR_NUMBER) { + console.error('set-pr-labels requires PR_TITLE, GITHUB_TOKEN, GITHUB_REPOSITORY and PR_NUMBER'); + exit(1); + } + + const labelsUrl = `https://api.github.com/repos/${env.GITHUB_REPOSITORY}/issues/${env.PR_NUMBER}/labels`; + + async function request(url: string, method = 'GET', body?: unknown): Promise { + const response = await fetch(url, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${env.GITHUB_TOKEN}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(body ? {'Content-Type': 'application/json'} : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + throw new Error(`GitHub API ${method} ${url} failed (${response.status}): ${await response.text()}`); + } + return response; + } + + const desired = labelsForPrTitle(env.PR_TITLE); + const response = await request(`${labelsUrl}?per_page=100`); + const current = ((await response.json()) as Array<{name: string}>).map((label) => label.name); + + const toAdd = desired.filter((name) => !current.includes(name)); + const toRemove = removableLabels.filter((name) => current.includes(name) && !desired.includes(name)); + + if (toAdd.length) { + await request(labelsUrl, 'POST', {labels: toAdd}); + console.info(`Added labels: ${toAdd.join(', ')}`); + } + for (const name of toRemove) { + await request(`${labelsUrl}/${encodeURIComponent(name)}`, 'DELETE'); + console.info(`Removed label: ${name}`); + } + if (!toAdd.length && !toRemove.length) { + console.info('PR labels already in sync'); + } +} + +const commands: Record void | Promise> = { + 'lint-pr-title': lintPrTitle, + 'set-pr-labels': setPrLabels, +}; + +const command = argv[2]; +const handler = commands[command]; +if (!handler) { + console.error(`Usage: ci-tools.ts <${Object.keys(commands).join('|')}>`); + exit(1); +} + +try { + await handler(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + exit(1); +}