diff --git a/.github/scripts/create-bot-changeset.sh b/.github/scripts/create-bot-changeset.sh new file mode 100755 index 00000000..25f1e933 --- /dev/null +++ b/.github/scripts/create-bot-changeset.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Creates a changeset for bot PRs (Dependabot, Aikido, etc.) +# Detects which dependencies were updated and which packages use them. +# +# Usage: +# .github/scripts/create-bot-changeset.sh +# +# Example (local testing): +# .github/scripts/create-bot-changeset.sh main 9999 "Bump minimatch" +# +# Set DRY_RUN=1 to preview without writing files: +# DRY_RUN=1 .github/scripts/create-bot-changeset.sh main 9999 "Bump minimatch" + +BASE="${1:?Usage: $0 }" +PR_NUMBER="${2:?Usage: $0 }" +PR_TITLE="${3:?Usage: $0 }" +DRY_RUN="${DRY_RUN:-0}" + +CHANGESET_NAME="bot-pr-${PR_NUMBER}" +CHANGESET_FILE=".changeset/${CHANGESET_NAME}.md" + +# Check for existing changeset +if [ -f "$CHANGESET_FILE" ]; then + echo "Changeset already exists: ${CHANGESET_FILE}" + exit 0 +fi + +# Collect updated dependency names from all sources +UPDATED_DEPS="" + +# 1. From yarn.lock diff — extract package names from added resolution lines +# Format: + resolution: "package-name@npm:x.y.z" +LOCKFILE_DEPS=$(git diff "${BASE}...HEAD" -- yarn.lock | grep -E '^\+\s+resolution:' | sed -E 's/.*"([^@]+)@.*/\1/' | sort | uniq || true) +UPDATED_DEPS="${UPDATED_DEPS}${LOCKFILE_DEPS}" + +# 2. From any changed package.json files — extract dependency names from added lines +PKG_JSON_DEPS=$(git diff "${BASE}...HEAD" -- '*/package.json' 'package.json' | grep -E '^\+\s+"[^"]+": "[\^~]?[0-9]' | sed -E 's/^\+\s+"([^"]+)".*/\1/' | sort | uniq || true) +if [ -n "$PKG_JSON_DEPS" ]; then + UPDATED_DEPS="${UPDATED_DEPS}\n${PKG_JSON_DEPS}" +fi + +UPDATED_DEPS=$(echo -e "$UPDATED_DEPS" | sort | uniq | grep -v '^$' || true) + +if [ -z "$UPDATED_DEPS" ]; then + echo "No updated dependencies detected." + exit 0 +fi + +echo "Updated dependencies:" +echo "$UPDATED_DEPS" +echo "" + +# Resolve transitive dependency graph via yarn.lock to find affected workspace packages +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC2086 +PACKAGES=$(node "${SCRIPT_DIR}/resolve-affected-packages.js" $UPDATED_DEPS) + +# Filter to only publishable Nx packages (those with a project.json) +FILTERED="" +for pkg in $PACKAGES; do + for PROJECT_JSON in $(find packages -name project.json -not -path '*/node_modules/*'); do + PKG_DIR=$(dirname "$PROJECT_JSON") + PKG_JSON="${PKG_DIR}/package.json" + [ -f "$PKG_JSON" ] || continue + + PKG_NAME=$(jq -r '.name' "$PKG_JSON") + if [ "$PKG_NAME" = "$pkg" ]; then + FILTERED="${FILTERED}${pkg}\n" + break + fi + done +done + +PACKAGES=$(echo -e "$FILTERED" | sort | uniq | grep -v '^$' || true) + +if [ -z "$PACKAGES" ]; then + echo "No publishable packages affected." + exit 0 +fi + +echo "Affected packages:" +echo "$PACKAGES" +echo "" + +# Build the changeset +CHANGESET_CONTENT=$( + { + echo "---" + echo "$PACKAGES" | while IFS= read -r pkg; do + [ -z "$pkg" ] && continue + echo "\"$pkg\": patch" + done + echo "---" + echo "" + echo "$PR_TITLE" + } +) + +if [ "$DRY_RUN" = "1" ]; then + echo "DRY RUN — would create ${CHANGESET_FILE}:" + echo "$CHANGESET_CONTENT" +else + echo "$CHANGESET_CONTENT" > "$CHANGESET_FILE" + echo "Created ${CHANGESET_FILE}:" + cat "$CHANGESET_FILE" +fi diff --git a/.github/scripts/resolve-affected-packages.js b/.github/scripts/resolve-affected-packages.js new file mode 100644 index 00000000..2cd03281 --- /dev/null +++ b/.github/scripts/resolve-affected-packages.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +/** + * Resolves which workspace packages are transitively affected by updated dependencies. + * + * Parses yarn.lock (Yarn Berry format) to build the dependency graph, then walks + * upward from the updated deps to find which workspace packages depend on them. + * + * Usage: node resolve-affected-packages.js ... + * Output: one workspace package name per line + */ + +const fs = require("fs"); + +const updatedDeps = process.argv.slice(2); +if (updatedDeps.length === 0) { + process.exit(0); +} + +const lockfile = fs.readFileSync("yarn.lock", "utf8"); + +// Parse yarn.lock into entries separated by blank lines +const blocks = lockfile.split(/\n\n+/).filter((b) => b.trim()); + +// For each entry, extract: package name, whether it's a workspace package, and its dependencies +const entries = []; + +for (const block of blocks) { + const lines = block.split("\n"); + const header = lines[0]; + + // Skip metadata lines (comments, __metadata, etc.) + if (!header.startsWith('"')) continue; + + // Extract package name from header + // Format: "@scope/name@npm:^1.0.0, @scope/name@npm:^1.1.0": + // or: "@scope/name@workspace:packages/foo": + const nameMatch = header.match(/^"(@?[^@]+)@/); + if (!nameMatch) continue; + + const pkgName = nameMatch[1]; + + // Check if workspace package + const isWorkspace = header.includes("@workspace:"); + + // Extract dependencies + const deps = []; + let inDeps = false; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (/^\s+dependencies:/.test(line)) { + inDeps = true; + continue; + } + if (inDeps) { + const depMatch = line.match(/^\s{4}([^\s:]+):/); + if (depMatch) { + deps.push(depMatch[1]); + } else { + inDeps = false; + } + } + } + + entries.push({ pkgName, isWorkspace, deps }); +} + +// Build reverse dependency map: dep name -> set of package names that depend on it +const reverseDeps = new Map(); +for (const entry of entries) { + for (const dep of entry.deps) { + if (!reverseDeps.has(dep)) reverseDeps.set(dep, new Set()); + reverseDeps.get(dep).add(entry.pkgName); + } +} + +// BFS upward from updated deps to find affected workspace packages +const visited = new Set(); +const queue = [...updatedDeps]; +const affectedWorkspaces = new Set(); + +// Index workspace packages by name for quick lookup +const workspaceNames = new Set( + entries.filter((e) => e.isWorkspace).map((e) => e.pkgName) +); + +while (queue.length > 0) { + const dep = queue.shift(); + if (visited.has(dep)) continue; + visited.add(dep); + + const dependents = reverseDeps.get(dep); + if (!dependents) continue; + + for (const dependent of dependents) { + if (workspaceNames.has(dependent)) { + affectedWorkspaces.add(dependent); + } else if (!visited.has(dependent)) { + queue.push(dependent); + } + } +} + +// Output sorted workspace package names +for (const pkg of [...affectedWorkspaces].sort()) { + console.log(pkg); +} diff --git a/.github/workflows/bot-changeset.yml b/.github/workflows/bot-changeset.yml new file mode 100644 index 00000000..8a246546 --- /dev/null +++ b/.github/workflows/bot-changeset.yml @@ -0,0 +1,54 @@ +name: 🤖 Bot Changeset + +on: + pull_request: + types: [opened, synchronize] + +permissions: + contents: write + pull-requests: read + +jobs: + create-changeset: + name: Create Changeset + runs-on: ubuntu-latest + if: >- + github.event.pull_request.user.type == 'Bot' && + (github.event.pull_request.user.login == 'dependabot[bot]' || + github.event.pull_request.user.login == 'aikido-autofix[bot]') + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - name: Fetch base ref + run: git fetch origin ${{ github.base_ref }} + + - name: Create changeset + env: + BASE_REF: ${{ github.base_ref }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + .github/scripts/create-bot-changeset.sh \ + "origin/$BASE_REF" \ + "${{ github.event.pull_request.number }}" \ + "$PR_TITLE" + + - name: Commit and push changeset + run: | + git add .changeset/ + if git diff --cached --quiet; then + echo "No changeset to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -m "Add changeset for bot PR" + git push