Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .github/scripts/pr-labeler.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,13 @@ export const scopeToArea = {
//
// Anything else (maintainer-added `area/*`, manual `kind/*`, anything
// outside these two namespaces) is preserved on every run.
export function computeLabels({ title = '', body = '', existingLabels = [] } = {}) {
const existing = new Set(existingLabels)
export function computeLabels({ title: rawTitle, body: rawBody, existingLabels: rawExisting } = {}) {
// Destructuring defaults only fire on `undefined`, not `null`. GitHub PR
// payloads can carry an explicit `null` for an empty body and (less often)
// labels — fall back via `||` so `.match()` and Set iteration are safe.
const title = rawTitle || ''
const body = rawBody || ''
const existing = new Set(rawExisting || [])
const toAdd = new Set()

// 1. Try Conventional Commits form: type(scope)?(!)?: description
Expand Down Expand Up @@ -186,7 +191,12 @@ export function computeLabels({ title = '', body = '', existingLabels = [] } = {
}

// 7. Fallback: no area/* applied -> area/uncategorized.
const hasArea = [...toAdd].some((l) => l.startsWith('area/'))
// Honour maintainer-added area/* already on the PR — if the title has no
// scope but a real area/* is already attached, the PR is categorised; do
// not pile area/uncategorized on top.
const hasArea =
[...toAdd].some((l) => l.startsWith('area/')) ||
[...existing].some((l) => l.startsWith('area/') && l !== 'area/uncategorized')
if (!hasArea) {
toAdd.add('area/uncategorized')
}
Expand Down
48 changes: 48 additions & 0 deletions .github/scripts/pr-labeler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,51 @@ test('non-labeler labels are never removed', () => {
})
assert.deepEqual(remove, [])
})

// ── Null-safety: destructuring defaults only fire on undefined ──

test('null body does not throw', () => {
// GitHub PR payloads carry an explicit `null` for an empty description.
const { add } = computeLabels({ title: 'feat(ui): x', body: null })
assert.ok(add.includes('kind/feature'))
assert.ok(add.includes('area/ui'))
})

test('null title is treated as empty', () => {
const { add } = computeLabels({ title: null })
assert.ok(add.includes('area/uncategorized'))
})

test('null existingLabels does not throw', () => {
const { add } = computeLabels({ title: 'feat(ui): x', existingLabels: null })
assert.ok(add.includes('area/ui'))
})

// ── hasArea honours existing maintainer-added area/* ──

test('retitle to scopeless when maintainer-added area/* exists: no area/uncategorized', () => {
// Maintainer manually attached area/forms. PR title is later retitled to a
// scopeless form. The labeler must not pile area/uncategorized on top of
// the already-categorised state, and must strip it if it was there from
// an earlier run.
const { add, remove } = computeLabels({
title: 'chore: housekeeping',
existingLabels: ['area/forms', 'area/uncategorized'],
})
assert.ok(!add.includes('area/uncategorized'))
assert.ok(remove.includes('area/uncategorized'))
})

test('existing only has area/uncategorized: scopeless title keeps area/uncategorized (no churn)', () => {
// The "honour existing area/*" rule must not count area/uncategorized as
// a real categorisation — otherwise a scopeless retitle would silently
// freeze the fallback in place. Expected net effect: fallback path is
// taken, area/uncategorized stays put (already-present so not re-added,
// and hasArea is false so not removed), kind/cleanup derives from chore.
const { add, remove } = computeLabels({
title: 'chore: housekeeping',
existingLabels: ['area/uncategorized'],
})
assert.deepEqual(add, ['kind/cleanup'])
assert.deepEqual(remove, [])
})
Loading