Skip to content

ci(labels): adopt Kubernetes-style label scheme#16

Merged
Maxim Kitsunoff (kitsunoff) merged 1 commit into
mainfrom
ci/labels-from-cozystack
May 26, 2026
Merged

ci(labels): adopt Kubernetes-style label scheme#16
Maxim Kitsunoff (kitsunoff) merged 1 commit into
mainfrom
ci/labels-from-cozystack

Conversation

@lexfrei
Copy link
Copy Markdown
Contributor

@lexfrei Aleksei Sviridkin (lexfrei) commented May 26, 2026

Mirror the label workflow already running in cozystack/cozystack so this repo gets the same Kubernetes-style scheme (kind/, area/, priority/, triage/, lifecycle/, do-not-merge/, security/, size/).

What this lands

  • .github/labels.yml — 68 labels, declarative, validated on every PR.
  • .github/scripts/pr-labeler.js — pure label-derivation logic, extracted so the rules can be unit-tested with node --test.
  • .github/scripts/pr-labeler.test.js — 21 cases covering happy path, retitle / stale-label removal, composite scopes, bracket form, breaking-change detection, and the "non-labeler labels are never removed" invariant.
  • .github/workflows/labels.yaml — validates schema, runs the JS unit tests, syncs labels via EndBug/label-sync@v2 on push to main, weekly cron, and workflow_dispatch.
  • .github/workflows/pr-labeler.yaml — derives kind/* and area/* from the Conventional Commits title on PR open/edit/synchronize.
  • .github/workflows/pr-size.yaml — applies size/{XS,S,M,L,XL,XXL} based on line count, ignoring lockfiles and generated paths.

What I adapted from cozystack/cozystack

The cross-cutting namespaces (kind/, priority/, triage/, lifecycle/, do-not-merge/, security/, size/) come over verbatim. The area/ set is rewritten for the SPA: backend-oriented areas (database, networking, storage, virtualization, monitoring, platform, kubernetes, extra, api, build, dashboard, release, testing) are out. The new set is console, forms, k8s-client, ui, types, tenants, auth, vm, container, ci, docs, tests, plus uncategorized as the fallback.

pr-labeler scope→area mapping covers scopes observed in the repo history (console, ui, backup, backups, external-ips, workflows, overview, forms) plus the widget and package names that show up in the codebase.

pr-size isIgnored is rewritten for a node workspace: pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lockb, *.lock, node_modules/, dist/, build/, generated/. The Go-specific paths (vendor/, zz_generated, .pb.go, charts) are dropped.

backport / backport-previous are dropped — there is no release-line backport process here.

Differences from the upstream version worth flagging

  • The labeler is no longer purely additive. It still preserves every label outside its narrow authoritative set, but it does now remove area/uncategorized once a real area/* is derived from the title, and removes kind/breaking-change once neither the ! marker nor a BREAKING CHANGE: footer remains. Maintainer-added labels are never touched. This fixes the title-edit regression where a retitled PR would end up with both area/uncategorized and the new area, or with a stale kind/breaking-change.
  • The validate job gains a description-vs-automation drift check: any label whose description advertises automation (auto-applied, auto-closed, auto-labeled, auto-close*) must be referenced by name in .github/workflows/ or .github/scripts/, so a description cannot promise behaviour that no code backs.
  • lifecycle/* descriptions and the lgtm description are reworded to reflect that they are manual markers in this repo — there is no stale-bot and no /lgtm command handler.
  • Label-derivation logic lives in a pure ES module so it can be unit-tested with node --test. The workflow imports it via await import().

Migration of existing labels

EndBug/label-sync runs with delete-other-labels: false. The existing GitHub defaults (bug, enhancement, documentation, question, duplicate) collapse into the new kind/* and triage/* names via aliases: in labels.yml — references on already-tagged items are preserved. Unmapped defaults (invalid, wontfix, help wanted, good first issue) are kept as-is.

Security

Both pull_request_target workflows are hardened: pr-labeler.yaml pins checkout to pull_request.base.sha with persist-credentials: false, so the labeler script can never come from an attacker-controlled PR head. pr-size.yaml doesn't check out at all. Permissions are minimal (contents: read, pull-requests: write). Both workflows have a per-PR concurrency group so a stale run cannot land its mutations after a newer run.

Validation

  • Schema validator (description ≤ 100 chars, hex color, name uniqueness, alias collisions, automation-claim drift): OK, 68 labels.
  • node --test: 21/21 pass.
  • actionlint clean on all three workflows.
  • Reviewed by Opus on the branch diff before opening.

Summary by CodeRabbit

  • New Features
    • Automatic pull request labeling based on commit type, scope, and breaking changes detection
    • Automatic pull request size categorization (XS–XXL) based on code change volume
    • Standardized repository label conventions and management system

Review Change Stack

Mirror the label workflow already running in cozystack/cozystack: a
declarative .github/labels.yml synced via EndBug/label-sync, a PR
auto-labeler that derives kind/* and area/* from the Conventional
Commits title, and a PR size labeler that ignores generated and
vendored paths.

labels.yml carries the generic categories (kind/, priority/, triage/,
lifecycle/, do-not-merge/, security/, size/) and is adapted to this
repo by replacing the backend-oriented area/* set (database, network,
storage, virtualization, platform, etc.) with area/* that match the
SPA: console, forms, k8s-client, ui, types, tenants, auth, vm,
container, ci, docs, tests, plus area/uncategorized as the fallback.

The labeler's authoritative-label set is narrow on purpose. Beyond
the additive pass it removes exactly two labels it knows it set
itself: area/uncategorized once a real area/* is derived, and
kind/breaking-change once neither the conventional-commit ! marker
nor a BREAKING CHANGE: footer remains. Maintainer-added labels are
preserved untouched. The derivation lives in
.github/scripts/pr-labeler.js as a pure function so the rules can be
covered by node:test cases (.github/scripts/pr-labeler.test.js)
exercising the title-edit scenarios the additive-only design used to
mishandle.

pr-size isIgnored is rewritten for a node workspace: pnpm-lock.yaml,
package-lock.json, yarn.lock, bun.lockb, *.lock, node_modules/,
dist/, build/, generated/. The Go-specific paths from cozystack
(vendor/, zz_generated, .pb.go, charts/) are dropped.

The validate job grows a description-vs-automation drift check: any
label whose description advertises automation (auto-applied,
auto-closed, auto-labeled, auto-closed) must be referenced by name
in .github/workflows/ or .github/scripts/, so a description cannot
promise behaviour that no code backs. The lifecycle/* descriptions
and the lgtm description are reworded to reflect that they are
manual markers in this repo — there is no stale-bot and no /lgtm
command handler. labels.yml header notes that size/* line-count
thresholds are the contract pr-size.yaml encodes.

EndBug/label-sync runs with delete-other-labels: false, so the
existing GitHub defaults (bug, enhancement, documentation, question,
duplicate) collapse into the new kind/* and triage/* names via the
aliases lists without losing references on already-tagged items.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR introduces a complete automated label management system for the repository. It defines a canonical label catalog, implements Conventional Commits-based label derivation from PR titles, adds workflows to synchronize and validate labels, and includes automatic PR size labeling based on diff line counts.

Changes

PR Label Automation

Layer / File(s) Summary
Label catalog and schema
.github/labels.yml
Defines the complete label taxonomy including kind/*, priority/*, triage/*, lifecycle/*, area/*, do-not-merge/*, size/*, security/*, and cross-cutting labels with color codes, descriptions, and legacy aliases.
PR title-based label derivation
.github/scripts/pr-labeler.js, .github/scripts/pr-labeler.test.js
Implements computeLabels() to parse Conventional Commits (type(scope)?: ...) and bracket scope ([scope] ...) formats, detect breaking changes via ! and BREAKING CHANGE: markers, compute kind/* and area/* labels with area/uncategorized fallback, and constrain label removal to only labeler-owned labels. Test suite covers type/scope mapping, breaking-change detection, stale-label removal on retitles, non-authoritative label preservation, and edge cases.
Label definition validation and synchronization
.github/workflows/labels.yaml
Orchestrates three jobs: a Python validator checking description length (≤100 chars), color hex format, name uniqueness, alias collisions, and automation claim drift by scanning workflows/scripts; a test runner enforcing at least one test execution; and a sync job applying validated labels to GitHub via EndBug/label-sync with delete-other-labels: false.
Automatic PR title-based labeling
.github/workflows/pr-labeler.yaml
Runs on pull_request_target events to dynamically import pr-labeler.js, compute labels from PR metadata and existing labels, emit warnings for unmapped types/scopes, remove stale labeler-owned labels with 404-resilient handling, and add computed labels.
Automatic PR size labeling
.github/workflows/pr-size.yaml
Runs on pull_request_target to paginate PR files, exclude non-real diffs (lockfiles, node_modules, dist/, build/, generated/), compute total line count, map to size buckets (XS–XXL), and reconcile size/* labels while preserving non-size labels.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

A rabbit hops through labels, neat and bright,
Deriving kinds from titles, left and right,
With sizes, scopes, and breaking changes clear—
The PR dance automated here!
🐰✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'ci(labels): adopt Kubernetes-style label scheme' directly and clearly describes the main change—implementing a Kubernetes-style label scheme for CI/GitHub workflows.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/labels-from-cozystack

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@lexfrei Aleksei Sviridkin (lexfrei) marked this pull request as ready for review May 26, 2026 14:10
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a custom PR labeler script (pr-labeler.js) and associated unit tests to automate label management based on Conventional Commits and PR titles. The review feedback highlights critical improvements for robust input handling against null values, preventing duplicate fallback labels when a maintainer has manually categorized a PR, and adding a corresponding unit test to cover this scenario.

Comment on lines +130 to +132
export function computeLabels({ title = '', body = '', existingLabels = [] } = {}) {
const existing = new Set(existingLabels)
const toAdd = new Set()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Defensive Programming: Handle Null Inputs Safely

If title, body, or existingLabels are explicitly passed as null (which is common in GitHub Actions payloads or context objects), the destructuring default values will not apply. This can lead to runtime TypeError exceptions (e.g., TypeError: Cannot read properties of null (reading 'match') or TypeError: object is not iterable).

Using explicit logical OR (||) fallbacks ensures robust defensive programming.

Suggested change
export function computeLabels({ title = '', body = '', existingLabels = [] } = {}) {
const existing = new Set(existingLabels)
const toAdd = new Set()
export function computeLabels({ title: rawTitle = '', body: rawBody = '', existingLabels = [] } = {}) {
const title = rawTitle || ''
const body = rawBody || ''
const existing = new Set(existingLabels || [])
const toAdd = new Set()

Comment on lines +189 to +192
const hasArea = [...toAdd].some((l) => l.startsWith('area/'))
if (!hasArea) {
toAdd.add('area/uncategorized')
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Prevent Duplicate/Conflicting Fallback Labels

Currently, hasArea only checks if an area/* label is present in the newly derived toAdd set. If a PR already has a manually assigned area/* label (e.g., area/forms) and is later retitled to a title without a scope (e.g., chore: housekeeping), hasArea will be false, causing area/uncategorized to be added. This results in the PR having both a specific area label and the uncategorized fallback.

By checking both toAdd and existing (excluding area/uncategorized itself), we prevent this duplication and automatically clean up area/uncategorized as soon as a maintainer manually categorizes the PR.

Suggested change
const hasArea = [...toAdd].some((l) => l.startsWith('area/'))
if (!hasArea) {
toAdd.add('area/uncategorized')
}
const hasArea = [...toAdd].some((l) => l.startsWith('area/')) ||
[...existing].some((l) => l.startsWith('area/') && l !== 'area/uncategorized')
if (!hasArea) {
toAdd.add('area/uncategorized')
}

Comment on lines +161 to +162
assert.deepEqual(remove, [])
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add Test Case for Retitling with Existing Maintainer-Added Area Labels

Let's add a unit test to verify that area/uncategorized is not added (and is removed if present) when a PR already has a maintainer-added area/* label, even if the new title has no scope.

Suggested change
assert.deepEqual(remove, [])
})
assert.deepEqual(remove, [])
})
test('retitle: no scope, but maintainer-added area/* exists — area/uncategorized is not added and is removed if present', () => {
const { add, remove } = computeLabels({
title: 'chore: bump workflow',
existingLabels: ['area/forms', 'area/uncategorized'],
})
assert.ok(!add.includes('area/uncategorized'))
assert.ok(remove.includes('area/uncategorized'))
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Solid review-grade scaffolding — authoritative-vs-additive split is correct, tests cover the retitle regressions, validate job catches description-vs-automation drift, pull_request_target workflows are properly hardened.

@kitsunoff Maxim Kitsunoff (kitsunoff) merged commit 92ff040 into main May 26, 2026
5 of 6 checks passed
@kitsunoff Maxim Kitsunoff (kitsunoff) deleted the ci/labels-from-cozystack branch May 26, 2026 14:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants