diff --git a/.github/workflows/issue-project-field-sync.yml b/.github/workflows/issue-project-field-sync.yml deleted file mode 100644 index 20b14b1556..0000000000 --- a/.github/workflows/issue-project-field-sync.yml +++ /dev/null @@ -1,386 +0,0 @@ -name: Issue Project Field Sync - -# Syncs open GitHub issues into a Projects v2 board and fills in -# Status, Priority, and Type fields based on the issue's labels. -# -# SETUP: -# 1. Go to https://github.com/orgs/lightspeedwp/projects and note -# the project number (the integer in the URL, e.g. /projects/5). -# 2. Add a repo or org-level Actions secret named GH_PROJECT_TOKEN -# with a PAT that has the `project` scope. If you only need -# repo-linked projects, the built-in GITHUB_TOKEN is sufficient -# when `projects: write` is set below. -# 3. Set the project number as a repo variable: Settings → Variables -# → Actions → New → Name: PROJECT_NUMBER, Value: -# 4. The field names (Status / Priority / Type) are auto-discovered. -# If your project uses different names, update FIELD_STATUS, -# FIELD_PRIORITY, FIELD_TYPE in the env block below. - -on: - workflow_dispatch: - inputs: - project_number: - description: "GitHub Projects v2 number (overrides PROJECT_NUMBER variable)" - required: false - type: string - dry_run: - description: "Dry run — report planned changes without applying them" - required: false - default: "false" - type: choice - options: - - "false" - - "true" - add_missing: - description: "Add issues not yet in the project" - required: false - default: "true" - type: choice - options: - - "true" - - "false" - schedule: - # Runs every Monday at 09:00 UTC (after issue-health-audit at 08:00) - - cron: "0 9 * * 1" - -permissions: - issues: read - projects: write - -env: - # Names of the project fields to sync — change these if your - # project uses different field names (case-insensitive match used). - FIELD_STATUS: "Status" - FIELD_PRIORITY: "Priority" - FIELD_TYPE: "Type" - -jobs: - sync-project-fields: - name: Sync Issue Fields → Project - runs-on: ubuntu-latest - - steps: - - name: Sync issues to project and fill fields - uses: actions/github-script@v9 - env: - # Use a PAT with `project` scope if available; fall back to GITHUB_TOKEN. - # GITHUB_TOKEN works for repo-linked projects when projects: write is set. - GH_TOKEN: ${{ secrets.GH_PROJECT_TOKEN || github.token }} - DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} - ADD_MISSING: ${{ github.event.inputs.add_missing || 'true' }} - PROJECT_NUMBER_INPUT: ${{ github.event.inputs.project_number || vars.PROJECT_NUMBER || '' }} - with: - github-token: ${{ secrets.GH_PROJECT_TOKEN || github.token }} - script: | - const dryRun = process.env.DRY_RUN === 'true'; - const addMissing = process.env.ADD_MISSING === 'true'; - const projectNumberStr = process.env.PROJECT_NUMBER_INPUT; - - if (!projectNumberStr) { - core.setFailed( - 'No project number supplied. Provide it via the workflow_dispatch input ' + - 'or set a repo/org variable named PROJECT_NUMBER.' - ); - return; - } - - const projectNumber = parseInt(projectNumberStr, 10); - const owner = context.repo.owner; - const repo = context.repo.repo; - - const report = { - added: [], - updated: [], - skipped: [], - errors: [], - }; - - // ─── GraphQL helper ────────────────────────────────────────── - async function gql(query, variables = {}) { - return github.graphql(query, variables); - } - - // ─── Step 1: Resolve project node ID and fields ────────────── - core.info(`Fetching project #${projectNumber} for org "${owner}"...`); - - let projectId, statusField, priorityField, typeField; - try { - const projectData = await gql(` - query($owner: String!, $number: Int!) { - organization(login: $owner) { - projectV2(number: $number) { - id - title - fields(first: 30) { - nodes { - ... on ProjectV2Field { - id name dataType - } - ... on ProjectV2SingleSelectField { - id name dataType - options { id name } - } - ... on ProjectV2IterationField { - id name dataType - } - } - } - } - } - } - `, { owner, number: projectNumber }); - - const project = projectData.organization.projectV2; - projectId = project.id; - core.info(`Found project: "${project.title}" (${projectId})`); - - const fieldNameMatch = (field, target) => - field.name.toLowerCase() === target.toLowerCase(); - - for (const field of project.fields.nodes) { - if (fieldNameMatch(field, process.env.FIELD_STATUS)) statusField = field; - if (fieldNameMatch(field, process.env.FIELD_PRIORITY)) priorityField = field; - if (fieldNameMatch(field, process.env.FIELD_TYPE)) typeField = field; - } - - core.info(`Status field: ${statusField?.name || 'NOT FOUND'}`); - core.info(`Priority field: ${priorityField?.name || 'NOT FOUND'}`); - core.info(`Type field: ${typeField?.name || 'NOT FOUND'}`); - } catch (err) { - core.setFailed(`Failed to fetch project: ${err.message}`); - return; - } - - // ─── Step 2: Build a map of issues already in the project ──── - core.info('Fetching existing project items...'); - let cursor = null; - const existingItems = new Map(); // issueNumber → projectItemId - - do { - const itemsData = await gql(` - query($projectId: ID!, $cursor: String) { - node(id: $projectId) { - ... on ProjectV2 { - items(first: 100, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { - id - content { - ... on Issue { number } - } - } - } - } - } - } - `, { projectId, cursor }); - - const page = itemsData.node.items; - for (const item of page.nodes) { - if (item.content?.number != null) { - existingItems.set(item.content.number, item.id); - } - } - cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; - } while (cursor); - - core.info(`Project has ${existingItems.size} existing items.`); - - // ─── Step 3: Fetch all open issues ─────────────────────────── - core.info('Fetching open issues...'); - const openIssues = await github.paginate(github.rest.issues.listForRepo, { - owner, - repo, - state: 'open', - per_page: 100, - }); - // listForRepo returns PRs too — filter them out - const issues = openIssues.filter(i => !i.pull_request); - core.info(`Found ${issues.length} open issues.`); - - // ─── Helper: find SingleSelect option ID by name (fuzzy) ───── - function findOptionId(field, labelValue) { - if (!field?.options) return null; - // Normalise: lowercase, strip leading "status:", "priority:", "type:" - const norm = (s) => s.toLowerCase().replace(/^(status|priority|type):/, ''); - const target = norm(labelValue); - const opt = field.options.find(o => norm(o.name) === target); - return opt?.id || null; - } - - // ─── Helper: map labels to field values ────────────────────── - function resolveStatus(labels) { - const names = labels.map(l => l.name); - if (names.some(n => n === 'status:in-progress')) return 'In Progress'; - if (names.some(n => n === 'status:needs-review')) return 'In Review'; - if (names.some(n => n === 'status:blocked')) return 'Blocked'; - if (names.some(n => n === 'status:ready')) return 'Ready'; - if (names.some(n => n === 'status:needs-more-info')) return 'Needs Info'; - if (names.some(n => ['status: completed', 'status:completed', 'completed'].includes(n))) return 'Done'; - return 'Todo'; - } - - function resolvePriority(labels) { - const names = labels.map(l => l.name); - if (names.some(n => n === 'priority:critical')) return 'Critical'; - if (names.some(n => n === 'priority:important' || n === 'priority:high')) return 'High'; - if (names.some(n => n === 'priority:normal' || n === 'priority:medium')) return 'Medium'; - if (names.some(n => n === 'priority:minor' || n === 'priority:low')) return 'Low'; - return null; - } - - function resolveType(labels) { - const typeLabel = labels.find(l => l.name.startsWith('type:')); - if (!typeLabel) return null; - // Convert "type:bug" → "Bug", "type:feature" → "Feature", etc. - const raw = typeLabel.name.replace('type:', ''); - return raw.charAt(0).toUpperCase() + raw.slice(1); - } - - // ─── Helper: set a project field value ─────────────────────── - async function setFieldValue(itemId, field, value) { - if (!field || !value) return false; - - if (field.dataType === 'SINGLE_SELECT') { - // Try exact match first, then fuzzy - let optId = findOptionId(field, value); - if (!optId) { - // Try partial match - const norm = value.toLowerCase(); - const opt = field.options.find(o => o.name.toLowerCase().includes(norm) || norm.includes(o.name.toLowerCase())); - optId = opt?.id || null; - } - if (!optId) { - core.info(` ⚠ No matching option "${value}" in ${field.name} field (options: ${field.options.map(o => o.name).join(', ')})`); - return false; - } - if (!dryRun) { - await gql(` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { singleSelectOptionId: $optionId } - }) { projectV2Item { id } } - } - `, { projectId, itemId, fieldId: field.id, optionId: optId }); - } - return true; - } - - if (field.dataType === 'TEXT') { - if (!dryRun) { - await gql(` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { text: $text } - }) { projectV2Item { id } } - } - `, { projectId, itemId, fieldId: field.id, text: value }); - } - return true; - } - - return false; - } - - // ─── Step 4: Process each open issue ───────────────────────── - for (const issue of issues) { - const labels = issue.labels || []; - const issueNumber = issue.number; - - let itemId = existingItems.get(issueNumber); - - // Add to project if not present - if (!itemId) { - if (!addMissing) { - report.skipped.push(`#${issueNumber} — not in project, add_missing=false`); - continue; - } - - core.info(`Adding #${issueNumber} to project...`); - if (!dryRun) { - try { - const addResult = await gql(` - mutation($projectId: ID!, $contentId: ID!) { - addProjectV2ItemById(input: { - projectId: $projectId - contentId: $contentId - }) { item { id } } - } - `, { projectId, contentId: issue.node_id }); - itemId = addResult.addProjectV2ItemById.item.id; - report.added.push(`#${issueNumber}: ${issue.title.slice(0, 60)}`); - } catch (err) { - report.errors.push(`#${issueNumber} add failed: ${err.message}`); - continue; - } - } else { - report.added.push(`#${issueNumber} [DRY RUN]: ${issue.title.slice(0, 60)}`); - continue; // Can't set fields without real itemId in dry run - } - } - - // Resolve target field values - const targetStatus = resolveStatus(labels); - const targetPriority = resolvePriority(labels); - const targetType = resolveType(labels); - - let changed = false; - - try { - if (statusField && targetStatus) { - const ok = await setFieldValue(itemId, statusField, targetStatus); - if (ok) changed = true; - } - if (priorityField && targetPriority) { - const ok = await setFieldValue(itemId, priorityField, targetPriority); - if (ok) changed = true; - } - if (typeField && targetType) { - const ok = await setFieldValue(itemId, typeField, targetType); - if (ok) changed = true; - } - - if (changed) { - const summary = [ - statusField && targetStatus ? `Status→${targetStatus}` : null, - priorityField && targetPriority ? `Priority→${targetPriority}` : null, - typeField && targetType ? `Type→${targetType}` : null, - ].filter(Boolean).join(', '); - report.updated.push(`#${issueNumber}: ${summary}`); - } - } catch (err) { - report.errors.push(`#${issueNumber} field update failed: ${err.message}`); - } - } - - // ─── Step 5: Summary report ────────────────────────────────── - const summary = [ - `# Issue → Project Field Sync Report`, - `> ${dryRun ? '🔍 DRY RUN — no changes were made' : '✅ Changes applied'}`, - `> Project #${projectNumber} | Run: ${new Date().toISOString()}`, - '', - `## Added to Project (${report.added.length})`, - report.added.length > 0 ? report.added.map(r => `- ${r}`).join('\n') : '_None_', - '', - `## Fields Updated (${report.updated.length})`, - report.updated.length > 0 ? report.updated.map(r => `- ${r}`).join('\n') : '_None_', - '', - `## Skipped (${report.skipped.length})`, - report.skipped.length > 0 ? report.skipped.map(r => `- ${r}`).join('\n') : '_None_', - '', - `## Errors (${report.errors.length})`, - report.errors.length > 0 ? report.errors.map(r => `- ⚠️ ${r}`).join('\n') : '_None_', - ].join('\n'); - - await core.summary.addRaw(summary).write(); - core.info(summary); - - if (report.errors.length > 0) { - core.setFailed(`Sync completed with ${report.errors.length} error(s).`); - } diff --git a/.github/workflows/issue-fields-backfill.yml b/.github/workflows/project-field-sync.yml similarity index 78% rename from .github/workflows/issue-fields-backfill.yml rename to .github/workflows/project-field-sync.yml index e1d6dd64f2..58d89d7fdb 100644 --- a/.github/workflows/issue-fields-backfill.yml +++ b/.github/workflows/project-field-sync.yml @@ -1,8 +1,14 @@ -name: Issue Fields • Bulk Backfill +name: Project Field Sync -# Back-fills GitHub native issue types and project board fields for all open -# issues in one run. Useful for catching up after enabling the integration or -# after a batch of issues was created without field values. +# Unified workflow for syncing GitHub native issue types and project board +# fields (Status, Priority, Type, Effort) across open issues. +# +# Two modes: +# bulk — processes all open issues (equivalent to the former +# issue-fields-backfill.yml) +# targeted — processes a filtered subset of issues identified by a label +# or a comma-separated list of issue numbers (equivalent to the +# former issue-project-field-sync.yml) # # PREREQUISITES # ───────────── @@ -15,19 +21,19 @@ name: Issue Fields • Bulk Backfill # The GitHub App must have: # • Issues: Read & Write # • Projects: Read & Write -# • Organization members: Read (needed to query org issue types) +# • Organisation members: Read (needed to query org issue types) on: workflow_dispatch: inputs: - dry_run: - description: "Dry run — report only, no changes applied" + mode: + description: "Sync mode" required: false - default: "false" + default: "bulk" type: choice options: - - "false" - - "true" + - bulk + - targeted sync_native_types: description: "Set GitHub native issue type from type:* labels" required: false @@ -44,10 +50,27 @@ on: options: - "true" - "false" + issue_filter: + description: "Targeted mode only — label name (e.g. 'priority:critical') OR comma-separated issue numbers (e.g. '12,34,56')" + required: false + default: "" + type: string issue_limit: - description: "Max issues to process (0 = all open issues)" + description: "Bulk mode only — max issues to process (0 = all open issues)" required: false default: "0" + type: string + dry_run: + description: "Dry run — report only, no changes applied" + required: false + default: "false" + type: choice + options: + - "false" + - "true" + schedule: + # Runs every Monday at 09:00 UTC to keep project board fields current + - cron: "0 9 * * 1" permissions: issues: write @@ -55,8 +78,8 @@ permissions: repository-projects: write jobs: - backfill: - name: Back-fill issue fields + sync: + name: Sync project fields (${{ github.event.inputs.mode || 'bulk' }} mode) runs-on: ubuntu-latest steps: @@ -77,29 +100,24 @@ jobs: id: app-token uses: actions/create-github-app-token@v3 with: - # LS_APP_CLIENT_ID is preferred; LS_APP_ID is the legacy numeric App ID - # and must be passed as app-id, not as a client-id fallback. + # LS_APP_CLIENT_ID is preferred; LS_APP_ID is the legacy numeric App ID. client-id: ${{ vars.LS_APP_CLIENT_ID }} app-id: ${{ vars.LS_APP_CLIENT_ID == '' && vars.LS_APP_ID || '' }} private-key: ${{ secrets.LS_APP_PRIVATE_KEY }} repositories: ${{ github.repository }} - # Least privilege, each justified against what this job actually does: - # issues : set native issue types on open issues - # organization-projects : add items to the org Projects v2 board and - # set Status / Priority / Type field values - # The repository checkout uses the default GITHUB_TOKEN, so this token - # needs no contents access. permission-issues: write permission-organization-projects: write - - name: Run bulk backfill + - name: Run project field sync uses: actions/github-script@v9 env: APP_TOKEN: ${{ steps.app-token.outputs.token }} - DRY_RUN: ${{ github.event.inputs.dry_run }} - SYNC_NATIVE_TYPES: ${{ github.event.inputs.sync_native_types }} - SYNC_PROJECT_FIELDS: ${{ github.event.inputs.sync_project_fields }} - ISSUE_LIMIT: ${{ github.event.inputs.issue_limit }} + MODE: ${{ github.event.inputs.mode || 'bulk' }} + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + SYNC_NATIVE_TYPES: ${{ github.event.inputs.sync_native_types || 'true' }} + SYNC_PROJECT_FIELDS: ${{ github.event.inputs.sync_project_fields || 'true' }} + ISSUE_FILTER: ${{ github.event.inputs.issue_filter || '' }} + ISSUE_LIMIT: ${{ github.event.inputs.issue_limit || '0' }} PROJECT_NUMBER: ${{ vars.LS_PROJECT_NUMBER || '' }} PROJECT_URL: ${{ vars.LS_PROJECT_URL || '' }} ISSUE_FIELDS_CONFIG: .github/issue-fields.yml @@ -110,9 +128,11 @@ jobs: const path = require('path'); const yaml = require('js-yaml'); + const mode = process.env.MODE || 'bulk'; const dryRun = process.env.DRY_RUN === 'true'; const syncNativeTypes = process.env.SYNC_NATIVE_TYPES !== 'false'; const syncProjectFields = process.env.SYNC_PROJECT_FIELDS !== 'false'; + const issueFilter = (process.env.ISSUE_FILTER || '').trim(); const issueLimit = parseInt(process.env.ISSUE_LIMIT || '0', 10); const owner = context.repo.owner; const repo = context.repo.repo; @@ -153,21 +173,56 @@ jobs: return ''; } - // ─── Step 1: Fetch all open issues ─────────────────────────── - core.info('Fetching open issues...'); - const allIssues = await github.paginate(github.rest.issues.listForRepo, { - owner, repo, state: 'open', per_page: 100, - }); - const issues = allIssues - .filter(i => !i.pull_request) - .slice(0, issueLimit > 0 ? issueLimit : undefined); + // ─── Step 1: Fetch issues based on mode ────────────────────── + core.info(`Mode: ${mode}`); + let issues = []; + + if (mode === 'targeted' && !issueFilter) { + core.warning( + 'targeted mode selected but issue_filter is empty — falling back to bulk (all open issues). ' + + 'Provide a label name or comma-separated issue numbers via the issue_filter input to use targeted mode.' + ); + } + + if (mode === 'targeted' && issueFilter) { + // Determine whether issueFilter is issue numbers or a label name + const numberList = issueFilter.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)); + if (numberList.length > 0) { + core.info(`Targeted mode — fetching ${numberList.length} issue(s) by number: ${numberList.join(', ')}`); + const fetched = await Promise.allSettled( + numberList.map(n => github.rest.issues.get({ owner, repo, issue_number: n })) + ); + for (const result of fetched) { + if (result.status === 'fulfilled' && result.value?.data && !result.value.data.pull_request) { + issues.push(result.value.data); + } else if (result.status === 'rejected') { + core.warning(`Could not fetch issue: ${result.reason?.message}`); + } + } + } else { + core.info(`Targeted mode — fetching issues with label: "${issueFilter}"`); + const labelIssues = await github.paginate(github.rest.issues.listForRepo, { + owner, repo, state: 'open', labels: issueFilter, per_page: 100, + }); + issues = labelIssues.filter(i => !i.pull_request); + } + } else { + core.info('Bulk mode — fetching all open issues...'); + const allIssues = await github.paginate(github.rest.issues.listForRepo, { + owner, repo, state: 'open', per_page: 100, + }); + issues = allIssues + .filter(i => !i.pull_request) + .slice(0, issueLimit > 0 ? issueLimit : undefined); + } + core.info(`Processing ${issues.length} issue(s).`); const report = { nativeTypeSet: [], projectFieldsSet: [], errors: [] }; // ─── Step 2 (optional): Native issue type sync ─────────────── if (syncNativeTypes) { - core.info('Fetching issue types...'); + core.info('Fetching org issue types...'); let orgTypeMap = new Map(); let orgFetchError = ''; @@ -184,7 +239,7 @@ jobs: for (const node of typeData.organization.issueTypes.nodes) { orgTypeMap.set(node.name.toLowerCase(), node.id); } - core.info(`Organization has ${orgTypeMap.size} issue type(s): ${[...orgTypeMap.keys()].join(', ')}`); + core.info(`Organisation has ${orgTypeMap.size} issue type(s): ${[...orgTypeMap.keys()].join(', ')}`); } catch (err) { orgFetchError = err.message; core.warning( @@ -252,7 +307,6 @@ jobs: if (syncProjectFields && projectUrl) { core.info(`Syncing project fields to ${projectUrl}...`); - // Resolve project node ID and fields const projNumFromUrl = resolveProjectNumberFromUrl(projectUrl); const projNum = Number.isInteger(projectNumber) && projectNumber > 0 ? projectNumber @@ -384,9 +438,10 @@ jobs: } // ─── Summary ──────────────────────────────────────────────── + const modeLabel = mode === 'targeted' ? `Targeted (filter: "${issueFilter || 'none'}")` : 'Bulk'; const lines = [ - `# Issue Fields Backfill Report`, - `> ${dryRun ? '🔍 DRY RUN — no changes applied' : '✅ Changes applied'}`, + `# Project Field Sync Report`, + `> Mode: ${modeLabel} | ${dryRun ? '🔍 DRY RUN — no changes applied' : '✅ Changes applied'}`, `> Run: ${new Date().toISOString()} | Issues processed: ${issues.length}`, '', `## Native Types Set (${report.nativeTypeSet.length})`, @@ -402,4 +457,4 @@ jobs: await core.summary.addRaw(lines).write(); core.info(lines); - if (report.errors.length > 0) core.setFailed(`Backfill completed with ${report.errors.length} error(s).`); + if (report.errors.length > 0) core.setFailed(`Sync completed with ${report.errors.length} error(s).`); diff --git a/CHANGELOG.md b/CHANGELOG.md index 007f361ed4..400a953301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- **Phase 4.3 Workflow Consolidation — Project Field Sync** — Unified `issue-fields-backfill.yml` and `issue-project-field-sync.yml` into a single `project-field-sync.yml` workflow. The new workflow supports `bulk` and `targeted` modes via a `workflow_dispatch` input, uses a consistent GitHub App token (`LS_APP_CLIENT_ID` / `LS_APP_PRIVATE_KEY`) across both modes (eliminating the PAT dependency in the former `issue-project-field-sync.yml`), and adds a `dry_run` mode for safe pre-production validation. `scripts/agents/includes/sync-issue-fields.cjs` updated to accept a `mode` parameter (`event` | `bulk`), exposing `runEvent` and `runBulk` as named exports. Net: −1 workflow. ([#1408](https://github.com/lightspeedwp/.github/issues/1408)) - **Label Prefix Enforcement — Phase 1: Stop New Label Prefix Violations — Issue #2283** — First phase of comprehensive label governance initiative preventing new violations through defective code removal and explicit governance documentation. Phase 1 deliverables: (1) **Deleted defective script** `scripts/automation/labeling-agent.js` that was applying bare labels without required family prefixes (e.g., `bug` instead of `type:bug`, violating canonical label governance); (2) **Updated CLAUDE.md** — Added "Label Creation Rules" section with explicit canonical label examples (`type:bug`, `type:feature`, `status:needs-triage`, `priority:critical`, `area:ci`, etc.) and clear "DO NOT USE" markers on bare labels (`bug`, `feature`, `urgent`, `ci`, `duplicate`); (3) **Updated AGENTS.md** — Added "Label Creation Governance" section with complete pre-creation validation checklist for programmatic issue/PR creation, bash script example with correct prefixed labels, incorrect bare label example, and validation steps; (4) **Disabled labeling step** in `.github/workflows/issue-management-orchestration.yml` with clear TODO comment awaiting corrected implementation; (5) **Updated workflow step outputs** to gracefully skip labeling operation with status=skipped. **Impact:** Prevents new issues/PRs from being created with bare, non-canonical labels; establishes explicit governance rules for all future programmatic label creation; unblocks Phase 2 (fix existing ~100 issue labels in #1604). **Related:** Issue #1592 (Label Prefix Governance Enforcement), Canonical labels (`.github/labels.yml` — 158 prefixed labels), Label strategy documentation (`docs/LABEL_STRATEGY.md`, `docs/LABELING.md`). ([#2283](https://github.com/lightspeedwp/.github/issues/2283), [#1592](https://github.com/lightspeedwp/.github/issues/1592), [PR #2476](https://github.com/lightspeedwp/.github/pull/2476)) ### Deprecated diff --git a/scripts/agents/includes/sync-issue-fields.cjs b/scripts/agents/includes/sync-issue-fields.cjs index e477a32dca..8b7eb12fac 100644 --- a/scripts/agents/includes/sync-issue-fields.cjs +++ b/scripts/agents/includes/sync-issue-fields.cjs @@ -7,12 +7,18 @@ * Sets the GitHub native issue type on an issue based on its type:* labels, * using the canonical mapping in .github/issue-fields.yml. * + * Supports two modes (set via the MODE environment variable): + * event (default) — processes the single issue from GITHUB_EVENT_PATH + * bulk — processes all open issues in the repository + * * Reads: * GITHUB_TOKEN - Token with issues:write + project scope * GITHUB_REPOSITORY - "owner/repo" - * GITHUB_EVENT_PATH - Path to the webhook event JSON + * GITHUB_EVENT_PATH - Path to the webhook event JSON (event mode only) * ISSUE_FIELDS_CONFIG - Optional path to issue-fields.yml (defaults to * .github/issue-fields.yml) + * MODE - "event" (default) or "bulk" + * DRY_RUN - "true" to report without applying changes * * Outputs (GITHUB_OUTPUT): * native_type_set - Name of the native type that was applied, or "" @@ -131,27 +137,14 @@ async function setIssueType(octokit, issueNodeId, typeId) { async function run() { const token = process.env.GITHUB_TOKEN; - const eventPath = process.env.GITHUB_EVENT_PATH; const repo = process.env.GITHUB_REPOSITORY || ""; const dryRun = (process.env.DRY_RUN || "false").toLowerCase() === "true"; + const mode = (process.env.MODE || "event").toLowerCase(); if (!token) throw new Error("GITHUB_TOKEN is required"); - if (!eventPath) throw new Error("GITHUB_EVENT_PATH is required"); if (!repo.includes("/")) throw new Error("GITHUB_REPOSITORY is required"); - const [owner] = repo.split("/"); - const event = readJsonFile(eventPath); - - // Only process issues (not PRs) - if (!event.issue) { - console.info("Event is not an issue — skipping native type sync."); - return { nativeTypeSet: "", nativeTypeId: "" }; - } - - const issue = event.issue; - const labelNames = (issue.labels ?? []).map((l) => l.name).filter(Boolean); - const issueNodeId = issue.node_id; - const issueNumber = issue.number; + const [owner, repoName] = repo.split("/"); // Read canonical config const configPath = process.env.ISSUE_FIELDS_CONFIG @@ -166,6 +159,35 @@ async function run() { ), ); + const octokit = getOctokit(token); + + if (mode === "bulk") { + return runBulk({ octokit, owner, repo: repoName, typeMapping, enabledTypes, dryRun }); + } + + return runEvent({ octokit, owner, typeMapping, enabledTypes, dryRun }); +} + +/** + * Process the single issue from the GitHub event payload (event mode). + */ +async function runEvent({ octokit, owner, typeMapping, enabledTypes, dryRun }) { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) throw new Error("GITHUB_EVENT_PATH is required"); + + const event = readJsonFile(eventPath); + + // Only process issues (not PRs) + if (!event.issue) { + console.info("Event is not an issue — skipping native type sync."); + return { nativeTypeSet: "", nativeTypeId: "" }; + } + + const issue = event.issue; + const labelNames = (issue.labels ?? []).map((l) => l.name).filter(Boolean); + const issueNodeId = issue.node_id; + const issueNumber = issue.number; + // Derive target native type name from labels const targetTypeName = deriveTypeFromLabels(labelNames, typeMapping); @@ -185,8 +207,6 @@ async function run() { return { nativeTypeSet: "", nativeTypeId: "" }; } - const octokit = getOctokit(token); - // Fetch org issue types to get the ID for targetTypeName const orgTypeMap = await fetchOrgIssueTypes(octokit, owner); @@ -226,6 +246,73 @@ async function run() { return { nativeTypeSet: appliedName, nativeTypeId: typeId }; } +/** + * Process all open issues in the repository (bulk mode). + * + * @param {{ octokit: import("@octokit/core").Octokit, owner: string, repo: string, + * typeMapping: Record, enabledTypes: Set, + * dryRun: boolean }} options + * @returns {Promise<{ applied: string[], skipped: string[], errors: string[] }>} + */ +async function runBulk({ octokit, owner, repo, typeMapping, enabledTypes, dryRun }) { + console.info("Bulk mode — fetching all open issues..."); + + const orgTypeMap = await fetchOrgIssueTypes(octokit, owner); + + if (orgTypeMap.size === 0) { + console.info( + `Org "${owner}" has no enabled issue types or access is insufficient — skipping bulk sync.`, + ); + return { applied: [], skipped: [], errors: [] }; + } + + // Paginate all open issues + const issues = await octokit.paginate(octokit.rest.issues.listForRepo, { + owner, + repo, + state: "open", + per_page: 100, + }); + + const openIssues = issues.filter((i) => !i.pull_request); + console.info(`Processing ${openIssues.length} open issue(s).`); + + const result = { applied: [], skipped: [], errors: [] }; + + for (const issue of openIssues) { + const labelNames = (issue.labels ?? []).map((l) => l.name).filter(Boolean); + const targetTypeName = deriveTypeFromLabels(labelNames, typeMapping); + + if (!targetTypeName || !enabledTypes.has(targetTypeName.toLowerCase())) { + result.skipped.push(`#${issue.number}: no matching enabled type`); + continue; + } + + const typeId = orgTypeMap.get(targetTypeName.toLowerCase()); + if (!typeId) { + result.skipped.push(`#${issue.number}: type "${targetTypeName}" not in org types`); + continue; + } + + console.info( + `#${issue.number}: native type → "${targetTypeName}"${dryRun ? " [DRY RUN]" : ""}`, + ); + + if (!dryRun) { + try { + await setIssueType(octokit, issue.node_id, typeId); + result.applied.push(`#${issue.number} → ${targetTypeName}`); + } catch (err) { + result.errors.push(`#${issue.number}: ${err.message}`); + } + } else { + result.applied.push(`#${issue.number} → ${targetTypeName} [DRY RUN]`); + } + } + + return result; +} + function writeOutputs({ nativeTypeSet, nativeTypeId }) { const outputFile = process.env.GITHUB_OUTPUT; if (!outputFile) return; @@ -249,4 +336,6 @@ module.exports = { fetchOrgIssueTypes, setIssueType, run, + runEvent, + runBulk, };