473 lines
20 KiB
YAML
473 lines
20 KiB
YAML
name: ui-review
|
|
|
|
on:
|
|
issue_comment:
|
|
types: [created]
|
|
pull_request:
|
|
paths:
|
|
- ".github/workflows/ui-review.yml"
|
|
- ".claude/skills/ui-review/**"
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}
|
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
|
|
permissions: {}
|
|
|
|
jobs:
|
|
# Auth gate: only users with admin or maintain role on the repo can trigger
|
|
# the UI review (plus the Copilot bot). For issue_comment, BOTH the PR author
|
|
# and the commenter are checked, so the bot never runs for external
|
|
# contributors (neither author nor commenter).
|
|
gate:
|
|
runs-on: ubuntu-slim
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: read
|
|
# Cheap author_association prefilter so random users can't spawn workflow
|
|
# runs. The gate step below does the authoritative admin/maintain role check.
|
|
if: >
|
|
(github.event_name == 'pull_request' &&
|
|
github.event.pull_request.head.repo.full_name == github.repository &&
|
|
github.event.pull_request.draft == false &&
|
|
(contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) ||
|
|
(github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')))
|
|
||
|
|
(github.event_name == 'issue_comment' &&
|
|
github.event.issue.pull_request &&
|
|
(
|
|
contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association) ||
|
|
(github.event.issue.user.login == 'Copilot' && github.event.issue.user.type == 'Bot')
|
|
) &&
|
|
contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
|
|
(github.event.comment.body == '/ui-review' || startsWith(github.event.comment.body, '/ui-review ')))
|
|
steps:
|
|
- name: Check user permission
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
REPO: ${{ github.repository }}
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
|
PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type || github.event.issue.user.type }}
|
|
COMMENTER: ${{ github.event.comment.user.login }}
|
|
run: |
|
|
check_user() {
|
|
local user=$1
|
|
local type=$2
|
|
if [ "$user" = "Copilot" ] && [ "$type" = "Bot" ]; then
|
|
echo "Allowing Copilot bot: $user"
|
|
return 0
|
|
fi
|
|
local role
|
|
role=$(gh api "repos/$REPO/collaborators/$user/permission" --jq .role_name) \
|
|
|| { echo "Failed to fetch permission for $user"; exit 1; }
|
|
case "$role" in
|
|
admin|maintain)
|
|
echo "User $user is allowed (admin or maintain role)"
|
|
;;
|
|
*)
|
|
echo "User $user is not allowed (admin or maintain role required)"
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
check_user "$PR_AUTHOR" "$PR_AUTHOR_TYPE"
|
|
if [ "$EVENT_NAME" = "issue_comment" ]; then
|
|
check_user "$COMMENTER" "User"
|
|
fi
|
|
|
|
review:
|
|
needs: gate
|
|
runs-on: ubuntu-latest
|
|
# GITHUB_TOKEN is unused — all GitHub API calls go through the app tokens
|
|
# minted below. Grant nothing so any future accidental use of GITHUB_TOKEN
|
|
# fails closed.
|
|
permissions: {}
|
|
# Heavy: yarn install + dev server boot + Chrome download + a vision/browser
|
|
# review loop. The Claude step is capped at 30m within this budget.
|
|
timeout-minutes: 45
|
|
steps:
|
|
# Two app tokens with split scopes:
|
|
# - app-token-read (read-only): the ONLY GitHub token exposed to Claude /
|
|
# agent-browser, so prompt-injection in the diff or a rendered page can't
|
|
# trigger comments, approvals, or any PR mutation.
|
|
# - app-token-write (write): used only by the React/Post/Report steps we control.
|
|
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
|
id: app-token-read
|
|
with:
|
|
client-id: ${{ secrets.APP_CLIENT_ID }}
|
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
permission-contents: read
|
|
permission-pull-requests: read
|
|
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
|
if: github.event_name == 'issue_comment'
|
|
id: app-token-write
|
|
with:
|
|
client-id: ${{ secrets.APP_CLIENT_ID }}
|
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
permission-actions: read
|
|
permission-contents: read
|
|
permission-pull-requests: write
|
|
|
|
- name: Resolve job URL
|
|
if: github.event_name == 'issue_comment'
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token-write.outputs.token }}
|
|
REPO: ${{ github.repository }}
|
|
RUN_ID: ${{ github.run_id }}
|
|
RUN_ATTEMPT: ${{ github.run_attempt }}
|
|
PR_NUMBER: ${{ github.event.issue.number }}
|
|
run: |
|
|
# first(...) // empty: pick exactly one id, or output nothing if no match.
|
|
JOB_ID=$(gh api "repos/$REPO/actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT/jobs" \
|
|
--jq 'first(.jobs[] | select(.name == "review") | .id) // empty')
|
|
if [ -n "$JOB_ID" ]; then
|
|
JOB_RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID/job/$JOB_ID?pr=$PR_NUMBER"
|
|
else
|
|
JOB_RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID?pr=$PR_NUMBER"
|
|
fi
|
|
echo "JOB_RUN_URL=$JOB_RUN_URL" >> "$GITHUB_ENV"
|
|
|
|
- name: React to comment
|
|
if: github.event_name == 'issue_comment'
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
|
with:
|
|
github-token: ${{ steps.app-token-write.outputs.token }}
|
|
retries: 3
|
|
script: |
|
|
const { comment } = context.payload;
|
|
// Anchor the status block on a stable marker so a workflow re-run replaces
|
|
// it instead of appending a duplicate. The original comment never contains it.
|
|
const MARKER = '<!-- ui-review-status -->';
|
|
const base = comment.body.split(MARKER)[0].replace(/\s+$/, '');
|
|
const message = `🎨 [UI Review running...](${process.env.JOB_RUN_URL})`;
|
|
const updatedBody = `${base}\n\n${MARKER}\n\n---\n\n${message}`;
|
|
|
|
await github.rest.issues.updateComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
comment_id: comment.id,
|
|
body: updatedBody,
|
|
});
|
|
|
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
persist-credentials: false
|
|
token: ${{ steps.app-token-read.outputs.token }}
|
|
# Review the post-merge UI. Depth 2 so HEAD^1 (the merge base parent)
|
|
# is reachable for the skill's `git diff HEAD^1` and `git show HEAD^1:`.
|
|
ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/merge
|
|
fetch-depth: 2
|
|
|
|
- uses: ./.github/actions/free-disk-space
|
|
- uses: ./.github/actions/setup-python
|
|
with:
|
|
pin-micro-version: false
|
|
- uses: ./.github/actions/setup-node
|
|
|
|
- name: Parse review arguments
|
|
id: prompt
|
|
env:
|
|
COMMENT_BODY: ${{ github.event_name == 'issue_comment' && github.event.comment.body || '' }}
|
|
run: |
|
|
cat > /tmp/parse_args.py <<'EOF'
|
|
import argparse
|
|
import shlex
|
|
import sys
|
|
|
|
MODEL_CHOICES = [
|
|
"fable",
|
|
"opus",
|
|
"sonnet",
|
|
"haiku",
|
|
"claude-fable-5",
|
|
"claude-opus-4-7",
|
|
"claude-sonnet-4-6",
|
|
"claude-haiku-4-5",
|
|
]
|
|
EFFORT_CHOICES = ["low", "medium", "high", "xhigh", "max"]
|
|
|
|
body = sys.stdin.read().strip().removeprefix("/ui-review")
|
|
try:
|
|
tokens = shlex.split(body)
|
|
except ValueError:
|
|
tokens = []
|
|
|
|
parser = argparse.ArgumentParser(add_help=False)
|
|
# -m/--model and -e/--effort mirror the /review command.
|
|
parser.add_argument("-m", "--model", choices=MODEL_CHOICES, default="claude-opus-4-7")
|
|
parser.add_argument("-e", "--effort", choices=EFFORT_CHOICES, default="high")
|
|
args = parser.parse_args(tokens)
|
|
|
|
sys.stdout.write(f"model={args.model}\neffort={args.effort}\n")
|
|
EOF
|
|
|
|
echo "$COMMENT_BODY" | python /tmp/parse_args.py >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Launch dev server with demo data
|
|
env:
|
|
MLFLOW_TRACKING_URI: sqlite:////tmp/ui-review/mlflow.db
|
|
CI: "false" # CRA dev server treats warnings as errors when CI=true
|
|
run: |
|
|
mkdir -p /tmp/ui-review
|
|
# Pre-populate the GenAI demo dataset (offline, no API key) so pages aren't empty.
|
|
uv run --frozen python -c "from mlflow.demo import generate_all_demos; generate_all_demos(refresh=True)"
|
|
# Install a credential-free stub `claude` so the Assistant provider's auth
|
|
# probe passes and its chat panel renders (the dev server has no
|
|
# ANTHROPIC_API_KEY). Scoped to the dev server's own PATH; the real CLI in
|
|
# the UI Review step below is untouched. Always on: it's harmless for
|
|
# non-Assistant reviews and keeps the Assistant reviewable on any PR.
|
|
# run_dev_server.py starts the backend + `yarn start` frontend (much faster than
|
|
# a full `yarn build`) and honors MLFLOW_TRACKING_URI for the store.
|
|
uv run --frozen dev/run_dev_server.py --stub-providers claude > /tmp/dev-server.log 2>&1 &
|
|
echo "DEV_PID=$!" >> "$GITHUB_ENV"
|
|
APP_URL=""
|
|
for _ in $(seq 1 120); do
|
|
[ -z "$APP_URL" ] && APP_URL=$(grep -oE 'Frontend: http://localhost:[0-9]+' /tmp/dev-server.log | head -1 | grep -oE 'http://localhost:[0-9]+' || true)
|
|
[ -n "$APP_URL" ] && curl -fsS "$APP_URL" >/dev/null 2>&1 && break
|
|
sleep 5
|
|
done
|
|
if [ -z "$APP_URL" ] || ! curl -fsS "$APP_URL" >/dev/null 2>&1; then
|
|
echo "::error::dev server did not become ready"; tail -80 /tmp/dev-server.log; exit 1
|
|
fi
|
|
echo "APP_URL=$APP_URL" >> "$GITHUB_ENV"
|
|
|
|
- name: Set up agent-browser
|
|
run: |
|
|
# `npm i -g` (alias form, which policy.rego's lockfile rule doesn't flag) puts
|
|
# agent-browser on PATH directly. `--before` keeps the repo's 7-day supply-chain
|
|
# cooldown (there's no .npmrc setting min-release-age).
|
|
npm i -g agent-browser --before="$(date -u -d '7 days ago' +%Y-%m-%d)"
|
|
agent-browser install # downloads Chrome (headless by default)
|
|
agent-browser --version
|
|
|
|
- name: Install Claude CLI
|
|
run: |
|
|
.claude/scripts/install-claude.sh
|
|
- name: Verify Claude CLI
|
|
run: |
|
|
claude --version
|
|
|
|
- name: UI Review
|
|
id: review
|
|
env:
|
|
# Read-only token: Claude / agent-browser can read the PR but cannot post anything.
|
|
GH_TOKEN: ${{ steps.app-token-read.outputs.token }}
|
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
REPO: ${{ github.repository }}
|
|
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
|
APP_URL: ${{ env.APP_URL }}
|
|
MODEL: ${{ steps.prompt.outputs.model }}
|
|
EFFORT: ${{ steps.prompt.outputs.effort }}
|
|
AGENT_BROWSER_SCREENSHOT_DIR: /tmp/ui-review-shots
|
|
AGENT_BROWSER_DEFAULT_TIMEOUT: "30000"
|
|
run: |
|
|
mkdir -p "$AGENT_BROWSER_SCREENSHOT_DIR"
|
|
OUTPUT_FILE="/tmp/ui-output.jsonl"
|
|
|
|
# PIPESTATUS[0] captures claude's exit (not stream.sh's), so a 124 timeout isn't masked.
|
|
EXIT_CODE=0
|
|
timeout 30m claude \
|
|
--model "$MODEL" \
|
|
--effort "$EFFORT" \
|
|
--max-budget-usd 8 \
|
|
--print \
|
|
--verbose \
|
|
--output-format stream-json \
|
|
"/ui-review $REPO $PR_NUMBER $APP_URL" \
|
|
| .claude/scripts/stream.sh "$OUTPUT_FILE" || EXIT_CODE="${PIPESTATUS[0]}"
|
|
if [ "${EXIT_CODE:-0}" -eq 124 ]; then
|
|
echo "Warning: Claude command timed out after 30 minutes"
|
|
elif [ "${EXIT_CODE:-0}" -ne 0 ]; then
|
|
echo "Error: Claude command failed with exit code $EXIT_CODE"
|
|
cat "$OUTPUT_FILE"
|
|
exit "$EXIT_CODE"
|
|
fi
|
|
|
|
- name: Stop dev server
|
|
if: always()
|
|
run: |
|
|
# The review loop is the only consumer of the dev server, so stop it as
|
|
# soon as Claude finishes; the remaining steps (check, upload, comment)
|
|
# don't need it. always() so it runs even if the review step failed or
|
|
# timed out. Guard on a non-empty PID so a bare `kill 0` can't signal the
|
|
# whole process group.
|
|
if [ -n "${DEV_PID:-}" ]; then
|
|
kill "$DEV_PID" 2>/dev/null || true
|
|
fi
|
|
|
|
- name: Check UI review output
|
|
run: |
|
|
# The skill writes Markdown (no JSON schema): just confirm it produced a
|
|
# non-empty body. The workflow wraps it with the header/footer below.
|
|
if [ ! -s /tmp/ui-review-body.md ]; then
|
|
echo "::error::Claude did not produce a non-empty /tmp/ui-review-body.md"
|
|
exit 1
|
|
fi
|
|
echo "::group::UI review body"
|
|
cat /tmp/ui-review-body.md
|
|
echo "::endgroup::"
|
|
|
|
- name: Collect stray screenshots
|
|
if: always()
|
|
run: |
|
|
# Safety net: agent-browser writes a bare-filename screenshot to the
|
|
# browser daemon's cwd (the workspace root), not $AGENT_BROWSER_SCREENSHOT_DIR.
|
|
# The skill is instructed to use absolute paths, but sweep any depth-1
|
|
# PNGs into the shots dir so evidence is never silently dropped.
|
|
mkdir -p /tmp/ui-review-shots
|
|
find "$GITHUB_WORKSPACE" -maxdepth 1 -name '*.png' -exec mv -f {} /tmp/ui-review-shots/ \; 2>/dev/null || true
|
|
|
|
- name: Upload screenshots
|
|
if: always()
|
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
|
with:
|
|
# Stable name (artifacts are per-run) so it matches the comment text below.
|
|
# overwrite handles workflow re-runs (same run id, new attempt).
|
|
name: ui-review-screenshots
|
|
path: /tmp/ui-review-shots
|
|
retention-days: 7
|
|
if-no-files-found: ignore
|
|
overwrite: true
|
|
|
|
- name: Post UI review comment
|
|
if: github.event_name == 'issue_comment'
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token-write.outputs.token }}
|
|
REPO: ${{ github.repository }}
|
|
PR_NUMBER: ${{ github.event.issue.number }}
|
|
ARTIFACT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
run: |
|
|
# The skill writes only the summary + findings (/tmp/ui-review-body.md).
|
|
# The workflow owns the run-specific wrapping: the find-and-update marker,
|
|
# the header, the screenshots-artifact link, and the provenance footer.
|
|
MARKER="<!-- ui-review-summary -->"
|
|
{
|
|
echo "$MARKER"
|
|
echo "## 🎨 UI Review"
|
|
echo
|
|
cat /tmp/ui-review-body.md
|
|
if find /tmp/ui-review-shots -maxdepth 1 -name '*.png' 2>/dev/null | grep -q .; then
|
|
echo
|
|
echo "📎 Screenshots: see the **ui-review-screenshots** artifact on [this run](${ARTIFACT_URL})."
|
|
fi
|
|
echo
|
|
echo "🤖 Generated with Claude"
|
|
} > /tmp/ui-review-comment.md
|
|
# Find-and-update an existing summary comment (by marker) so workflow
|
|
# re-runs replace it instead of appending a duplicate; create if none.
|
|
CID=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
|
|
--jq "[.[] | select(.body | startswith(\"$MARKER\")) | .id] | last // empty")
|
|
if [ -n "$CID" ]; then
|
|
gh api "repos/$REPO/issues/comments/$CID" -X PATCH -F body=@/tmp/ui-review-comment.md
|
|
else
|
|
gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file /tmp/ui-review-comment.md
|
|
fi
|
|
|
|
- name: Report review results
|
|
if: always() && github.event_name == 'issue_comment'
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
|
env:
|
|
JOB_STATUS: ${{ job.status }}
|
|
MODEL: ${{ steps.prompt.outputs.model }}
|
|
with:
|
|
github-token: ${{ steps.app-token-write.outputs.token }}
|
|
retries: 3
|
|
script: |
|
|
const fs = require('fs');
|
|
const { owner, repo } = context.repo;
|
|
const { comment } = context.payload;
|
|
const jobStatus = process.env.JOB_STATUS;
|
|
const model = process.env.MODEL;
|
|
|
|
const outputPath = '/tmp/ui-output.jsonl';
|
|
let claudeOutput = '';
|
|
try {
|
|
if (fs.existsSync(outputPath)) {
|
|
claudeOutput = fs.readFileSync(outputPath, 'utf8');
|
|
}
|
|
} catch (e) {
|
|
console.log('Failed to read Claude output file:', e);
|
|
}
|
|
|
|
let resultEvent = null;
|
|
if (claudeOutput) {
|
|
try {
|
|
const events = claudeOutput.trim().split('\n').filter(Boolean).map(JSON.parse);
|
|
resultEvent = events.findLast(({ type }) => type === 'result');
|
|
} catch (e) {
|
|
console.log('Failed to parse Claude output as JSON:', e);
|
|
}
|
|
}
|
|
|
|
const jobRunUrl = process.env.JOB_RUN_URL;
|
|
const failed = jobStatus !== 'success' || resultEvent?.is_error;
|
|
const icon = failed ? '❌' : '✅';
|
|
const verb = failed ? 'failed' : 'completed';
|
|
let resultLine = `${icon} [UI Review](${jobRunUrl}) ${verb}`;
|
|
|
|
if (resultEvent) {
|
|
const seconds = (resultEvent.duration_ms / 1000).toFixed(1);
|
|
const tokens = (resultEvent.usage?.input_tokens ?? 0) + (resultEvent.usage?.output_tokens ?? 0);
|
|
const cost = (Math.round(resultEvent.total_cost_usd * 100) / 100).toFixed(2);
|
|
resultLine += ` (${model}, ${seconds}s, ${resultEvent.num_turns} turns, ${tokens} tokens, $${cost})`;
|
|
if (resultEvent.is_error && /529|Overloaded/i.test(resultEvent.result || '')) {
|
|
resultLine += `\n\nThe Claude API is overloaded. Check [status.claude.com](https://status.claude.com/) and retry.`;
|
|
}
|
|
} else if (failed) {
|
|
resultLine += `\n\nSee [workflow logs](${jobRunUrl}) for details.`;
|
|
}
|
|
|
|
const { data: currentComment } = await github.rest.issues.getComment({
|
|
owner,
|
|
repo,
|
|
comment_id: comment.id,
|
|
});
|
|
|
|
const MARKER = '<!-- ui-review-status -->';
|
|
const originalBody = currentComment.body.split(MARKER)[0].replace(/\s+$/, '');
|
|
const updatedBody = `${originalBody}\n\n${MARKER}\n\n---\n\n${resultLine}`;
|
|
|
|
await github.rest.issues.updateComment({
|
|
owner,
|
|
repo,
|
|
comment_id: comment.id,
|
|
body: updatedBody,
|
|
});
|
|
|
|
- name: Redact secrets from transcript
|
|
if: always()
|
|
env:
|
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
GH_TOKEN_READ: ${{ steps.app-token-read.outputs.token }}
|
|
GH_TOKEN_WRITE: ${{ steps.app-token-write.outputs.token }}
|
|
run: |
|
|
python <<'PY'
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
path = Path("/tmp/ui-output.jsonl")
|
|
if not path.exists():
|
|
sys.exit(0)
|
|
data = path.read_text()
|
|
for name in ("ANTHROPIC_API_KEY", "GH_TOKEN_READ", "GH_TOKEN_WRITE"):
|
|
if val := os.environ.get(name):
|
|
data = data.replace(val, "***")
|
|
path.write_text(data)
|
|
PY
|
|
|
|
- name: Upload Claude stream transcript
|
|
if: always()
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
|
with:
|
|
name: ui-review-stream-${{ github.run_id }}-${{ github.run_attempt }}
|
|
path: /tmp/ui-output.jsonl
|
|
retention-days: 1
|
|
if-no-files-found: ignore
|