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) {