Skip to content

feat(run): enable inline text document attachments via chat APIs #12016

feat(run): enable inline text document attachments via chat APIs

feat(run): enable inline text document attachments via chat APIs #12016

name: Claude PR Review
on:
pull_request:
types: [opened, synchronize, ready_for_review]
issue_comment:
types: [created]
jobs:
pr-review:
if: |
(github.event_name == 'pull_request' &&
(github.event.sender.type != 'Bot' || github.event.sender.login == 'inkeep-internal-ci[bot]' || github.event.sender.login == 'github-actions[bot]') &&
!startsWith(github.event.pull_request.head.ref, 'changeset-release/')
) ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
github.event.issue.state == 'open' &&
contains(github.event.comment.body, '@claude') &&
(contains(github.event.comment.body, '--review') || contains(github.event.comment.body, '--full-review')) &&
!contains(github.actor, '[bot]') &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
)
)
# Job-level concurrency so bot comments (which skip via if:) don't cancel
# in-progress human-triggered runs. Draft check is intentionally NOT in the
# if: condition — it's done via live API in the "Get PR Details" step.
# Reason: when synchronize + ready_for_review events race into the same
# concurrency group, the ready_for_review run can be lost, and the surviving
# synchronize run's webhook payload has stale draft=true. The live API check
# sees the current state regardless of which run survives.
concurrency:
group: pr-review-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: true
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: read
id-token: write
steps:
- name: Get PR Details
id: pr
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER='${{ github.event.pull_request.number || github.event.issue.number }}'
PR_JSON=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}")
TITLE=$(echo "$PR_JSON" | jq -r '.title')
BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
AUTHOR_LOGIN=$(echo "$PR_JSON" | jq -r '.user.login')
BASE_REF=$(echo "$PR_JSON" | jq -r '.base.ref')
HEAD_SHA=$(echo "$PR_JSON" | jq -r '.head.sha')
ADDITIONS=$(echo "$PR_JSON" | jq -r '.additions')
DELETIONS=$(echo "$PR_JSON" | jq -r '.deletions')
CHANGED_FILES_COUNT=$(echo "$PR_JSON" | jq -r '.changed_files')
LABELS=$(echo "$PR_JSON" | jq -r '[.labels[].name] | join(", ")')
DRAFT=$(echo "$PR_JSON" | jq -r '.draft')
echo "number=$PR_NUMBER" >> $GITHUB_OUTPUT
{
echo "title<<EOF"
echo "$TITLE"
echo "EOF"
} >> $GITHUB_OUTPUT
{
echo "body<<EOF"
echo "$BODY"
echo "EOF"
} >> $GITHUB_OUTPUT
echo "author_login=$AUTHOR_LOGIN" >> $GITHUB_OUTPUT
echo "base_ref=$BASE_REF" >> $GITHUB_OUTPUT
echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT
echo "additions=$ADDITIONS" >> $GITHUB_OUTPUT
echo "deletions=$DELETIONS" >> $GITHUB_OUTPUT
echo "changed_files_count=$CHANGED_FILES_COUNT" >> $GITHUB_OUTPUT
echo "labels=$LABELS" >> $GITHUB_OUTPUT
echo "draft=$DRAFT" >> $GITHUB_OUTPUT
# Determine trigger command (issue_comment only; pull_request defaults to auto)
# IMPORTANT: read comment body from event payload via jq — do NOT inline
# github.event.comment.body into bash source (it can contain quotes/newlines).
if [ "${{ github.event_name }}" = "issue_comment" ]; then
COMMENT_BODY="$(jq -r '.comment.body // ""' "$GITHUB_EVENT_PATH")"
# Check --full-review BEFORE --review (substring overlap).
# We use -- prefix (not /) because claude-code-action reserves /xxx for skill invocations.
if printf '%s' "$COMMENT_BODY" | grep -Eq '(^|[[:space:]])--full-review([[:space:]]|$)'; then
echo "trigger_command=full-review" >> $GITHUB_OUTPUT
else
echo "trigger_command=review" >> $GITHUB_OUTPUT
fi
else
echo "trigger_command=auto" >> $GITHUB_OUTPUT
fi
- name: Checkout
if: steps.pr.outputs.draft != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0 # Full history for merge-base
ref: refs/pull/${{ steps.pr.outputs.number }}/head
- name: Fetch base branch
if: steps.pr.outputs.draft != 'true'
run: |
git fetch origin "${{ steps.pr.outputs.base_ref }}" --force --prune --no-tags
- name: Get PR Diff
if: steps.pr.outputs.draft != 'true'
id: diff
run: |
# Exclude noisy auto-generated files from diff
EXCLUDE_PATTERNS=(
':!pnpm-lock.yaml'
':!package-lock.json'
':!yarn.lock'
':!agents-api/__snapshots__/openapi.json'
':!**/drizzle/*/meta/*_snapshot.json'
':!**/drizzle/*/meta/_journal.json'
':!**/CHANGELOG.md'
)
BASE="origin/${{ steps.pr.outputs.base_ref }}"
# Get the diff for the PR (excluding noisy files)
DIFF=$(git diff "$BASE"...HEAD -- . "${EXCLUDE_PATTERNS[@]}")
DIFF_SIZE=${#DIFF}
# Get changed files list (excluding noisy files)
FILES=$(git diff --name-only "$BASE"...HEAD -- . "${EXCLUDE_PATTERNS[@]}")
echo "changed_files<<EOF" >> $GITHUB_OUTPUT
echo "$FILES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Compute filtered size metrics from numstat (respects EXCLUDE_PATTERNS,
# unlike the raw GitHub API additions/deletions which include lockfiles etc.)
NUMSTAT=$(git diff --numstat "$BASE"...HEAD -- . "${EXCLUDE_PATTERNS[@]}")
FILTERED_ADDITIONS=$(printf '%s\n' "$NUMSTAT" | awk '$1 != "-" {s+=$1} END{print s+0}')
FILTERED_DELETIONS=$(printf '%s\n' "$NUMSTAT" | awk '$2 != "-" {s+=$2} END{print s+0}')
if [ -n "$FILES" ]; then
FILTERED_FILE_COUNT=$(printf '%s\n' "$FILES" | wc -l | tr -d ' ')
else
FILTERED_FILE_COUNT=0
fi
echo "filtered_additions=$FILTERED_ADDITIONS" >> $GITHUB_OUTPUT
echo "filtered_deletions=$FILTERED_DELETIONS" >> $GITHUB_OUTPUT
echo "filtered_file_count=$FILTERED_FILE_COUNT" >> $GITHUB_OUTPUT
# Per-file diff stats (compact size overview)
DIFF_STATS=$(git diff --stat "$BASE"...HEAD -- . "${EXCLUDE_PATTERNS[@]}")
{
echo "diff_stats<<EOF"
echo "$DIFF_STATS"
echo "EOF"
} >> $GITHUB_OUTPUT
# Commit log (chronological — oldest first)
COMMIT_LOG=$(git log --oneline --reverse "$BASE"...HEAD)
COMMIT_COUNT=$(git rev-list --count "$BASE"...HEAD)
{
echo "commit_log<<EOF"
echo "$COMMIT_LOG"
echo "EOF"
} >> $GITHUB_OUTPUT
echo "commit_count=$COMMIT_COUNT" >> $GITHUB_OUTPUT
# ── Adaptive diff strategy ──────────────────────────────────────────
# Diffs within INLINE_THRESHOLD are embedded directly in the pr-context
# skill so reviewers see them immediately (zero tool calls needed).
#
# Larger diffs switch to "summary mode": pr-context gets metadata +
# file list + diff stats only, and the full diff is written to a file.
# Reviewers read specific file diffs on-demand via `git diff`.
#
# Why: each subagent reviewer loads pr-context as a skill (~system
# prompt). A 500KB diff ≈ 128K tokens leaves < 40K for reasoning in a
# 200K context window, causing most reviewers to hit context limits.
# ───────────────────────────────────────────────────────────────────
INLINE_THRESHOLD=100000 # ~100KB ≈ 25K tokens — safe for all reviewer budgets
DIFF_DELIM="DIFF_DELIM_${RANDOM}_${RANDOM}_$(date +%s%N)"
if [ $DIFF_SIZE -le $INLINE_THRESHOLD ]; then
# ── Inline mode: embed full diff in pr-context skill ──
echo "diff_mode=inline" >> $GITHUB_OUTPUT
{
echo "diff_content<<$DIFF_DELIM"
echo '```diff'
printf '%s\n' "$DIFF"
echo '```'
echo "$DIFF_DELIM"
} >> $GITHUB_OUTPUT
echo "truncated=false" >> $GITHUB_OUTPUT
else
# ── Summary mode: diff too large to inline ──
echo "diff_mode=summary" >> $GITHUB_OUTPUT
echo "diff_size_bytes=$DIFF_SIZE" >> $GITHUB_OUTPUT
echo "truncated=true" >> $GITHUB_OUTPUT
# Write full diff to a known file for on-demand reading
mkdir -p .claude/pr-diff
printf '%s' "$DIFF" > .claude/pr-diff/full.diff
echo "Full diff written to .claude/pr-diff/full.diff (${DIFF_SIZE} bytes) — summary mode enabled"
# Build the summary-mode placeholder for pr-context
FILE_COUNT=$(echo "$FILES" | wc -l | tr -d ' ')
{
echo "diff_content<<$DIFF_DELIM"
echo "> **⚠️ LARGE PR (summary mode)** — The diff (~${DIFF_SIZE} bytes across ~${FILE_COUNT} files) exceeds the inline threshold (~$((INLINE_THRESHOLD / 1000))KB)."
echo "> The diff is NOT included in this context to preserve reviewer context budgets."
echo ">"
echo "> **How to read diffs on-demand:**"
echo "> - Specific file: \`git diff origin/${{ steps.pr.outputs.base_ref }}...HEAD -- path/to/file.ts\`"
echo "> - Full PR diff: \`gh pr diff ${{ steps.pr.outputs.number }}\`"
echo "> - Pre-written full diff: read \`.claude/pr-diff/full.diff\`"
echo ">"
echo "> **Prioritize** using the Changed Files diff stats above. Focus on files most relevant to your review domain."
echo "$DIFF_DELIM"
} >> $GITHUB_OUTPUT
fi
- name: Get Review Context
if: steps.pr.outputs.draft != 'true'
id: review_context
env:
GH_TOKEN: ${{ github.token }}
run: |
RESULT=$(gh api graphql -f query='
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
reviewDecision
closingIssuesReferences(first: 10) {
nodes {
number
title
body
url
state
labels(first: 10) { nodes { name } }
}
}
reviewThreads(last: 100) {
totalCount
nodes {
isResolved
isOutdated
path
line
startLine
diffSide
subjectType
comments(first: 50) {
nodes {
author { __typename login }
body
diffHunk
url
createdAt
}
totalCount
}
}
}
reviews(last: 20) {
totalCount
nodes {
author { login }
body
state
submittedAt
url
commit { oid }
}
}
}
}
}
' -f owner="${{ github.repository_owner }}" -f repo="${{ github.event.repository.name }}" -F pr=${{ steps.pr.outputs.number }})
THREADS=$(echo "$RESULT" | jq '.data.repository.pullRequest.reviewThreads.nodes // []')
REVIEWS=$(echo "$RESULT" | jq '.data.repository.pullRequest.reviews.nodes // []')
THREADS_TOTAL=$(echo "$RESULT" | jq '.data.repository.pullRequest.reviewThreads.totalCount // 0')
REVIEWS_TOTAL=$(echo "$RESULT" | jq '.data.repository.pullRequest.reviews.totalCount // 0')
# ── Review decision ──
REVIEW_DECISION=$(echo "$RESULT" | jq -r '.data.repository.pullRequest.reviewDecision // "NONE"')
echo "review_decision=$REVIEW_DECISION" >> $GITHUB_OUTPUT
# ── Linked issues ──
LINKED_ISSUES=$(echo "$RESULT" | jq -r '
.data.repository.pullRequest.closingIssuesReferences.nodes // [] |
if length == 0 then
"_None._"
else
map(
"#### [#" + (.number | tostring) + "](" + .url + ") — " + .title +
" (" + .state + ")" +
(if (.labels.nodes | length) > 0 then " `" + ([.labels.nodes[].name] | join("` `") ) + "`" else "" end) +
"\n\n" +
((.body // "") | if (. | length) > 1000 then .[0:1000] + "\n\n_[truncated — see issue for full details]_" else . end)
) | join("\n\n---\n\n")
end
')
{
echo "linked_issues<<ENDCTX"
echo "$LINKED_ISSUES"
echo "ENDCTX"
} >> $GITHUB_OUTPUT
# ── Automated review comments (claude[bot]) — full threads, latest-first ──
CLAUDE_COMMENTS=$(echo "$THREADS" | jq -r '
[.[]
| select((.comments.nodes | length) > 0)
| select((.comments.nodes[0].author.login // "") == "claude[bot]")
] |
sort_by(.comments.nodes[-1].createdAt) | reverse |
if length == 0 then
"_None — this is the first review run._"
else
(map(
"**`" + .path + ":" + (if .startLine and .line and (.startLine != .line) then ((.startLine | tostring) + "-" + (.line | tostring)) elif .line then (.line | tostring) else "—" end) + "`** | " +
(if .isResolved then "Resolved" elif .isOutdated then "Outdated" else "**ACTIVE**" end) +
(if .comments.totalCount > (.comments.nodes | length) then " | ⚠️ showing " + ((.comments.nodes | length) | tostring) + " of " + (.comments.totalCount | tostring) + " messages" else "" end) +
" | [view](" + (.comments.nodes[0].url // "") + ")" +
"\n\n" +
(.comments.nodes | map(
"> **" + (.author.login // "unknown") + "** (" + .createdAt + "):\n> " +
((.body // "") | gsub("\n"; "\n> "))
) | join("\n\n"))
) | join("\n\n---\n\n"))
end
')
if [ "$THREADS_TOTAL" -gt 100 ] 2>/dev/null; then
CLAUDE_COMMENTS="⚠️ _PR has $THREADS_TOTAL review threads; only the latest 100 are shown._"$'\n\n'"$CLAUDE_COMMENTS"
fi
{
echo "claude_prior_comments<<ENDCTX"
echo "$CLAUDE_COMMENTS"
echo "ENDCTX"
} >> $GITHUB_OUTPUT
# ── Human review comments — full threads, all statuses, latest-first ──
HUMAN_THREADS=$(echo "$THREADS" | jq -r '
[.[]
| select((.comments.nodes | length) > 0)
| select((.comments.nodes[0].author.__typename // "") != "Bot")
] |
sort_by(.comments.nodes[-1].createdAt) | reverse |
if length == 0 then
"_No human review comments._"
else
(map(
"**`" + .path + ":" + (if .startLine and .line and (.startLine != .line) then ((.startLine | tostring) + "-" + (.line | tostring)) elif .line then (.line | tostring) else "—" end) + "`** | " +
(if .isResolved then "Resolved" elif .isOutdated then "Outdated" else "**ACTIVE**" end) +
(if .comments.totalCount > (.comments.nodes | length) then " | ⚠️ showing " + ((.comments.nodes | length) | tostring) + " of " + (.comments.totalCount | tostring) + " messages" else "" end) +
" | [view](" + (.comments.nodes[0].url // "") + ")" +
"\n\n" +
(.comments.nodes | map(
"> **" + (.author.login // "unknown") + "** (" + .createdAt + "):\n> " +
((.body // "") | gsub("\n"; "\n> "))
) | join("\n\n"))
) | join("\n\n---\n\n"))
end
')
{
echo "human_threads<<ENDCTX"
echo "$HUMAN_THREADS"
echo "ENDCTX"
} >> $GITHUB_OUTPUT
# ── Previous review submissions — latest-first ──
PREV_REVIEWS=$(echo "$REVIEWS" | jq -r '
if length == 0 then
"_No previous reviews._"
else
(sort_by(.submittedAt) | reverse | map(
"---\n**" + (.author.login // "unknown") + "** — " +
.state + " (" + .submittedAt + ") — [view](" + (.url // "") + ")\n\n" +
((.body // "") | .[0:2000]) +
(if ((.body // "") | length) > 2000 then "\n\n_[body truncated]_" else "" end)
) | join("\n\n"))
end
')
if [ "$REVIEWS_TOTAL" -gt 20 ] 2>/dev/null; then
PREV_REVIEWS="⚠️ _PR has $REVIEWS_TOTAL review submissions; only the latest 20 are shown._"$'\n\n'"$PREV_REVIEWS"
fi
{
echo "previous_reviews<<ENDCTX"
echo "$PREV_REVIEWS"
echo "ENDCTX"
} >> $GITHUB_OUTPUT
# ── Changes since last Claude review ──
# Uses commit { oid } from the GraphQL reviews query to find the exact SHA
# the last automated review was made against, then computes the delta.
PR_HEAD="${{ steps.pr.outputs.head_sha }}"
# COUPLED: The regex below must match the heading in team-skills ci/pr-review/agents/pr-review.md Phase 6.2.
# The pr-review agent is constrained to always start its review body with "## PR Review Summary".
LAST_REVIEW_SHA=$(echo "$REVIEWS" | jq -r '
[.[] | select(
(.author.login == "claude" or .author.login == "claude[bot]") and
((.body // "") | test("^## PR Review Summary"))
)] |
sort_by(.submittedAt) | last | .commit.oid // ""
')
SINCE_REVIEW_COUNT=0 # default; overwritten below if a prior review SHA exists
if [ -n "$LAST_REVIEW_SHA" ] && [ "$LAST_REVIEW_SHA" != "null" ] && git cat-file -e "$LAST_REVIEW_SHA" 2>/dev/null; then
SINCE_REVIEW_COUNT=$(git rev-list --count "$LAST_REVIEW_SHA".."$PR_HEAD")
if [ "$SINCE_REVIEW_COUNT" = "0" ]; then
echo "is_re_review=false" >> $GITHUB_OUTPUT
echo "since_review_files=" >> $GITHUB_OUTPUT
SINCE_REVIEW_SECTION="_Re-review — no new commits since last automated review (commit \`${LAST_REVIEW_SHA:0:12}\`)._"
else
echo "is_re_review=true" >> $GITHUB_OUTPUT
DIFF_EXCLUDE=(
':!pnpm-lock.yaml'
':!package-lock.json'
':!yarn.lock'
':!agents-api/__snapshots__/openapi.json'
':!**/drizzle/*/meta/*_snapshot.json'
':!**/drizzle/*/meta/_journal.json'
':!**/CHANGELOG.md'
)
SINCE_REVIEW_LOG=$(git log --oneline "$LAST_REVIEW_SHA".."$PR_HEAD")
SINCE_REVIEW_STATS=$(git diff --stat "$LAST_REVIEW_SHA".."$PR_HEAD" -- . "${DIFF_EXCLUDE[@]}")
SINCE_REVIEW_FILES=$(git diff --name-only "$LAST_REVIEW_SHA".."$PR_HEAD" -- . "${DIFF_EXCLUDE[@]}")
{
echo "since_review_files<<ENDFILES"
echo "$SINCE_REVIEW_FILES"
echo "ENDFILES"
} >> $GITHUB_OUTPUT
# Include the actual delta diff if it's small enough to be useful
# without blowing context budgets (loaded by all reviewers via pr-context)
SINCE_REVIEW_DIFF=$(git diff "$LAST_REVIEW_SHA".."$PR_HEAD" -- . "${DIFF_EXCLUDE[@]}")
SINCE_REVIEW_DIFF_SIZE=${#SINCE_REVIEW_DIFF}
SINCE_REVIEW_INLINE_THRESHOLD=30000 # ~30KB ≈ 7.5K tokens
if [ $SINCE_REVIEW_DIFF_SIZE -le $SINCE_REVIEW_INLINE_THRESHOLD ]; then
SINCE_REVIEW_DIFF_SECTION="### Diff since last review
\`\`\`diff
$SINCE_REVIEW_DIFF
\`\`\`"
else
SINCE_REVIEW_DIFF_SECTION="### Diff since last review
> Delta diff too large (~${SINCE_REVIEW_DIFF_SIZE} bytes) to inline. Use \`git diff ${LAST_REVIEW_SHA:0:12}..HEAD\` to read on-demand."
fi
SINCE_REVIEW_FILE_COUNT=$(echo "$SINCE_REVIEW_FILES" | wc -l | tr -d ' ')
SINCE_REVIEW_SECTION="| Field | Value |
|---|---|
| **Last review commit** | \`${LAST_REVIEW_SHA:0:12}\` |
| **Commits since** | $SINCE_REVIEW_COUNT |
| **Files changed** | $SINCE_REVIEW_FILE_COUNT |
### Files changed since last review
\`\`\`
$SINCE_REVIEW_FILES
\`\`\`
### Commits since last review
\`\`\`
$SINCE_REVIEW_LOG
\`\`\`
### Diff stats since last review
\`\`\`
$SINCE_REVIEW_STATS
\`\`\`
$SINCE_REVIEW_DIFF_SECTION"
fi
else
echo "is_re_review=false" >> $GITHUB_OUTPUT
echo "since_review_files=" >> $GITHUB_OUTPUT
SINCE_REVIEW_SECTION="_First automated review — no prior review to compare against._"
fi
{
echo "since_review_section<<ENDREVIEW"
echo "$SINCE_REVIEW_SECTION"
echo "ENDREVIEW"
} >> $GITHUB_OUTPUT
# ── Compute review_scope ──────────────────────────────────────────
# review_scope is the single source of truth for delta vs full scoping.
# full = review entire PR (first review, no new commits, or --full-review override)
# delta = scope to changes since last automated review
TRIGGER='${{ steps.pr.outputs.trigger_command }}'
if [ "$TRIGGER" = "full-review" ]; then
echo "review_scope=full" >> $GITHUB_OUTPUT
elif [ "$SINCE_REVIEW_COUNT" -gt 0 ]; then
echo "review_scope=delta" >> $GITHUB_OUTPUT
else
echo "review_scope=full" >> $GITHUB_OUTPUT
fi
- name: Get PR Comments
if: steps.pr.outputs.draft != 'true'
id: pr_comments
env:
GH_TOKEN: ${{ github.token }}
run: |
# Fetch PR comments, include humans + our bot, exclude other bots
RAW_COMMENTS=$(gh api graphql -f query='
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
comments(last: 50) {
nodes {
author {
__typename
login
}
body
createdAt
url
}
}
}
}
}
' -f owner="${{ github.repository_owner }}" \
-f repo="${{ github.event.repository.name }}" \
-F pr=${{ steps.pr.outputs.number }} \
--jq '[.data.repository.pullRequest.comments.nodes[] | select(.author.__typename == "User" or (.author.login | test("github-actions|claude"; "i")))]')
# Format as markdown, latest-first
PR_DISCUSSION=$(echo "$RAW_COMMENTS" | jq -r '
if length == 0 then
"_No PR discussion._"
else
(sort_by(.createdAt) | reverse | map(
"**" + (.author.login // "unknown") + "** (" + .createdAt + ") — [view](" + (.url // "") + ")\n\n" +
"> " + ((.body // "") | gsub("\n"; "\n> "))
) | join("\n\n---\n\n"))
end
')
{
echo "pr_comments<<ENDCTX"
echo "$PR_DISCUSSION"
echo "ENDCTX"
} >> $GITHUB_OUTPUT
- name: Clean up stale Claude comments
if: steps.pr.outputs.draft != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
# Delete progress/error/output comments from cancelled/errored previous runs.
# "Claude finished" covers completed runs where the review submission failed.
# claude-code-action posts as "claude[bot]"; older versions or custom setups may use "github-actions[bot]".
gh api repos/${{ github.repository }}/issues/${{ steps.pr.outputs.number }}/comments \
--jq '.[] | select(.user.login == "claude[bot]" and (.body | test("Claude encountered an error|Claude finished|Starting PR review|🔄|PR Review in Progress"))) | .id' \
| xargs -I {} gh api -X DELETE repos/${{ github.repository }}/issues/comments/{} 2>/dev/null || true
- name: Generate PR Context Skill
if: steps.pr.outputs.draft != 'true'
run: |
mkdir -p .claude/skills/pr-context
cat > .claude/skills/pr-context/SKILL.md << 'SKILL_EOF'
---
name: pr-context
description: PR metadata, diff, changed files, and discussion context. Auto-generated at CI time.
---
# PR Review Context
(!IMPORTANT)
Use this context to:
1. Get an initial sense of the purpose and scope of the changes
2. Note what's already been flagged or discussed
3. Identify if previous feedback was addressed or ignored
---
## PR Metadata
| Field | Value |
|---|---|
| **PR** | #${{ steps.pr.outputs.number }} — ${{ steps.pr.outputs.title }} |
| **Author** | ${{ steps.pr.outputs.author_login }} |
| **Base** | `${{ steps.pr.outputs.base_ref }}` |
| **Repo** | ${{ github.repository }} |
| **Head SHA** | `${{ steps.pr.outputs.head_sha }}` |
| **Size** | ${{ steps.diff.outputs.commit_count }} commits · +${{ steps.diff.outputs.filtered_additions }}/-${{ steps.diff.outputs.filtered_deletions }} · ${{ steps.diff.outputs.filtered_file_count }} files |
| **Labels** | ${{ steps.pr.outputs.labels || '_None_' }} |
| **Review state** | ${{ steps.review_context.outputs.review_decision }} |
| **Diff mode** | `${{ steps.diff.outputs.diff_mode }}` — ${{ steps.diff.outputs.diff_mode == 'summary' && 'reviewers must read file diffs on-demand via `git diff`' || 'full diff included below' }} |
| **Event** | `${{ github.event_name }}:${{ github.event.action }}` |
| **Trigger command** | `${{ steps.pr.outputs.trigger_command }}` — ${{ steps.pr.outputs.trigger_command == 'full-review' && 'manual full-scope override' || steps.pr.outputs.trigger_command == 'review' && 'manual review request' || 'automatic CI trigger' }} |
| **Review scope** | `${{ steps.review_context.outputs.review_scope }}` — ${{ steps.review_context.outputs.review_scope == 'delta' && 'scoped to changes since last automated review' || 'full PR scope' }} |
## Description
Author-provided description (may not fully reflect actual scope):
${{ steps.pr.outputs.body }}
## Linked Issues
Issues this PR closes. These provide the "why" — the problem being solved, acceptance criteria, and prior discussion.
${{ steps.review_context.outputs.linked_issues }}
## Commit History
Commits in this PR (oldest → newest). Use to understand the development narrative and whether previous feedback was addressed in follow-up commits.
```
${{ steps.diff.outputs.commit_log }}
```
## Changed Files
Per-file diff stats (for prioritizing review effort):
```
${{ steps.diff.outputs.diff_stats }}
```
Full file list (untruncated paths):
```
${{ steps.diff.outputs.changed_files }}
```
## Diff
${{ steps.diff.outputs.diff_content }}
## Changes Since Last Review
${{ steps.review_context.outputs.since_review_section }}
${{ steps.review_context.outputs.review_scope == 'delta' && '## Review Focus
> **RE-REVIEW — scope to the delta.** A prior automated review already covered this PR. Your review scope is the code that changed since that review (see "Changes Since Last Review" above). Apply your normal review process — same quality bar, same severity criteria — but scoped to the delta. Do not re-analyze unchanged files or re-raise issues on code that was already reviewed. If prior feedback appears unaddressed in the delta, note it; otherwise, focus your effort on what is new or modified.' || steps.review_context.outputs.review_scope == 'full' && steps.review_context.outputs.is_re_review == 'true' && '## Review Focus
> **FULL-SCOPE REVIEW (manual override).** A prior automated review exists, but a full-scope review was explicitly requested via `--full-review`. Review the entire PR — do not limit scope to the delta. The "Changes Since Last Review" section above is provided for context only.' || '' }}
## Prior Feedback
> **IMPORTANT:** Everything in this section has already been raised on this PR. Do NOT re-raise, re-enumerate, or repeat any issue that appears below — even if you independently arrive at the same conclusion. Treat these as already-handled. If an issue below is still relevant and unresolved, reference it (link back) rather than restating it.
### Automated Review Comments
Comments previously posted by bot reviewers (e.g. claude) on specific lines of this PR.
- **ACTIVE** entries are on code that has not changed since the comment was posted.
- **Outdated** entries are on code that has since been modified (the issue may or may not still apply).
- **Resolved** entries have been explicitly resolved.
${{ steps.review_context.outputs.claude_prior_comments }}
### Human Review Comments
Comments from human reviewers on specific lines of this PR. Each thread shows the full conversation including all replies.
${{ steps.review_context.outputs.human_threads }}
### Previous Review Summaries
Full review submissions previously posted on this PR. Each includes the reviewer, verdict, and the review body.
${{ steps.review_context.outputs.previous_reviews }}
### PR Discussion
General comments on the PR (not attached to specific lines).
${{ steps.pr_comments.outputs.pr_comments }}
## GitHub URL Base (for hyperlinks)
Use these to construct clickable links in your findings:
- **File link pattern:** `https://github.com/${{ github.repository }}/blob/${{ steps.pr.outputs.head_sha }}/{path}#L{line}` or `#L{start}-L{end}`
- **PR link:** `https://github.com/${{ github.repository }}/pull/${{ steps.pr.outputs.number }}`
SKILL_EOF
echo "PR context skill generated at .claude/skills/pr-context/SKILL.md ($(wc -c < .claude/skills/pr-context/SKILL.md) bytes)"
- name: Fetch Web Design Guidelines
if: steps.pr.outputs.draft != 'true'
run: |
# Fetch latest guidelines and inject into the skill's references/ folder
# so pr-review-frontend can read them locally (no WebFetch needed at review time)
mkdir -p .agents/skills/web-design-guidelines/references
curl -sf https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md \
-o .agents/skills/web-design-guidelines/references/guidelines.md
echo "Web design guidelines fetched ($(wc -l < .agents/skills/web-design-guidelines/references/guidelines.md) lines)"
- name: Generate GitHub App token for team-skills
if: steps.pr.outputs.draft != 'true'
id: app-token
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
with:
app-id: ${{ secrets.INTERNAL_CI_APP_ID }}
private-key: ${{ secrets.INTERNAL_CI_APP_PRIVATE_KEY }}
owner: inkeep
repositories: team-skills
- name: Clone team-skills (CI-only PR review agents)
if: steps.pr.outputs.draft != 'true'
run: gh repo clone inkeep/team-skills /tmp/team-skills -- --depth 1
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
- name: Run PR Review
if: steps.pr.outputs.draft != 'true'
id: claude
# Pinned to SDK 0.2.25 (last working version) due to AJV validation crash in 0.2.27+
# Tracking: https://github.com/anthropics/claude-code-action/issues/892
# Remove pin when issue is resolved and @v1 is stable again
uses: anthropics/claude-code-action@01e756b34ef7a1447e9508f674143b07d20c2631
env:
CLAUDE_CODE_DEBUG_LOGS_DIR: ${{ runner.temp }}/claude-debug
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_bots: "claude[bot]"
track_progress: true
claude_args: |
--model opus
--plugin-dir /tmp/team-skills/ci/pr-review
--agent pr-review:pr-review
--mcp-config '{"mcpServers":{"exa":{"command":"npx","args":["-y","exa-mcp-server"],"env":{"EXA_API_KEY":"${{ secrets.EXA_API_KEY }}"}}}}'
--allowedTools "Task,Read,Write,Grep,Glob,mcp__exa__web_search_exa,mcp__github__create_pending_pull_request_review,mcp__github__add_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git merge-base:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api:*),Bash(mkdir:*)"
prompt: |
Review PR #${{ steps.pr.outputs.number }}. Begin Phase 1.
- name: Clean up progress comment
if: success() && steps.pr.outputs.draft != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
# Delete the progress/output comment posted by claude-code-action for THIS run.
# Scoped by run ID embedded in the "View job" link so other Claude automations are untouched.
gh api repos/${{ github.repository }}/issues/${{ steps.pr.outputs.number }}/comments \
--jq '.[] | select(.user.login == "claude[bot]" and (.body | contains("actions/runs/${{ github.run_id }}"))) | .id' \
| xargs -I {} gh api -X DELETE repos/${{ github.repository }}/issues/comments/{} 2>/dev/null || true
- name: Upload Debug Artifacts
if: always() && steps.pr.outputs.draft != 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: claude-debug-pr-${{ steps.pr.outputs.number }}-${{ github.run_id }}
path: |
${{ runner.temp }}/claude-debug/
${{ steps.claude.outputs.execution_file }}
retention-days: 7
if-no-files-found: ignore