From d833a8ec0c0260654b3ca6273aaade550701f847 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:45:49 +0000 Subject: [PATCH 1/2] Initial plan From efdf48a57d74263adfecd1bcb7eafd4e24ebdc90 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:50:59 +0000 Subject: [PATCH 2/2] Implement automated catalog maintenance system Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- .github/workflows/catalog-maintenance.yml | 161 ++++++++++++++++ .gitignore | 2 + CATALOG_LIFECYCLE.md | 89 +++++++++ README.md | 14 ++ scripts/check-staleness.js | 221 ++++++++++++++++++++++ src/content/config.ts | 4 + src/content/ip/astro-starter.md | 3 + src/content/ip/branding-kit.md | 3 + 8 files changed, 497 insertions(+) create mode 100644 .github/workflows/catalog-maintenance.yml create mode 100644 CATALOG_LIFECYCLE.md create mode 100755 scripts/check-staleness.js diff --git a/.github/workflows/catalog-maintenance.yml b/.github/workflows/catalog-maintenance.yml new file mode 100644 index 0000000..69adf37 --- /dev/null +++ b/.github/workflows/catalog-maintenance.yml @@ -0,0 +1,161 @@ +name: 📅 Catalog Maintenance + +on: + schedule: + # Runs every Monday at 9:00 AM UTC + - cron: "0 9 * * 1" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check-staleness: + name: 🔍 Check for Stale Content + 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: 🔍 Run Staleness Check + id: check + run: | + node scripts/check-staleness.js || true + + # Read the report + if [ -f stale-content-report.json ]; then + echo "REPORT_EXISTS=true" >> $GITHUB_OUTPUT + STALE_COUNT=$(jq '.staleEntries | length' stale-content-report.json) + echo "STALE_COUNT=$STALE_COUNT" >> $GITHUB_OUTPUT + + # Export report for next step + cat stale-content-report.json > /tmp/report.json + else + echo "REPORT_EXISTS=false" >> $GITHUB_OUTPUT + echo "STALE_COUNT=0" >> $GITHUB_OUTPUT + fi + + - name: 📝 Create Issues for Stale Content + if: steps.check.outputs.REPORT_EXISTS == 'true' && steps.check.outputs.STALE_COUNT > 0 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "📋 Processing stale content notifications..." + + # Read stale entries + STALE_ENTRIES=$(jq -c '.staleEntries[]' /tmp/report.json) + + # Process each stale entry + while IFS= read -r entry; do + FILE=$(echo "$entry" | jq -r '.file') + TITLE=$(echo "$entry" | jq -r '.title') + OWNER=$(echo "$entry" | jq -r '.owner // "N/A"') + DAYS=$(echo "$entry" | jq -r '.daysSinceUpdate') + SEVERITY=$(echo "$entry" | jq -r '.severity') + + echo "Creating issue for: $FILE ($DAYS days stale)" + + # Create issue title + ISSUE_TITLE="📅 Catalog Review Needed: $TITLE" + + # Create issue body + ISSUE_BODY="## Stale Content Notification + +This catalog entry has not been updated in **$DAYS days** and needs your attention. + +**Details:** +- 📄 File: \`src/content/ip/$FILE\` +- 👤 Owner: $OWNER +- ⏰ Days since update: $DAYS +- 🔴 Severity: $SEVERITY + +**Action Required:** + +Please review this content and take one of the following actions: + +1. **Update the content** if changes are needed +2. **Refresh** the \`last_updated\` field to confirm review (even if no changes) +3. **Set status to \`deprecated\`** if no longer relevant + +### How to Update + +Edit \`src/content/ip/$FILE\` and update the frontmatter: + +\`\`\`yaml +--- +last_updated: $(date +%Y-%m-%d) # Update to today's date +status: ready # Or 'deprecated' if outdated +--- +\`\`\` + +For more information, see [CATALOG_LIFECYCLE.md](https://github.com/DevExpGbb/devexpgbb.github.io/blob/main/CATALOG_LIFECYCLE.md). + +--- +*This issue was created automatically by the Catalog Maintenance workflow.*" + + # Check if issue already exists for this file + EXISTING_ISSUE=$(gh issue list \ + --label "catalog-maintenance" \ + --search "\"$TITLE\" in:title" \ + --json number,title \ + --jq ".[0].number // empty" || echo "") + + if [ -n "$EXISTING_ISSUE" ]; then + echo " Issue already exists: #$EXISTING_ISSUE" + # Update the existing issue with a comment + gh issue comment "$EXISTING_ISSUE" \ + --body "⏰ Still stale after $DAYS days. Please review and update." + else + # Create new issue + ASSIGNEE="" + if [ "$OWNER" != "N/A" ] && [ -n "$OWNER" ]; then + OWNER_CLEAN=$(echo "$OWNER" | sed 's/@//') + ASSIGNEE="--assignee $OWNER_CLEAN" + fi + + gh issue create \ + --title "$ISSUE_TITLE" \ + --body "$ISSUE_BODY" \ + --label "catalog-maintenance,needs-review" \ + $ASSIGNEE || echo " Failed to assign to $OWNER (user may not have repo access)" + fi + + done <<< "$STALE_ENTRIES" + + echo "✅ Processed ${{ steps.check.outputs.STALE_COUNT }} stale entries" + + - name: 📊 Summary + if: always() + run: | + echo "## Catalog Maintenance Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.check.outputs.REPORT_EXISTS }}" == "true" ]; then + TOTAL=$(jq '.totalEntries' /tmp/report.json) + STALE=$(jq '.staleEntries | length' /tmp/report.json) + HEALTHY=$(jq '.healthyEntries | length' /tmp/report.json) + MISSING=$(jq '.missingMetadata | length' /tmp/report.json) + + echo "- 📚 Total entries: $TOTAL" >> $GITHUB_STEP_SUMMARY + echo "- ⚠️ Stale entries: $STALE" >> $GITHUB_STEP_SUMMARY + echo "- ✅ Healthy entries: $HEALTHY" >> $GITHUB_STEP_SUMMARY + echo "- ❓ Missing metadata: $MISSING" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$STALE" -gt 0 ]; then + echo "### Stale Content" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| File | Title | Owner | Days Stale |" >> $GITHUB_STEP_SUMMARY + echo "|------|-------|-------|------------|" >> $GITHUB_STEP_SUMMARY + + jq -r '.staleEntries[] | "| \(.file) | \(.title) | \(.owner // "N/A") | \(.daysSinceUpdate) |"' /tmp/report.json >> $GITHUB_STEP_SUMMARY + fi + else + echo "❌ No report generated" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.gitignore b/.gitignore index a492166..0d7c944 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* +# staleness reports +stale-content-report.json # environment variables .env diff --git a/CATALOG_LIFECYCLE.md b/CATALOG_LIFECYCLE.md new file mode 100644 index 0000000..1719646 --- /dev/null +++ b/CATALOG_LIFECYCLE.md @@ -0,0 +1,89 @@ +# Catalog Lifecycle Management + +This document describes the automated lifecycle management process for IP Atlas catalog entries. + +## Metadata Fields + +Each catalog entry in `src/content/ip/*.md` should include these lifecycle metadata fields: + +```yaml +--- +title: "Your IP Title" +summary: "Brief description" +category: "Category" +owner: "@githubhandle" # GitHub handle of the content owner +status: "ready" # wip | ready | deprecated +last_updated: 2026-02-04 # Date of last content update +--- +``` + +### Field Definitions + +- **owner**: GitHub handle (e.g., `@username`) or email of the person responsible for this content +- **status**: Current lifecycle status + - `wip` - Work in progress, not yet ready for catalog + - `ready` - Ready for catalog, actively maintained + - `deprecated` - No longer maintained, scheduled for removal +- **last_updated**: ISO date of last meaningful content update + +## Lifecycle Rules + +### Staleness Threshold + +- **Stale after**: 90 days (3 months) without updates +- **First reminder**: When asset becomes stale +- **Second reminder**: 2 weeks after first reminder +- **Escalation**: 4 weeks after first reminder (team lead notification) +- **Auto-deprecation**: 180 days (6 months) stale (optional, requires team approval) + +### Status Transitions + +``` +wip → ready → deprecated → (removed) + ↑ ↓ + └──────┘ (can return to wip for major updates) +``` + +## Automated Processes + +### Staleness Detection + +A scheduled GitHub Action runs weekly to: + +1. Parse all catalog entries +2. Identify assets not updated within 90 days +3. Generate reminders for owners +4. Create GitHub issues for stale content + +### Owner Responsibilities + +When you receive a staleness reminder: + +1. **Review** the content for accuracy and relevance +2. **Update** if changes are needed +3. **Refresh** `last_updated` field even if no content changes (confirms review) +4. **Deprecate** if no longer relevant (set `status: deprecated`) + +### Team Rituals + +**Monthly Review Meeting** (suggested): +- Review newly added assets +- Discuss recently updated content +- Confirm deprecations +- Celebrate new IP contributions + +## Success Criteria + +- ≥90% of assets have `owner`, `status`, and `last_updated` fields +- Automated reminders sent on schedule +- Reduced manual catalog maintenance +- Regular review cadence established + +## Getting Started + +1. **Update existing content**: Add lifecycle metadata to your IP entries +2. **Set yourself as owner**: Use your GitHub handle +3. **Mark status**: Start with `ready` for published content, `wip` for drafts +4. **Set last_updated**: Use the date of your last content update + +For questions or issues, please open a GitHub issue or contact the IP Atlas maintainers. diff --git a/README.md b/README.md index 4e984ef..c5f42d7 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,23 @@ published: true date: 2026-02-04 author: "Team" link: "https://optional-link" +# Lifecycle metadata (for automated maintenance) +owner: "@githubhandle" # Your GitHub handle +status: "ready" # wip | ready | deprecated +last_updated: 2026-02-04 # Last content update date --- ``` +### Automated Catalog Maintenance + +IP Atlas includes automated lifecycle management to keep content fresh and relevant: + +- **Staleness Detection**: Runs weekly to identify content not updated in 90+ days +- **Owner Notifications**: Creates GitHub issues to remind owners to review stale content +- **Lifecycle Tracking**: Uses `status` field to track content state (wip/ready/deprecated) + +See [CATALOG_LIFECYCLE.md](CATALOG_LIFECYCLE.md) for complete details on the automated maintenance process. + ## 🧞 Commands | Command | Action | diff --git a/scripts/check-staleness.js b/scripts/check-staleness.js new file mode 100755 index 0000000..5e4bac2 --- /dev/null +++ b/scripts/check-staleness.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +/** + * Staleness Detection Script + * + * Scans catalog entries (src/content/ip/*.md) and identifies stale content + * based on the last_updated field. Generates a report and optionally + * creates GitHub issues to remind owners. + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Configuration +const CONTENT_DIR = path.join(__dirname, '../src/content/ip'); +const STALE_THRESHOLD_DAYS = 90; // 3 months +const OUTPUT_FILE = path.join(__dirname, '../stale-content-report.json'); + +/** + * Parse frontmatter from markdown file + */ +function parseFrontmatter(content) { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + + const frontmatter = {}; + const lines = match[1].split('\n'); + let currentKey = null; + + for (const line of lines) { + if (line.trim() === '') continue; + + // Handle multi-line arrays + if (line.startsWith(' - ')) { + if (currentKey && Array.isArray(frontmatter[currentKey])) { + frontmatter[currentKey].push(line.trim().substring(2)); + } + continue; + } + + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) continue; + + const key = line.substring(0, colonIndex).trim(); + let value = line.substring(colonIndex + 1).trim(); + + // Remove quotes + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.substring(1, value.length - 1); + } + + // Check if this starts an array + if (line.trim().endsWith(':') || value === '') { + frontmatter[key] = []; + currentKey = key; + } else { + frontmatter[key] = value; + currentKey = key; + } + } + + return frontmatter; +} + +/** + * Calculate days since last update + */ +function daysSinceUpdate(dateString) { + if (!dateString) return Infinity; + + const lastUpdate = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now - lastUpdate); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + return diffDays; +} + +/** + * Scan content directory and identify stale entries + */ +function scanContent() { + const files = fs.readdirSync(CONTENT_DIR) + .filter(f => f.endsWith('.md')); + + const report = { + scannedAt: new Date().toISOString(), + totalEntries: files.length, + staleEntries: [], + healthyEntries: [], + missingMetadata: [] + }; + + for (const file of files) { + const filePath = path.join(CONTENT_DIR, file); + const content = fs.readFileSync(filePath, 'utf-8'); + const frontmatter = parseFrontmatter(content); + + if (!frontmatter) { + report.missingMetadata.push({ file, reason: 'No frontmatter found' }); + continue; + } + + const entry = { + file, + title: frontmatter.title || 'Untitled', + owner: frontmatter.owner || null, + status: frontmatter.status || 'unknown', + last_updated: frontmatter.last_updated || null, + daysSinceUpdate: daysSinceUpdate(frontmatter.last_updated) + }; + + // Check for missing metadata + if (!entry.owner || !entry.last_updated) { + report.missingMetadata.push({ + file, + title: entry.title, + missingFields: [ + !entry.owner && 'owner', + !entry.last_updated && 'last_updated' + ].filter(Boolean) + }); + } + + // Check if stale + if (entry.daysSinceUpdate > STALE_THRESHOLD_DAYS) { + entry.severity = entry.daysSinceUpdate > 180 ? 'critical' : + entry.daysSinceUpdate > 120 ? 'high' : 'medium'; + report.staleEntries.push(entry); + } else { + report.healthyEntries.push(entry); + } + } + + // Sort stale entries by severity + report.staleEntries.sort((a, b) => b.daysSinceUpdate - a.daysSinceUpdate); + + return report; +} + +/** + * Generate markdown summary + */ +function generateSummary(report) { + const lines = [ + '# Catalog Staleness Report', + '', + `Generated: ${new Date(report.scannedAt).toLocaleString()}`, + '', + '## Summary', + '', + `- Total entries: ${report.totalEntries}`, + `- Stale entries (>${STALE_THRESHOLD_DAYS} days): ${report.staleEntries.length}`, + `- Healthy entries: ${report.healthyEntries.length}`, + `- Missing metadata: ${report.missingMetadata.length}`, + '', + ]; + + if (report.staleEntries.length > 0) { + lines.push('## Stale Content'); + lines.push(''); + lines.push('| File | Title | Owner | Days Stale | Severity |'); + lines.push('|------|-------|-------|------------|----------|'); + + for (const entry of report.staleEntries) { + lines.push(`| ${entry.file} | ${entry.title} | ${entry.owner || 'N/A'} | ${entry.daysSinceUpdate} | ${entry.severity} |`); + } + lines.push(''); + } + + if (report.missingMetadata.length > 0) { + lines.push('## Missing Metadata'); + lines.push(''); + lines.push('| File | Title | Missing Fields |'); + lines.push('|------|-------|----------------|'); + + for (const entry of report.missingMetadata) { + const fields = entry.missingFields ? entry.missingFields.join(', ') : 'all'; + lines.push(`| ${entry.file} | ${entry.title || 'N/A'} | ${fields} |`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Main execution + */ +function main() { + console.log('🔍 Scanning catalog for stale content...'); + console.log(`📁 Content directory: ${CONTENT_DIR}`); + console.log(`⏰ Stale threshold: ${STALE_THRESHOLD_DAYS} days`); + console.log(''); + + const report = scanContent(); + + // Save JSON report + fs.writeFileSync(OUTPUT_FILE, JSON.stringify(report, null, 2)); + console.log(`✅ Report saved to: ${OUTPUT_FILE}`); + + // Print summary + console.log(''); + console.log(generateSummary(report)); + + // Exit with status code based on findings + if (report.staleEntries.length > 0) { + console.log(`⚠️ Found ${report.staleEntries.length} stale entries`); + process.exit(1); + } else { + console.log('✅ All catalog entries are up to date!'); + process.exit(0); + } +} + +main(); diff --git a/src/content/config.ts b/src/content/config.ts index 8fd49b1..c735aac 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -12,6 +12,10 @@ const ip = defineCollection({ author: z.string().optional(), link: z.string().url().optional(), cover: z.string().optional(), + // Lifecycle metadata for automated maintenance + owner: z.string().optional(), // GitHub handle or email + status: z.enum(['wip', 'ready', 'deprecated']).default('ready'), + last_updated: z.date().optional(), // Last content update date }), }); diff --git a/src/content/ip/astro-starter.md b/src/content/ip/astro-starter.md index 9564970..251b3cd 100644 --- a/src/content/ip/astro-starter.md +++ b/src/content/ip/astro-starter.md @@ -10,6 +10,9 @@ published: true date: 2026-02-04 author: "IP Atlas Team" link: "https://astro.build/" +owner: "@raykao" +status: "ready" +last_updated: 2026-02-04 --- Welcome to **IP Atlas**. This starter shows you how to structure and publish team materials: diff --git a/src/content/ip/branding-kit.md b/src/content/ip/branding-kit.md index 74f2b8d..af42a24 100644 --- a/src/content/ip/branding-kit.md +++ b/src/content/ip/branding-kit.md @@ -9,6 +9,9 @@ published: true date: 2026-01-15 author: "Design Team" link: "https://example.com/branding-kit" +owner: "@raykao" +status: "ready" +last_updated: 2026-01-15 --- Includes: