This repository was archived by the owner on Jul 30, 2026. It is now read-only.
03-Classify Repos #58
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: '03-Classify Repos' | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| batch_limit: | |
| description: 'Repos per batch' | |
| type: number | |
| default: 15 | |
| workflow_run: | |
| workflows: ["02-Sync Starred Repos"] | |
| types: | |
| - completed | |
| permissions: | |
| contents: write | |
| issues: write | |
| models: read | |
| actions: write | |
| env: | |
| BATCH_LIMIT: ${{ inputs.batch_limit || 15 }} | |
| jobs: | |
| classify: | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| ref: main | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| - name: Install pnpm | |
| run: npm install -g pnpm@latest | |
| - name: Install dependencies | |
| run: pnpm install | |
| - run: | | |
| for i in 1 2 3; do | |
| git pull origin main && break || sleep 5 | |
| done | |
| - name: Setup | |
| run: mkdir -p .github-stars/data | |
| - name: Prepare repos | |
| id: prep | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const { execSync } = require('child_process'); | |
| if (!fs.existsSync('repos.yml')) { | |
| core.setOutput('has_repos', 'false'); | |
| return; | |
| } | |
| const yaml = fs.readFileSync('repos.yml', 'utf8'); | |
| // yq (mikefarah) pre-installed on ubuntu-latest since 2023 | |
| const json = execSync('yq eval -o=json -', { input: yaml, encoding: 'utf8', maxBuffer: 50*1024*1024 }); | |
| const data = JSON.parse(json); | |
| const unclassified = data.repositories.filter(r => | |
| r.categories.includes('unclassified') && | |
| (r.needs_review || !r.ai_classification?.classified_at) | |
| ); | |
| const limit = parseInt(process.env.BATCH_LIMIT) || 15; | |
| const batch = unclassified.sort((a,b) => new Date(a.user_starred_at) - new Date(b.user_starred_at)).slice(0, Math.min(limit, 100)); | |
| console.log(`Processing ${batch.length} of ${unclassified.length} unclassified repos`); | |
| if (batch.length === 0) { | |
| core.setOutput('has_repos', 'false'); | |
| return; | |
| } | |
| // SECURITY: Sanitize inputs to prevent prompt injection | |
| const sanitizeText = (text) => { | |
| if (!text || typeof text !== 'string') return ''; | |
| // Remove control characters, excessive whitespace, and potential injection patterns | |
| return text | |
| .replace(/[\x00-\x1F\x7F]/g, '') // Remove control chars | |
| .replace(/\s+/g, ' ') // Normalize whitespace | |
| .replace(/(['"`])\1+/g, '$1') // Remove only consecutive duplicate quotes | |
| .trim() | |
| .slice(0, 500); // Limit length | |
| }; | |
| const repoNamePattern = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; | |
| const repoData = batch.map(r => { | |
| const sanitizedRepo = String(r.repo).replace(/[^a-zA-Z0-9/_.-]/g, ''); | |
| if (!repoNamePattern.test(sanitizedRepo)) { | |
| console.log(`Warning: Skipping invalid repo name format: ${sanitizedRepo}`); | |
| return null; | |
| } | |
| return { | |
| repo: sanitizedRepo, | |
| summary: sanitizeText(r.summary), | |
| language: r.github_metadata?.language ? sanitizeText(r.github_metadata.language) : null, | |
| topics: (r.github_metadata?.topics || []) | |
| .filter(t => typeof t === 'string') | |
| .map(t => sanitizeText(t)) | |
| .filter(t => t.length > 0) | |
| .slice(0, 10), | |
| stargazers_count: Math.max(0, parseInt(r.github_metadata?.stargazers_count, 10) || 0) | |
| }; | |
| }).filter(Boolean); | |
| // SECURITY: Load taxonomy dynamically to ensure consistency | |
| const allowedCategories = data.taxonomy?.categories_allowed || []; | |
| const allowedFrameworks = data.taxonomy?.frameworks_allowed || []; | |
| // Fail early if taxonomy is missing or empty | |
| if (allowedCategories.length === 0) { | |
| core.setFailed('Taxonomy categories_allowed is empty or missing - cannot classify'); | |
| return; | |
| } | |
| // Generate language tags dynamically from batch, with fallback | |
| const languageAliasMap = { | |
| javascript: 'js', typescript: 'ts', 'c++': 'cpp', 'c#': 'csharp' | |
| }; | |
| let languageTags = [...new Set( | |
| repoData.map(r => r.language).filter(Boolean) | |
| .map(l => l.toLowerCase()) | |
| .map(l => languageAliasMap[l] || l) | |
| )]; | |
| if (languageTags.length === 0) { | |
| languageTags = ['js', 'ts', 'python', 'rust', 'go', 'java', 'cpp', 'csharp']; | |
| } | |
| const systemPrompt = `Classify GitHub repos. Output ONLY a JSON array. | |
| CATEGORIES (ONLY use from this list): ${allowedCategories.join(', ')} | |
| FRAMEWORKS (ONLY use from this list OR null): ${allowedFrameworks.join(', ')} | |
| LANGUAGE TAGS: ${languageTags.map(l => `lang:${l}`).join(', ')} | |
| RULES: | |
| - 2-4 categories per repo | |
| - 3-6 descriptive tags (include lang:X if language provided) | |
| - framework must be from list above OR null | |
| - Output ONLY valid JSON array, no markdown | |
| - First char must be [, last must be ] | |
| EXAMPLE: [{"repo":"microsoft/vscode","categories":["dev-tools"],"tags":["code-editor","lang:ts"],"framework":null}]`; | |
| fs.writeFileSync('.github-stars/data/system-prompt.txt', systemPrompt); | |
| fs.writeFileSync('.github-stars/data/user-prompt.txt', | |
| 'Classify these repositories:\n\n' + JSON.stringify(repoData, null, 2)); | |
| core.setOutput('has_repos', 'true'); | |
| core.setOutput('repo_count', batch.length); | |
| - name: AI classify | |
| id: ai | |
| if: steps.prep.outputs.has_repos == 'true' | |
| uses: actions/ai-inference@v2 | |
| with: | |
| model: openai/gpt-4o | |
| max-tokens: 3000 | |
| system-prompt-file: .github-stars/data/system-prompt.txt | |
| prompt-file: .github-stars/data/user-prompt.txt | |
| - name: Apply | |
| id: apply | |
| if: steps.prep.outputs.has_repos == 'true' | |
| uses: actions/github-script@v8 | |
| env: | |
| AI_RESPONSE_FILE: ${{ steps.ai.outputs.response-file }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const { execSync } = require('child_process'); | |
| // Read from file to avoid template literal escaping issues | |
| const responseFile = process.env.AI_RESPONSE_FILE; | |
| let result = fs.readFileSync(responseFile, 'utf8').trim() | |
| .replace(/^```json\s*/i, '').replace(/\s*```$/i, ''); | |
| // Try to fix common JSON issues | |
| // Remove trailing commas before ] or } | |
| result = result.replace(/,(\s*[\]}])/g, '$1'); | |
| // Fix unescaped newlines in strings | |
| result = result.replace(/:\s*"([^"]*)\n([^"]*)"/g, (m, a, b) => `: "${a}\\n${b}"`); | |
| let classifications; | |
| try { | |
| // Basic cleaning of common AI artifacts | |
| let cleanedResult = result; | |
| if (cleanedResult.includes('```')) { | |
| const jsonMatch = cleanedResult.match(/```(?:json)?\s*([\s\S]*?)\s*```/); | |
| if (jsonMatch) cleanedResult = jsonMatch[1]; | |
| } | |
| // Handle potential truncated JSON | |
| if (cleanedResult.endsWith('...')) { | |
| cleanedResult = cleanedResult.substring(0, cleanedResult.lastIndexOf('}') + 1); | |
| if (!cleanedResult.endsWith(']')) cleanedResult += ']'; | |
| } | |
| classifications = JSON.parse(cleanedResult); | |
| } catch (e) { | |
| console.log('JSON parse failed, attempting fallback extraction:', e.message); | |
| // Try to extract valid JSON array if wrapped in other content | |
| const match = result.match(/\[[\s\S]*\]/); | |
| if (match) { | |
| try { | |
| classifications = JSON.parse(match[0]); | |
| } catch (e2) { | |
| console.log('Fallback extraction failed, will retry workflow'); | |
| core.setOutput('retry', 'true'); | |
| core.setOutput('count', 0); | |
| return; | |
| } | |
| } else { | |
| console.log('No JSON array found, will retry workflow'); | |
| core.setOutput('retry', 'true'); | |
| core.setOutput('count', 0); | |
| return; | |
| } | |
| } | |
| if (!Array.isArray(classifications)) { | |
| console.log('Not an array, will retry workflow'); | |
| core.setOutput('retry', 'true'); | |
| core.setOutput('count', 0); | |
| return; | |
| } | |
| const yaml = fs.readFileSync('repos.yml', 'utf8'); | |
| const json = execSync('yq eval -o=json -', { input: yaml, encoding: 'utf8', maxBuffer: 50*1024*1024 }); | |
| const data = JSON.parse(json); | |
| // SECURITY: Extract taxonomy and create canonical sets for validation | |
| const allowedCategories = new Set((data.taxonomy?.categories_allowed || []).map(c => c.trim().toLowerCase())); | |
| const allowedFrameworks = new Set((data.taxonomy?.frameworks_allowed || []).map(f => f.trim().toLowerCase())); | |
| console.log(`Loaded taxonomy: ${allowedCategories.size} categories, ${allowedFrameworks.size} frameworks`); | |
| let count = 0; | |
| const tagPattern = /^([a-z]+:)?[a-z0-9][a-z0-9-]*$/; | |
| for (const c of classifications) { | |
| const idx = data.repositories.findIndex(r => r.repo === c.repo); | |
| if (idx !== -1) { | |
| // SECURITY: Validate categories against taxonomy with canonicalization | |
| const safeCategories = (c.categories || []) | |
| .filter(cat => typeof cat === 'string' && /^[a-z][a-z0-9-]*$/.test(cat)) | |
| .map(cat => cat.trim().toLowerCase()) | |
| .filter(cat => allowedCategories.has(cat)) | |
| .slice(0, 5); | |
| if (safeCategories.length === 0 && (c.categories || []).length > 0) { | |
| console.log(`Warning: All categories rejected for ${c.repo}, invalid: ${(c.categories || []).join(', ')}`); | |
| } | |
| // Sanitize tags: lowercase, replace space with dash, filter by pattern | |
| const safeTags = (c.tags || []) | |
| .map(t => String(t).toLowerCase().replace(/[^a-z0-9-:]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')) | |
| .filter(t => tagPattern.test(t)) | |
| .slice(0, 20); | |
| // SECURITY: Validate framework against taxonomy with canonicalization | |
| let validFramework = null; | |
| if (c.framework && typeof c.framework === 'string') { | |
| const canonicalFramework = c.framework.trim().toLowerCase(); | |
| validFramework = allowedFrameworks.has(canonicalFramework) ? canonicalFramework : null; | |
| if (!validFramework && c.framework) { | |
| console.log(`Warning: Framework "${c.framework}" not in taxonomy for ${c.repo}`); | |
| } | |
| } | |
| const allCategoriesRejected = safeCategories.length === 0 && (c.categories || []).length > 0; | |
| data.repositories[idx].categories = safeCategories.length > 0 ? safeCategories : ["unclassified"]; | |
| data.repositories[idx].tags = safeTags; | |
| data.repositories[idx].framework = validFramework; | |
| // Keep needs_review=true if all AI categories were rejected, so it can be retried | |
| data.repositories[idx].needs_review = allCategoriesRejected; | |
| data.repositories[idx].ai_classification = { | |
| model: "gpt-4o", | |
| classified_at: new Date().toISOString(), | |
| prompt_version: "v3" | |
| }; | |
| count++; | |
| } | |
| } | |
| data.manifest_metadata.manifest_updated_at = new Date().toISOString(); | |
| fs.writeFileSync('.github-stars/data/manifest.json', JSON.stringify(data, null, 2)); | |
| core.setOutput('count', count); | |
| - name: Convert to YAML | |
| if: steps.apply.outputs.count > 0 | |
| run: yq eval '.' .github-stars/data/manifest.json -o=yaml > repos.yml | |
| - name: Cleanup | |
| if: always() | |
| run: rm -f .github-stars/data/*.json .github-stars/data/*.txt | |
| - name: Validate | |
| if: steps.apply.outputs.count > 0 | |
| uses: cardinalby/schema-validator-action@v3 | |
| with: | |
| schema: schemas/repos-schema.json | |
| file: repos.yml | |
| mode: lax | |
| - name: Normalize Manifest | |
| if: steps.apply.outputs.count > 0 | |
| run: | | |
| echo "Running taxonomy normalization..." | |
| pnpm normalize | |
| echo "Normalization complete" | |
| - name: Post-Classification Verification | |
| if: steps.apply.outputs.count > 0 | |
| run: | | |
| echo "Running strict taxonomy validation..." | |
| pnpm validate | |
| echo "Validation passed" | |
| - name: Commit | |
| if: steps.apply.outputs.count > 0 && success() | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add repos.yml | |
| git commit -m "AI classify ${{ steps.apply.outputs.count }} repos [skip ci]" | |
| for i in 1 2 3 4 5; do | |
| git pull --rebase --autostash origin main | |
| git push && break || sleep 10 | |
| done | |
| - name: Check remaining | |
| id: remaining | |
| if: steps.apply.outputs.count > 0 | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const { execSync } = require('child_process'); | |
| const yaml = fs.readFileSync('repos.yml', 'utf8'); | |
| const json = execSync('yq eval -o=json -', { input: yaml, encoding: 'utf8', maxBuffer: 50*1024*1024 }); | |
| const data = JSON.parse(json); | |
| const remaining = data.repositories.filter(r => | |
| r.categories.includes('unclassified') && (r.needs_review || !r.ai_classification?.classified_at) | |
| ).length; | |
| core.setOutput('count', remaining); | |
| core.setOutput('has_more', remaining > 0); | |
| - name: Trigger next | |
| if: steps.remaining.outputs.has_more == 'true' || steps.apply.outputs.retry == 'true' | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| await github.rest.actions.createWorkflowDispatch({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| workflow_id: '03-classify-repos.yml', | |
| ref: 'main', | |
| inputs: { batch_limit: String(process.env.BATCH_LIMIT) } | |
| }); | |
| - run: | | |
| echo "## Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "Classified: ${{ steps.apply.outputs.count || 0 }}" >> $GITHUB_STEP_SUMMARY | |
| echo "Remaining: ${{ steps.remaining.outputs.count || 'N/A' }}" >> $GITHUB_STEP_SUMMARY |