Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/claude/prompt.js
Original file line number Diff line number Diff line change
@@ -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
*/

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"
// 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();
171 changes: 171 additions & 0 deletions .github/claude/summary.js
Original file line number Diff line number Diff line change
@@ -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 <execution-file>
*/

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.
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 <execution-file>');
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();
27 changes: 27 additions & 0 deletions .github/claude/system-prompt.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading