From 29b99de940a7cc2fdb20f66b6c3629a65238b0ea Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 24 Jul 2026 06:43:42 +0200 Subject: [PATCH] ci: derive topic labels from PR title (#38595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derives topic labels from the PR title scope: `enhance(actions): ...` gets `topic/gitea-actions`. Scopes map to `topic/`, with aliases for names that differ (`oauth` → `topic/authentication`). Labels stay in sync with the title, while ones applied by hand are left alone. Scopes with no matching label on the repo are ignored, so no new labels get created. --------- Co-authored-by: Giteabot --- .github/workflows/pull-labeler.yml | 1 + tools/ci-tools.ts | 71 +++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pull-labeler.yml b/.github/workflows/pull-labeler.yml index e2c3e42fb1..8ee744a552 100644 --- a/.github/workflows/pull-labeler.yml +++ b/.github/workflows/pull-labeler.yml @@ -43,5 +43,6 @@ jobs: - run: node ./tools/ci-tools.ts set-pr-labels env: PR_TITLE: ${{ github.event.pull_request.title }} + PR_TITLE_BEFORE: ${{ github.event.changes.title.from }} PR_NUMBER: ${{ github.event.pull_request.number }} GITHUB_TOKEN: ${{ github.token }} diff --git a/tools/ci-tools.ts b/tools/ci-tools.ts index b872c35187..8c3dcedf6a 100644 --- a/tools/ci-tools.ts +++ b/tools/ci-tools.ts @@ -20,9 +20,12 @@ 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 { +function parsePrTitle(title: string): {type: CommitType, scope: string, breaking: boolean} | null { const match = titlePattern.exec(title); - return match ? {type: match[1] as CommitType, breaking: Boolean(match[3])} : null; + if (!match) return null; + // Keep the first segment, so "webhook/discord" matches "webhook". + const scope = match[2] ? match[2].slice(1, -1).toLowerCase().split('/')[0] : ''; + return {type: match[1] as CommitType, scope, breaking: Boolean(match[3])}; } const breakingLabel = 'pr/breaking'; @@ -36,21 +39,44 @@ const typeLabels: Partial> = { test: 'type/testing', }; -// Non-type labels, only added, never auto-removed, so manual labeling is not clobbered. +// Non-type labels, removed only when the previous title implied them. const extraLabels: Partial> = { chore: 'skip-changelog', ci: 'skip-changelog', build: 'topic/build', }; +// Scopes whose label differs from "topic/". +const scopeAliases: Record = { + actions: 'topic/gitea-actions', + auth: 'topic/authentication', + issue: 'topic/issues', + markup: 'topic/content-rendering', + oauth: 'topic/authentication', + oauth2: 'topic/authentication', + pull: 'topic/pr', + pulls: 'topic/pr', + webhook: 'topic/webhooks', +}; + +function labelForScope(scope: string): string | undefined { + if (!scope) return undefined; + return scopeAliases[scope] ?? `topic/${scope}`; +} + // 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); + const labels = [ + typeLabels[parsed.type], + extraLabels[parsed.type], + labelForScope(parsed.scope), + parsed.breaking ? breakingLabel : undefined, + ].filter((label): label is string => label !== undefined); + return [...new Set(labels)]; } // Command: validate PR_TITLE against the allowed Conventional Commits format. @@ -76,7 +102,7 @@ async function setPrLabels(): Promise { 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 { + async function request(url: string, method = 'GET', body?: unknown, ignoreStatus?: number): Promise { const response = await fetch(url, { method, headers: { @@ -87,25 +113,46 @@ async function setPrLabels(): Promise { }, body: body ? JSON.stringify(body) : undefined, }); - if (!response.ok) { + if (!response.ok && response.status !== ignoreStatus) { 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); + async function fetchNames(url: string): Promise { + const names = []; + for (let page = 1; page <= 10; page++) { + const labels = (await (await request(`${url}?per_page=100&page=${page}`)).json()) as Array<{name: string}>; + names.push(...labels.map((label) => label.name)); + if (labels.length < 100) break; + } + return names; + } + // Labels the repo does not have are dropped instead of failing the job. + const candidates = labelsForPrTitle(env.PR_TITLE); + const repoLabelsUrl = `https://api.github.com/repos/${env.GITHUB_REPOSITORY}/labels`; + const [current, existing]: string[][] = await Promise.all([ + fetchNames(labelsUrl), + candidates.length ? fetchNames(repoLabelsUrl) : [], + ]); + const desired = candidates.filter((name) => { + if (existing.includes(name)) return true; + console.info(`Skipping label not present on the repo: ${name}`); + return false; + }); + + const stale = new Set([...removableLabels, ...labelsForPrTitle(env.PR_TITLE_BEFORE ?? '')]); const toAdd = desired.filter((name) => !current.includes(name)); - const toRemove = removableLabels.filter((name) => current.includes(name) && !desired.includes(name)); + const toRemove = current.filter((name) => stale.has(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'); + // 404 means the label is already gone. + await request(`${labelsUrl}/${encodeURIComponent(name)}`, 'DELETE', undefined, 404); console.info(`Removed label: ${name}`); } if (!toAdd.length && !toRemove.length) {