Fix CI heredoc errors, add Visual/UX reviewers & composite actions #18
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| branches: ['**'] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: read | |
| concurrency: | |
| group: ci-${{ github.head_ref || github.ref_name }} | |
| cancel-in-progress: true | |
| env: | |
| NODE_VERSION: '22' | |
| jobs: | |
| # ============================================ | |
| # STATIC ANALYSIS | |
| # ============================================ | |
| lint: | |
| name: "Static Analysis / Lint (ESLint)" | |
| runs-on: ubuntu-latest | |
| outputs: | |
| has_app: ${{ steps.detect.outputs.has_app }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Detect environment | |
| id: detect | |
| run: | | |
| # Detect package manager | |
| PM="npm" | |
| [ -f "bun.lockb" ] || [ -f "bun.lock" ] && PM="bun" | |
| [ -f "pnpm-lock.yaml" ] && PM="pnpm" | |
| [ -f "yarn.lock" ] && PM="yarn" | |
| echo "pm=$PM" >> $GITHUB_OUTPUT | |
| # Detect app | |
| HAS_APP=false | |
| [ -f "next.config.js" ] && HAS_APP=true | |
| [ -f "next.config.mjs" ] && HAS_APP=true | |
| [ -f "next.config.ts" ] && HAS_APP=true | |
| [ -f "vite.config.ts" ] && HAS_APP=true | |
| [ -f "vite.config.js" ] && HAS_APP=true | |
| [ -f "playwright.config.ts" ] && HAS_APP=true | |
| ls apps/*/package.json >/dev/null 2>&1 && HAS_APP=true | |
| [ -d "src/app" ] && HAS_APP=true | |
| echo "has_app=$HAS_APP" >> $GITHUB_OUTPUT | |
| - uses: actions/setup-node@v4 | |
| if: steps.detect.outputs.pm != 'bun' | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - uses: oven-sh/setup-bun@v2 | |
| if: steps.detect.outputs.pm == 'bun' | |
| - name: Install dependencies | |
| run: | | |
| case "${{ steps.detect.outputs.pm }}" in | |
| bun) bun install --frozen-lockfile ;; | |
| pnpm) corepack enable && pnpm install --frozen-lockfile ;; | |
| yarn) yarn --frozen-lockfile ;; | |
| *) npm ci ;; | |
| esac | |
| - name: Lint | |
| run: | | |
| PM="${{ steps.detect.outputs.pm }}" | |
| $PM run lint | |
| types: | |
| name: "Static Analysis / Types (TypeScript)" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Detect package manager | |
| id: detect | |
| run: | | |
| PM="npm" | |
| [ -f "bun.lockb" ] || [ -f "bun.lock" ] && PM="bun" | |
| [ -f "pnpm-lock.yaml" ] && PM="pnpm" | |
| [ -f "yarn.lock" ] && PM="yarn" | |
| echo "pm=$PM" >> $GITHUB_OUTPUT | |
| - uses: actions/setup-node@v4 | |
| if: steps.detect.outputs.pm != 'bun' | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - uses: oven-sh/setup-bun@v2 | |
| if: steps.detect.outputs.pm == 'bun' | |
| - name: Install dependencies | |
| run: | | |
| case "${{ steps.detect.outputs.pm }}" in | |
| bun) bun install --frozen-lockfile ;; | |
| pnpm) corepack enable && pnpm install --frozen-lockfile ;; | |
| yarn) yarn --frozen-lockfile ;; | |
| *) npm ci ;; | |
| esac | |
| - name: Typecheck | |
| run: | | |
| PM="${{ steps.detect.outputs.pm }}" | |
| $PM run typecheck || npx tsc --noEmit | |
| # ============================================ | |
| # TESTS (requires Static Analysis) | |
| # ============================================ | |
| unit: | |
| name: "Tests / Unit (Vitest)" | |
| needs: [lint, types] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Detect package manager | |
| id: detect | |
| run: | | |
| PM="npm" | |
| [ -f "bun.lockb" ] || [ -f "bun.lock" ] && PM="bun" | |
| [ -f "pnpm-lock.yaml" ] && PM="pnpm" | |
| [ -f "yarn.lock" ] && PM="yarn" | |
| echo "pm=$PM" >> $GITHUB_OUTPUT | |
| - uses: actions/setup-node@v4 | |
| if: steps.detect.outputs.pm != 'bun' | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - uses: oven-sh/setup-bun@v2 | |
| if: steps.detect.outputs.pm == 'bun' | |
| - name: Install dependencies | |
| run: | | |
| case "${{ steps.detect.outputs.pm }}" in | |
| bun) bun install --frozen-lockfile ;; | |
| pnpm) corepack enable && pnpm install --frozen-lockfile ;; | |
| yarn) yarn --frozen-lockfile ;; | |
| *) npm ci ;; | |
| esac | |
| - name: Run unit tests | |
| run: | | |
| PM="${{ steps.detect.outputs.pm }}" | |
| if [ -f "vitest.config.ts" ] || [ -f "vitest.config.js" ]; then | |
| $PM run test -- --passWithNoTests || npx vitest run --passWithNoTests | |
| else | |
| echo "No vitest config found, skipping" | |
| fi | |
| e2e: | |
| name: "Tests / E2E (Playwright)" | |
| needs: [lint, unit] | |
| if: needs.lint.outputs.has_app == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Detect package manager | |
| id: detect | |
| run: | | |
| PM="npm" | |
| [ -f "bun.lockb" ] || [ -f "bun.lock" ] && PM="bun" | |
| [ -f "pnpm-lock.yaml" ] && PM="pnpm" | |
| [ -f "yarn.lock" ] && PM="yarn" | |
| echo "pm=$PM" >> $GITHUB_OUTPUT | |
| - uses: actions/setup-node@v4 | |
| if: steps.detect.outputs.pm != 'bun' | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - uses: oven-sh/setup-bun@v2 | |
| if: steps.detect.outputs.pm == 'bun' | |
| - name: Install dependencies | |
| run: | | |
| case "${{ steps.detect.outputs.pm }}" in | |
| bun) bun install --frozen-lockfile ;; | |
| pnpm) corepack enable && pnpm install --frozen-lockfile ;; | |
| yarn) yarn --frozen-lockfile ;; | |
| *) npm ci ;; | |
| esac | |
| - run: npx playwright install chromium --with-deps | |
| - name: Run E2E tests | |
| run: | | |
| mkdir -p .claude/screenshots | |
| npx playwright test --project=chromium | |
| - uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: playwright-screenshots-${{ github.sha }} | |
| path: .claude/screenshots/ | |
| retention-days: 7 | |
| # ============================================ | |
| # REVIEWS (requires Tests, PR only) | |
| # ============================================ | |
| requirements: | |
| name: "Reviews / Requirements" | |
| needs: [unit, e2e] | |
| if: github.event_name == 'pull_request' && !failure() && !cancelled() | |
| runs-on: ubuntu-latest | |
| outputs: | |
| passed: ${{ steps.extract.outputs.passed }} | |
| result: ${{ steps.extract.outputs.result }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Get context | |
| id: context | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| # Create review context directory | |
| mkdir -p .claude/review-context | |
| BRANCH="${{ github.head_ref }}" | |
| echo "branch=$BRANCH" >> $GITHUB_OUTPUT | |
| # Extract issue number | |
| ISSUE="" | |
| [[ $BRANCH =~ ^([0-9]+)- ]] && ISSUE="${BASH_REMATCH[1]}" | |
| [[ $BRANCH =~ ^(feature|fix)/([0-9]+) ]] && ISSUE="${BASH_REMATCH[2]}" | |
| echo "issue=$ISSUE" >> $GITHUB_OUTPUT | |
| # Get changed files | |
| gh pr view ${{ github.event.pull_request.number }} --json files --jq '.files[].path' > .claude/review-context/changed.txt | |
| # Get issue context | |
| if [ -n "$ISSUE" ]; then | |
| gh issue view $ISSUE --json title,body > .claude/review-context/issue.json 2>/dev/null || echo "{}" > .claude/review-context/issue.json | |
| fi | |
| - name: Run review | |
| id: review | |
| uses: anthropics/claude-code-base-action@beta | |
| with: | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| allowed_tools: "View,GlobTool,Grep,Bash(git diff:*),Bash(cat:*)" | |
| max_turns: 15 | |
| prompt: | | |
| # Requirements Review | |
| Check if changes align with issue requirements. | |
| - Changed files: .claude/review-context/changed.txt | |
| - Issue context: .claude/review-context/issue.json | |
| Output ```json {"passed": bool, "summary": "..."} ``` | |
| - name: Extract result | |
| id: extract | |
| run: | | |
| EXEC="${{ steps.review.outputs.execution_file }}" | |
| if [ -f "$EXEC" ]; then | |
| TEXT=$(jq -r '[.[] | select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text] | last // ""' "$EXEC") | |
| JSON=$(echo "$TEXT" | sed -n '/```json/,/```/{/```/d;p;}') | |
| if echo "$JSON" | jq . >/dev/null 2>&1; then | |
| echo "$JSON" > /tmp/result.json | |
| echo "passed=$(jq -r '.passed // true' /tmp/result.json)" >> $GITHUB_OUTPUT | |
| echo "result=$(jq -c '.' /tmp/result.json)" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| fi | |
| echo "passed=true" >> $GITHUB_OUTPUT | |
| echo 'result={"passed":true,"summary":"Review completed"}' >> $GITHUB_OUTPUT | |
| - name: Update review comment | |
| if: always() | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| MARKER="<!-- ci-review-${{ github.sha }} -->" | |
| PR="${{ github.event.pull_request.number }}" | |
| REVIEW="Requirements" | |
| PASSED="${{ steps.extract.outputs.passed }}" | |
| RESULT='${{ steps.extract.outputs.result }}' | |
| STATUS="✓" | |
| [ "$PASSED" != "true" ] && STATUS="✗" | |
| SUMMARY=$(echo "$RESULT" | jq -r '.summary // "N/A"' 2>/dev/null || echo "N/A") | |
| ROW="| $REVIEW | $STATUS | $SUMMARY |" | |
| # Check for existing comment | |
| EXISTING=$(gh api "/repos/${{ github.repository }}/issues/$PR/comments" \ | |
| --jq ".[] | select(.body | contains(\"$MARKER\")) | {id: .id, body: .body}" | head -1) | |
| if [ -n "$EXISTING" ]; then | |
| COMMENT_ID=$(echo "$EXISTING" | jq -r '.id') | |
| OLD_BODY=$(echo "$EXISTING" | jq -r '.body') | |
| # Append new row | |
| NEW_BODY=$(echo "$OLD_BODY" | sed "/^| $REVIEW |/d") | |
| NEW_BODY=$(printf '%s\n%s' "$NEW_BODY" "$ROW") | |
| gh api --method PATCH "/repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$NEW_BODY" | |
| else | |
| # Create new comment | |
| COMMENT=$(printf '%s\n## Reviews\n\n| Review | Status | Summary |\n|--------|--------|---------|\n%s\n\n---\n*Claude Code CI*' "$MARKER" "$ROW") | |
| gh pr comment "$PR" --body "$COMMENT" | |
| fi | |
| code-quality: | |
| name: "Reviews / Code Quality" | |
| needs: [requirements] | |
| if: github.event_name == 'pull_request' && needs.requirements.outputs.passed == 'true' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| passed: ${{ steps.extract.outputs.passed }} | |
| result: ${{ steps.extract.outputs.result }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Get changed files | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| mkdir -p .claude/review-context | |
| gh pr view ${{ github.event.pull_request.number }} --json files --jq '.files[].path' > .claude/review-context/changed.txt | |
| - name: Run review | |
| id: review | |
| uses: anthropics/claude-code-base-action@beta | |
| with: | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| allowed_tools: "View,GlobTool,Grep,Bash(git diff:*),Bash(cat:*)" | |
| max_turns: 15 | |
| prompt: | | |
| # Code Quality Review | |
| Evaluate DRY, YAGNI, modularity for files in .claude/review-context/changed.txt | |
| Output ```json {"passed": bool, "summary": "..."} ``` | |
| - name: Extract result | |
| id: extract | |
| run: | | |
| EXEC="${{ steps.review.outputs.execution_file }}" | |
| if [ -f "$EXEC" ]; then | |
| TEXT=$(jq -r '[.[] | select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text] | last // ""' "$EXEC") | |
| JSON=$(echo "$TEXT" | sed -n '/```json/,/```/{/```/d;p;}') | |
| if echo "$JSON" | jq . >/dev/null 2>&1; then | |
| echo "$JSON" > /tmp/result.json | |
| echo "passed=$(jq -r '.passed // true' /tmp/result.json)" >> $GITHUB_OUTPUT | |
| echo "result=$(jq -c '.' /tmp/result.json)" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| fi | |
| echo "passed=true" >> $GITHUB_OUTPUT | |
| echo 'result={"passed":true,"summary":"Review completed"}' >> $GITHUB_OUTPUT | |
| - name: Update review comment | |
| if: always() | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| MARKER="<!-- ci-review-${{ github.sha }} -->" | |
| PR="${{ github.event.pull_request.number }}" | |
| REVIEW="Code Quality" | |
| PASSED="${{ steps.extract.outputs.passed }}" | |
| RESULT='${{ steps.extract.outputs.result }}' | |
| STATUS="✓" | |
| [ "$PASSED" != "true" ] && STATUS="✗" | |
| SUMMARY=$(echo "$RESULT" | jq -r '.summary // "N/A"' 2>/dev/null || echo "N/A") | |
| ROW="| $REVIEW | $STATUS | $SUMMARY |" | |
| # Check for existing comment | |
| EXISTING=$(gh api "/repos/${{ github.repository }}/issues/$PR/comments" \ | |
| --jq ".[] | select(.body | contains(\"$MARKER\")) | {id: .id, body: .body}" | head -1) | |
| if [ -n "$EXISTING" ]; then | |
| COMMENT_ID=$(echo "$EXISTING" | jq -r '.id') | |
| OLD_BODY=$(echo "$EXISTING" | jq -r '.body') | |
| # Append new row before the footer | |
| NEW_BODY=$(echo "$OLD_BODY" | sed "/---/i\\$ROW") | |
| gh api --method PATCH "/repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$NEW_BODY" | |
| else | |
| # Create new comment (shouldn't happen as requirements runs first) | |
| COMMENT=$(printf '%s\n## Reviews\n\n| Review | Status | Summary |\n|--------|--------|---------|\n%s\n\n---\n*Claude Code CI*' "$MARKER" "$ROW") | |
| gh pr comment "$PR" --body "$COMMENT" | |
| fi | |
| context: | |
| name: "Reviews / Context" | |
| needs: [requirements] | |
| if: github.event_name == 'pull_request' && needs.requirements.outputs.passed == 'true' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| passed: ${{ steps.extract.outputs.passed }} | |
| result: ${{ steps.extract.outputs.result }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Find parent CLAUDE.md files | |
| id: find | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| # Create review context directory | |
| mkdir -p .claude/review-context | |
| gh pr view ${{ github.event.pull_request.number }} --json files --jq '.files[].path' > .claude/review-context/changed.txt | |
| > .claude/review-context/claude_files.txt | |
| while IFS= read -r file; do | |
| dir=$(dirname "$file") | |
| while [ "$dir" != "." ]; do | |
| [ -f "$dir/CLAUDE.md" ] && echo "$dir/CLAUDE.md" >> .claude/review-context/claude_files.txt | |
| dir=$(dirname "$dir") | |
| done | |
| [ -f "CLAUDE.md" ] && echo "CLAUDE.md" >> .claude/review-context/claude_files.txt | |
| done < .claude/review-context/changed.txt | |
| sort -u .claude/review-context/claude_files.txt -o .claude/review-context/claude_files.txt | |
| COUNT=$(wc -l < .claude/review-context/claude_files.txt | tr -d ' ') | |
| echo "count=$COUNT" >> $GITHUB_OUTPUT | |
| - name: Run review | |
| if: steps.find.outputs.count != '0' | |
| id: review | |
| uses: anthropics/claude-code-base-action@beta | |
| with: | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| allowed_tools: "View,GlobTool,Grep,Bash(cat:*)" | |
| max_turns: 15 | |
| prompt: | | |
| # Context Review | |
| Check changes against CLAUDE.md files listed in .claude/review-context/claude_files.txt | |
| Changed files: .claude/review-context/changed.txt | |
| Output ```json {"passed": bool, "summary": "..."} ``` | |
| - name: Extract result | |
| id: extract | |
| run: | | |
| if [ "${{ steps.find.outputs.count }}" = "0" ]; then | |
| echo "passed=true" >> $GITHUB_OUTPUT | |
| echo 'result={"passed":true,"summary":"No CLAUDE.md files to check"}' >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| EXEC="${{ steps.review.outputs.execution_file }}" | |
| if [ -f "$EXEC" ]; then | |
| TEXT=$(jq -r '[.[] | select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text] | last // ""' "$EXEC") | |
| JSON=$(echo "$TEXT" | sed -n '/```json/,/```/{/```/d;p;}') | |
| if echo "$JSON" | jq . >/dev/null 2>&1; then | |
| echo "$JSON" > /tmp/result.json | |
| echo "passed=$(jq -r '.passed // true' /tmp/result.json)" >> $GITHUB_OUTPUT | |
| echo "result=$(jq -c '.' /tmp/result.json)" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| fi | |
| echo "passed=true" >> $GITHUB_OUTPUT | |
| echo 'result={"passed":true,"summary":"Review completed"}' >> $GITHUB_OUTPUT | |
| - name: Update review comment | |
| if: always() | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| MARKER="<!-- ci-review-${{ github.sha }} -->" | |
| PR="${{ github.event.pull_request.number }}" | |
| REVIEW="Context" | |
| PASSED="${{ steps.extract.outputs.passed }}" | |
| RESULT='${{ steps.extract.outputs.result }}' | |
| STATUS="✓" | |
| [ "$PASSED" != "true" ] && STATUS="✗" | |
| SUMMARY=$(echo "$RESULT" | jq -r '.summary // "N/A"' 2>/dev/null || echo "N/A") | |
| ROW="| $REVIEW | $STATUS | $SUMMARY |" | |
| # Check for existing comment | |
| EXISTING=$(gh api "/repos/${{ github.repository }}/issues/$PR/comments" \ | |
| --jq ".[] | select(.body | contains(\"$MARKER\")) | {id: .id, body: .body}" | head -1) | |
| if [ -n "$EXISTING" ]; then | |
| COMMENT_ID=$(echo "$EXISTING" | jq -r '.id') | |
| OLD_BODY=$(echo "$EXISTING" | jq -r '.body') | |
| # Append new row before the footer | |
| NEW_BODY=$(echo "$OLD_BODY" | sed "/---/i\\$ROW") | |
| gh api --method PATCH "/repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$NEW_BODY" | |
| else | |
| # Create new comment (shouldn't happen as requirements runs first) | |
| COMMENT=$(printf '%s\n## Reviews\n\n| Review | Status | Summary |\n|--------|--------|---------|\n%s\n\n---\n*Claude Code CI*' "$MARKER" "$ROW") | |
| gh pr comment "$PR" --body "$COMMENT" | |
| fi | |
| visual: | |
| name: "Reviews / Visual" | |
| needs: [lint, requirements, e2e] | |
| if: github.event_name == 'pull_request' && needs.lint.outputs.has_app == 'true' && needs.requirements.outputs.passed == 'true' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| passed: ${{ steps.extract.outputs.passed }} | |
| result: ${{ steps.extract.outputs.result }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: playwright-screenshots-${{ github.sha }} | |
| path: .claude/screenshots/ | |
| continue-on-error: true | |
| - name: Check screenshots | |
| id: check | |
| run: | | |
| COUNT=$(ls -1 .claude/screenshots/*.png 2>/dev/null | wc -l || echo "0") | |
| echo "count=$COUNT" >> $GITHUB_OUTPUT | |
| - name: Run review | |
| if: steps.check.outputs.count != '0' | |
| id: review | |
| uses: anthropics/claude-code-base-action@beta | |
| with: | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| allowed_tools: "View,GlobTool,Bash(ls:*)" | |
| max_turns: 20 | |
| model: claude-sonnet-4-5-20250929 | |
| prompt: | | |
| # UI Review | |
| Review screenshots in .claude/screenshots/ | |
| Evaluate design, accessibility, UX | |
| Output ```json {"passed": bool, "summary": "..."} ``` | |
| - name: Extract result | |
| id: extract | |
| run: | | |
| if [ "${{ steps.check.outputs.count }}" = "0" ]; then | |
| echo "passed=true" >> $GITHUB_OUTPUT | |
| echo 'result={"passed":true,"summary":"No screenshots to review"}' >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| EXEC="${{ steps.review.outputs.execution_file }}" | |
| if [ -f "$EXEC" ]; then | |
| TEXT=$(jq -r '[.[] | select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text] | last // ""' "$EXEC") | |
| JSON=$(echo "$TEXT" | sed -n '/```json/,/```/{/```/d;p;}') | |
| if echo "$JSON" | jq . >/dev/null 2>&1; then | |
| echo "$JSON" > /tmp/result.json | |
| echo "passed=$(jq -r '.passed // true' /tmp/result.json)" >> $GITHUB_OUTPUT | |
| echo "result=$(jq -c '.' /tmp/result.json)" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| fi | |
| echo "passed=true" >> $GITHUB_OUTPUT | |
| echo 'result={"passed":true,"summary":"Review completed"}' >> $GITHUB_OUTPUT | |
| - name: Update review comment | |
| if: always() | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| MARKER="<!-- ci-review-${{ github.sha }} -->" | |
| PR="${{ github.event.pull_request.number }}" | |
| REVIEW="Visual" | |
| PASSED="${{ steps.extract.outputs.passed }}" | |
| RESULT='${{ steps.extract.outputs.result }}' | |
| STATUS="✓" | |
| [ "$PASSED" != "true" ] && STATUS="✗" | |
| SUMMARY=$(echo "$RESULT" | jq -r '.summary // "N/A"' 2>/dev/null || echo "N/A") | |
| ROW="| $REVIEW | $STATUS | $SUMMARY |" | |
| # Check for existing comment | |
| EXISTING=$(gh api "/repos/${{ github.repository }}/issues/$PR/comments" \ | |
| --jq ".[] | select(.body | contains(\"$MARKER\")) | {id: .id, body: .body}" | head -1) | |
| if [ -n "$EXISTING" ]; then | |
| COMMENT_ID=$(echo "$EXISTING" | jq -r '.id') | |
| OLD_BODY=$(echo "$EXISTING" | jq -r '.body') | |
| # Append new row before the footer | |
| NEW_BODY=$(echo "$OLD_BODY" | sed "/---/i\\$ROW") | |
| gh api --method PATCH "/repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$NEW_BODY" | |
| else | |
| # Create new comment (shouldn't happen as requirements runs first) | |
| COMMENT=$(printf '%s\n## Reviews\n\n| Review | Status | Summary |\n|--------|--------|---------|\n%s\n\n---\n*Claude Code CI*' "$MARKER" "$ROW") | |
| gh pr comment "$PR" --body "$COMMENT" | |
| fi | |
| ux: | |
| name: "Reviews / UX" | |
| needs: [lint, requirements, e2e] | |
| if: github.event_name == 'pull_request' && needs.lint.outputs.has_app == 'true' && needs.requirements.outputs.passed == 'true' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| passed: ${{ steps.extract.outputs.passed }} | |
| result: ${{ steps.extract.outputs.result }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Get context | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| mkdir -p .claude/review-context | |
| gh pr view ${{ github.event.pull_request.number }} --json files --jq '.files[].path' > .claude/review-context/changed.txt | |
| - name: Check for UX-relevant changes | |
| id: check | |
| run: | | |
| RELEVANT=$(grep -E '\.(tsx?|jsx?|css|scss|html)$' .claude/review-context/changed.txt 2>/dev/null | wc -l || echo "0") | |
| echo "count=$RELEVANT" >> $GITHUB_OUTPUT | |
| - name: Load agent prompt | |
| id: agent | |
| run: | | |
| if [ -f ".claude/agents/reviewers/ux.md" ]; then | |
| PROMPT=$(cat .claude/agents/reviewers/ux.md) | |
| else | |
| PROMPT="# UX Review | |
| Analyze the PR for user experience issues: | |
| 1. Console errors and warnings | |
| 2. Interactivity and responsiveness patterns | |
| 3. Loading states and error handling | |
| 4. Accessibility concerns | |
| 5. User flow consistency | |
| Changed files: .claude/review-context/changed.txt | |
| Output \`\`\`json {\"passed\": bool, \"summary\": \"...\", \"issues\": [...]} \`\`\`" | |
| fi | |
| echo "prompt<<ENDOFPROMPT" >> $GITHUB_OUTPUT | |
| echo "$PROMPT" >> $GITHUB_OUTPUT | |
| echo "ENDOFPROMPT" >> $GITHUB_OUTPUT | |
| - name: Run review | |
| if: steps.check.outputs.count != '0' | |
| id: review | |
| uses: anthropics/claude-code-base-action@beta | |
| with: | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| allowed_tools: "View,GlobTool,Grep,Bash(git diff:*),Bash(cat:*),Bash(ls:*)" | |
| max_turns: 15 | |
| prompt: ${{ steps.agent.outputs.prompt }} | |
| - name: Extract result | |
| id: extract | |
| run: | | |
| if [ "${{ steps.check.outputs.count }}" = "0" ]; then | |
| echo "passed=true" >> $GITHUB_OUTPUT | |
| echo 'result={"passed":true,"summary":"No UX-relevant changes to review"}' >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| EXEC="${{ steps.review.outputs.execution_file }}" | |
| if [ -f "$EXEC" ]; then | |
| TEXT=$(jq -r '[.[] | select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text] | last // ""' "$EXEC") | |
| JSON=$(echo "$TEXT" | sed -n '/```json/,/```/{/```/d;p;}') | |
| if echo "$JSON" | jq . >/dev/null 2>&1; then | |
| echo "$JSON" > /tmp/result.json | |
| echo "passed=$(jq -r '.passed // true' /tmp/result.json)" >> $GITHUB_OUTPUT | |
| echo "result=$(jq -c '.' /tmp/result.json)" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| fi | |
| echo "passed=true" >> $GITHUB_OUTPUT | |
| echo 'result={"passed":true,"summary":"Review completed"}' >> $GITHUB_OUTPUT | |
| - name: Update review comment | |
| if: always() | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| MARKER="<!-- ci-review-${{ github.sha }} -->" | |
| PR="${{ github.event.pull_request.number }}" | |
| REVIEW="UX" | |
| PASSED="${{ steps.extract.outputs.passed }}" | |
| RESULT='${{ steps.extract.outputs.result }}' | |
| STATUS="✓" | |
| [ "$PASSED" != "true" ] && STATUS="✗" | |
| SUMMARY=$(echo "$RESULT" | jq -r '.summary // "N/A"' 2>/dev/null || echo "N/A") | |
| ROW="| $REVIEW | $STATUS | $SUMMARY |" | |
| # Check for existing comment | |
| EXISTING=$(gh api "/repos/${{ github.repository }}/issues/$PR/comments" \ | |
| --jq ".[] | select(.body | contains(\"$MARKER\")) | {id: .id, body: .body}" | head -1) | |
| if [ -n "$EXISTING" ]; then | |
| COMMENT_ID=$(echo "$EXISTING" | jq -r '.id') | |
| OLD_BODY=$(echo "$EXISTING" | jq -r '.body') | |
| NEW_BODY=$(echo "$OLD_BODY" | sed "/---/i\\$ROW") | |
| gh api --method PATCH "/repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$NEW_BODY" | |
| else | |
| COMMENT=$(printf '%s\n## Reviews\n\n| Review | Status | Summary |\n|--------|--------|---------|\n%s\n\n---\n*Claude Code CI*' "$MARKER" "$ROW") | |
| gh pr comment "$PR" --body "$COMMENT" | |
| fi |