ci: derive topic labels from PR title (#38595)

Derives topic labels from the PR title scope: `enhance(actions): ...`
gets `topic/gitea-actions`. Scopes map to `topic/<scope>`, 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 <teabot@gitea.io>
This commit is contained in:
silverwind
2026-07-24 06:43:42 +02:00
committed by GitHub
parent 0e8bafc267
commit 29b99de940
2 changed files with 60 additions and 12 deletions

View File

@@ -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 }}

View File

@@ -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<Record<CommitType, string>> = {
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<Record<CommitType, string>> = {
chore: 'skip-changelog',
ci: 'skip-changelog',
build: 'topic/build',
};
// Scopes whose label differs from "topic/<scope>".
const scopeAliases: Record<string, string> = {
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<void> {
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<Response> {
async function request(url: string, method = 'GET', body?: unknown, ignoreStatus?: number): Promise<Response> {
const response = await fetch(url, {
method,
headers: {
@@ -87,25 +113,46 @@ async function setPrLabels(): Promise<void> {
},
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<string[]> {
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) {