From 8e6c1591fa4d61c7d5e67b95e1efda34fd4696f4 Mon Sep 17 00:00:00 2001 From: plalleman Date: Tue, 3 Feb 2026 17:01:13 -0800 Subject: [PATCH] feat: Add GitHub Action to auto-sync capabilities matrix Add automated workflow that detects mismatches between connector documentation and the capabilities matrix, then creates a PR with fixes. Components: - .github/workflows/sync-capabilities.yml: Workflow triggered on pushes to baton/*.mdx files (except capabilities.mdx) - .github/scripts/sync-capabilities.js: Script that parses connector docs and compares against the matrix Key features: - Only processes changed connector files (not full scan) - Supports manual trigger with specific connector or "all" option - High-confidence detection to minimize false positives Detection logic: - Parses capabilities tables in connector docs for provisioning info - Detects account provisioning/deprovisioning from explicit text patterns - Compares only changed files against the matrix Co-Authored-By: Claude Opus 4.5 --- .github/scripts/sync-capabilities.js | 360 ++++++++++++++++++++++++ .github/workflows/sync-capabilities.yml | 87 ++++++ 2 files changed, 447 insertions(+) create mode 100644 .github/scripts/sync-capabilities.js create mode 100644 .github/workflows/sync-capabilities.yml diff --git a/.github/scripts/sync-capabilities.js b/.github/scripts/sync-capabilities.js new file mode 100644 index 0000000..55a92ea --- /dev/null +++ b/.github/scripts/sync-capabilities.js @@ -0,0 +1,360 @@ +#!/usr/bin/env node + +/** + * Sync Capabilities Matrix + * + * This script parses specified connector documentation files and compares their + * stated capabilities against the capabilities matrix in capabilities.mdx. + * If mismatches are found, it updates the matrix and outputs a summary. + * + * Usage: node sync-capabilities.js [file1.mdx] [file2.mdx] ... + * + * Detection is based on explicit statements in connector docs: + * - Account provisioning: "account provisioning" or Accounts row with Provision checkmark + * - Account deprovisioning: "and deprovisioning" or "deprovision" without negation + * - Entitlement provisioning: Non-account resources with Provision checkmark + */ + +const fs = require('fs'); +const path = require('path'); + +const BATON_DIR = path.join(__dirname, '../../baton'); +const CAPABILITIES_FILE = path.join(BATON_DIR, 'capabilities.mdx'); + +/** + * Parse the capabilities table from a connector doc + * Returns structured info about what can be provisioned + */ +function parseCapabilitiesTable(content) { + const result = { + hasTable: false, + accountsCanProvision: false, + entitlementsCanProvision: false, + resources: [], + }; + + // Find the capabilities table + const tableMatch = content.match(/##\s*Capabilities[\s\S]*?\|[^|]*Resource[^|]*\|[^|]*Sync[^|]*\|[^|]*Provision[^|]*\|([\s\S]*?)(?=\n\n|\n##|\n<)/i); + + if (!tableMatch) return result; + + result.hasTable = true; + const tableContent = tableMatch[0]; + + // Parse each row + const rowRegex = /^\|\s*([^|]+)\s*\|([^|]*)\|([^|]*)\|/gm; + let match; + + while ((match = rowRegex.exec(tableContent)) !== null) { + const resource = match[1].trim(); + const provisionCol = match[3]; + + // Skip header row + if (resource.toLowerCase() === 'resource' || resource.startsWith(':') || resource.startsWith('-')) { + continue; + } + + const canProvision = provisionCol.includes('square-check'); + result.resources.push({ resource, canProvision }); + + // Check if this is accounts provisioning + if (/^accounts?$/i.test(resource) && canProvision) { + result.accountsCanProvision = true; + } + + // Check for entitlement-type resources (non-accounts that can be provisioned) + if (!/^accounts?$/i.test(resource) && canProvision) { + result.entitlementsCanProvision = true; + } + } + + return result; +} + +/** + * Parse a connector doc file and extract capabilities + */ +function parseConnectorDoc(filePath) { + const content = fs.readFileSync(filePath, 'utf-8'); + const fileName = path.basename(filePath, '.mdx'); + + // Parse the capabilities table first + const tableInfo = parseCapabilitiesTable(content); + + const capabilities = { + fileName, + cloudHosted: false, + selfHosted: false, + provisionsEntitlements: false, + provisionsAccounts: false, + deprovisionsAccounts: false, + confidence: 'low', + }; + + // Check for hosting options + capabilities.cloudHosted = /'); + if (caps.provisionsAccounts) icons.push(''); + if (caps.deprovisionsAccounts) icons.push(''); + return icons.join(' '); +} + +/** + * Compare detected capabilities with matrix and find mismatches + * Only report mismatches where we have high confidence in the doc parsing + */ +function findMismatches(docCaps, matrixCaps) { + const mismatches = []; + + // Only report mismatches for deprovisioning, which is more reliably detected + if (docCaps.deprovisionsAccounts !== matrixCaps.provisioning.deprovisions) { + mismatches.push({ + field: 'deprovisioning (face-confused icon)', + doc: docCaps.deprovisionsAccounts, + matrix: matrixCaps.provisioning.deprovisions, + }); + } + + // Only report account provisioning if we have high confidence + if (docCaps.confidence === 'high' && + docCaps.provisionsAccounts !== matrixCaps.provisioning.accounts) { + mismatches.push({ + field: 'accounts (user icon)', + doc: docCaps.provisionsAccounts, + matrix: matrixCaps.provisioning.accounts, + }); + } + + // Only report entitlement provisioning if we have a capabilities table + if (docCaps.confidence === 'high' && + docCaps.provisionsEntitlements !== matrixCaps.provisioning.entitlements) { + mismatches.push({ + field: 'entitlements (key icon)', + doc: docCaps.provisionsEntitlements, + matrix: matrixCaps.provisioning.entitlements, + }); + } + + return mismatches; +} + +/** + * Main function + */ +function main() { + // Get files to process from command line arguments + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log(JSON.stringify({ + hasChanges: false, + mismatchCount: 0, + summary: 'No connector files specified.', + mismatches: [], + }, null, 2)); + return; + } + + // Read capabilities matrix + const matrixContent = fs.readFileSync(CAPABILITIES_FILE, 'utf-8'); + const matrixConnectors = parseCapabilitiesMatrix(matrixContent); + + const allMismatches = []; + let updatedContent = matrixContent; + + for (const filePath of args) { + // Handle both relative and absolute paths + const fullPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); + + if (!fs.existsSync(fullPath)) { + console.error(`Warning: File not found: ${fullPath}`); + continue; + } + + const fileName = path.basename(fullPath, '.mdx'); + + // Skip non-connector files + if (fileName === 'capabilities' || fileName.startsWith('_') || fileName.startsWith('baton-')) { + continue; + } + + // Parse the connector doc + const docCaps = parseConnectorDoc(fullPath); + + // Find the connector in the matrix + const matrixEntry = matrixConnectors.get(fileName); + + if (!matrixEntry) { + // Connector not in matrix - skip + continue; + } + + // Compare capabilities + const mismatches = findMismatches(docCaps, matrixEntry); + + if (mismatches.length > 0) { + allMismatches.push({ + connector: matrixEntry.name, + fileName, + mismatches, + docCaps, + matrixEntry, + }); + + // Build the updated provisioning icons based on what the doc says + const updatedCaps = { + provisionsEntitlements: mismatches.find(m => m.field.includes('entitlements')) + ? docCaps.provisionsEntitlements + : matrixEntry.provisioning.entitlements, + provisionsAccounts: mismatches.find(m => m.field.includes('accounts')) + ? docCaps.provisionsAccounts + : matrixEntry.provisioning.accounts, + deprovisionsAccounts: mismatches.find(m => m.field.includes('deprovisioning')) + ? docCaps.deprovisionsAccounts + : matrixEntry.provisioning.deprovisions, + }; + + const newProvisioningIcons = buildProvisioningIcons(updatedCaps); + + // Update the row + const newRow = matrixEntry.fullMatch.replace( + /\|([^|]*)\|([^|]*)\|$/, + `| ${newProvisioningIcons} |$2|` + ); + + updatedContent = updatedContent.replace(matrixEntry.fullMatch, newRow); + } + } + + // Write updated content if there are changes + const hasChanges = allMismatches.length > 0; + + if (hasChanges) { + fs.writeFileSync(CAPABILITIES_FILE, updatedContent); + } + + // Build summary + let summary = ''; + if (hasChanges) { + for (const { connector, fileName, mismatches } of allMismatches) { + summary += `### ${connector}\n`; + summary += `File: \`baton/${fileName}.mdx\`\n\n`; + for (const m of mismatches) { + const docStatus = m.doc ? 'supports' : 'does not support'; + const matrixStatus = m.matrix ? 'shows' : 'does not show'; + summary += `- **${m.field}**: Documentation says connector ${docStatus} this, but matrix ${matrixStatus} it\n`; + } + summary += '\n'; + } + } else { + summary = 'No mismatches found. The capabilities matrix is in sync with the checked connector docs.'; + } + + // Output result as JSON + const result = { + hasChanges, + mismatchCount: allMismatches.length, + summary, + mismatches: allMismatches.map(m => ({ + connector: m.connector, + fileName: m.fileName, + issues: m.mismatches, + })), + }; + + console.log(JSON.stringify(result, null, 2)); +} + +main(); diff --git a/.github/workflows/sync-capabilities.yml b/.github/workflows/sync-capabilities.yml new file mode 100644 index 0000000..7cac4e5 --- /dev/null +++ b/.github/workflows/sync-capabilities.yml @@ -0,0 +1,87 @@ +name: Sync Capabilities Matrix + +on: + push: + branches: + - main + paths: + - 'baton/*.mdx' + - '!baton/capabilities.mdx' + workflow_dispatch: + inputs: + connector: + description: 'Connector filename (without .mdx) to check, or "all" for full scan' + required: false + default: '' + +permissions: + contents: write + pull-requests: write + +jobs: + sync-capabilities: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Get changed connector files + id: changed + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.connector }}" ]; then + if [ "${{ github.event.inputs.connector }}" = "all" ]; then + echo "files=$(ls baton/*.mdx | grep -v capabilities.mdx | grep -v '^baton/_' | grep -v '^baton/baton-' | tr '\n' ' ')" >> $GITHUB_OUTPUT + else + echo "files=baton/${{ github.event.inputs.connector }}.mdx" >> $GITHUB_OUTPUT + fi + else + # Get only changed connector files from the push + CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'baton/*.mdx' | grep -v capabilities.mdx | grep -v '^baton/_' | grep -v '^baton/baton-' | tr '\n' ' ') + echo "files=$CHANGED" >> $GITHUB_OUTPUT + fi + + - name: Setup Node.js + if: steps.changed.outputs.files != '' + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run sync script + if: steps.changed.outputs.files != '' + id: sync + run: | + node .github/scripts/sync-capabilities.js ${{ steps.changed.outputs.files }} > sync-output.json + echo "has_changes=$(jq -r '.hasChanges' sync-output.json)" >> $GITHUB_OUTPUT + echo "summary<> $GITHUB_OUTPUT + jq -r '.summary' sync-output.json >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create Pull Request + if: steps.changed.outputs.files != '' && steps.sync.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'fix(capabilities): Auto-sync matrix from connector docs' + title: 'fix(capabilities): Auto-sync matrix from connector docs' + body: | + ## Summary + + This PR was automatically generated by the capabilities sync workflow. + + The following mismatches were detected between connector documentation and the capabilities matrix: + + ${{ steps.sync.outputs.summary }} + + ## Test plan + - [ ] Review each change to verify it matches the connector documentation + - [ ] Confirm the matrix renders correctly + + --- + Generated by [sync-capabilities workflow](.github/workflows/sync-capabilities.yml) + branch: auto/sync-capabilities + delete-branch: true + labels: | + automated + documentation