diff --git a/.gbbcatalog.yml b/.gbbcatalog.yml new file mode 100644 index 0000000..496792f --- /dev/null +++ b/.gbbcatalog.yml @@ -0,0 +1,9 @@ +schema_version: 1 + +catalog: + enabled: true + owner: 0GiS0 + display_name: "IP Atlas" + description: "Repository catalog and content hub for the DevExpGbb team. Automatically aggregates and displays all repositories from the DevExpGbb organization with lifecycle management." + maturity: production + review_cycle_days: 180 diff --git a/.gbbcatalog.yml.example b/.gbbcatalog.yml.example new file mode 100644 index 0000000..fd397ac --- /dev/null +++ b/.gbbcatalog.yml.example @@ -0,0 +1,10 @@ +schema_version: 1 + +catalog: + enabled: true + owner: your-github-username + display_name: "Your Project Name" + description: "Brief description of your project. Keep it to 1-3 sentences explaining what it does and who it's for." + maturity: incubating # Options: incubating | production | deprecated + review_cycle_days: 180 # Optional: days between required reviews (default: 180) + # last_reviewed: 2026-02-11 # Optional: Format YYYY-MM-DD (defaults to repo's last push date) diff --git a/.github/workflows/catalog-sync.yml b/.github/workflows/catalog-sync.yml new file mode 100644 index 0000000..93fca67 --- /dev/null +++ b/.github/workflows/catalog-sync.yml @@ -0,0 +1,227 @@ +name: 📋 Catalog Lifecycle Sync + +on: + schedule: + # Run weekly on Monday at 9:00 AM UTC + - cron: "0 9 * * 1" + workflow_dispatch: + push: + paths: + - 'scripts/validate-catalog.cjs' + - 'scripts/fetch-catalog-metadata.cjs' + - '.github/workflows/catalog-sync.yml' + +permissions: + contents: write + issues: write + +jobs: + sync-catalog: + name: 🔄 Sync Catalog Metadata + runs-on: ubuntu-latest + steps: + - name: đŸ“Ĩ Checkout + uses: actions/checkout@v4 + + - name: 🔧 Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: đŸ“Ļ Install dependencies + run: | + npm install js-yaml --no-save + + - name: 🔍 Fetch catalog metadata from repos + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node scripts/fetch-catalog-metadata.cjs + + - name: 📝 Commit and push if changed + id: commit + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if ! git diff --quiet src/data/catalog-metadata.json; then + git add src/data/catalog-metadata.json + git commit -m "📋 Update catalog metadata [skip ci]" + git pull --rebase origin main + git push + echo "changed=true" >> $GITHUB_OUTPUT + echo "✓ Catalog metadata updated" + else + echo "changed=false" >> $GITHUB_OUTPUT + echo "📭 No changes to catalog metadata" + fi + + check-stale-reviews: + name: 🔍 Check for Stale Reviews + runs-on: ubuntu-latest + needs: sync-catalog + steps: + - name: đŸ“Ĩ Checkout + uses: actions/checkout@v4 + with: + ref: main + + - name: 🔧 Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: đŸ“Ļ Install dependencies + run: | + npm install js-yaml --no-save + + - name: 🔍 Check for repositories needing review + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + + # Pull latest changes + git pull origin main + + # Check if catalog-metadata.json exists + if [ ! -f "src/data/catalog-metadata.json" ]; then + echo "No catalog metadata found" + exit 0 + fi + + echo "📋 Checking for repositories needing review..." + + # Read catalog metadata and check for stale reviews + node -e " + const fs = require('fs'); + const validator = require('./scripts/validate-catalog.cjs'); + const catalogData = JSON.parse(fs.readFileSync('src/data/catalog-metadata.json', 'utf8')); + + const needsReviewRepos = []; + + for (const [repoName, metadata] of Object.entries(catalogData)) { + if (metadata.state === 'needs-review') { + needsReviewRepos.push({ + repo: repoName, + owner: metadata.owner, + lastReviewed: metadata.last_reviewed, + displayName: metadata.display_name + }); + } + } + + if (needsReviewRepos.length === 0) { + console.log('✓ No repositories need review'); + process.exit(0); + } + + console.log(\`âš ī¸ Found \${needsReviewRepos.length} repositories needing review:\`); + needsReviewRepos.forEach(r => { + console.log(\` - \${r.repo} (owner: @\${r.owner}, last reviewed: \${r.lastReviewed})\`); + }); + + // Save to file for next step + fs.writeFileSync('needs-review.json', JSON.stringify(needsReviewRepos, null, 2)); + " + + - name: đŸ“Ŧ Create issues for stale reviews + if: hashFiles('needs-review.json') != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ ! -f "needs-review.json" ]; then + echo "No repositories need review" + exit 0 + fi + + # Read the repos needing review + repos=$(cat needs-review.json) + count=$(echo "$repos" | jq length) + + if [ "$count" -eq 0 ]; then + echo "No repositories need review" + exit 0 + fi + + echo "Creating issues for $count repositories..." + + # Process each repo + echo "$repos" | jq -c '.[]' | while read -r repo; do + repo_name=$(echo "$repo" | jq -r '.repo') + owner=$(echo "$repo" | jq -r '.owner') + last_reviewed=$(echo "$repo" | jq -r '.lastReviewed') + display_name=$(echo "$repo" | jq -r '.displayName') + + echo "Processing: $repo_name" + + # Check if an open issue already exists + existing_issue=$(gh issue list \ + --repo "DevExpGbb/$repo_name" \ + --state open \ + --label "catalog-review-needed" \ + --json number \ + --jq 'length' 2>/dev/null || echo "0") + + if [ "$existing_issue" -gt 0 ]; then + echo " â„šī¸ Issue already exists for $repo_name" + continue + fi + + # Create issue + issue_title="📋 Catalog Review Needed for $display_name" + issue_body="## Catalog Review Required + +Hello @$owner 👋 + +The catalog metadata for **$display_name** needs to be reviewed and updated. + +**Last reviewed:** $last_reviewed + +### Action Required + +Please review and update the \`.gbbcatalog.yml\` file in the repository root: + +1. Verify the information is still accurate: + - \`display_name\`: Current project name + - \`description\`: Current project description + - \`maturity\`: Current maturity level (incubating, production, or deprecated) + - \`owner\`: Current owner's GitHub username + +2. Update the \`last_reviewed\` field to today's date (YYYY-MM-DD format) + +3. Commit and push the changes + +### Example Update + +\`\`\`yaml +schema_version: 1 + +catalog: + enabled: true + owner: $owner + display_name: \"$display_name\" + description: \"Your updated description\" + maturity: production + last_reviewed: $(date +%Y-%m-%d) + review_cycle_days: 180 +\`\`\` + +Once updated, this issue will be automatically closed. + +--- + +📚 [View Catalog Lifecycle Documentation](https://github.com/DevExpGbb/devexpgbb.github.io#catalog-lifecycle) +" + + gh issue create \ + --repo "DevExpGbb/$repo_name" \ + --title "$issue_title" \ + --body "$issue_body" \ + --label "catalog-review-needed" \ + --assignee "$owner" || echo " âš ī¸ Failed to create issue for $repo_name" + + echo " ✓ Created issue for $repo_name" + done + + echo "✓ Finished creating review issues" diff --git a/.github/workflows/refresh-data.yml b/.github/workflows/refresh-data.yml index 190f4d2..bec3aa0 100644 --- a/.github/workflows/refresh-data.yml +++ b/.github/workflows/refresh-data.yml @@ -22,6 +22,15 @@ jobs: with: python-version: '3.12' + - name: 🔧 Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: đŸ“Ļ Install Node dependencies + run: | + npm install js-yaml --no-save + - name: 🔍 Fetch repositories from DevExpGbb env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -101,6 +110,13 @@ jobs: --config "src/data/blogs.yaml" \ --out "src/data/blog-posts.json" + - name: 📋 Fetch catalog metadata + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "📋 Fetching catalog metadata from repositories..." + node scripts/fetch-catalog-metadata.cjs + - name: 📝 Commit and push if changed id: commit run: | @@ -115,13 +131,16 @@ jobs: if ! git diff --quiet src/data/blog-posts.json; then changed=true fi + if ! git diff --quiet src/data/catalog-metadata.json 2>/dev/null; then + changed=true + fi if [ "$changed" = false ]; then echo "📭 No changes detected" echo "changes=false" >> $GITHUB_OUTPUT else - git add src/data/repositories.json src/data/blog-posts.json || true - git commit -m "🔄 Refresh data (repos + blogs) [skip ci]" + git add src/data/repositories.json src/data/blog-posts.json src/data/catalog-metadata.json || true + git commit -m "🔄 Refresh data (repos + blogs + catalog) [skip ci]" git pull --rebase origin main git push echo "đŸ“Ŧ Changes committed and pushed" diff --git a/CATALOG_SPEC.md b/CATALOG_SPEC.md new file mode 100644 index 0000000..b0a4942 --- /dev/null +++ b/CATALOG_SPEC.md @@ -0,0 +1,268 @@ +# GBB Catalog - .gbbcatalog.yml Specification + +This document describes the `.gbbcatalog.yml` specification for explicit opt-in catalog publication. + +## Purpose + +The `.gbbcatalog.yml` file allows repositories to explicitly opt-in to the IP Atlas catalog with proper metadata, lifecycle management, and ownership tracking. + +## Location + +Place `.gbbcatalog.yml` in the root directory of your repository. + +## Schema v1 + +```yaml +schema_version: 1 + +catalog: + enabled: true # Required: true = include in catalog, false = exclude + owner: username # Required: GitHub username of the owner + display_name: "Project Name" # Required: Human-friendly title + description: "Short summary" # Required: 1-3 sentences describing the project + maturity: incubating # Required: incubating | production | deprecated + last_reviewed: 2026-02-10 # Optional: YYYY-MM-DD format (defaults to repo's last push date) + review_cycle_days: 180 # Optional: defaults to 180 days +``` + +### Required Fields + +| Field | Type | Description | +|-------|------|-------------| +| `schema_version` | number | Must be `1` for this version | +| `catalog.enabled` | boolean | `true` to publish, `false` to exclude | +| `catalog.owner` | string | GitHub username (without @) | +| `catalog.display_name` | string | Human-friendly project name | +| `catalog.description` | string | Brief description (1-3 sentences) | +| `catalog.maturity` | string | One of: `incubating`, `production`, `deprecated` | + +### Optional Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `catalog.last_reviewed` | string | repo's last push date | Date in YYYY-MM-DD format. If not provided, uses the repository's last push date | +| `catalog.review_cycle_days` | number | 180 | Days between required reviews | + +## Lifecycle States + +Based on the metadata and repository activity, repositories are categorized into these states: + +| State | Condition | +|-------|-----------| +| **Not in Catalog** | No `.gbbcatalog.yml` OR `enabled: false` OR needs review (> 6 months since last update) | +| **Published** | `enabled: true` and recently updated (within review cycle) | +| **Deprecated** | `maturity: deprecated` | + +**Important**: Repositories are automatically removed from the catalog if they haven't been updated in more than 6 months (or the configured `review_cycle_days`). To re-enable a repository in the catalog after it's been removed, you must update the repository code (push new commits) or explicitly set a recent `last_reviewed` date in the `.gbbcatalog.yml` file. + +## Maturity Levels + +- **incubating**: Work in progress, early stage, experimental +- **production**: Stable, production-ready, actively maintained +- **deprecated**: No longer maintained, archived or replaced + +## Automated Workflows + +### Catalog Sync (Weekly) + +- Runs every Monday at 9:00 AM UTC +- Scans all DevExpGbb repositories for `.gbbcatalog.yml` files +- Validates metadata +- Updates the catalog index +- Can be manually triggered via workflow_dispatch + +### Stale Review Detection + +- Automatically detects repositories with no updates in the last 6 months (or configured `review_cycle_days`) +- Automatically removes stale repositories from the catalog (sets `enabled: false`) +- Opens GitHub issues tagged with `catalog-review-needed` +- Assigns issue to the repository owner +- To re-enable, push new commits or update `last_reviewed` date in `.gbbcatalog.yml` + +## Examples + +### Incubating Project + +```yaml +schema_version: 1 + +catalog: + enabled: true + owner: johndoe + display_name: "Azure DevOps Migration Tool" + description: "Automated tool to migrate repositories from Azure DevOps to GitHub. Supports code, work items, and pipelines." + maturity: incubating + review_cycle_days: 90 +``` + +### Production Project + +```yaml +schema_version: 1 + +catalog: + enabled: true + owner: janedoe + display_name: "GitHub Copilot Workshop" + description: "Comprehensive hands-on workshop for GitHub Copilot. Includes exercises for multiple programming languages and real-world scenarios." + maturity: production + review_cycle_days: 180 +``` + +### Production Project with Explicit Review Date + +```yaml +schema_version: 1 + +catalog: + enabled: true + owner: janedoe + display_name: "GitHub Copilot Workshop" + description: "Comprehensive hands-on workshop for GitHub Copilot. Includes exercises for multiple programming languages and real-world scenarios." + maturity: production + last_reviewed: 2026-02-11 + review_cycle_days: 180 +``` + +### Deprecated Project + +```yaml +schema_version: 1 + +catalog: + enabled: true + owner: johndoe + display_name: "Legacy Authentication Service" + description: "Legacy authentication service. Replaced by the new unified auth system. Kept for reference only." + maturity: deprecated + review_cycle_days: 365 +``` + +### Excluding from Catalog + +```yaml +schema_version: 1 + +catalog: + enabled: false + owner: johndoe + display_name: "Experimental Feature" + description: "Experimental feature in development" + maturity: incubating +``` + +## Validation + +Validate your `.gbbcatalog.yml` file locally: + +```bash +# Install dependencies +npm install js-yaml + +# Validate the file +node scripts/validate-catalog.js .gbbcatalog.yml +``` + +## Workflow for GBB Users + +### 1. Create a New IP + +1. Create your repository with appropriate visibility (public/private/internal) +2. Develop and test your IP +3. When ready to publish to the catalog, add `.gbbcatalog.yml` + +### 2. Publish to Catalog + +Add `.gbbcatalog.yml` to the repository root: + +```yaml +schema_version: 1 + +catalog: + enabled: true + owner: your-github-username + display_name: "Your Project Name" + description: "Your project description" + maturity: incubating + review_cycle_days: 180 +``` + +Commit and push: + +```bash +git add .gbbcatalog.yml +git commit -m "Add catalog metadata" +git push +``` + +**Note**: The `last_reviewed` date is optional. If not provided, the system will automatically use your repository's last push date. + +### 3. Review Updates + +When you receive a "Catalog Review Needed" issue: + +**Option 1: Push new code** (Recommended) +- Make updates to your repository +- Push new commits +- The system will automatically use the new push date + +**Option 2: Update metadata manually** +1. Update `.gbbcatalog.yml`: + ```yaml + last_reviewed: 2026-02-11 # Update to today's date + ``` +2. Update any other fields if needed +3. Commit and push changes + +### 4. Deprecate a Project + +When deprecating a project: + +1. Update `.gbbcatalog.yml`: + ```yaml + maturity: deprecated + ``` +2. Optionally archive the repository via GitHub settings +3. The catalog will show it as deprecated + +### 5. Remove from Catalog + +To remove from catalog without deleting the repository: + +1. Set `enabled: false` in `.gbbcatalog.yml` +2. OR delete `.gbbcatalog.yml` file +3. Commit and push + +## Benefits + +✅ **Foolproof**: No accidental catalog inclusion +✅ **Explicit Ownership**: Clear owner per IP +✅ **Separation of Concerns**: Experimentation vs. catalog publishing +✅ **Automated Maintenance**: Validation, review, and sync +✅ **Stale Detection**: Prevents IP decay +✅ **Cross-org Reusable**: Pattern can be adopted elsewhere +✅ **Scalable Governance**: Minimal friction, maximum control + +## FAQ + +**Q: Does repository visibility affect catalog inclusion?** +A: No. Visibility (public/private/internal) is independent of catalog inclusion. Only `.gbbcatalog.yml` with `enabled: true` determines catalog inclusion. + +**Q: Can I test my IP publicly without adding it to the catalog?** +A: Yes. Simply don't add `.gbbcatalog.yml` or set `enabled: false`. Your repo can be public without being in the catalog. + +**Q: What happens if my `.gbbcatalog.yml` is invalid?** +A: The validation workflow will fail and notify you. Your repo will not appear in the catalog until the file is fixed. + +**Q: How often do I need to review my catalog metadata?** +A: By default, every 180 days (6 months). You can customize this with `review_cycle_days`. The review date is automatically tracked based on your repository's last push date. If your repository isn't updated within the review cycle, it will be automatically removed from the catalog. + +**Q: Can I change the owner of an IP?** +A: Yes. Update the `owner` field in `.gbbcatalog.yml` and commit the change. + +**Q: What happens if my repository hasn't been updated in 6 months?** +A: The automated workflow will remove it from the catalog (set `enabled: false`) and create a GitHub issue assigned to you as a reminder. To re-enable, either push new commits or manually update the `last_reviewed` date in `.gbbcatalog.yml`. + +## Support + +For questions or issues, open an issue in the [devexpgbb.github.io](https://github.com/DevExpGbb/devexpgbb.github.io) repository. diff --git a/package.json b/package.json index 6320c4b..ce6e06a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@astrojs/check": "^0.9.6", "@tailwindcss/vite": "^4.1.18", "astro": "^5.17.1", + "js-yaml": "^4.1.1", "tailwindcss": "^4.1.18", "typescript": "^5.9.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7024eb..a4a67c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: astro: specifier: ^5.17.1 version: 5.17.1(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.57.1)(typescript@5.9.3)(yaml@2.8.2) + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 tailwindcss: specifier: ^4.1.18 version: 4.1.18 diff --git a/scripts/fetch-catalog-metadata.cjs b/scripts/fetch-catalog-metadata.cjs new file mode 100755 index 0000000..50e9ba8 --- /dev/null +++ b/scripts/fetch-catalog-metadata.cjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node + +/** + * Fetches .gbbcatalog.yml files from DevExpGbb organization repositories + * + * Usage: + * GH_TOKEN= node scripts/fetch-catalog-metadata.js + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const ORG_NAME = 'DevExpGbb'; + +/** + * Executes a shell command and returns stdout + */ +function exec(command) { + try { + return execSync(command, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (error) { + return null; + } +} + +/** + * Fetches repository metadata (updatedAt) + */ +function fetchRepoMetadata(repoName) { + const command = `gh api "repos/${ORG_NAME}/${repoName}" --jq '{updatedAt: .updated_at, pushedAt: .pushed_at}' 2>/dev/null`; + const output = exec(command); + + if (!output) { + return null; + } + + try { + return JSON.parse(output.trim()); + } catch (err) { + console.error(`Error parsing repo metadata for ${repoName}:`, err.message); + return null; + } +} + +/** + * Fetches .gbbcatalog.yml content from a repository + */ +function fetchCatalogFile(repoName) { + const command = `gh api "repos/${ORG_NAME}/${repoName}/contents/.gbbcatalog.yml" --jq '.content' 2>/dev/null`; + const base64Content = exec(command); + + if (!base64Content) { + return null; + } + + try { + const content = Buffer.from(base64Content.trim(), 'base64').toString('utf8'); + return content; + } catch (err) { + console.error(`Error decoding .gbbcatalog.yml for ${repoName}:`, err.message); + return null; + } +} + +/** + * Parses YAML content + */ +function parseYaml(content) { + try { + const yaml = require('js-yaml'); + return yaml.load(content); + } catch (err) { + return null; + } +} + +/** + * Main function + */ +function main() { + if (!process.env.GH_TOKEN && !process.env.GITHUB_TOKEN) { + console.error('Error: GH_TOKEN or GITHUB_TOKEN environment variable is required'); + process.exit(1); + } + + console.log('📋 Fetching repositories from', ORG_NAME); + + // Get all repositories + const reposJson = exec(`gh repo list ${ORG_NAME} --json name --limit 200`); + if (!reposJson) { + console.error('Error: Failed to fetch repositories'); + process.exit(1); + } + + const repos = JSON.parse(reposJson); + console.log(`Found ${repos.length} repositories`); + + const catalogMetadata = {}; + let processedCount = 0; + let enabledCount = 0; + + // Process each repository + for (const repo of repos) { + const repoName = repo.name; + process.stdout.write(`\r Processing: ${repoName.padEnd(50)}`); + + // Only process repos that have .gbbcatalog.yml + // This ensures we don't create noise for repos that haven't opted into catalog lifecycle management + const content = fetchCatalogFile(repoName); + if (!content) { + continue; + } + + const data = parseYaml(content); + if (!data || !data.catalog) { + console.log(`\n âš ī¸ ${repoName}: Invalid .gbbcatalog.yml format`); + continue; + } + + // Fetch repo metadata to get last update date + const repoMetadata = fetchRepoMetadata(repoName); + if (!repoMetadata) { + console.log(`\n âš ī¸ ${repoName}: Failed to fetch repository metadata`); + continue; + } + + // Use last_reviewed from catalog file, or fall back to repo's pushedAt/updatedAt + let lastReviewed = data.catalog.last_reviewed; + if (!lastReviewed) { + // Use pushedAt (last commit push time) or updatedAt as fallback + const repoDate = repoMetadata.pushedAt || repoMetadata.updatedAt; + lastReviewed = repoDate.split('T')[0]; // Convert to YYYY-MM-DD + } + + // Add last_reviewed to the catalog data for validation + data.catalog.last_reviewed = lastReviewed; + + // Validate + const validator = require('./validate-catalog.cjs'); + const result = validator.validateCatalogMetadata(data); + + if (!result.valid) { + console.log(`\n ✗ ${repoName}: Validation failed`); + result.errors.forEach(error => console.log(` - ${error}`)); + continue; + } + + processedCount++; + + // Check if review is needed (> 6 months / 180 days) + const reviewCycleDays = data.catalog.review_cycle_days || validator.DEFAULT_REVIEW_CYCLE_DAYS; + const needsReview = validator.needsReview(lastReviewed, reviewCycleDays); + + // Get catalog state + const state = validator.getCatalogState(data.catalog); + + // Only include in catalog if enabled AND not needing review + // But always add to catalog-metadata.json (even if stale) so we can notify owners + const enabled = data.catalog.enabled; + + if (needsReview && enabled) { + console.log(`\n âš ī¸ ${repoName}: Needs review (last update: ${lastReviewed}), will notify owner`); + } + + // Add to catalog metadata for all repos with .gbbcatalog.yml + catalogMetadata[repoName] = { + ...data.catalog, + last_reviewed: lastReviewed, + enabled: enabled && !needsReview, // Disable if stale, but keep in metadata for issue creation + state, + schema_version: data.schema_version + }; + + // Count only truly enabled repos (not stale) + if (enabled && !needsReview) { + enabledCount++; + } + } + + process.stdout.write('\r' + ' '.repeat(70) + '\r'); + console.log(`✓ Processed ${processedCount} repositories with .gbbcatalog.yml`); + console.log(`✓ Found ${enabledCount} enabled in catalog`); + + // Write output + const outputPath = path.join(__dirname, '..', 'src', 'data', 'catalog-metadata.json'); + fs.writeFileSync(outputPath, JSON.stringify(catalogMetadata, null, 2)); + console.log(`✓ Wrote catalog metadata to ${outputPath}`); +} + +if (require.main === module) { + main(); +} + +module.exports = { fetchCatalogFile, fetchRepoMetadata, parseYaml }; diff --git a/scripts/validate-catalog.cjs b/scripts/validate-catalog.cjs new file mode 100755 index 0000000..7226a9c --- /dev/null +++ b/scripts/validate-catalog.cjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node + +/** + * Validates .gbbcatalog.yml files + * + * Usage: + * node scripts/validate-catalog.js + * node scripts/validate-catalog.js --data + */ + +const fs = require('fs'); +const path = require('path'); + +// Schema version 1 +const SCHEMA_VERSION = 1; + +// Valid maturity states +const VALID_MATURITY_STATES = ['incubating', 'production', 'deprecated']; + +// Default review cycle in days +const DEFAULT_REVIEW_CYCLE_DAYS = 180; + +/** + * Validates catalog metadata + * @param {object} data - Parsed YAML data + * @returns {object} - { valid: boolean, errors: string[] } + */ +function validateCatalogMetadata(data) { + const errors = []; + + // Check schema_version + if (!data.schema_version) { + errors.push('Missing required field: schema_version'); + } else if (typeof data.schema_version !== 'number') { + errors.push('schema_version must be a number'); + } else if (data.schema_version !== SCHEMA_VERSION) { + errors.push(`Unsupported schema_version: ${data.schema_version}. Expected: ${SCHEMA_VERSION}`); + } + + // Check catalog object + if (!data.catalog) { + errors.push('Missing required field: catalog'); + return { valid: false, errors }; + } + + const catalog = data.catalog; + + // Check enabled (required, boolean) + if (catalog.enabled === undefined || catalog.enabled === null) { + errors.push('Missing required field: catalog.enabled'); + } else if (typeof catalog.enabled !== 'boolean') { + errors.push('catalog.enabled must be a boolean (true or false)'); + } + + // Check owner (required, string) + if (!catalog.owner) { + errors.push('Missing required field: catalog.owner'); + } else if (typeof catalog.owner !== 'string') { + errors.push('catalog.owner must be a string'); + } else if (catalog.owner.trim().length === 0) { + errors.push('catalog.owner cannot be empty'); + } + + // Check display_name (required, string) + if (!catalog.display_name) { + errors.push('Missing required field: catalog.display_name'); + } else if (typeof catalog.display_name !== 'string') { + errors.push('catalog.display_name must be a string'); + } else if (catalog.display_name.trim().length === 0) { + errors.push('catalog.display_name cannot be empty'); + } + + // Check description (required, string) + if (!catalog.description) { + errors.push('Missing required field: catalog.description'); + } else if (typeof catalog.description !== 'string') { + errors.push('catalog.description must be a string'); + } else if (catalog.description.trim().length === 0) { + errors.push('catalog.description cannot be empty'); + } + + // Check maturity (required, enum) + if (!catalog.maturity) { + errors.push('Missing required field: catalog.maturity'); + } else if (!VALID_MATURITY_STATES.includes(catalog.maturity)) { + errors.push(`catalog.maturity must be one of: ${VALID_MATURITY_STATES.join(', ')}. Got: ${catalog.maturity}`); + } + + // Check last_reviewed (optional, valid date if provided) + // If not provided, will use repo updatedAt date from GitHub + if (catalog.last_reviewed !== undefined && catalog.last_reviewed !== null) { + if (typeof catalog.last_reviewed === 'string') { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(catalog.last_reviewed)) { + errors.push('catalog.last_reviewed must be in YYYY-MM-DD format'); + } else { + const date = new Date(catalog.last_reviewed); + if (isNaN(date.getTime())) { + errors.push('catalog.last_reviewed is not a valid date'); + } + } + } else if (catalog.last_reviewed instanceof Date) { + // YAML parser might parse it as Date object + if (isNaN(catalog.last_reviewed.getTime())) { + errors.push('catalog.last_reviewed is not a valid date'); + } + } else { + errors.push('catalog.last_reviewed must be a date string in YYYY-MM-DD format'); + } + } + + // Check review_cycle_days (optional, number) + if (catalog.review_cycle_days !== undefined && catalog.review_cycle_days !== null) { + if (typeof catalog.review_cycle_days !== 'number') { + errors.push('catalog.review_cycle_days must be a number'); + } else if (catalog.review_cycle_days <= 0) { + errors.push('catalog.review_cycle_days must be greater than 0'); + } + } + + return { + valid: errors.length === 0, + errors + }; +} + +/** + * Checks if catalog needs review based on last_reviewed date + * @param {string|Date} lastReviewed - Last review date + * @param {number} reviewCycleDays - Review cycle in days + * @returns {boolean} - True if needs review + */ +function needsReview(lastReviewed, reviewCycleDays = DEFAULT_REVIEW_CYCLE_DAYS) { + const reviewDate = typeof lastReviewed === 'string' ? new Date(lastReviewed) : lastReviewed; + const today = new Date(); + const daysSinceReview = Math.floor((today - reviewDate) / (1000 * 60 * 60 * 24)); + return daysSinceReview > reviewCycleDays; +} + +/** + * Gets catalog state based on metadata + * @param {object} catalog - Catalog metadata + * @returns {string} - State: 'not-in-catalog', 'published', 'needs-review', 'deprecated' + */ +function getCatalogState(catalog) { + if (!catalog || catalog.enabled === false) { + return 'not-in-catalog'; + } + + if (catalog.maturity === 'deprecated') { + return 'deprecated'; + } + + const reviewCycle = catalog.review_cycle_days || DEFAULT_REVIEW_CYCLE_DAYS; + if (needsReview(catalog.last_reviewed, reviewCycle)) { + return 'needs-review'; + } + + return 'published'; +} + +// CLI interface +if (require.main === module) { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.error('Usage: node validate-catalog.js '); + console.error(' or: node validate-catalog.js --data '); + process.exit(1); + } + + let data; + + if (args[0] === '--data') { + // Parse JSON data from command line + try { + data = JSON.parse(args[1]); + } catch (err) { + console.error('Error parsing JSON data:', err.message); + process.exit(1); + } + } else { + // Read from file + const filePath = args[0]; + + if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + process.exit(1); + } + + try { + const yaml = require('js-yaml'); + const content = fs.readFileSync(filePath, 'utf8'); + data = yaml.load(content); + } catch (err) { + console.error('Error reading/parsing file:', err.message); + process.exit(1); + } + } + + const result = validateCatalogMetadata(data); + + if (result.valid) { + const state = getCatalogState(data.catalog); + console.log('✓ Validation passed'); + console.log(` Catalog state: ${state}`); + if (state === 'needs-review') { + console.log(` âš ī¸ Repository needs review (last reviewed: ${data.catalog.last_reviewed})`); + } + process.exit(0); + } else { + console.error('✗ Validation failed:'); + result.errors.forEach(error => console.error(` - ${error}`)); + process.exit(1); + } +} + +module.exports = { + validateCatalogMetadata, + needsReview, + getCatalogState, + VALID_MATURITY_STATES, + DEFAULT_REVIEW_CYCLE_DAYS, + SCHEMA_VERSION +}; diff --git a/src/components/RepoCard.astro b/src/components/RepoCard.astro index 246d170..bbfe4cc 100644 --- a/src/components/RepoCard.astro +++ b/src/components/RepoCard.astro @@ -15,6 +15,24 @@ const updatedDate = formatDate(repo.updatedAt); // Check if recently updated (within last 7 days) const isRecentlyUpdated = (new Date().getTime() - new Date(repo.updatedAt).getTime()) < 7 * 24 * 60 * 60 * 1000; + +// Get catalog metadata +const catalogMeta = repo.catalogMetadata; +const hasCatalogMeta = catalogMeta && catalogMeta.enabled; + +// Maturity badge colors +const maturityColors = { + 'incubating': { bg: 'bg-yellow-50 dark:bg-yellow-900/30', text: 'text-yellow-700 dark:text-yellow-300', emoji: '🌱' }, + 'production': { bg: 'bg-green-50 dark:bg-green-900/30', text: 'text-green-700 dark:text-green-300', emoji: '✅' }, + 'deprecated': { bg: 'bg-gray-50 dark:bg-gray-900/30', text: 'text-gray-600 dark:text-gray-400', emoji: 'âš ī¸' } +}; + +const stateColors = { + 'published': { bg: 'bg-blue-50 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300', emoji: '📋' }, + 'needs-review': { bg: 'bg-orange-50 dark:bg-orange-900/30', text: 'text-orange-700 dark:text-orange-300', emoji: '🔄' }, + 'deprecated': { bg: 'bg-gray-50 dark:bg-gray-900/30', text: 'text-gray-600 dark:text-gray-400', emoji: 'âš ī¸' }, + 'not-in-catalog': { bg: '', text: '', emoji: '' } +}; --- - + + {/* Catalog Maturity Badge */} + {hasCatalogMeta && catalogMeta.maturity && ( + + {maturityColors[catalogMeta.maturity].emoji} + {catalogMeta.maturity.charAt(0).toUpperCase() + catalogMeta.maturity.slice(1)} + + )} + + {/* Catalog State Badge (if needs review) */} + {hasCatalogMeta && catalogMeta.state === 'needs-review' && ( + + {stateColors['needs-review'].emoji} + Needs Review + + )} + {isRecentlyUpdated && !repo.isArchived && ( Active )} - + {repo.isArchived && ( Archived )} - + {repo.isFork && ( @@ -66,16 +100,26 @@ const isRecentlyUpdated = (new Date().getTime() - new Date(repo.updatedAt).getTi )} - {/* Title */} + {/* Title - use catalog display name if available */}

