name: Convert Docs to Issues on: push: branches: - main - development paths: - 'docs/issues/**/*.md' - '!docs/issues/created/**' - '!docs/issues/_TEMPLATE.md' - '!docs/issues/README.md' # 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 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]' steps: - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: fetch-depth: 2 - name: Set up Node.js uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: node-version: '24.12.0' - name: Install dependencies run: npm install gray-matter - name: Detect changed files id: changes uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const fs = require('fs'); const path = require('path'); // 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: context.sha }); 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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 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 read file; do if [ -f "$file" ] && [ ! -z "$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/ git diff --staged --quiet || git commit -m "chore: move processed issue files to created/ [skip ci]" git push - name: Summary if: always() run: | echo "## Docs to Issues Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY CREATED='${{ steps.process.outputs.created_issues }}' ERRORS='${{ steps.process.outputs.errors }}' DRY_RUN='${{ github.event.inputs.dry_run }}' if [ "$DRY_RUN" = "true" ]; then echo "🔍 **Dry Run Mode** - No issues were actually created" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY fi echo "### Created Issues" >> $GITHUB_STEP_SUMMARY if [ -n "$CREATED" ] && [ "$CREATED" != "[]" ] && [ "$CREATED" != "null" ]; then echo "$CREATED" | jq -r '.[] | "- \(.title) (#\(.issueNumber // "dry-run"))"' >> $GITHUB_STEP_SUMMARY || echo "_Parse error_" >> $GITHUB_STEP_SUMMARY else echo "_No issues created_" >> $GITHUB_STEP_SUMMARY fi echo "" >> $GITHUB_STEP_SUMMARY echo "### Errors" >> $GITHUB_STEP_SUMMARY if [ -n "$ERRORS" ] && [ "$ERRORS" != "[]" ] && [ "$ERRORS" != "null" ]; then echo "$ERRORS" | jq -r '.[] | "- ❌ \(.file): \(.error)"' >> $GITHUB_STEP_SUMMARY || echo "_Parse error_" >> $GITHUB_STEP_SUMMARY else echo "_No errors_" >> $GITHUB_STEP_SUMMARY fi