From 3832dfd548fb8b7c5f5b5e3633da47f3b3de7933 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Tue, 1 Sep 2026 20:06:26 -0400 Subject: [PATCH] feat: define organization label taxonomy --- .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/organization-work.yml | 16 + .github/labels/catalog.v1.json | 183 ++++++++++ .github/labels/repositories.v1.json | 13 + .github/labels/schema/catalog.v1.schema.json | 69 ++++ .../labels/schema/repositories.v1.schema.json | 39 +++ .github/workflows/validate-label-contract.yml | 51 +++ DECISIONS.md | 17 + README.md | 2 + docs/issue-routing.md | 24 +- docs/label-governance.md | 96 ++++++ fixtures/labels/dot-github.expected.json | 158 +++++++++ scripts/project_labels.py | 76 +++++ scripts/validate_labels.py | 322 ++++++++++++++++++ tests/test_labels.py | 91 +++++ 15 files changed, 1160 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/labels/catalog.v1.json create mode 100644 .github/labels/repositories.v1.json create mode 100644 .github/labels/schema/catalog.v1.schema.json create mode 100644 .github/labels/schema/repositories.v1.schema.json create mode 100644 .github/workflows/validate-label-contract.yml create mode 100644 docs/label-governance.md create mode 100644 fixtures/labels/dot-github.expected.json create mode 100644 scripts/project_labels.py create mode 100644 scripts/validate_labels.py create mode 100644 tests/test_labels.py diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c2cc662 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Organization issue routing guide + url: https://github.com/egohygiene/.github/blob/main/docs/issue-routing.md + about: Check ownership and routing guidance before opening organization-level work. diff --git a/.github/ISSUE_TEMPLATE/organization-work.yml b/.github/ISSUE_TEMPLATE/organization-work.yml index 6f21aba..0ff7c40 100644 --- a/.github/ISSUE_TEMPLATE/organization-work.yml +++ b/.github/ISSUE_TEMPLATE/organization-work.yml @@ -1,6 +1,8 @@ name: Organization work description: Route organization-level, cross-repository, or not-yet-owned work. title: "[org]: " +labels: + - needs-routing body: - type: markdown attributes: @@ -24,6 +26,20 @@ body: validations: required: true + - type: dropdown + id: routing_state + attributes: + label: Routing state + description: Choose the current organization coordination state. + options: + - needs-routing + - routed + - cross-repo + - blocked + - ready + validations: + required: true + - type: textarea id: scope attributes: diff --git a/.github/labels/catalog.v1.json b/.github/labels/catalog.v1.json new file mode 100644 index 0000000..cebe298 --- /dev/null +++ b/.github/labels/catalog.v1.json @@ -0,0 +1,183 @@ +{ + "$schema": "./schema/catalog.v1.schema.json", + "schema_version": 1, + "catalog_version": "1.0.0", + "owner": "egohygiene/.github", + "defaults": { + "required": true, + "removal_policy": "retain" + }, + "universal": [ + { + "name": "priority:p0", + "description": "Correctness, security, or data-integrity blocker.", + "color": "b60205", + "category": "priority" + }, + { + "name": "priority:p1", + "description": "Required foundation or near-term release work.", + "color": "d93f0b", + "category": "priority" + }, + { + "name": "priority:p2", + "description": "Important planned capability.", + "color": "fbca04", + "category": "priority" + }, + { + "name": "priority:p3", + "description": "Research, exploration, or later direction.", + "color": "c5def5", + "category": "priority" + }, + { + "name": "type:architecture", + "description": "Architecture, boundaries, or system-design work.", + "color": "1f4d8f", + "category": "type" + }, + { + "name": "type:feature", + "description": "New or expanded user-visible capability.", + "color": "0e8a16", + "category": "type" + }, + { + "name": "type:bug", + "description": "Incorrect or unexpected behavior.", + "color": "dc2626", + "category": "type" + }, + { + "name": "type:documentation", + "description": "Documentation or knowledge-organization work.", + "color": "64748b", + "category": "type" + }, + { + "name": "type:research", + "description": "Research, discovery, or evidence gathering.", + "color": "0f766e", + "category": "type" + }, + { + "name": "type:maintenance", + "description": "Maintenance, cleanup, or dependency work.", + "color": "6b7280", + "category": "type" + }, + { + "name": "area:automation", + "description": "Automation, continuous integration, and delivery.", + "color": "0369a1", + "category": "area" + }, + { + "name": "area:developer-experience", + "description": "Developer workflows, tooling, and ergonomics.", + "color": "7c5c1e", + "category": "area" + }, + { + "name": "area:governance", + "description": "Organization policy, contracts, and coordination.", + "color": "6d28d9", + "category": "area" + }, + { + "name": "area:security", + "description": "Security, privacy, provenance, and trust boundaries.", + "color": "b91c1c", + "category": "area" + }, + { + "name": "cross-repo", + "description": "Work coordinated across multiple repositories.", + "color": "5319e7", + "category": "coordination" + }, + { + "name": "needs-routing", + "description": "Work that does not yet have a durable repository owner.", + "color": "d4c5f9", + "category": "coordination" + }, + { + "name": "blocked", + "description": "Work waiting on an explicit dependency or decision.", + "color": "b60205", + "category": "status" + }, + { + "name": "ready", + "description": "Work with sufficient scope, ownership, and acceptance criteria.", + "color": "0e8a16", + "category": "status" + } + ], + "overlays": [ + { + "id": "ecosystem", + "description": "Named Ego Hygiene ecosystem capabilities.", + "labels": [ + {"name": "☁️ aether", "description": "Local AI, agents, prompts, skills, and workflows ☁️", "color": "4338ca"}, + {"name": "🌱 mindgarden", "description": "Knowledge management, digital garden, and long-term thinking 🌱", "color": "3a7d44"}, + {"name": "🎬 dreamscape", "description": "Creative operating system and AI orchestration 🎬", "color": "6d28d9"} + ] + }, + { + "id": "engineering", + "description": "Engineering changes, platforms, packages, and specifications.", + "labels": [ + {"name": "♻️ refactor", "description": "Repository cleanup, restructuring, modernization, and technical debt ♻️", "color": "16a34a"}, + {"name": "βš™οΈ infra", "description": "Infrastructure, repositories, tooling, and configuration βš™οΈ", "color": "274472"}, + {"name": "⚑ dx", "description": "Developer experience, tooling, and ergonomics ⚑", "color": "7c5c1e"}, + {"name": "πŸ—οΈ architecture", "description": "Architecture, foundations, and system design πŸ—οΈ", "color": "1f4d8f"}, + {"name": "πŸ› bug", "description": "Something isn't working πŸ›", "color": "dc2626"}, + {"name": "πŸ“„ documentation", "description": "Documentation improvements and knowledge organization πŸ“„", "color": "64748b"}, + {"name": "πŸ“‹ backlog", "description": "Captured ideas and future work πŸ“‹", "color": "4b5563"}, + {"name": "πŸ“ schema", "description": "Schemas, specifications, contracts, and data models πŸ“", "color": "7c3aed"}, + {"name": "πŸ“¦ package", "description": "Reusable packages and shared libraries πŸ“¦", "color": "0369a1"}, + {"name": "πŸ“± flutter", "description": "Flutter application and Flutter Foundation πŸ“±", "color": "0175c2"}, + {"name": "πŸ”„ lifecycle", "description": "Application lifecycles, managers, and orchestration πŸ”„", "color": "0f766e"}, + {"name": "πŸ”Œ plugin", "description": "Plugin architecture, registries, and extensibility πŸ”Œ", "color": "2563eb"} + ] + }, + { + "id": "creative-publishing", + "description": "Creative production, publishing, design, and outreach.", + "labels": [ + {"name": "✍️ writing", "description": "Writing workflow, editorial improvements, and authoring ✍️", "color": "7c3aed"}, + {"name": "🌐 website", "description": "Website, documentation site, and public experience 🌐", "color": "0284c7"}, + {"name": "πŸŽ₯ video", "description": "Video production, animation, and cinematic content πŸŽ₯", "color": "b91c1c"}, + {"name": "🎨 design", "description": "Design system, UI, UX, branding, and visuals 🎨", "color": "4e8065"}, + {"name": "🎡 music", "description": "Music, albums, lyrics, and audio production 🎡", "color": "7f1d1d"}, + {"name": "πŸ“– magazine", "description": "Magazine, comic, editorial, and visual storytelling πŸ“–", "color": "9333ea"}, + {"name": "πŸ“š article", "description": "Long-form articles and essays πŸ“š", "color": "2563eb"}, + {"name": "πŸ“ publishing", "description": "Publishing platform, workflows, and distribution πŸ“", "color": "0e7490"}, + {"name": "πŸ“£ marketing", "description": "Marketing, social media, SEO, and outreach πŸ“£", "color": "15803d"} + ] + }, + { + "id": "human-practice", + "description": "Human domains, practices, philosophy, and journeys.", + "labels": [ + {"name": "🌍 domain", "description": "Life domains, domain models, and domain-specific functionality 🌍", "color": "15803d"}, + {"name": "🎯 practice", "description": "Practices, rituals, habits, and exercises 🎯", "color": "15803d"}, + {"name": "πŸ›οΈ philosophy", "description": "Purpose, principles, ontology, and conceptual evolution πŸ›οΈ", "color": "6d28d9"}, + {"name": "πŸ—ΊοΈ journey", "description": "Journey modeling, progress, and timeline systems πŸ—ΊοΈ", "color": "0891b2"} + ] + }, + { + "id": "research-funding", + "description": "Research and the resources that support it.", + "labels": [ + {"name": "πŸ”¬ research", "description": "Research, literature, evidence, and references πŸ”¬", "color": "0f766e"}, + {"name": "πŸ’° funding", "description": "Grants, sponsorships, partnerships, and monetization πŸ’°", "color": "b45309"} + ] + } + ], + "deprecations": [] +} diff --git a/.github/labels/repositories.v1.json b/.github/labels/repositories.v1.json new file mode 100644 index 0000000..2d70bf6 --- /dev/null +++ b/.github/labels/repositories.v1.json @@ -0,0 +1,13 @@ +{ + "$schema": "./schema/repositories.v1.schema.json", + "schema_version": 1, + "catalog_version": "1.0.0", + "repositories": [ + { + "repository": "egohygiene/.github", + "include_universal": true, + "overlays": ["engineering"], + "additional_labels": [] + } + ] +} diff --git a/.github/labels/schema/catalog.v1.schema.json b/.github/labels/schema/catalog.v1.schema.json new file mode 100644 index 0000000..e136912 --- /dev/null +++ b/.github/labels/schema/catalog.v1.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/contracts/labels/catalog.v1.schema.json", + "title": "Ego Hygiene label catalog", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema_version", "catalog_version", "owner", "defaults", "universal", "overlays", "deprecations"], + "properties": { + "$schema": {"type": "string"}, + "schema_version": {"const": 1}, + "catalog_version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"}, + "owner": {"const": "egohygiene/.github"}, + "defaults": { + "type": "object", + "additionalProperties": false, + "required": ["required", "removal_policy"], + "properties": { + "required": {"const": true}, + "removal_policy": {"enum": ["retain"]} + } + }, + "universal": {"type": "array", "items": {"$ref": "#/$defs/universalLabel"}}, + "overlays": {"type": "array", "items": {"$ref": "#/$defs/overlay"}}, + "deprecations": {"type": "array", "items": {"$ref": "#/$defs/deprecation"}} + }, + "$defs": { + "label": { + "type": "object", + "additionalProperties": false, + "required": ["name", "description", "color"], + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 50}, + "description": {"type": "string", "minLength": 1, "maxLength": 100}, + "color": {"type": "string", "pattern": "^[0-9a-f]{6}$"} + } + }, + "universalLabel": { + "type": "object", + "additionalProperties": false, + "required": ["name", "description", "color", "category"], + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 50}, + "description": {"type": "string", "minLength": 1, "maxLength": 100}, + "color": {"type": "string", "pattern": "^[0-9a-f]{6}$"}, + "category": {"enum": ["priority", "type", "area", "coordination", "status"]} + } + }, + "overlay": { + "type": "object", + "additionalProperties": false, + "required": ["id", "description", "labels"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9-]*$"}, + "description": {"type": "string", "minLength": 1}, + "labels": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/label"}} + } + }, + "deprecation": { + "type": "object", + "additionalProperties": false, + "required": ["name", "replacement", "remove_after"], + "properties": { + "name": {"type": "string"}, + "replacement": {"type": ["string", "null"]}, + "remove_after": {"type": "string", "format": "date"} + } + } + } +} diff --git a/.github/labels/schema/repositories.v1.schema.json b/.github/labels/schema/repositories.v1.schema.json new file mode 100644 index 0000000..e1b5642 --- /dev/null +++ b/.github/labels/schema/repositories.v1.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/contracts/labels/repositories.v1.schema.json", + "title": "Ego Hygiene repository label assignments", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema_version", "catalog_version", "repositories"], + "properties": { + "$schema": {"type": "string"}, + "schema_version": {"const": 1}, + "catalog_version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"}, + "repositories": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "include_universal", "overlays", "additional_labels"], + "properties": { + "repository": {"type": "string", "pattern": "^egohygiene/[A-Za-z0-9._-]+$"}, + "include_universal": {"const": true}, + "overlays": {"type": "array", "uniqueItems": true, "items": {"type": "string"}}, + "additional_labels": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "description", "color"], + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 50}, + "description": {"type": "string", "minLength": 1, "maxLength": 100}, + "color": {"type": "string", "pattern": "^[0-9a-f]{6}$"} + } + } + } + } + } + } + } +} diff --git a/.github/workflows/validate-label-contract.yml b/.github/workflows/validate-label-contract.yml new file mode 100644 index 0000000..87357e3 --- /dev/null +++ b/.github/workflows/validate-label-contract.yml @@ -0,0 +1,51 @@ +name: Validate label contract + +on: + pull_request: + paths: + - ".github/labels/**" + - ".github/workflows/validate-label-contract.yml" + - "fixtures/labels/**" + - "scripts/project_labels.py" + - "scripts/validate_labels.py" + - "tests/test_labels.py" + push: + branches: + - main + paths: + - ".github/labels/**" + - ".github/workflows/validate-label-contract.yml" + - "fixtures/labels/**" + - "scripts/project_labels.py" + - "scripts/validate_labels.py" + - "tests/test_labels.py" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: labels-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Validate governed documents + run: python scripts/validate_labels.py + + - name: Run contract tests + run: python -m unittest discover --start-directory tests --verbose + + - name: Prove deterministic reference projection + run: | + python scripts/project_labels.py \ + --repository "egohygiene/.github" \ + --output "${RUNNER_TEMP}/labels.json" + diff --unified fixtures/labels/dot-github.expected.json "${RUNNER_TEMP}/labels.json" diff --git a/DECISIONS.md b/DECISIONS.md index dd7df3c..01ec864 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -41,6 +41,7 @@ Do not rewrite historical context to fit current understanding. Amend a record f - ADR-002: Use this repository as the organization-facing inbox and defaults surface - ADR-003: Avoid duplicating implementation libraries - ADR-004: Define trust policy before reusable enforcement +- ADR-005: Keep organization label meaning canonical in `.github` ## ADR-001: Keep ecosystem architecture canonical in Hygiene @@ -87,6 +88,22 @@ Do not rewrite historical context to fit current understanding. Amend a record f - **Reconsider when:** The ownership boundary prevents independent policy review, reusable implementation, or accurate conformance evidence. +## ADR-005: Keep organization label meaning canonical in `.github` + +- **Status:** Accepted as the current architectural direction +- **Date:** 2026-09-02 +- **Context:** Organization intake requires stable label meaning while provider + synchronization, path labeling, and contributor automation belong to Relay. +- **Decision:** Keep the versioned organization label catalog, overlay model, + and repository assignments in `.github`. Relay consumes the contract and owns + provider mutations; repository additions cannot redefine canonical labels. +- **Consequences:** Human-facing defaults and their machine-readable meaning + share one owner. Synchronization remains reusable and independently released. + Every consumer must bind to an immutable catalog revision and fail closed on + incompatible versions. +- **Reconsider when:** GitHub-specific representation leaks into the canonical + domain contract or the boundary prevents safe non-GitHub consumers. + ## Open decisions - Release and compatibility policy for the first stable version. diff --git a/README.md b/README.md index 7d960ed..f93deb0 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,5 @@ exceptions, and staged adoption. - [Organization issue routing](docs/issue-routing.md) β€” route work to its narrowest durable owner. +- [Organization label governance](docs/label-governance.md) β€” canonical + taxonomy, overlays, lifecycle, and safe adoption. diff --git a/docs/issue-routing.md b/docs/issue-routing.md index 277d01d..bf11d55 100644 --- a/docs/issue-routing.md +++ b/docs/issue-routing.md @@ -18,7 +18,10 @@ Use `.github` when work is genuinely organization-facing or when ownership is no | `blocked` | A dependency prevents useful implementation. | Record the dependency and unblock condition. | | `ready` | Scope, owner, dependencies, and acceptance criteria are sufficient for implementation. | Execute in the owning repository. | -These names describe the intended taxonomy. Label creation/synchronization should be performed by the organization automation owned by Relay rather than hand-maintained independently in every repository. +These names are governed by the machine-readable [label +catalog](../.github/labels/catalog.v1.json). Label creation and synchronization +should be performed by the organization automation owned by Relay rather than +hand-maintained independently in every repository. ## Stable label taxonomy @@ -51,7 +54,24 @@ Use `area:` only for stable domains that improve filtering. Do not creat - `blocked` - `ready` -Repository-specific overlays may add labels, but must not redefine organization label semantics. +The canonical catalog also defines a deliberately small `area:*` set for +automation, developer experience, governance, and security. Add another +universal area only when it is stable across the organization; repository or +domain concepts belong in overlays. + +The approved expressive catalog is classified into five opt-in overlays: + +| Overlay | Scope | +| --- | --- | +| `ecosystem` | Named Ego Hygiene capabilities. | +| `engineering` | Engineering changes, platforms, packages, and specifications. | +| `creative-publishing` | Creative production, design, publishing, and outreach. | +| `human-practice` | Human domains, practices, philosophy, and journeys. | +| `research-funding` | Research and the resources that support it. | + +Repository-specific additions may add labels, but must not redefine universal +or overlay semantics. See [label governance](label-governance.md) for the +versioning, collision, deprecation, and removal rules. ## Umbrella issues diff --git a/docs/label-governance.md b/docs/label-governance.md new file mode 100644 index 0000000..dfa3de9 --- /dev/null +++ b/docs/label-governance.md @@ -0,0 +1,96 @@ +# Organization Label Governance + +The versioned label contract under [`.github/labels`](../.github/labels) is the +canonical source for Ego Hygiene label names, descriptions, colors, and +repository overlay assignments. Human-facing documentation explains the +contract; it does not redefine it. + +Colors use GitHub's provider-ready form: exactly six lowercase hexadecimal +digits without a leading `#`. + +## Ownership + +- `.github` owns the organization taxonomy, overlay definitions, and repository + assignments. +- Relay consumes an immutable revision of this contract and owns previewing and + applying provider mutations. +- Repository owners may request overlays and add genuinely local labels through + the assignment document. They may not redefine canonical names. +- Pace may later report adoption and drift. It does not mutate labels without a + separately reviewed Relay plan. + +The contract contains no credentials and grants no mutation authority. + +## Contract files + +| File | Purpose | +| --- | --- | +| `catalog.v1.json` | Universal labels, approved overlays, lifecycle policy, and catalog version. | +| `repositories.v1.json` | Explicit repository adoption and overlay selections. | +| `schema/*.schema.json` | Provider-neutral document shapes for consumers. | + +Universal labels are required for every configured repository. Overlays are +opt-in. A repository may add labels only when their names do not collide with a +universal or overlay label. + +## Precedence and collision rules + +1. Universal definitions always win and cannot be disabled or overridden. +2. Overlay definitions are canonical and cannot be changed by a repository. +3. Repository additions must use unique names and remain owned by that + repository. +4. Selecting multiple overlays produces their union. Duplicate canonical names + are invalid rather than resolved by order. +5. An unknown overlay or catalog-version mismatch fails closed before planning + any provider mutation. + +Similar concepts may intentionally have separate universal and expressive +labelsβ€”for example, `type:bug` is machine-oriented while `πŸ› bug` belongs to the +opt-in engineering overlay. Their distinct names prevent accidental semantic +replacement. + +## Lifecycle + +Changes follow semantic versioning at the catalog level: + +- patch: spelling or clarification that does not change matching semantics; +- minor: additive labels, overlays, or repository assignments; +- major: rename, semantic change, removal, or incompatible schema behavior. + +A rename is modeled as an addition plus a deprecation. Deprecations must name a +replacement when one exists and provide a review date. Relay must preview +renames and removals separately from additive synchronization. + +Deletion is never inferred from absence. The catalog's default removal policy +is `retain`; destructive removal requires an explicit future contract change, +a generated preview, and human approval. Unrelated manually applied labels on +issues and pull requests remain outside taxonomy synchronization. + +## Adoption and rollback + +To adopt a repository: + +1. Add one entry to `repositories.v1.json` and select only relevant overlays. +2. Run `python scripts/validate_labels.py`. +3. Preview the exact provider-neutral projection: + + ```shell + python scripts/project_labels.py --repository "egohygiene/example" + ``` + +4. Review and merge the contract change. +5. Have Relay consume the merged commit immutably and preview provider changes + before applying them. + +Rollback means reverting the assignment or catalog commit and generating a new +preview. Because the default is retain, rollback does not delete labels or +remove labels already attached to work items. + +## Local issue templates + +The organization work form is the fallback for organization-level or unowned +work. Repositories should still materialize their own issue templates and +`config.yml`: GitHub inheritance varies by repository visibility and a local +template can express repository-owned policy more accurately. A local template +must retain owner, dependency, acceptance, and security fields when it accepts +cross-repository work. diff --git a/fixtures/labels/dot-github.expected.json b/fixtures/labels/dot-github.expected.json new file mode 100644 index 0000000..4761640 --- /dev/null +++ b/fixtures/labels/dot-github.expected.json @@ -0,0 +1,158 @@ +{ + "schema_version": 1, + "catalog_version": "1.0.0", + "repository": "egohygiene/.github", + "removal_policy": "retain", + "labels": [ + { + "name": "area:automation", + "description": "Automation, continuous integration, and delivery.", + "color": "0369a1" + }, + { + "name": "area:developer-experience", + "description": "Developer workflows, tooling, and ergonomics.", + "color": "7c5c1e" + }, + { + "name": "area:governance", + "description": "Organization policy, contracts, and coordination.", + "color": "6d28d9" + }, + { + "name": "area:security", + "description": "Security, privacy, provenance, and trust boundaries.", + "color": "b91c1c" + }, + { + "name": "blocked", + "description": "Work waiting on an explicit dependency or decision.", + "color": "b60205" + }, + { + "name": "cross-repo", + "description": "Work coordinated across multiple repositories.", + "color": "5319e7" + }, + { + "name": "needs-routing", + "description": "Work that does not yet have a durable repository owner.", + "color": "d4c5f9" + }, + { + "name": "priority:p0", + "description": "Correctness, security, or data-integrity blocker.", + "color": "b60205" + }, + { + "name": "priority:p1", + "description": "Required foundation or near-term release work.", + "color": "d93f0b" + }, + { + "name": "priority:p2", + "description": "Important planned capability.", + "color": "fbca04" + }, + { + "name": "priority:p3", + "description": "Research, exploration, or later direction.", + "color": "c5def5" + }, + { + "name": "ready", + "description": "Work with sufficient scope, ownership, and acceptance criteria.", + "color": "0e8a16" + }, + { + "name": "type:architecture", + "description": "Architecture, boundaries, or system-design work.", + "color": "1f4d8f" + }, + { + "name": "type:bug", + "description": "Incorrect or unexpected behavior.", + "color": "dc2626" + }, + { + "name": "type:documentation", + "description": "Documentation or knowledge-organization work.", + "color": "64748b" + }, + { + "name": "type:feature", + "description": "New or expanded user-visible capability.", + "color": "0e8a16" + }, + { + "name": "type:maintenance", + "description": "Maintenance, cleanup, or dependency work.", + "color": "6b7280" + }, + { + "name": "type:research", + "description": "Research, discovery, or evidence gathering.", + "color": "0f766e" + }, + { + "name": "♻️ refactor", + "description": "Repository cleanup, restructuring, modernization, and technical debt ♻️", + "color": "16a34a" + }, + { + "name": "βš™οΈ infra", + "description": "Infrastructure, repositories, tooling, and configuration βš™οΈ", + "color": "274472" + }, + { + "name": "⚑ dx", + "description": "Developer experience, tooling, and ergonomics ⚑", + "color": "7c5c1e" + }, + { + "name": "πŸ—οΈ architecture", + "description": "Architecture, foundations, and system design πŸ—οΈ", + "color": "1f4d8f" + }, + { + "name": "πŸ› bug", + "description": "Something isn't working πŸ›", + "color": "dc2626" + }, + { + "name": "πŸ“„ documentation", + "description": "Documentation improvements and knowledge organization πŸ“„", + "color": "64748b" + }, + { + "name": "πŸ“‹ backlog", + "description": "Captured ideas and future work πŸ“‹", + "color": "4b5563" + }, + { + "name": "πŸ“ schema", + "description": "Schemas, specifications, contracts, and data models πŸ“", + "color": "7c3aed" + }, + { + "name": "πŸ“¦ package", + "description": "Reusable packages and shared libraries πŸ“¦", + "color": "0369a1" + }, + { + "name": "πŸ“± flutter", + "description": "Flutter application and Flutter Foundation πŸ“±", + "color": "0175c2" + }, + { + "name": "πŸ”„ lifecycle", + "description": "Application lifecycles, managers, and orchestration πŸ”„", + "color": "0f766e" + }, + { + "name": "πŸ”Œ plugin", + "description": "Plugin architecture, registries, and extensibility πŸ”Œ", + "color": "2563eb" + } + ] +} diff --git a/scripts/project_labels.py b/scripts/project_labels.py new file mode 100644 index 0000000..33944e1 --- /dev/null +++ b/scripts/project_labels.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Project the canonical label catalog for one configured repository.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from validate_labels import ( + CATALOG_PATH, + REPOSITORIES_PATH, + ValidationError, + load_json, + validate_catalog, + validate_repositories, +) + + +def project(repository: str) -> dict[str, Any]: + """Return a deterministic, provider-neutral label projection.""" + + catalog = load_json(CATALOG_PATH) + assignments = load_json(REPOSITORIES_PATH) + names, overlay_ids = validate_catalog(catalog) + validate_repositories(assignments, catalog, names, overlay_ids) + + assignment = next( + (item for item in assignments["repositories"] if item["repository"] == repository), + None, + ) + if assignment is None: + raise ValidationError(f"repository {repository!r} has no governed assignment") + + selected_overlays = set(assignment["overlays"]) + labels = [ + {key: label[key] for key in ("name", "description", "color")} + for label in catalog["universal"] + ] + for overlay in catalog["overlays"]: + if overlay["id"] in selected_overlays: + labels.extend(overlay["labels"]) + labels.extend(assignment["additional_labels"]) + labels.sort(key=lambda label: label["name"]) + return { + "schema_version": 1, + "catalog_version": catalog["catalog_version"], + "repository": repository, + "removal_policy": catalog["defaults"]["removal_policy"], + "labels": labels, + } + + +def main() -> int: + """Parse arguments and write the JSON projection to stdout or a file.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True, help="Repository in owner/name form") + parser.add_argument("--output", type=Path, help="Write to this path instead of stdout") + arguments = parser.parse_args() + try: + payload = json.dumps(project(arguments.repository), ensure_ascii=False, indent=2) + "\n" + except ValidationError as error: + print(f"cannot project labels: {error}", file=sys.stderr) + return 1 + if arguments.output is None: + sys.stdout.write(payload) + else: + arguments.output.write_text(payload, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_labels.py b/scripts/validate_labels.py new file mode 100644 index 0000000..2b13539 --- /dev/null +++ b/scripts/validate_labels.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Validate the organization label catalog and repository assignments.""" + +from __future__ import annotations + +import hashlib +import json +import re +import sys +from datetime import date +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +CATALOG_PATH = ROOT / ".github/labels/catalog.v1.json" +REPOSITORIES_PATH = ROOT / ".github/labels/repositories.v1.json" +HEX_COLOR = re.compile(r"^[0-9a-f]{6}$") +SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") +REPOSITORY = re.compile(r"^egohygiene/[A-Za-z0-9._-]+$") + +EXPECTED_OVERLAY_LABELS = { + "☁️ aether", + "♻️ refactor", + "βš™οΈ infra", + "⚑ dx", + "✍️ writing", + "🌍 domain", + "🌐 website", + "🌱 mindgarden", + "πŸŽ₯ video", + "🎨 design", + "🎬 dreamscape", + "🎯 practice", + "🎡 music", + "πŸ—οΈ architecture", + "πŸ›οΈ philosophy", + "πŸ› bug", + "πŸ’° funding", + "πŸ“„ documentation", + "πŸ“‹ backlog", + "πŸ“ schema", + "πŸ“– magazine", + "πŸ“š article", + "πŸ“ publishing", + "πŸ“£ marketing", + "πŸ“¦ package", + "πŸ“± flutter", + "πŸ”„ lifecycle", + "πŸ”Œ plugin", + "πŸ”¬ research", + "πŸ—ΊοΈ journey", +} +REQUIRED_CATEGORIES = {"priority", "type", "area", "coordination", "status"} +APPROVED_OVERLAY_DIGEST = ( + "b66b6e4ff8e0e1ffa36536633a3d82981bbfd78a61a80a9c4ba5a980e06c7038" +) + + +class ValidationError(ValueError): + """Raised when a governed label document violates its contract.""" + + +def load_json(path: Path) -> dict[str, Any]: + """Load a JSON object while rejecting duplicate keys.""" + + def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValidationError(f"{path}: duplicate key {key!r}") + result[key] = value + return result + + try: + value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=reject_duplicates) + except (OSError, json.JSONDecodeError) as error: + raise ValidationError(f"{path}: {error}") from error + if not isinstance(value, dict): + raise ValidationError(f"{path}: root must be an object") + return value + + +def require_keys(value: dict[str, Any], required: set[str], context: str) -> None: + """Require an exact object shape.""" + + actual = set(value) + if actual != required: + raise ValidationError( + f"{context}: expected keys {sorted(required)}, got {sorted(actual)}" + ) + + +def validate_label(label: Any, context: str, *, universal: bool = False) -> str: + """Validate one label and return its name.""" + + if not isinstance(label, dict): + raise ValidationError(f"{context}: label must be an object") + keys = {"name", "description", "color", "category"} if universal else { + "name", + "description", + "color", + } + require_keys(label, keys, context) + name = label["name"] + description = label["description"] + color = label["color"] + if not isinstance(name, str) or not 1 <= len(name) <= 50: + raise ValidationError(f"{context}: name must contain 1 to 50 characters") + if not isinstance(description, str) or not 1 <= len(description) <= 100: + raise ValidationError(f"{context}: description must contain 1 to 100 characters") + if not isinstance(color, str) or HEX_COLOR.fullmatch(color) is None: + raise ValidationError(f"{context}: color must be six lowercase hexadecimal digits") + if universal and label["category"] not in REQUIRED_CATEGORIES: + raise ValidationError(f"{context}: unknown universal category {label['category']!r}") + return name + + +def validate_catalog(catalog: dict[str, Any]) -> tuple[set[str], set[str]]: + """Validate the canonical catalog and return label and overlay identifiers.""" + + require_keys( + catalog, + { + "$schema", + "schema_version", + "catalog_version", + "owner", + "defaults", + "universal", + "overlays", + "deprecations", + }, + "catalog", + ) + if catalog["schema_version"] != 1: + raise ValidationError("catalog: schema_version must be 1") + if catalog["$schema"] != "./schema/catalog.v1.schema.json": + raise ValidationError("catalog: $schema must reference the checked-in v1 schema") + if not isinstance(catalog["catalog_version"], str) or SEMVER.fullmatch( + catalog["catalog_version"] + ) is None: + raise ValidationError("catalog: catalog_version must be semantic versioning") + if catalog["owner"] != "egohygiene/.github": + raise ValidationError("catalog: owner must be egohygiene/.github") + if catalog["defaults"] != {"required": True, "removal_policy": "retain"}: + raise ValidationError("catalog: defaults must require universal labels and retain removals") + + universal = catalog["universal"] + overlays = catalog["overlays"] + if not isinstance(universal, list) or not universal: + raise ValidationError("catalog: universal must be a non-empty array") + if not isinstance(overlays, list) or not overlays: + raise ValidationError("catalog: overlays must be a non-empty array") + + names: set[str] = set() + categories: set[str] = set() + for index, label in enumerate(universal): + name = validate_label(label, f"catalog.universal[{index}]", universal=True) + if name in names: + raise ValidationError(f"catalog: duplicate label name {name!r}") + names.add(name) + categories.add(label["category"]) + if categories != REQUIRED_CATEGORIES: + raise ValidationError( + f"catalog: universal categories must be {sorted(REQUIRED_CATEGORIES)}" + ) + + overlay_ids: set[str] = set() + overlay_names: set[str] = set() + for overlay_index, overlay in enumerate(overlays): + context = f"catalog.overlays[{overlay_index}]" + if not isinstance(overlay, dict): + raise ValidationError(f"{context}: overlay must be an object") + require_keys(overlay, {"id", "description", "labels"}, context) + overlay_id = overlay["id"] + if not isinstance(overlay_id, str) or re.fullmatch(r"[a-z][a-z0-9-]*", overlay_id) is None: + raise ValidationError(f"{context}: invalid overlay id") + if overlay_id in overlay_ids: + raise ValidationError(f"catalog: duplicate overlay id {overlay_id!r}") + overlay_ids.add(overlay_id) + if not isinstance(overlay["description"], str) or not overlay["description"]: + raise ValidationError(f"{context}: description must be non-empty") + if not isinstance(overlay["labels"], list) or not overlay["labels"]: + raise ValidationError(f"{context}: labels must be a non-empty array") + for label_index, label in enumerate(overlay["labels"]): + name = validate_label(label, f"{context}.labels[{label_index}]") + if name in names: + raise ValidationError(f"catalog: duplicate label name {name!r}") + names.add(name) + overlay_names.add(name) + + if overlay_names != EXPECTED_OVERLAY_LABELS: + missing = sorted(EXPECTED_OVERLAY_LABELS - overlay_names) + unexpected = sorted(overlay_names - EXPECTED_OVERLAY_LABELS) + raise ValidationError( + f"catalog: approved overlay catalog mismatch; missing={missing}, unexpected={unexpected}" + ) + approved_rows = sorted( + (label["name"], label["description"], label["color"]) + for overlay in overlays + for label in overlay["labels"] + ) + approved_digest = hashlib.sha256( + json.dumps( + approved_rows, ensure_ascii=False, separators=(",", ":") + ).encode("utf-8") + ).hexdigest() + if approved_digest != APPROVED_OVERLAY_DIGEST: + raise ValidationError( + "catalog: approved overlay names, descriptions, or colors changed" + ) + deprecations = catalog["deprecations"] + if not isinstance(deprecations, list): + raise ValidationError("catalog: deprecations must be an array") + deprecated_names: set[str] = set() + for index, deprecation in enumerate(deprecations): + context = f"catalog.deprecations[{index}]" + if not isinstance(deprecation, dict): + raise ValidationError(f"{context}: deprecation must be an object") + require_keys(deprecation, {"name", "replacement", "remove_after"}, context) + name = deprecation["name"] + replacement = deprecation["replacement"] + if not isinstance(name, str): + raise ValidationError(f"{context}: name must be a string") + if replacement is not None and not isinstance(replacement, str): + raise ValidationError(f"{context}: replacement must be a string or null") + if name not in names: + raise ValidationError(f"{context}: deprecated label must remain in the catalog") + if name in deprecated_names: + raise ValidationError(f"{context}: label is deprecated more than once") + deprecated_names.add(name) + if replacement is not None and replacement not in names: + raise ValidationError(f"{context}: replacement must name a catalog label") + if replacement == name: + raise ValidationError(f"{context}: replacement must differ from the deprecated label") + try: + date.fromisoformat(deprecation["remove_after"]) + except (TypeError, ValueError) as error: + raise ValidationError(f"{context}: remove_after must be an ISO date") from error + return names, overlay_ids + + +def validate_repositories( + assignments: dict[str, Any], catalog: dict[str, Any], names: set[str], overlay_ids: set[str] +) -> None: + """Validate repository assignments against the exact catalog version.""" + + require_keys( + assignments, + {"$schema", "schema_version", "catalog_version", "repositories"}, + "repositories", + ) + if assignments["schema_version"] != 1: + raise ValidationError("repositories: schema_version must be 1") + if assignments["$schema"] != "./schema/repositories.v1.schema.json": + raise ValidationError("repositories: $schema must reference the checked-in v1 schema") + if assignments["catalog_version"] != catalog["catalog_version"]: + raise ValidationError("repositories: catalog_version must match the catalog") + repositories = assignments["repositories"] + if not isinstance(repositories, list) or not repositories: + raise ValidationError("repositories: repositories must be a non-empty array") + + seen: set[str] = set() + for index, assignment in enumerate(repositories): + context = f"repositories.repositories[{index}]" + if not isinstance(assignment, dict): + raise ValidationError(f"{context}: assignment must be an object") + require_keys( + assignment, + {"repository", "include_universal", "overlays", "additional_labels"}, + context, + ) + repository = assignment["repository"] + if not isinstance(repository, str) or REPOSITORY.fullmatch(repository) is None: + raise ValidationError(f"{context}: invalid repository name") + if repository in seen: + raise ValidationError(f"repositories: duplicate repository {repository!r}") + seen.add(repository) + if assignment["include_universal"] is not True: + raise ValidationError(f"{context}: universal taxonomy cannot be disabled") + overlays = assignment["overlays"] + if not isinstance(overlays, list) or any(not isinstance(item, str) for item in overlays): + raise ValidationError(f"{context}: overlays must be an array of strings") + if len(overlays) != len(set(overlays)): + raise ValidationError(f"{context}: overlays must be unique") + unknown = set(overlays) - overlay_ids + if unknown: + raise ValidationError(f"{context}: unknown overlays {sorted(unknown)}") + additional = assignment["additional_labels"] + if not isinstance(additional, list): + raise ValidationError(f"{context}: additional_labels must be an array") + local_names: set[str] = set() + for label_index, label in enumerate(additional): + name = validate_label(label, f"{context}.additional_labels[{label_index}]") + if name in names or name in local_names: + raise ValidationError(f"{context}: additional label collides with {name!r}") + local_names.add(name) + + +def validate() -> None: + """Validate all governed label documents.""" + + catalog = load_json(CATALOG_PATH) + assignments = load_json(REPOSITORIES_PATH) + names, overlay_ids = validate_catalog(catalog) + validate_repositories(assignments, catalog, names, overlay_ids) + + +def main() -> int: + """Run validation and report one actionable diagnostic.""" + + try: + validate() + except ValidationError as error: + print(f"label contract invalid: {error}", file=sys.stderr) + return 1 + print("label contract valid") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..97e535d --- /dev/null +++ b/tests/test_labels.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import copy +import json +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from project_labels import project # noqa: E402 +from validate_labels import ( # noqa: E402 + CATALOG_PATH, + REPOSITORIES_PATH, + ValidationError, + load_json, + validate, + validate_catalog, + validate_repositories, +) + + +class LabelContractTests(unittest.TestCase): + def setUp(self) -> None: + self.catalog = load_json(CATALOG_PATH) + self.repositories = load_json(REPOSITORIES_PATH) + + def test_checked_in_contract_is_valid(self) -> None: + validate() + + def test_projection_matches_golden_consumer(self) -> None: + expected = json.loads( + (ROOT / "fixtures/labels/dot-github.expected.json").read_text(encoding="utf-8") + ) + self.assertEqual(project("egohygiene/.github"), expected) + + def test_unknown_overlay_fails_closed(self) -> None: + assignments = copy.deepcopy(self.repositories) + assignments["repositories"][0]["overlays"].append("unknown") + names, overlay_ids = validate_catalog(self.catalog) + with self.assertRaisesRegex(ValidationError, "unknown overlays"): + validate_repositories(assignments, self.catalog, names, overlay_ids) + + def test_repository_label_cannot_redefine_canonical_label(self) -> None: + assignments = copy.deepcopy(self.repositories) + assignments["repositories"][0]["additional_labels"].append( + {"name": "ready", "description": "Different meaning", "color": "ffffff"} + ) + names, overlay_ids = validate_catalog(self.catalog) + with self.assertRaisesRegex(ValidationError, "collides"): + validate_repositories(assignments, self.catalog, names, overlay_ids) + + def test_universal_taxonomy_cannot_be_disabled(self) -> None: + assignments = copy.deepcopy(self.repositories) + assignments["repositories"][0]["include_universal"] = False + names, overlay_ids = validate_catalog(self.catalog) + with self.assertRaisesRegex(ValidationError, "cannot be disabled"): + validate_repositories(assignments, self.catalog, names, overlay_ids) + + def test_unapproved_catalog_change_fails(self) -> None: + catalog = copy.deepcopy(self.catalog) + catalog["overlays"][0]["labels"].pop() + with self.assertRaisesRegex(ValidationError, "approved overlay catalog mismatch"): + validate_catalog(catalog) + + def test_approved_description_and_color_are_exact(self) -> None: + catalog = copy.deepcopy(self.catalog) + catalog["overlays"][0]["labels"][0]["color"] = "ffffff" + with self.assertRaisesRegex(ValidationError, "descriptions, or colors changed"): + validate_catalog(catalog) + + def test_deprecation_must_reference_catalog_labels(self) -> None: + catalog = copy.deepcopy(self.catalog) + catalog["deprecations"].append( + { + "name": "not-a-label", + "replacement": "ready", + "remove_after": "2027-01-01", + } + ) + with self.assertRaisesRegex(ValidationError, "must remain in the catalog"): + validate_catalog(catalog) + + def test_missing_repository_assignment_fails_projection(self) -> None: + with self.assertRaisesRegex(ValidationError, "no governed assignment"): + project("egohygiene/not-configured") + + +if __name__ == "__main__": + unittest.main()