- {repo.name} + {hasCatalogMeta && catalogMeta.display_name ? catalogMeta.display_name : repo.name}

- {/* Description */} + {/* Description - use catalog description if available */}

- {repo.description || 'No description provided'} + {hasCatalogMeta && catalogMeta.description ? catalogMeta.description : (repo.description || 'No description provided')}

+ {/* Catalog Owner (if different from contributor) */} + {hasCatalogMeta && catalogMeta.owner && ( +
+ + + + Owner: @{catalogMeta.owner} +
+ )} + {/* Languages */} {topLanguages.length > 0 && (
diff --git a/src/data/catalog-metadata.json b/src/data/catalog-metadata.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/data/catalog-metadata.json @@ -0,0 +1 @@ +{} diff --git a/src/pages/index.astro b/src/pages/index.astro index 2338438..6051ba4 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -23,8 +23,22 @@ import repositoriesData from '../data/repositories.json'; import softwareGbbsData from '../data/software-gbbs.json'; import blogPostsData from '../data/blog-posts.json'; +// Import catalog metadata +let catalogMetadataData: Record = {}; +try { + catalogMetadataData = await import('../data/catalog-metadata.json').then(m => m.default); +} catch (err) { + console.warn('Catalog metadata not found, continuing without it'); +} + +// Merge catalog metadata with repository data +const repositoriesWithCatalog = (repositoriesData as Repository[]).map(repo => ({ + ...repo, + catalogMetadata: catalogMetadataData[repo.name] +})); + // Filter out the .github repository (special GitHub org config repo) -const repositories = (repositoriesData as Repository[]).filter(repo => repo.name !== '.github'); +const repositories = repositoriesWithCatalog.filter(repo => repo.name !== '.github'); const categorizedRepos = categorizeRepositories(repositories); // Filter out archived repos by default and sort by updated date diff --git a/src/utils/categorize.ts b/src/utils/categorize.ts index b6e4126..a05f30a 100644 --- a/src/utils/categorize.ts +++ b/src/utils/categorize.ts @@ -1,6 +1,29 @@ // Categorization utility for GitHub repositories // This file contains logic to automatically categorize repos based on their metadata +// Catalog lifecycle states +export type CatalogState = + | "not-in-catalog" + | "published" + | "needs-review" + | "deprecated"; + +// Catalog maturity levels +export type CatalogMaturity = "incubating" | "production" | "deprecated"; + +// Catalog metadata interface +export interface CatalogMetadata { + enabled: boolean; + owner: string; + display_name: string; + description: string; + maturity: CatalogMaturity; + last_reviewed: string; + review_cycle_days?: number; + state: CatalogState; + schema_version: number; +} + // Asset Category (what product/technology it relates to) export type AssetCategory = | "github-copilot" @@ -166,6 +189,8 @@ export interface Repository { updatedAt: string; stargazerCount: number; topContributor: { login: string; avatarUrl: string } | null; + // Catalog metadata (from .gbbcatalog.yml) + catalogMetadata?: CatalogMetadata; } export interface CategorizedRepository extends Repository {