diff --git a/.github/workflows/labeling.yml b/.github/workflows/labeling.yml index b36d08c5..bc5ee7f6 100644 --- a/.github/workflows/labeling.yml +++ b/.github/workflows/labeling.yml @@ -73,6 +73,9 @@ jobs: run: node scripts/validation/validate-issue-fields.cjs - name: Sync labels with canonical set + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || inputs.dry_run || 'false' }} run: | node scripts/agents/includes/label-sync.js shell: bash diff --git a/docs/ISSUE_LABELS.md b/docs/ISSUE_LABELS.md index 3a62deb6..cf8ce7da 100644 --- a/docs/ISSUE_LABELS.md +++ b/docs/ISSUE_LABELS.md @@ -72,6 +72,9 @@ Colors are assigned by family and purpose; see `../.github/labels.yml` for mappi ## Automation - **Labeling, status, type, and standardization** are all handled by the **unified agent and workflow** ([labeling.agent.js](../scripts/agents/labeling.agent.js), [labeling.yml](../.github/workflows/labeling.yml)). +- The label sync step (`scripts/agents/includes/label-sync.js`) now runs as an executable CLI in CI. + - On `pull_request` events it runs in dry-run mode. + - On other labeling workflow events it enforces canonical sync against `.github/labels.yml`. - **Default labels** are applied and enforced on all issues. - **Label conflicts and non-canonical labels** are removed or migrated automatically. diff --git a/scripts/agents/includes/label-sync.js b/scripts/agents/includes/label-sync.js index 5ef94b83..eb05318e 100755 --- a/scripts/agents/includes/label-sync.js +++ b/scripts/agents/includes/label-sync.js @@ -13,6 +13,11 @@ // TODO: Align this helper with the latest automation spec updates. import { findStandardLabel } from "./label-lookup.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import yaml from "js-yaml"; +import github from "@actions/github"; /** * Sync repository labels with canonical set. @@ -435,3 +440,96 @@ export { standardizeLabelsOnRepo, generateSyncReport, }; + +/** + * Load canonical label definitions from configured labels.yml file. + * @param {string} labelsPath - Path to labels.yml + * @returns {Promise} Canonical labels array + */ +async function loadCanonicalLabels(labelsPath) { + const raw = await fs.readFile(labelsPath, "utf8"); + const parsed = yaml.load(raw); + if (!Array.isArray(parsed)) { + throw new Error(`${labelsPath} must be an array of labels`); + } + return parsed; +} + +/** + * Parse a string env value to boolean. + * @param {string|undefined} value - Env value + * @returns {boolean} Parsed boolean + */ +function asBoolean(value) { + if (!value) return false; + return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); +} + +/** + * CLI runner for repository label sync. + * Reads context from GitHub Actions env and syncs labels against canonical config. + * Exits non-zero when sync/validation fails. + */ +async function runCli() { + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error("GITHUB_TOKEN is required to run label sync"); + } + + const labelsPath = process.env.LABELS_CONFIG || ".github/labels.yml"; + const dryRun = asBoolean(process.env.DRY_RUN); + + const owner = + process.env.GITHUB_REPOSITORY_OWNER || github.context.repo.owner; + const repo = + process.env.GITHUB_REPOSITORY?.split("/")[1] || github.context.repo.repo; + + if (!owner || !repo) { + throw new Error("Unable to resolve repository owner/name from environment"); + } + + const canonicalLabels = await loadCanonicalLabels(labelsPath); + const octokit = github.getOctokit(token); + + const syncReport = await syncLabelsWithCanonical( + octokit, + owner, + repo, + canonicalLabels, + dryRun, + ); + const validationReport = await validateRepoLabels( + octokit, + owner, + repo, + canonicalLabels, + ); + + const markdown = generateSyncReport(syncReport, validationReport, null); + process.stdout.write(`${markdown}\n`); + + if (!validationReport.valid && dryRun) { + console.warn( + `[label-sync] Dry-run detected label drift (missing=${validationReport.summary.missingCount}, extra=${validationReport.summary.extraCount}, nonCompliant=${validationReport.summary.nonCompliantCount})`, + ); + return; + } + + if (!validationReport.valid) { + throw new Error( + `Label validation failed (missing=${validationReport.summary.missingCount}, extra=${validationReport.summary.extraCount}, nonCompliant=${validationReport.summary.nonCompliantCount})`, + ); + } +} + +const isDirectRun = + process.argv[1] && + path.resolve(fileURLToPath(import.meta.url)) === + path.resolve(process.argv[1]); + +if (isDirectRun) { + runCli().catch((error) => { + console.error(`[label-sync] ${error.message}`); + process.exit(1); + }); +}