diff --git a/.github/workflows/issue-audit-remediation.yml b/.github/workflows/issue-audit-remediation.yml new file mode 100644 index 0000000000..87ef062d91 --- /dev/null +++ b/.github/workflows/issue-audit-remediation.yml @@ -0,0 +1,487 @@ +name: Issue Audit & Remediation + +on: + schedule: + # Run every Monday at 08:00 UTC + - cron: "0 8 * * 1" + workflow_dispatch: + inputs: + mode: + description: "Operation mode" + required: false + default: "full" + type: choice + options: + - audit # Reopen closed issues with unchecked DoD items + - remediate # Fix labels/milestones/templates on recent issues + - full # Run both audit and remediate + days: + description: "Scope: issues created in last N days (remediate mode, default: 7)" + required: false + default: "7" + type: string + dry_run: + description: "Run without making changes" + required: false + default: "true" + type: choice + options: + - "true" + - "false" + remediate_milestones: + description: "Auto-assign missing milestones (remediate mode)" + required: false + default: "true" + type: choice + options: + - "true" + - "false" + remediate_labels: + description: "Add missing type labels (remediate mode)" + required: false + default: "true" + type: choice + options: + - "true" + - "false" + remediate_templates: + description: "Post remediation checklists for template gaps (remediate mode)" + required: false + default: "true" + type: choice + options: + - "true" + - "false" + audit_fix_labels: + description: "Apply missing type/priority labels to open issues (audit mode)" + required: false + default: "true" + type: choice + options: + - "true" + - "false" + +permissions: + contents: read + issues: write + +jobs: + # ─── Audit: reopen closed issues with unchecked DoD items ──────────────────── + audit: + name: Audit issue health + runs-on: ubuntu-latest + if: >- + github.event_name == 'schedule' || + inputs.mode == 'audit' || + inputs.mode == 'full' + steps: + - name: Run issue health audit + uses: actions/github-script@v9 + env: + DRY_RUN: ${{ github.event_name == 'schedule' && 'false' || inputs.dry_run || 'true' }} + AUDIT_FIX_LABELS: ${{ inputs.audit_fix_labels || 'true' }} + with: + script: | + const dryRun = process.env.DRY_RUN === 'true'; + const auditFixLabels = process.env.AUDIT_FIX_LABELS !== 'false'; + + const report = { + reopened: [], + labeledType: [], + labeledPriority: [], + skipped: [], + errors: [], + }; + + // ─── Type inference from title patterns ─────────────────────── + function inferTypeLabel(title) { + const t = title.toLowerCase(); + if (/^fix[\(:]/.test(t) || /^bug/.test(t)) return 'type:bug'; + if (/^feat[\(:]/.test(t) || /^feature/.test(t)) return 'type:feature'; + if (/^chore[\(:]/.test(t)) return 'type:chore'; + if (/^refactor[\(:]/.test(t)) return 'type:refactor'; + if (/^docs[\(:]/.test(t) || /documentation/.test(t)) return 'type:documentation'; + if (/^audit[\(:]/.test(t) || /\baudit\b/.test(t)) return 'type:audit'; + if (/^build[\(-]ci|^ci[\(:]|^build[\(:]/.test(t)) return 'type:build'; + if (/^test[\(:]|testing/.test(t)) return 'type:test'; + if (/^perf[\(:]|performance/.test(t)) return 'type:performance'; + if (/^security[\(:]|security/.test(t)) return 'type:security'; + if (/^automation|workflows?\s+consolidation/i.test(t)) return 'type:automation'; + if (/^epic:/i.test(t) || /\bepic\b/.test(t)) return 'type:epic'; + if (/research/.test(t)) return 'type:research'; + if (/maintenance/.test(t)) return 'type:maintenance'; + return null; + } + + function hasTypeLabel(labels) { + return labels.some(l => l.name.startsWith('type:')); + } + + function hasPriorityLabel(labels) { + return labels.some(l => l.name.startsWith('priority:')); + } + + // ─── Fetch recently closed issues (last 28 days) ──────────── + const since = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(); + + core.info('Fetching recently closed issues...'); + const allClosed = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed', + per_page: 100, + }); + const closedIssues = allClosed.filter( + i => !i.pull_request && i.closed_at && i.closed_at >= since + ); + core.info(`Found ${closedIssues.length} recently closed issues.`); + + // ─── Step 1: Reopen issues with incomplete DoD ─────────────── + for (const issue of closedIssues) { + const hasForceClose = issue.labels.some(l => l.name === 'meta:force-close'); + if (hasForceClose) { + report.skipped.push(`#${issue.number} (meta:force-close label present)`); + continue; + } + + const body = (issue.body || '').trim(); + const dodMatch = body.match(/^#{2,3}\s+Definition of Done \(DoD\)\s*$\n?([\s\S]*?)(?=\n#{1,3}\s+|$)/im); + if (!dodMatch) { + report.skipped.push(`#${issue.number} (no DoD section)`); + continue; + } + const dodSection = dodMatch[1].trim(); + + const uncheckedMatches = (dodSection.match(/(?:^|\n)\s*-\s*\[\s\]\s+/g) || []).length; + const checkedMatches = (dodSection.match(/(?:^|\n)\s*-\s*\[[xX]\]\s+/g) || []).length; + const totalCheckboxes = uncheckedMatches + checkedMatches; + + if (totalCheckboxes > 0 && uncheckedMatches > 0) { + core.info(`#${issue.number}: ${uncheckedMatches}/${totalCheckboxes} unchecked — reopening`); + + if (!dryRun) { + try { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'open', + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `♻️ **Reopened by Issue Audit & Remediation**\n\nThis issue was closed with **${uncheckedMatches} of ${totalCheckboxes} task(s) still unchecked**. Please complete all tasks before closing.\n\n> If this was intentional, add the \`meta:force-close\` label and close again.`, + }); + report.reopened.push(`#${issue.number}: ${issue.title.slice(0, 60)}`); + } catch (err) { + report.errors.push(`#${issue.number} reopen failed: ${err.message}`); + } + } else { + report.reopened.push(`#${issue.number} [DRY RUN]: ${issue.title.slice(0, 60)}`); + } + } + } + + // ─── Step 2: Fix labels on open issues ─────────────────────── + if (auditFixLabels) { + core.info('Fetching all open issues...'); + let openIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + openIssues = openIssues.filter(i => !i.pull_request); + core.info(`Found ${openIssues.length} open issues.`); + + for (const issue of openIssues) { + const labels = issue.labels || []; + const labelsToAdd = []; + + let inferredType = null; + let needsTriageLabel = false; + if (!hasTypeLabel(labels)) { + inferredType = inferTypeLabel(issue.title); + if (!inferredType) { + const hasNeedsTriage = labels.some(l => l.name === 'status:needs-triage'); + if (!hasNeedsTriage) { + needsTriageLabel = true; + labelsToAdd.push('status:needs-triage'); + } + } else { + labelsToAdd.push(inferredType); + } + } + + let addPriority = false; + if (!hasPriorityLabel(labels)) { + addPriority = true; + labelsToAdd.push('priority:normal'); + } + + if (labelsToAdd.length > 0) { + core.info(`#${issue.number}: adding labels [${labelsToAdd.join(', ')}]`); + if (!dryRun) { + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: labelsToAdd, + }); + if (inferredType) report.labeledType.push(`#${issue.number} → ${inferredType}`); + if (needsTriageLabel) report.labeledType.push(`#${issue.number} → status:needs-triage`); + if (addPriority) report.labeledPriority.push(`#${issue.number}`); + } catch (err) { + report.errors.push(`#${issue.number} label failed: ${err.message}`); + } + } else { + if (inferredType) report.labeledType.push(`#${issue.number} [DRY RUN] → ${inferredType}`); + if (needsTriageLabel) report.labeledType.push(`#${issue.number} [DRY RUN] → status:needs-triage`); + if (addPriority) report.labeledPriority.push(`#${issue.number} [DRY RUN]`); + } + } + } + } // end auditFixLabels + + // ─── Generate summary report ───────────────────────────────── + const summary = [ + `# Issue Audit & Remediation Report`, + `> ${dryRun ? '🔍 DRY RUN — no changes were made' : '✅ Changes applied'}`, + `> Run: ${new Date().toISOString()}`, + '', + `## Reopened Issues (${report.reopened.length})`, + report.reopened.length > 0 + ? report.reopened.map(r => `- ${r}`).join('\n') + : '_None_', + '', + `## Type Labels Applied (${report.labeledType.length})`, + report.labeledType.length > 0 + ? report.labeledType.map(r => `- ${r}`).join('\n') + : '_None_', + '', + `## Priority Labels Applied (${report.labeledPriority.length})`, + report.labeledPriority.length > 0 + ? `Issues: ${report.labeledPriority.join(', ')}` + : '_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(`Audit completed with ${report.errors.length} error(s).`); + } + + # ─── Remediate: fix labels/milestones/templates for recent issues ──────────── + remediate: + name: Bulk issue remediation + runs-on: ubuntu-latest + if: >- + inputs.mode == 'remediate' || + inputs.mode == 'full' + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version-file: ".nvmrc" + + - name: Install dependencies + run: npm ci + + - name: Fetch non-compliant issues + id: fetch + uses: actions/github-script@v9 + env: + DAYS: ${{ inputs.days || '7' }} + with: + script: | + const days = parseInt(process.env.DAYS || '7', 10); + const createdSince = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + .toISOString() + .split('T')[0]; + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + since: createdSince, + per_page: 100, + }); + + const nonCompliant = issues + .filter((issue) => !issue.pull_request) + .filter((issue) => { + const body = (issue.body || '').toLowerCase(); + const hasDoR = /definition of ready \(dor\)/i.test(body); + const hasDoD = /definition of done \(dod\)/i.test(body); + const hasType = (issue.labels || []).some((l) => + (typeof l === 'string' ? l : l.name).startsWith('type:') + ); + const hasMilestone = !!issue.milestone; + + return !hasType || !hasMilestone || !hasDoR || !hasDoD; + }); + + core.info( + `Found ${nonCompliant.length} non-compliant issue(s) from last ${days} day(s)` + ); + + core.setOutput('count', nonCompliant.length); + core.setOutput('issues', JSON.stringify(nonCompliant)); + + - name: Assign milestones + if: ${{ inputs.remediate_milestones != 'false' && steps.fetch.outputs.count > 0 }} + shell: bash + run: | + set +e + node scripts/workflows/assign-milestones-workflow.cjs + echo "Exit code: $?" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUES_JSON: ${{ steps.fetch.outputs.issues }} + DRY_RUN: ${{ inputs.dry_run || 'true' }} + RUN_ID: ${{ github.run_id }} + + - name: Add missing type labels + if: ${{ inputs.remediate_labels != 'false' && steps.fetch.outputs.count > 0 }} + uses: actions/github-script@v9 + env: + ISSUES_JSON: ${{ steps.fetch.outputs.issues }} + DRY_RUN: ${{ inputs.dry_run || 'true' }} + with: + script: | + const issues = JSON.parse(process.env.ISSUES_JSON || '[]'); + const dryRun = (process.env.DRY_RUN || 'true') === 'true'; + + let labelCount = 0; + for (const issue of issues) { + const hasType = (issue.labels || []).some((l) => + (typeof l === 'string' ? l : l.name).startsWith('type:') + ); + + if (!hasType) { + if (!dryRun) { + let typeLabel = 'type:task'; + const body = (issue.body || '').toLowerCase(); + + if (body.includes('bug') || body.includes('error') || body.includes('broken')) { + typeLabel = 'type:bug'; + } else if (body.includes('feature') || body.includes('enhancement')) { + typeLabel = 'type:feature'; + } else if (body.includes('refactor') || body.includes('clean up')) { + typeLabel = 'type:refactor'; + } else if (body.includes('test') || body.includes('coverage')) { + typeLabel = 'type:test'; + } else if (body.includes('doc') || body.includes('guide')) { + typeLabel = 'type:documentation'; + } else if (body.includes('design')) { + typeLabel = 'type:design'; + } + + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [typeLabel], + }); + labelCount++; + } catch (error) { + core.warning(`Failed to add label to #${issue.number}: ${error.message}`); + } + } else { + labelCount++; + } + } + } + + core.info(`Type label remediation: ${labelCount} issue(s) labeled (dry-run: ${dryRun})`); + + - name: Post remediation checklists + if: ${{ inputs.remediate_templates != 'false' && steps.fetch.outputs.count > 0 }} + uses: actions/github-script@v9 + with: + script: | + const { RemediationChecklistGenerator } = require('./scripts/agents/includes/remediation-checklist-generator.js'); + const issues = JSON.parse('${{ steps.fetch.outputs.issues }}'); + const dryRun = '${{ inputs.dry_run || 'true' }}' === 'true'; + + const generator = new RemediationChecklistGenerator(github, context.repo.owner, context.repo.repo); + const results = await generator.postRemediationChecklists(issues, { dryRun }); + + const posted = results.filter((r) => r.status === 'checklist-posted' || r.status === 'checklist-updated' || r.status === 'ready-to-post'); + const compliant = results.filter((r) => r.status === 'compliant'); + const errors = results.filter((r) => r.status === 'error'); + + core.summary + .addHeading('Remediation Checklist Summary') + .addRaw(`- Total: ${results.length}\n`) + .addRaw(`- Checklists posted: ${posted.length}\n`) + .addRaw(`- Already compliant: ${compliant.length}\n`) + .addRaw(`- Errors: ${errors.length}\n`) + .write(); + + core.info(`Remediation checklists: ${posted.length} posted, ${compliant.length} compliant, ${errors.length} errors`); + + - name: Trigger labeling workflow + if: success() + uses: actions/github-script@v9 + with: + script: | + core.info('Triggering unified labeling workflow...'); + + try { + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'labeling.yml', + ref: 'develop', + inputs: { + dry_run: '${{ inputs.dry_run || 'true' }}', + report_commit: 'false', + }, + }); + core.info('Labeling workflow triggered successfully'); + } catch (error) { + core.warning(`Could not trigger labeling workflow: ${error.message}`); + } + + - name: Upload reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: remediation-reports-${{ github.run_id }} + path: | + .github/reports/remediation/ + .github/reports/labeling/ + retention-days: 30 + + - name: Summary + if: always() + uses: actions/github-script@v9 + with: + script: | + const dryRun = '${{ inputs.dry_run || 'true' }}' === 'true'; + const mode = dryRun ? '🏁 DRY-RUN' : '✅ APPLIED'; + + core.summary + .addHeading(`${mode} - Bulk Issue Remediation Complete`) + .addRaw(`\n**Reports available in artifacts**\n`) + .addRaw(`- Milestone assignment: .github/reports/remediation/milestone-assignment-${{ github.run_id }}.md\n`) + .addRaw(`- Labeling workflow initiated\n`) + .write(); diff --git a/.github/workflows/issue-health-audit.yml b/.github/workflows/issue-health-audit.yml deleted file mode 100644 index f9629be03f..0000000000 --- a/.github/workflows/issue-health-audit.yml +++ /dev/null @@ -1,267 +0,0 @@ -name: Issue Health Audit - -on: - workflow_dispatch: - inputs: - dry_run: - description: "Dry run (report only, no changes)" - required: false - default: "false" - type: choice - options: - - "false" - - "true" - reopen_incomplete: - description: "Reopen issues closed with unchecked DoD items" - required: false - default: "true" - type: choice - options: - - "true" - - "false" - fix_labels: - description: "Apply missing type/priority labels to open issues" - required: false - default: "true" - type: choice - options: - - "true" - - "false" - schedule: - # Run every Monday at 08:00 UTC - - cron: "0 8 * * 1" - -jobs: - audit-and-fix: - name: Audit & Fix Issue Health - runs-on: ubuntu-latest - permissions: - issues: write - contents: read - - steps: - - name: Run issue health audit - uses: actions/github-script@v9 - env: - DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} - REOPEN_INCOMPLETE: ${{ github.event.inputs.reopen_incomplete || 'true' }} - FIX_LABELS: ${{ github.event.inputs.fix_labels || 'true' }} - with: - script: | - const dryRun = process.env.DRY_RUN === 'true'; - const reopenIncomplete = process.env.REOPEN_INCOMPLETE === 'true'; - const fixLabels = process.env.FIX_LABELS === 'true'; - - const report = { - reopened: [], - labeledType: [], - labeledPriority: [], - skipped: [], - errors: [], - }; - - // ─── Type inference from title patterns ─────────────────────── - function inferTypeLabel(title) { - const t = title.toLowerCase(); - if (/^fix[\(:]/.test(t) || /^bug/.test(t)) return 'type:bug'; - if (/^feat[\(:]/.test(t) || /^feature/.test(t)) return 'type:feature'; - if (/^chore[\(:]/.test(t)) return 'type:chore'; - if (/^refactor[\(:]/.test(t)) return 'type:refactor'; - if (/^docs[\(:]/.test(t) || /documentation/.test(t)) return 'type:documentation'; - if (/^audit[\(:]/.test(t) || /\baudit\b/.test(t)) return 'type:audit'; - if (/^build[\(-]ci|^ci[\(:]|^build[\(:]/.test(t)) return 'type:build'; - if (/^test[\(:]|testing/.test(t)) return 'type:test'; - if (/^perf[\(:]|performance/.test(t)) return 'type:performance'; - if (/^security[\(:]|security/.test(t)) return 'type:security'; - if (/^automation|workflows?\s+consolidation/i.test(t)) return 'type:automation'; - if (/^epic:/i.test(t) || /\bepic\b/.test(t)) return 'type:epic'; - if (/research/.test(t)) return 'type:research'; - if (/maintenance/.test(t)) return 'type:maintenance'; - return null; // Cannot infer — needs manual triage - } - - // ─── Check for type label ─────────────────────────────────── - function hasTypeLabel(labels) { - return labels.some(l => l.name.startsWith('type:')); - } - - // ─── Check for priority label ─────────────────────────────── - function hasPriorityLabel(labels) { - return labels.some(l => l.name.startsWith('priority:')); - } - - // ─── Fetch all open issues (paginated) ────────────────────── - core.info('Fetching all open issues...'); - const openIssues = await github.paginate(github.rest.issues.listForRepo, { - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - per_page: 100, - }); - // Filter to only issues (not PRs) - openIssues = openIssues.filter(i => !i.pull_request); - core.info(`Found ${openIssues.length} open issues.`); - - // ─── Fetch recently closed issues (last 28 days) ──────────── - const since = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(); - - let closedIssues = []; - if (reopenIncomplete) { - core.info('Fetching recently closed issues...'); - const allClosed = await github.paginate(github.rest.issues.listForRepo, { - owner: context.repo.owner, - repo: context.repo.repo, - state: 'closed', - per_page: 100, - }); - // Filter to only issues (not PRs) closed within the last 28 days - closedIssues = allClosed.filter(i => !i.pull_request && i.closed_at && i.closed_at >= since); - core.info(`Found ${closedIssues.length} recently closed issues.`); - } - - // ─── Step 1: Reopen issues with incomplete DoD ─────────────── - if (reopenIncomplete) { - for (const issue of closedIssues) { - // Skip force-closed issues - const hasForceClose = issue.labels.some(l => l.name === 'meta:force-close'); - if (hasForceClose) { - report.skipped.push(`#${issue.number} (meta:force-close label present)`); - continue; - } - - const body = (issue.body || '').trim(); - - // Extract DoD section only (matches validate-issue-dod-before-close.yml policy) - const dodMatch = body.match(/^#{2,3}\s+Definition of Done \(DoD\)\s*$\n?([\s\S]*?)(?=\n#{1,3}\s+|$)/im); - if (!dodMatch) { - report.skipped.push(`#${issue.number} (no DoD section)`); - continue; - } - const dodSection = dodMatch[1].trim(); - - // Count unchecked and checked checkboxes within the DoD section only - const uncheckedMatches = (dodSection.match(/(?:^|\n)\s*-\s*\[\s\]\s+/g) || []).length; - const checkedMatches = (dodSection.match(/(?:^|\n)\s*-\s*\[[xX]\]\s+/g) || []).length; - const totalCheckboxes = uncheckedMatches + checkedMatches; - - if (totalCheckboxes > 0 && uncheckedMatches > 0) { - core.info(`#${issue.number}: ${uncheckedMatches}/${totalCheckboxes} unchecked — reopening`); - - if (!dryRun) { - try { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - state: 'open', - }); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `♻️ **Reopened by Issue Health Audit**\n\nThis issue was closed with **${uncheckedMatches} of ${totalCheckboxes} task(s) still unchecked**. Please complete all tasks before closing.\n\n> If this was intentional, add the \`meta:force-close\` label and close again.`, - }); - report.reopened.push(`#${issue.number}: ${issue.title.slice(0, 60)}`); - } catch (err) { - report.errors.push(`#${issue.number} reopen failed: ${err.message}`); - } - } else { - report.reopened.push(`#${issue.number} [DRY RUN]: ${issue.title.slice(0, 60)}`); - } - } - } - } - - // ─── Step 2: Fix labels on open issues ─────────────────────── - if (fixLabels) { - for (const issue of openIssues) { - const labels = issue.labels || []; - const labelsToAdd = []; - - // Infer and apply missing type label - let inferredType = null; - let needsTriageLabel = false; - if (!hasTypeLabel(labels)) { - inferredType = inferTypeLabel(issue.title); - if (!inferredType) { - // Cannot infer — apply needs-triage if not already present - const hasNeedsTriage = labels.some(l => l.name === 'status:needs-triage'); - if (!hasNeedsTriage) { - needsTriageLabel = true; - labelsToAdd.push('status:needs-triage'); - } - } else { - labelsToAdd.push(inferredType); - } - } - - // Apply missing priority label - let addPriority = false; - if (!hasPriorityLabel(labels)) { - addPriority = true; - labelsToAdd.push('priority:normal'); - } - - if (labelsToAdd.length > 0) { - core.info(`#${issue.number}: adding labels [${labelsToAdd.join(', ')}]`); - if (!dryRun) { - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: labelsToAdd, - }); - if (inferredType) report.labeledType.push(`#${issue.number} → ${inferredType}`); - if (needsTriageLabel) report.labeledType.push(`#${issue.number} → status:needs-triage`); - if (addPriority) report.labeledPriority.push(`#${issue.number}`); - } catch (err) { - report.errors.push(`#${issue.number} label failed: ${err.message}`); - } - } else { - if (inferredType) report.labeledType.push(`#${issue.number} [DRY RUN] → ${inferredType}`); - if (needsTriageLabel) report.labeledType.push(`#${issue.number} [DRY RUN] → status:needs-triage`); - if (addPriority) report.labeledPriority.push(`#${issue.number} [DRY RUN]`); - } - } - } - } - - // ─── Generate summary report ───────────────────────────────── - const summary = [ - `# Issue Health Audit Report`, - `> ${dryRun ? '🔍 DRY RUN — no changes were made' : '✅ Changes applied'}`, - `> Run: ${new Date().toISOString()}`, - '', - `## Reopened Issues (${report.reopened.length})`, - report.reopened.length > 0 - ? report.reopened.map(r => `- ${r}`).join('\n') - : '_None_', - '', - `## Type Labels Applied (${report.labeledType.length})`, - report.labeledType.length > 0 - ? report.labeledType.map(r => `- ${r}`).join('\n') - : '_None_', - '', - `## Priority Labels Applied (${report.labeledPriority.length})`, - report.labeledPriority.length > 0 - ? `Issues: ${report.labeledPriority.join(', ')}` - : '_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(`Audit completed with ${report.errors.length} error(s).`); - } diff --git a/.github/workflows/issue-remediation-bulk.yml b/.github/workflows/issue-remediation-bulk.yml deleted file mode 100644 index e570ae396c..0000000000 --- a/.github/workflows/issue-remediation-bulk.yml +++ /dev/null @@ -1,299 +0,0 @@ -name: Bulk Issue Remediation - -on: - workflow_dispatch: - inputs: - days: - description: "Remediate issues created in the last N days (default: 7)" - required: false - default: "7" - type: string - dry_run: - description: "Run without making changes" - required: false - default: "true" - type: choice - options: - - "true" - - "false" - remediate_milestones: - description: "Auto-assign missing milestones" - required: false - default: "true" - type: choice - options: - - "true" - - "false" - remediate_labels: - description: "Add missing type labels" - required: false - default: "true" - type: choice - options: - - "true" - - "false" - remediate_templates: - description: "Post remediation checklists for template gaps" - required: false - default: "true" - type: choice - options: - - "true" - - "false" - -permissions: - contents: read - issues: write - -jobs: - bulk-remediation: - name: Bulk issue remediation - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Setup Node - uses: actions/setup-node@v7 - with: - node-version-file: ".nvmrc" - - - name: Install dependencies - run: npm ci - - - name: Fetch non-compliant issues - id: fetch - uses: actions/github-script@v9 - env: - DAYS: ${{ inputs.days }} - with: - script: | - const days = parseInt(process.env.DAYS || '7', 10); - const createdSince = new Date(Date.now() - days * 24 * 60 * 60 * 1000) - .toISOString() - .split('T')[0]; - - core.info(`📋 Fetching issues created since ${createdSince}...`); - - const issues = await github.paginate(github.rest.issues.listForRepo, { - owner: context.repo.owner, - repo: context.repo.repo, - state: 'all', - since: createdSince, - per_page: 100, - }); - - core.info(`✓ Found ${issues.length} total issue(s)`); - - const nonCompliant = issues - .filter((issue) => !issue.pull_request) // Exclude PRs - .filter((issue) => { - const body = (issue.body || '').toLowerCase(); - const hasDoR = /definition of ready \(dor\)/i.test(body); - const hasDoD = /definition of done \(dod\)/i.test(body); - const hasType = (issue.labels || []).some((l) => - (typeof l === 'string' ? l : l.name).startsWith('type:') - ); - const hasMilestone = !!issue.milestone; - - // Flag as non-compliant if missing any of: type label, milestone, DoR, DoD - return !hasType || !hasMilestone || !hasDoR || !hasDoD; - }); - - // Analyze compliance gaps - const complianceGaps = { - missingType: 0, - missingMilestone: 0, - missingDoR: 0, - missingDoD: 0, - }; - - nonCompliant.forEach((issue) => { - const body = (issue.body || '').toLowerCase(); - const hasType = (issue.labels || []).some((l) => - (typeof l === 'string' ? l : l.name).startsWith('type:') - ); - const hasMilestone = !!issue.milestone; - const hasDoR = /definition of ready \(dor\)/i.test(body); - const hasDoD = /definition of done \(dod\)/i.test(body); - - if (!hasType) complianceGaps.missingType++; - if (!hasMilestone) complianceGaps.missingMilestone++; - if (!hasDoR) complianceGaps.missingDoR++; - if (!hasDoD) complianceGaps.missingDoD++; - }); - - core.info(`🔍 Compliance Analysis:`); - core.info(` - Non-compliant issues: ${nonCompliant.length}`); - core.info(` - Missing type labels: ${complianceGaps.missingType}`); - core.info(` - Missing milestones: ${complianceGaps.missingMilestone}`); - core.info(` - Missing DoR: ${complianceGaps.missingDoR}`); - core.info(` - Missing DoD: ${complianceGaps.missingDoD}`); - - core.setOutput('count', nonCompliant.length); - core.setOutput('gaps', JSON.stringify(complianceGaps)); - core.setOutput('issues', JSON.stringify(nonCompliant)); - - - name: Assign milestones - if: ${{ inputs.remediate_milestones == 'true' && steps.fetch.outputs.count > 0 }} - shell: bash - run: | - set +e - echo "=== DEBUG: Running milestone assignment ===" >&2 - node scripts/workflows/assign-milestones-workflow.js 2>&1 - echo "Exit code: $?" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUES_JSON: ${{ steps.fetch.outputs.issues }} - DRY_RUN: ${{ inputs.dry_run }} - RUN_ID: ${{ github.run_id }} - - - name: Add missing type labels - if: ${{ inputs.remediate_labels == 'true' && steps.fetch.outputs.count > 0 }} - uses: actions/github-script@v9 - with: - script: | - const issues = JSON.parse('${{ steps.fetch.outputs.issues }}'); - const dryRun = '${{ inputs.dry_run }}' === 'true'; - - let labelCount = 0; - for (const issue of issues) { - const hasType = (issue.labels || []).some((l) => - (typeof l === 'string' ? l : l.name).startsWith('type:') - ); - - if (!hasType) { - if (!dryRun) { - // Try to infer type from body or use default - let typeLabel = 'type:task'; - const body = (issue.body || '').toLowerCase(); - - if (body.includes('bug') || body.includes('error') || body.includes('broken')) { - typeLabel = 'type:bug'; - } else if (body.includes('feature') || body.includes('enhancement')) { - typeLabel = 'type:feature'; - } else if (body.includes('refactor') || body.includes('clean up')) { - typeLabel = 'type:refactor'; - } else if (body.includes('test') || body.includes('coverage')) { - typeLabel = 'type:test'; - } else if (body.includes('doc') || body.includes('guide')) { - typeLabel = 'type:documentation'; - } else if (body.includes('design')) { - typeLabel = 'type:design'; - } - - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [typeLabel], - }); - labelCount++; - } catch (error) { - core.warning(`Failed to add label to #${issue.number}: ${error.message}`); - } - } else { - labelCount++; - } - } - } - - core.info(`Type label remediation: ${labelCount} issue(s) labeled (dry-run: ${dryRun})`); - - - name: Post remediation checklists - if: ${{ inputs.remediate_templates == 'true' && steps.fetch.outputs.count > 0 }} - uses: actions/github-script@v9 - with: - script: | - const { RemediationChecklistGenerator } = require('./scripts/agents/includes/remediation-checklist-generator.js'); - const issues = JSON.parse('${{ steps.fetch.outputs.issues }}'); - const dryRun = '${{ inputs.dry_run }}' === 'true'; - - const generator = new RemediationChecklistGenerator(github, context.repo.owner, context.repo.repo); - const results = await generator.postRemediationChecklists(issues, { dryRun }); - - const posted = results.filter((r) => r.status === 'checklist-posted' || r.status === 'checklist-updated' || r.status === 'ready-to-post'); - const compliant = results.filter((r) => r.status === 'compliant'); - const errors = results.filter((r) => r.status === 'error'); - - core.summary - .addHeading('Remediation Checklist Summary') - .addRaw(`- Total: ${results.length}\n`) - .addRaw(`- Checklists posted: ${posted.length}\n`) - .addRaw(`- Already compliant: ${compliant.length}\n`) - .addRaw(`- Errors: ${errors.length}\n`) - .write(); - - core.info(`Remediation checklists: ${posted.length} posted, ${compliant.length} compliant, ${errors.length} errors`); - - - name: Run labeling workflow - if: success() - uses: actions/github-script@v9 - with: - script: | - core.info('Triggering unified labeling workflow...'); - - try { - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'labeling.yml', - ref: 'develop', - inputs: { - dry_run: '${{ inputs.dry_run }}', - report_commit: 'false', - }, - }); - core.info('Labeling workflow triggered successfully'); - } catch (error) { - core.warning(`Could not trigger labeling workflow: ${error.message}`); - } - - - name: Upload reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: remediation-reports-${{ github.run_id }} - path: | - .github/reports/remediation/ - .github/reports/labeling/ - retention-days: 30 - - - name: Summary - if: always() - uses: actions/github-script@v9 - with: - script: | - const dryRun = '${{ inputs.dry_run }}' === 'true'; - const mode = dryRun ? '🏁 DRY-RUN' : '✅ APPLIED'; - const issueCount = '${{ steps.fetch.outputs.count }}'; - const gaps = JSON.parse('${{ steps.fetch.outputs.gaps }}' || '{}'); - - let summary = core.summary - .addHeading(`${mode} - Bulk Issue Remediation Complete`) - .addRaw(`\n## Execution Summary\n`) - .addRaw(`- **Mode**: ${dryRun ? 'Dry-run (preview only)' : 'Applied (making changes)'}\n`) - .addRaw(`- **Issues processed**: ${issueCount}\n`) - .addRaw(`- **Run ID**: ${{ github.run_id }}\n`); - - if (issueCount > 0) { - summary - .addRaw(`\n## Compliance Gaps Found\n`) - .addRaw(`- Missing type labels: ${gaps.missingType || 0}\n`) - .addRaw(`- Missing milestones: ${gaps.missingMilestone || 0}\n`) - .addRaw(`- Missing DoR: ${gaps.missingDoR || 0}\n`) - .addRaw(`- Missing DoD: ${gaps.missingDoD || 0}\n`); - } - - summary - .addRaw(`\n## Remediation Steps\n`) - .addRaw(`- ${{ inputs.remediate_milestones == 'true' ? '✓' : '✗' }} Milestone assignment\n`) - .addRaw(`- ${{ inputs.remediate_labels == 'true' ? '✓' : '✗' }} Type label inference\n`) - .addRaw(`- ${{ inputs.remediate_templates == 'true' ? '✓' : '✗' }} Remediation checklists\n`) - .addRaw(`\n## Reports & Artifacts\n`) - .addRaw(`- Detailed reports: \`.github/reports/remediation/\`\n`) - .addRaw(`- Labeling workflow: Triggered for validation\n`) - .write(); - - core.info(`✅ Remediation workflow completed in ${mode} mode`); diff --git a/CHANGELOG.md b/CHANGELOG.md index ce72a8f81e..8f020926f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Issue Compliance Workflow Consolidation — Issue #1407** — Consolidated three overlapping issue-close governance workflows (`template-enforcement.yml`, `checklist-finalisation.yml`, `validate-issue-dod-before-close.yml`) into a single authoritative `.github/workflows/issue-compliance.yml`. All original behaviours are preserved with conditional job guards (`enforce-template`, `enforce-close-guard`, `validate-dod-on-close`, `finalise-checklists`). Net reduction: −2 workflow files (3 → 1), eliminating 3× duplicate GitHub API calls on every issue close event. ([#1407](https://github.com/lightspeedwp/.github/issues/1407)) +- **Phase 4.6 Workflow Consolidation — Issue #1411** — Consolidated `issue-remediation-bulk.yml` and `issue-health-audit.yml` into a single `issue-audit-remediation.yml` workflow. The new workflow supports three operation modes via `workflow_dispatch`: `audit` (reopen closed issues with unchecked DoD items), `remediate` (fix labels/milestones/templates for recent issues), and `full` (run both). The weekly Monday 08:00 UTC schedule is preserved on the `audit` job. Dry-run support is consistent across all modes. Net: −1 workflow. ([#1411](https://github.com/lightspeedwp/.github/issues/1411)) + - **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