Convert Docs to Issues #703
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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@53b83947a5a98c8d113130e565377fae1a50d02f # 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" |