From e329b856b6edaf0fa9d8949252ed8796f27788b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Tue, 7 Jul 2026 14:41:29 +0200 Subject: [PATCH 1/5] chore: add claude workflow --- .github/claude/prompt.js | 69 +++++++++++++ .github/claude/summary.js | 171 ++++++++++++++++++++++++++++++++ .github/claude/system-prompt.md | 27 +++++ .github/workflows/claude.yml | 144 +++++++++++++++++++++++++++ 4 files changed, 411 insertions(+) create mode 100644 .github/claude/prompt.js create mode 100644 .github/claude/summary.js create mode 100644 .github/claude/system-prompt.md create mode 100644 .github/workflows/claude.yml diff --git a/.github/claude/prompt.js b/.github/claude/prompt.js new file mode 100644 index 00000000000..b81ebfd9a8a --- /dev/null +++ b/.github/claude/prompt.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * Build the extra prompt passed to the Claude Code Action from the webhook + * payload ($GITHUB_EVENT_NAME, $GITHUB_EVENT_PATH). Writes it to $GITHUB_OUTPUT + * as `prompt` using a heredoc delimiter, so multi-line prompts pass through + * unescaped; prints to stdout when run locally. + * + * Usage: + * node .github/claude/prompt.js + */ + +const fs = require('fs'); + +// The action's tag-mode prompt only contains the trigger comment's body, with +// no link to the review thread it replies to — a reply like "@claude fix this" +// is ambiguous and Claude tends to fix ALL review comments. Anchor the run to +// the triggering thread (file/line/comment ids). The line and reply clauses are +// only added when present, so a thread-starting comment reads cleanly. +function buildReviewCommentPrompt(comment) { + const line = comment.original_line ? ` (line ${comment.original_line})` : ''; + const reply = comment.in_reply_to_id + ? `, in reply to comment id ${comment.in_reply_to_id}` + : ''; + return `SCOPE: You were triggered by review comment id ${comment.id} in a single \ +review thread on file \`${comment.path}\`${line}${reply}. Find that thread in the \ +review comments above and address ONLY the issue discussed in it. All other review \ +comments and threads are background context — do not address them unless the trigger \ +comment explicitly asks you to.`; +} + +// Returns the prompt string for the given event, or '' when is not specific +// prompt for that event. +function buildPrompt(eventName, payload) { + if (eventName === 'pull_request_review_comment') { + return buildReviewCommentPrompt(payload.comment); + } + return ''; +} + +// Write a (possibly multi-line) value to $GITHUB_OUTPUT under `name` using the +// heredoc form GitHub expects. The delimiter must not appear in the value; the +// prompt is fully controlled here and never contains it. +function writeOutput(name, value) { + const delimiter = 'CLAUDE_PROMPT_EOF'; + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `${name}<<${delimiter}\n${value}\n${delimiter}\n` + ); +} + +function main() { + const eventName = process.env.GITHUB_EVENT_NAME; + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventName || !eventPath) { + console.error('GITHUB_EVENT_NAME and GITHUB_EVENT_PATH must be set'); + process.exit(2); + } + + const payload = JSON.parse(fs.readFileSync(eventPath, 'utf8')); + const prompt = buildPrompt(eventName, payload); + + if (process.env.GITHUB_OUTPUT) { + writeOutput('prompt', prompt); + } else { + console.log(prompt); + } +} + +main(); diff --git a/.github/claude/summary.js b/.github/claude/summary.js new file mode 100644 index 00000000000..160460a9139 --- /dev/null +++ b/.github/claude/summary.js @@ -0,0 +1,171 @@ +#!/usr/bin/env node +/** + * Summarize a Claude Code Action run for the GitHub job summary. + * + * The claude-code-action writes its full stream-json log to a file and + * exposes the path as the `execution_file` step output. The action itself + * only prints an aggregate to the console (e.g. "permission_denials_count": + * 13) and leaves the job green even when Claude was blocked from using tools. + * This script reads that file and surfaces the parts worth a glance: + * + * - a session summary (outcome, turns, duration, cost, token usage) + * - the tools Claude was denied, so the maintainer knows what to add to + * `--allowedTools` + * + * The summary is appended to $GITHUB_STEP_SUMMARY (or printed to stdout when + * run locally). + * + * Usage: + * node .github/claude/summary.js + */ + +const fs = require('fs'); + +// Pull the final result event out of the stream-json log. It is the last +// message with type "result" and carries the run's aggregates. +function readResult(executionFile) { + const messages = JSON.parse(fs.readFileSync(executionFile, 'utf8')); + if (!Array.isArray(messages)) { + throw new Error('Execution file is not a JSON array of messages'); + } + const result = [...messages].reverse().find((m) => m && m.type === 'result'); + if (!result) { + throw new Error('No result event found in execution file'); + } + return result; +} + +function formatDuration(ms) { + if (!ms && ms !== 0) return '—'; + const totalSeconds = Math.round(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; +} + +function formatTokens(n) { + return typeof n === 'number' ? n.toLocaleString('en-US') : '—'; +} + +function formatCost(usd) { + return typeof usd === 'number' ? `$${usd.toFixed(4)}` : '—'; +} + +// Show the bare command/url for the common single-field tool inputs; fall +// back to the raw JSON for anything else. +function denialInput(input) { + if (!input) return ''; + return input.command ?? input.url ?? JSON.stringify(input); +} + +// The outcome is "success" or one of several error subtypes; is_error also +// flags a run that ended badly. +function outcomeLabel(result) { + if (result.subtype === 'success' && !result.is_error) { + return '✅ Success'; + } + return `❌ ${result.subtype || 'error'}`; +} + +function renderSessionSummary(result) { + const lines = []; + lines.push('## Claude Code session'); + lines.push(''); + lines.push('| Description | Value |'); + lines.push('|---|---|'); + lines.push(`| Outcome | ${outcomeLabel(result)} |`); + lines.push(`| Turns | ${result.num_turns ?? '—'} |`); + lines.push(`| Duration | ${formatDuration(result.duration_ms)} |`); + lines.push(`| API time | ${formatDuration(result.duration_api_ms)} |`); + lines.push(`| Cost | ${formatCost(result.total_cost_usd)} |`); + if (result.session_id) { + lines.push(`| Session | \`${result.session_id}\` |`); + } + lines.push(''); + return lines; +} + +// modelUsage is keyed by model name; each entry has per-model token counts +// and cost. Render one row per model so multi-model runs stay readable. +function renderTokenUsage(result) { + const modelUsage = result.modelUsage; + if (!modelUsage || Object.keys(modelUsage).length === 0) { + return []; + } + const lines = []; + lines.push('### Token usage'); + lines.push(''); + lines.push('| Model | Input | Output | Cache read | Cache write | Cost |'); + lines.push('|---|--:|--:|--:|--:|--:|'); + for (const [model, u] of Object.entries(modelUsage)) { + lines.push( + `| \`${model}\` | ${formatTokens(u.inputTokens)} | ${formatTokens(u.outputTokens)} ` + + `| ${formatTokens(u.cacheReadInputTokens)} | ${formatTokens(u.cacheCreationInputTokens)} ` + + `| ${formatCost(u.costUSD)} |` + ); + } + lines.push(''); + return lines; +} + +// Group denials by tool name so a tool blocked repeatedly shows as one row +// with a count, rather than N near-identical rows. +function groupDenials(denials) { + const byTool = new Map(); + for (const denial of denials) { + const entry = byTool.get(denial.tool_name) || { count: 0, sample: denial.tool_input }; + entry.count += 1; + byTool.set(denial.tool_name, entry); + } + return [...byTool.entries()] + .map(([tool, { count, sample }]) => ({ tool, count, sample })) + .sort((a, b) => b.count - a.count); +} + +function renderPermissionDenials(result) { + const denials = result.permission_denials || []; + const lines = []; + lines.push('### Permission denials'); + lines.push(''); + if (denials.length === 0) { + lines.push('None — Claude used only allowed tools.'); + lines.push(''); + return lines; + } + lines.push( + `Claude was blocked ${denials.length} time(s). Consider adding these to \`--allowedTools\`:` + ); + lines.push(''); + lines.push('| Count | Tool | Example input |'); + lines.push('|--:|---|---|'); + for (const { tool, count, sample } of groupDenials(denials)) { + lines.push(`| ${count} | \`${tool}\` | \`${denialInput(sample)}\` |`); + } + lines.push(''); + return lines; +} + +function main() { + const executionFile = process.argv[2]; + if (!executionFile) { + console.error('Usage: node .github/claude/summary.js '); + process.exit(2); + } + + const result = readResult(executionFile); + + const markdown = [ + ...renderSessionSummary(result), + ...renderTokenUsage(result), + ...renderPermissionDenials(result) + ]; + + const output = markdown.join('\n'); + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${output}\n`); + } else { + console.log(output); + } +} + +main(); diff --git a/.github/claude/system-prompt.md b/.github/claude/system-prompt.md new file mode 100644 index 00000000000..1bcb570f6da --- /dev/null +++ b/.github/claude/system-prompt.md @@ -0,0 +1,27 @@ +# Repository-specific instructions + +## Upstream reference checkouts + +When running in CI, read-only checkouts of two upstream Vaadin repositories are +available as additional working directories, for extra context only: + +- **vaadin/flow** (path ends in `/reference/flow`) — the Vaadin Flow framework + (Java). Consult it to understand base component classes, the `Element` API, + and framework internals this repository builds on. +- **vaadin/web-components** (path ends in `/reference/web-components`) — the + Vaadin web components (JavaScript/Lit). Consult it to understand the + client-side properties, events, and DOM behavior of the components that the + Flow components here wrap. + +### How to use them + +- Consult these checkouts only when the code and docs in this repository do not + answer the question. They are a deliberate side-trip, not part of normal + research. +- When you search them, pass the reference directory to Grep/Glob explicitly. + Never run a repository-wide search that mixes reference sources into results + for this repository. +- Reference only: never modify, stage, or commit anything inside them. +- They track each repository's default branch and may not match the versions + this repository depends on. Never treat them as authoritative over this + repository's own dependencies. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000000..c3a97f440f0 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,144 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + +env: + # Pin the Playwright MCP server so the browser and server versions stay in + # lockstep. Bump this to upgrade; the browser cache key tracks it. + PLAYWRIGHT_MCP_VERSION: '0.0.77' + +jobs: + claude: + # Skip workflow if the comment does not tag claude or the author is not a + # maintainer. Having this check here ensures that this scenario does not + # even start a runner. + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association)) + runs-on: ubuntu-latest + permissions: + # Required by actions/checkout + contents: read + # Required by anthropics/claude-code-action to generate a short-lived + # app token (contents/pull_requests/issues: write) that is revoked at the + # end of the run. Thus, there should be no need to grant additional write + # permissions to the workflow itself. + id-token: write + # Required by github_ci MCP server, which uses the workflow token instead + # of the short-lived app token + actions: read + steps: + - name: Checkout repository + # Pin to the default branch: pull_request_review_comment/review events + # would otherwise check out the PR merge ref (only issue_comment defaults + # to the default branch). Kept as the first checkout so the trusted action + # infra staged below comes from the default branch, never the PR branch. + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 1 + + - name: Stage action infra + # Copy the action infra out of the repo *before* the PR branch is checked + # out, so the PR-branch checkout can't remove or modify these files + # (branches may predate them, and the system prompt must not come from an + # untrusted PR branch). Referenced by absolute path in later steps. + run: | + cp .github/claude/system-prompt.md "${{ runner.temp }}/system-prompt.md" + cp .github/claude/summary.js "${{ runner.temp }}/summary.js" + cp .github/claude/prompt.js "${{ runner.temp }}/prompt.js" + + - name: Setup JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Install TestBench license + if: env.TB_LICENSE != '' + env: + TB_LICENSE: ${{ secrets.TB_LICENSE }} + run: | + mkdir -p ~/.vaadin + user="${TB_LICENSE%%/*}" + key="${TB_LICENSE#*/}" + echo "{\"username\":\"${user}\",\"proKey\":\"${key}\"}" > ~/.vaadin/proKey + + - name: Clone upstream references (read-only context) + run: | + git clone --depth 1 https://github.com/vaadin/flow.git \ + "${{ runner.temp }}/reference/flow" + git clone --depth 1 https://github.com/vaadin/web-components.git \ + "${{ runner.temp }}/reference/web-components" + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-mcp-${{ env.PLAYWRIGHT_MCP_VERSION }} + + - name: Install Chromium for Playwright MCP + run: | + core_version=$(npm view "@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}" dependencies.playwright-core) + npx --yes "playwright@${core_version}" install --with-deps chromium + + - name: Build Claude prompt + id: prompt + run: node "${{ runner.temp }}/prompt.js" + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # Setting a custom prompt would switch the action into agent mode + # track_progress forces tag mode back + track_progress: true + prompt: ${{ steps.prompt.outputs.prompt }} + additional_permissions: | + actions: read + settings: | + { + "permissions": { + "additionalDirectories": [ + "${{ runner.temp }}/reference/flow", + "${{ runner.temp }}/reference/web-components" + ] + } + } + claude_args: | + --append-system-prompt-file ${{ runner.temp }}/system-prompt.md + --mcp-config '{"mcpServers":{"playwright":{"command":"npx","args":["@playwright/mcp@${{ env.PLAYWRIGHT_MCP_VERSION }}","--headless","--isolated"]}}}' + --allowedTools "mcp__playwright,Bash(mvn:*),Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git branch:*),Bash(git add:*),Bash(git commit:*),Bash(git checkout:*),Bash(git restore:*),Bash(git stash:*),Bash(cd:*),Bash(npm install),Bash(npm ci),Bash(node scripts/wtr.js:*),Bash(node scripts/computeModules.js:*),Bash(scripts/checkstyle.sh:*),Bash(./scripts/checkstyle.sh:*),Bash(ls:*),Bash(cat:*),Bash(find:*),Bash(grep:*),Bash(rg:*),Bash(head:*),Bash(tail:*),Bash(sed:*),Bash(wc:*),Bash(echo:*)" + + - name: Summarize Claude session + if: always() && steps.claude.outputs.execution_file != '' + run: node "${{ runner.temp }}/summary.js" "${{ steps.claude.outputs.execution_file }}" + + - name: Upload Claude execution log + # The execution_file is the full stream-json trace: every tool_use with + # its input and every tool_result. Off by default because on a public + # repo the artifact is world-readable; set the CLAUDE_DEBUG repository + # variable to 'true' to capture it when debugging a run. + if: always() && vars.CLAUDE_DEBUG == 'true' && steps.claude.outputs.execution_file != '' + uses: actions/upload-artifact@v4 + with: + name: claude-execution-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.claude.outputs.execution_file }} + retention-days: 7 From 3da177590438884cb03cad80020f8efa43a14a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Tue, 7 Jul 2026 16:09:57 +0200 Subject: [PATCH 2/5] simplify Playwright MCP setup --- .github/workflows/claude.yml | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index c3a97f440f0..051cc51a6ec 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -9,8 +9,9 @@ on: types: [submitted] env: - # Pin the Playwright MCP server so the browser and server versions stay in - # lockstep. Bump this to upgrade; the browser cache key tracks it. + # Pin the Playwright MCP server version for reproducible runs. The MCP drives + # the runner's preinstalled Chrome (--browser chrome), so no browser download + # is needed. Bump this to upgrade the server. PLAYWRIGHT_MCP_VERSION: '0.0.77' jobs: @@ -87,17 +88,6 @@ jobs: git clone --depth 1 https://github.com/vaadin/web-components.git \ "${{ runner.temp }}/reference/web-components" - - name: Cache Playwright browsers - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-mcp-${{ env.PLAYWRIGHT_MCP_VERSION }} - - - name: Install Chromium for Playwright MCP - run: | - core_version=$(npm view "@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}" dependencies.playwright-core) - npx --yes "playwright@${core_version}" install --with-deps chromium - - name: Build Claude prompt id: prompt run: node "${{ runner.temp }}/prompt.js" @@ -124,7 +114,7 @@ jobs: } claude_args: | --append-system-prompt-file ${{ runner.temp }}/system-prompt.md - --mcp-config '{"mcpServers":{"playwright":{"command":"npx","args":["@playwright/mcp@${{ env.PLAYWRIGHT_MCP_VERSION }}","--headless","--isolated"]}}}' + --mcp-config '{"mcpServers":{"playwright":{"command":"npx","args":["@playwright/mcp@${{ env.PLAYWRIGHT_MCP_VERSION }}","--headless","--isolated","--browser","chrome"]}}}' --allowedTools "mcp__playwright,Bash(mvn:*),Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git branch:*),Bash(git add:*),Bash(git commit:*),Bash(git checkout:*),Bash(git restore:*),Bash(git stash:*),Bash(cd:*),Bash(npm install),Bash(npm ci),Bash(node scripts/wtr.js:*),Bash(node scripts/computeModules.js:*),Bash(scripts/checkstyle.sh:*),Bash(./scripts/checkstyle.sh:*),Bash(ls:*),Bash(cat:*),Bash(find:*),Bash(grep:*),Bash(rg:*),Bash(head:*),Bash(tail:*),Bash(sed:*),Bash(wc:*),Bash(echo:*)" - name: Summarize Claude session From b610e503561723581ecdc65d755860c1f1f6b622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Tue, 7 Jul 2026 16:39:04 +0200 Subject: [PATCH 3/5] use import in node scripts --- .github/claude/prompt.js | 2 +- .github/claude/summary.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/claude/prompt.js b/.github/claude/prompt.js index b81ebfd9a8a..00d8e4c8264 100644 --- a/.github/claude/prompt.js +++ b/.github/claude/prompt.js @@ -9,7 +9,7 @@ * node .github/claude/prompt.js */ -const fs = require('fs'); +import fs from 'node:fs'; // The action's tag-mode prompt only contains the trigger comment's body, with // no link to the review thread it replies to — a reply like "@claude fix this" diff --git a/.github/claude/summary.js b/.github/claude/summary.js index 160460a9139..b457d0c5a3d 100644 --- a/.github/claude/summary.js +++ b/.github/claude/summary.js @@ -19,7 +19,7 @@ * node .github/claude/summary.js */ -const fs = require('fs'); +import fs from 'node:fs'; // Pull the final result event out of the stream-json log. It is the last // message with type "result" and carries the run's aggregates. From 5f63050e9a19bc009b23cdbddd85940c8935f842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Thu, 9 Jul 2026 09:45:33 +0200 Subject: [PATCH 4/5] pin action to commit SHA --- .github/workflows/claude.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 051cc51a6ec..311fa936052 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -94,7 +94,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@37b464ce72700f7b2c5ff8d2db7fa7b15df792f5 # v1.0.169 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Setting a custom prompt would switch the action into agent mode From 889fa119047c89064378ac02b32c3a7c03e7a4dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Fri, 10 Jul 2026 08:34:31 +0200 Subject: [PATCH 5/5] fix token setup --- .github/workflows/claude.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 311fa936052..4b9516a846f 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -48,6 +48,7 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} fetch-depth: 1 + persist-credentials: false - name: Stage action infra # Copy the action infra out of the repo *before* the PR branch is checked