Skip to content
This repository was archived by the owner on Jul 30, 2026. It is now read-only.

03-Classify Repos

03-Classify Repos #58

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