Skip to content

feat(labels): add gated migrate and delete paths to labels-sync.rb #465

Description

@ss-o

Problem

scripts/labels-sync.rb can create and update canonical labels, but it has no
delete or migrate code path. Its header states this explicitly: it "never creates,
updates, deletes, or migrates labels unless both --apply and --confirm-apply
are passed" — and in practice only create/update are implemented.

lib/labels.yml already declares the intended cleanup policy, but nothing enforces
or executes it:

sync_policy:
  delete_unknown_labels: false
  delete_legacy_labels_only_when_unused: true
  preserve_labels_on_open_items_before_removal: true

So legacy labels accumulate indefinitely. The canonical set was rolled out
org-wide, which means most repositories now carry both taxonomies at once.

Measured scope

Across 86 active, public, non-fork repositories, counting every issue and pull
request in any state:

Set (repo, label) pairs Labelled items Policy verdict
Legacy, unused 1,405 0 Deletable — delete_legacy_labels_only_when_unused
Legacy, in use 60 1,002 Migrate to canonical first, then delete
Unknown 1,503 76 items Never deletedelete_unknown_labels: false

In-use legacy labels, by migration target:

Legacy label Migrates to Items Repos
dependencies 📦 area:dependencies 754 18
ci 🤖 area:ci 99 1
documentation 📝 type:docs 46 5
maintenance 📈 type:maintenance 25 4
enhancement ✨ type:maintenance 21 4
annex 🌀 area:annex 19 7
13 others various 38

Note that the canonical set contains both dependencies (the Dependabot and
Renovate auto-applied label, "Pull requests that update a dependency file") and
area:dependencies. Only the emoji-suffixed dependencies 📦 is legacy. The bare
dependencies label must not be touched.

Proposal

Add two gated code paths to labels-sync.rb, following the existing
--apply / --confirm-apply two-step pattern so previews stay read-only:

  1. --migrate-legacy — for each in-use legacy label, add the mapped canonical
    label to every carrying item, verify it landed, and only then remove the legacy
    label. Order is load-bearing: deleting first destroys the association
    permanently.
  2. --delete-unused-legacy — delete legacy labels whose usage count is zero.

Behavior must be read from sync_policy in labels.yml rather than hardcoded, so
the policy file stays the single source of truth.

Required refusal paths

Mirroring the existing guardrail tests in scripts/test-labels-dry-run.sh:

  • Refuse to delete any label not present in legacy_migrations (unknown labels)
  • Refuse to delete an in-use legacy label unless migration has completed for it
  • Refuse to act without the matching --confirm-* flag
  • Refuse when sync_policy disables the corresponding operation

Acceptance criteria

  • Migrate and delete paths implemented, gated behind explicit confirm flags
  • sync_policy keys are honoured, not reimplemented in code
  • Refusal-path tests added to scripts/test-labels-dry-run.sh using the existing fake-gh harness
  • Preview mode makes zero write calls
  • Usage counts re-verified per label at execution time, not read from a cached audit

Out of scope

Executing the cleanup against the organization. That is roughly 2,400 irreversible
write operations and needs its own approval, with figures re-measured at run time.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:metaOrganization-wide policy, templates, or meta-repo work.meta:org-trackedIndicates this issue has cross-repository tracking implications (synced to Linear).status:triageAwaiting initial review or classification.type:featureA request for new behavior or capability.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions