From efd440e0017979614fef5d9c6471fed97daea871 Mon Sep 17 00:00:00 2001 From: Daryl Collins Date: Mon, 29 Jun 2026 13:24:23 +0100 Subject: [PATCH] feat(claude-review): review against configured team-standards context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional two-job flow: a context job fetches configured read-only sources with a short-lived app token and hands them to the review as an artifact; the review job (no fetch credentials) injects the standards into CLAUDE.md. Sources are supplied via a TEAM_CONTEXT_SOURCES secret, so no source repos or paths live in this public repo. Backward compatible — without the inputs the review runs as before. --- .github/actions/fetch-team-context/action.yml | 44 +++++++++ .github/actions/fetch-team-context/fetch.sh | 59 ++++++++++++ .../apps-claude-code-review-trigger.yml | 5 + .github/workflows/apps-claude-code-review.yml | 93 ++++++++++++++++++- 4 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 .github/actions/fetch-team-context/action.yml create mode 100644 .github/actions/fetch-team-context/fetch.sh diff --git a/.github/actions/fetch-team-context/action.yml b/.github/actions/fetch-team-context/action.yml new file mode 100644 index 0000000..0262175 --- /dev/null +++ b/.github/actions/fetch-team-context/action.yml @@ -0,0 +1,44 @@ +name: Fetch review context +description: Fetch read-only files from configured source repositories with a short-lived app token, for use as review context. +inputs: + app-id: + description: GitHub App ID. + required: true + private-key: + description: GitHub App private key. + required: true + sources: + description: > + Newline list of `repo:path[,path...]` to fetch from the 0xPolygon org + (paths ending in `/` are directories). + required: true + output-dir: + description: Directory to write the fetched files into. + required: false + default: review-context +runs: + using: composite + steps: + # The dedicated app is installed only on the source repos with contents:read, + # so the minted token is already least-privilege — no extra scoping needed. + - id: app-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.private-key }} + owner: 0xPolygon + permission-contents: read + - name: Fetch + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCES: ${{ inputs.sources }} + OUTPUT_DIR: ${{ inputs.output-dir }} + ACTION_PATH: ${{ github.action_path }} + run: bash "$ACTION_PATH/fetch.sh" + - name: Revoke token + if: always() + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: gh api --method DELETE /installation/token || true diff --git a/.github/actions/fetch-team-context/fetch.sh b/.github/actions/fetch-team-context/fetch.sh new file mode 100644 index 0000000..953c9ff --- /dev/null +++ b/.github/actions/fetch-team-context/fetch.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Fetch the files listed in $SOURCES into $OUTPUT_DIR. Each source line is +# `repo:path[,path...]`; a path ending in `/` is a directory (cone +# sparse-checkout, which excludes sibling subdirectories), otherwise a single +# file. Only file contents are copied out — never a clone's .git dir, so the +# token in the clone URL is not carried into the output. Logs counts, not names. +set -euo pipefail + +: "${GH_TOKEN:?GH_TOKEN required}" +: "${OUTPUT_DIR:?OUTPUT_DIR required}" +: "${SOURCES:?SOURCES required}" + +owner=0xPolygon +out="$(mkdir -p "$OUTPUT_DIR" && cd "$OUTPUT_DIR" && pwd)" +count=0 + +fetch_file() { # repo path + local repo="$1" path="$2" + mkdir -p "$out/$repo/$(dirname "$path")" + if gh api "repos/$owner/$repo/contents/$path" \ + -H "Accept: application/vnd.github.raw" > "$out/$repo/$path" 2>/dev/null; then + count=$((count + 1)) + else + echo "::warning::could not fetch a configured file" + rm -f "$out/$repo/$path" + fi +} + +fetch_dir() { # repo dir (no trailing slash) + local repo="$1" dir="$2" tmp + tmp="$(mktemp -d)" + git clone --no-checkout --depth 1 --filter=blob:none \ + "https://x-access-token:${GH_TOKEN}@github.com/${owner}/${repo}.git" "$tmp" >/dev/null 2>&1 + git -C "$tmp" sparse-checkout set "$dir" >/dev/null + git -C "$tmp" checkout >/dev/null 2>&1 + if [[ -d "$tmp/$dir" ]]; then + mkdir -p "$out/$repo/$(dirname "$dir")" + cp -R "$tmp/$dir" "$out/$repo/$(dirname "$dir")/" # content only — never $tmp/.git + count=$((count + $(find "$tmp/$dir" -type f | wc -l))) + else + echo "::warning::could not fetch a configured directory" + fi + rm -rf "$tmp" +} + +while IFS= read -r line; do + [[ -z "${line// /}" ]] && continue + repo="${line%%:*}" + paths="${line#*:}" + IFS=',' read -ra parts <<< "$paths" + for p in "${parts[@]}"; do + p="$(echo "$p" | xargs)" # trim whitespace + [[ -z "$p" ]] && continue + if [[ "$p" == */ ]]; then fetch_dir "$repo" "${p%/}"; else fetch_file "$repo" "$p"; fi + done +done <<< "$SOURCES" + +echo "Fetched $count file(s) into $OUTPUT_DIR" diff --git a/.github/workflows/apps-claude-code-review-trigger.yml b/.github/workflows/apps-claude-code-review-trigger.yml index f81ebba..22ce613 100644 --- a/.github/workflows/apps-claude-code-review-trigger.yml +++ b/.github/workflows/apps-claude-code-review-trigger.yml @@ -16,3 +16,8 @@ jobs: uses: ./.github/workflows/apps-claude-code-review.yml secrets: CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} + # Optional: enables team-standards context in the review. Provide a + # read-only app's credentials and an APPS_TEAM_CONTEXT_SOURCES secret. + TEAM_CONTEXT_APP_ID: ${{ secrets.APPS_TEAM_CONTEXT_APP_ID }} + TEAM_CONTEXT_APP_PRIVATE_KEY: ${{ secrets.APPS_TEAM_CONTEXT_APP_PRIVATE_KEY }} + TEAM_CONTEXT_SOURCES: ${{ secrets.APPS_TEAM_CONTEXT_SOURCES }} diff --git a/.github/workflows/apps-claude-code-review.yml b/.github/workflows/apps-claude-code-review.yml index 176533d..7d9abe4 100644 --- a/.github/workflows/apps-claude-code-review.yml +++ b/.github/workflows/apps-claude-code-review.yml @@ -5,19 +5,75 @@ on: secrets: CLAUDE_API_KEY: required: true + # Optional team-context credentials. If absent, the review still runs, + # just without injected standards. + TEAM_CONTEXT_APP_ID: + required: false + TEAM_CONTEXT_APP_PRIVATE_KEY: + required: false + # Newline list of `repo:path[,path...]` to fetch. + TEAM_CONTEXT_SOURCES: + required: false permissions: {} +# Run conditions (claude-review inherits via `needs`): same-repo PRs only, skip +# changeset-release branches, skip the disable-claude-code-review label. + jobs: - claude-review: - # Only run on PRs from the same repository (not forks) to avoid API costs on external contributions. - # Skip changeset-release branches — those are automated version-bump PRs, not code to review. - # Skip PRs labelled disable-claude-code-review. + # Job 1 — fetches review context with a short-lived, read-only token and hands + # it to job 2 as an artifact. No PR content is checked out here, and the token + # is never passed to the review job. + fetch-context: if: >- github.event.pull_request.head.repo.full_name == github.repository && !startsWith(github.head_ref, 'changeset-release/') && !contains(github.event.pull_request.labels.*.name, 'disable-claude-code-review') runs-on: ubuntu-latest + permissions: {} + steps: + - id: guard + name: Detect whether team-context inputs were provided + shell: bash + env: + APP_ID: ${{ secrets.TEAM_CONTEXT_APP_ID }} + SOURCES: ${{ secrets.TEAM_CONTEXT_SOURCES }} + run: | + if [[ -n "$APP_ID" && -n "$SOURCES" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::notice::Team context not configured — review will run without injected standards." + fi + - name: Prepare context dir + shell: bash + # Always produce an artifact so job 2's download never fails. The marker + # is non-hidden because upload-artifact excludes dotfiles by default. + run: | + mkdir -p review-context + touch review-context/placeholder + - name: Fetch review context + if: steps.guard.outputs.enabled == 'true' + # TODO(before merge): switch @feat/... back to @main. The branch ref lets + # this PR self-test the action before it exists on main; reusable + # workflows resolve composite actions by full path, not local ./. + uses: 0xPolygon/pipelines/.github/actions/fetch-team-context@feat/claude-review-team-context + with: + app-id: ${{ secrets.TEAM_CONTEXT_APP_ID }} + private-key: ${{ secrets.TEAM_CONTEXT_APP_PRIVATE_KEY }} + sources: ${{ secrets.TEAM_CONTEXT_SOURCES }} + output-dir: review-context + - name: Upload context artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: review-context + path: review-context + retention-days: 1 + + # Job 2 — the review. Separate runner; not given the team-context credentials. + claude-review: + needs: fetch-context + runs-on: ubuntu-latest permissions: contents: read pull-requests: write @@ -29,6 +85,35 @@ jobs: uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 1 + - name: Download review context + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: review-context + path: .review-context + - name: Inject team standards into CLAUDE.md + shell: bash + # Load the standards natively via CLAUDE.md so they're guaranteed in the + # review's context; any other fetched docs stay on disk for on-demand reads. + run: | + std="$(find .review-context -type f -name team-standards.md | head -1)" + if [[ -z "$std" ]]; then + echo "::warning::No team standards in context — reviewing without them." + exit 0 + fi + { + echo "" + echo "" + echo "# Team standards (injected for this review)" + echo "" + echo "These team standards govern this review. Additional reference material" + echo "(rationale docs and a service dependency graph for cross-repo impact)" + echo "is available on disk under \`.review-context/\` — consult it as needed." + echo "" + cat "$std" + echo "" + echo "" + } >> CLAUDE.md + echo "Injected $(wc -l < "$std") lines of team standards into CLAUDE.md" - name: Run Claude Code Review id: claude-review uses: anthropics/claude-code-action@df37d2f0760a4b5683a6e617c9325bc1a36443f6 # v1