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
+1
View File
@@ -43,5 +43,6 @@ jobs:
- run: node ./tools/ci-tools.ts set-pr-labels - run: node ./tools/ci-tools.ts set-pr-labels
env: env:
PR_TITLE: ${{ github.event.pull_request.title }} PR_TITLE: ${{ github.event.pull_request.title }}
PR_TITLE_BEFORE: ${{ github.event.changes.title.from }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
+59 -12
View File
@@ -20,9 +20,12 @@ type CommitType = typeof allowedTypes[number];
const allowedTypesList = allowedTypes.join(', '); const allowedTypesList = allowedTypes.join(', ');
const titlePattern = new RegExp(`^(${allowedTypes.join('|')})(\\([\\w/.-]+\\))?(!)?: .+$`); 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); 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'; const breakingLabel = 'pr/breaking';
@@ -36,21 +39,44 @@ const typeLabels: Partial<Record<CommitType, string>> = {
test: 'type/testing', 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>> = { const extraLabels: Partial<Record<CommitType, string>> = {
chore: 'skip-changelog', chore: 'skip-changelog',
ci: 'skip-changelog', ci: 'skip-changelog',
build: 'topic/build', 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. // Labels this tool may remove when the title no longer implies them.
const removableLabels = [...Object.values(typeLabels), breakingLabel]; const removableLabels = [...Object.values(typeLabels), breakingLabel];
function labelsForPrTitle(title: string): string[] { function labelsForPrTitle(title: string): string[] {
const parsed = parsePrTitle(title); const parsed = parsePrTitle(title);
if (!parsed) return []; if (!parsed) return [];
return [typeLabels[parsed.type], extraLabels[parsed.type], parsed.breaking ? breakingLabel : undefined] const labels = [
.filter((label): label is string => label !== undefined); 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. // 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`; 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, { const response = await fetch(url, {
method, method,
headers: { headers: {
@@ -87,25 +113,46 @@ async function setPrLabels(): Promise<void> {
}, },
body: body ? JSON.stringify(body) : undefined, 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()}`); throw new Error(`GitHub API ${method} ${url} failed (${response.status}): ${await response.text()}`);
} }
return response; return response;
} }
const desired = labelsForPrTitle(env.PR_TITLE); async function fetchNames(url: string): Promise<string[]> {
const response = await request(`${labelsUrl}?per_page=100`); const names = [];
const current = ((await response.json()) as Array<{name: string}>).map((label) => label.name); 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 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) { if (toAdd.length) {
await request(labelsUrl, 'POST', {labels: toAdd}); await request(labelsUrl, 'POST', {labels: toAdd});
console.info(`Added labels: ${toAdd.join(', ')}`); console.info(`Added labels: ${toAdd.join(', ')}`);
} }
for (const name of toRemove) { 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}`); console.info(`Removed label: ${name}`);
} }
if (!toAdd.length && !toRemove.length) { if (!toAdd.length && !toRemove.length) {