380 lines
14 KiB
YAML
380 lines
14 KiB
YAML
name: Convert Docs to Issues
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
- development
|
|
- feature/**
|
|
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
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ 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]'
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
|
with:
|
|
fetch-depth: 2
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # 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@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/
|
|
# 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: |
|
|
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
|