feat(run): enable inline text document attachments via chat APIs #12016
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: 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 |