name: Convert Docs to Issues on: workflow_run: workflows: ["Docker Build, Publish & Test"] types: [completed] # Allow manual trigger workflow_dispatch: inputs: dry_run: description: 'Dry run (no issues created)' required: false default: false type: boolean file_path: description: 'Specific file to process (optional)' required: false type: string concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }} cancel-in-progress: false env: NODE_VERSION: '24.12.0' permissions: contents: write issues: write pull-requests: write jobs: convert-docs: name: Convert Markdown to Issues runs-on: ubuntu-latest if: github.actor != 'github-actions[bot]' && (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 2 ref: ${{ github.event.workflow_run.head_sha || github.sha }} - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} - name: Install dependencies run: npm install gray-matter - name: Detect changed files id: changes uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: COMMIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} with: script: | const fs = require('fs'); const path = require('path'); const commitSha = process.env.COMMIT_SHA || context.sha; // Manual file specification const manualFile = '${{ github.event.inputs.file_path }}'; if (manualFile) { if (fs.existsSync(manualFile)) { core.setOutput('files', JSON.stringify([manualFile])); return; } else { core.setFailed(`File not found: ${manualFile}`); return; } } // Get changed files from commit const { data: commit } = await github.rest.repos.getCommit({ owner: context.repo.owner, repo: context.repo.repo, ref: commitSha }); const changedFiles = (commit.files || []) .filter(f => f.filename.startsWith('docs/issues/')) .filter(f => !f.filename.startsWith('docs/issues/created/')) .filter(f => !f.filename.includes('_TEMPLATE')) .filter(f => !f.filename.includes('README')) .filter(f => f.filename.endsWith('.md')) .filter(f => f.status !== 'removed') .map(f => f.filename); console.log('Changed issue files:', changedFiles); core.setOutput('files', JSON.stringify(changedFiles)); - name: Process issue files id: process uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} with: script: | const fs = require('fs'); const path = require('path'); const matter = require('gray-matter'); const files = JSON.parse('${{ steps.changes.outputs.files }}'); const isDryRun = process.env.DRY_RUN === 'true'; const createdIssues = []; const errors = []; if (files.length === 0) { console.log('No issue files to process'); core.setOutput('created_count', 0); core.setOutput('created_issues', '[]'); core.setOutput('errors', '[]'); return; } // Label color map const labelColors = { testing: 'BFD4F2', feature: 'A2EEEF', enhancement: '84B6EB', bug: 'D73A4A', documentation: '0075CA', backend: '1D76DB', frontend: '5EBEFF', security: 'EE0701', ui: '7057FF', caddy: '1F6FEB', 'needs-triage': 'FBCA04', acl: 'C5DEF5', regression: 'D93F0B', 'manual-testing': 'BFD4F2', 'bulk-acl': '006B75', 'error-handling': 'D93F0B', 'ui-ux': '7057FF', integration: '0E8A16', performance: 'EDEDED', 'cross-browser': '5319E7', plus: 'FFD700', beta: '0052CC', alpha: '5319E7', high: 'D93F0B', medium: 'FBCA04', low: '0E8A16', critical: 'B60205', architecture: '006B75', database: '006B75', 'post-beta': '006B75' }; // Helper: Ensure label exists async function ensureLabel(name) { try { await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: name }); } catch (e) { if (e.status === 404) { await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: name, color: labelColors[name.toLowerCase()] || '666666' }); console.log(`Created label: ${name}`); } } } // Helper: Parse markdown file function parseIssueFile(filePath) { const content = fs.readFileSync(filePath, 'utf8'); const { data: frontmatter, content: body } = matter(content); // Extract title: frontmatter > first H1 > filename let title = frontmatter.title; if (!title) { const h1Match = body.match(/^#\s+(.+)$/m); title = h1Match ? h1Match[1] : path.basename(filePath, '.md').replace(/-/g, ' '); } // Build labels array const labels = [...(frontmatter.labels || [])]; if (frontmatter.priority) labels.push(frontmatter.priority); if (frontmatter.type) labels.push(frontmatter.type); return { title, body: body.trim(), labels: [...new Set(labels)], assignees: frontmatter.assignees || [], milestone: frontmatter.milestone, parent_issue: frontmatter.parent_issue, create_sub_issues: frontmatter.create_sub_issues || false }; } // Helper: Extract sub-issues from H2 sections function extractSubIssues(body, parentLabels) { const sections = []; const lines = body.split('\n'); let currentSection = null; let currentBody = []; for (const line of lines) { const h2Match = line.match(/^##\s+(?:Sub-Issue\s*#?\d*:?\s*)?(.+)$/); if (h2Match) { if (currentSection) { sections.push({ title: currentSection, body: currentBody.join('\n').trim(), labels: [...parentLabels] }); } currentSection = h2Match[1].trim(); currentBody = []; } else if (currentSection) { currentBody.push(line); } } if (currentSection) { sections.push({ title: currentSection, body: currentBody.join('\n').trim(), labels: [...parentLabels] }); } return sections; } // Process each file for (const filePath of files) { console.log(`\nProcessing: ${filePath}`); try { const parsed = parseIssueFile(filePath); console.log(` Title: ${parsed.title}`); console.log(` Labels: ${parsed.labels.join(', ')}`); if (isDryRun) { console.log(' [DRY RUN] Would create issue'); createdIssues.push({ file: filePath, title: parsed.title, dryRun: true }); continue; } // Ensure labels exist for (const label of parsed.labels) { await ensureLabel(label); } // Create the main issue const issueBody = parsed.body + `\n\n---\n*Auto-created from [${path.basename(filePath)}](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${context.sha}/${filePath})*`; const issueResponse = await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: parsed.title, body: issueBody, labels: parsed.labels, assignees: parsed.assignees }); const issueNumber = issueResponse.data.number; console.log(` Created issue #${issueNumber}`); // Handle sub-issues if (parsed.create_sub_issues) { const subIssues = extractSubIssues(parsed.body, parsed.labels); for (const sub of subIssues) { for (const label of sub.labels) { await ensureLabel(label); } const subResponse = await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: `[${parsed.title}] ${sub.title}`, body: sub.body + `\n\n---\n*Sub-issue of #${issueNumber}*`, labels: sub.labels, assignees: parsed.assignees }); console.log(` Created sub-issue #${subResponse.data.number}: ${sub.title}`); } } // Link to parent issue if specified if (parsed.parent_issue) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: parsed.parent_issue, body: `Sub-issue created: #${issueNumber}` }); } createdIssues.push({ file: filePath, title: parsed.title, issueNumber }); } catch (error) { console.error(` Error processing ${filePath}: ${error.message}`); errors.push({ file: filePath, error: error.message }); } } core.setOutput('created_count', createdIssues.length); core.setOutput('created_issues', JSON.stringify(createdIssues)); core.setOutput('errors', JSON.stringify(errors)); if (errors.length > 0) { core.warning(`${errors.length} file(s) had errors`); } - name: Move processed files if: steps.process.outputs.created_count != '0' && github.event.inputs.dry_run != 'true' run: | mkdir -p docs/issues/created CREATED_ISSUES='${{ steps.process.outputs.created_issues }}' echo "$CREATED_ISSUES" | jq -r '.[].file' | while IFS= read -r file; do if [ -f "$file" ] && [ -n "$file" ]; then filename=$(basename "$file") timestamp=$(date +%Y%m%d) mv "$file" "docs/issues/created/${timestamp}-${filename}" echo "Moved: $file -> docs/issues/created/${timestamp}-${filename}" fi done - name: Commit moved files if: steps.process.outputs.created_count != '0' && github.event.inputs.dry_run != 'true' run: | git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" git add docs/issues/ # Removed [skip ci] to allow CI checks to run on PRs # Infinite loop protection: path filter excludes docs/issues/created/** AND github.actor guard prevents bot loops git diff --staged --quiet || git commit -m "chore: move processed issue files to created/" git push - name: Summary if: always() run: | CREATED='${{ steps.process.outputs.created_issues }}' ERRORS='${{ steps.process.outputs.errors }}' DRY_RUN='${{ github.event.inputs.dry_run }}' { echo "## Docs to Issues Summary" echo "" if [ "$DRY_RUN" = "true" ]; then echo "🔍 **Dry Run Mode** - No issues were actually created" echo "" fi echo "### Created Issues" if [ -n "$CREATED" ] && [ "$CREATED" != "[]" ] && [ "$CREATED" != "null" ]; then echo "$CREATED" | jq -r '.[] | "- \(.title) (#\(.issueNumber // "dry-run"))"' || echo "_Parse error_" else echo "_No issues created_" fi echo "" echo "### Errors" if [ -n "$ERRORS" ] && [ "$ERRORS" != "[]" ] && [ "$ERRORS" != "null" ]; then echo "$ERRORS" | jq -r '.[] | "- ❌ \(.file): \(.error)"' || echo "_Parse error_" else echo "_No errors_" fi } >> "$GITHUB_STEP_SUMMARY"