Removed unnecessary try-catch block around Mermaid's render method. #526
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.pull_request.draft == false && github.event.sender.type != 'Bot') || | |
| ( | |
| 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' | |
| ) | |
| ) | |
| 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" | |
| if [ "${{ github.event_name }}" = "issue_comment" ]; then | |
| COMMENT_BODY="$(jq -r '.comment.body // ""' "$GITHUB_EVENT_PATH")" | |
| 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 | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| ref: refs/pull/${{ steps.pr.outputs.number }}/head | |
| - name: Fetch base branch | |
| run: | | |
| git fetch origin "${{ steps.pr.outputs.base_ref }}" --force --prune --no-tags | |
| - name: Get PR Diff | |
| id: diff | |
| run: | | |
| EXCLUDE_PATTERNS=( | |
| ':!bun.lock' | |
| ':!pnpm-lock.yaml' | |
| ':!package-lock.json' | |
| ':!yarn.lock' | |
| ':!**/CHANGELOG.md' | |
| ':!tmp/ship/**' | |
| ':!dist/**' | |
| ':!build/**' | |
| ':!.next/**' | |
| ':!.turbo/**' | |
| ':!node_modules/**' | |
| ':!.codex/**' | |
| ':!.agents/skills/pr-context/**' | |
| ':!specs/**' | |
| ':!reports/**' | |
| ':!tech-probes/**' | |
| ':!stories/**' | |
| ':!projects/**' | |
| ':!strategy/**' | |
| ) | |
| BASE="origin/${{ steps.pr.outputs.base_ref }}" | |
| DIFF=$(git diff "$BASE"...HEAD -- . "${EXCLUDE_PATTERNS[@]}") | |
| DIFF_SIZE=${#DIFF} | |
| FILES=$(git diff --name-only "$BASE"...HEAD -- . "${EXCLUDE_PATTERNS[@]}") | |
| echo "changed_files<<EOF" >> "$GITHUB_OUTPUT" | |
| echo "$FILES" >> "$GITHUB_OUTPUT" | |
| echo "EOF" >> "$GITHUB_OUTPUT" | |
| 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" | |
| DIFF_STATS=$(git diff --stat "$BASE"...HEAD -- . "${EXCLUDE_PATTERNS[@]}") | |
| { | |
| echo "diff_stats<<EOF" | |
| echo "$DIFF_STATS" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| 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" | |
| INLINE_THRESHOLD=100000 | |
| DIFF_DELIM="DIFF_DELIM_${RANDOM}_${RANDOM}_$(date +%s%N)" | |
| if [ "$DIFF_SIZE" -le "$INLINE_THRESHOLD" ]; then | |
| 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 | |
| echo "diff_mode=summary" >> "$GITHUB_OUTPUT" | |
| echo "diff_size_bytes=$DIFF_SIZE" >> "$GITHUB_OUTPUT" | |
| echo "truncated=true" >> "$GITHUB_OUTPUT" | |
| 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" | |
| 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 | |
| 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=$(echo "$RESULT" | jq -r '.data.repository.pullRequest.reviewDecision // "NONE"') | |
| echo "review_decision=$REVIEW_DECISION" >> "$GITHUB_OUTPUT" | |
| 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" | |
| CLAUDE_COMMENTS=$(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 | |
| "_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_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" | |
| 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" | |
| PR_HEAD="${{ steps.pr.outputs.head_sha }}" | |
| # COUPLED: The regex below must match the heading in the pr-review agent's 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]" or .author.login == "inkeep-internal-ci[bot]") and | |
| ((.body // "") | test("^## PR Review Summary")) | |
| )] | | |
| sort_by(.submittedAt) | last | .commit.oid // "" | |
| ') | |
| SINCE_REVIEW_COUNT=0 | |
| 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=( | |
| ':!bun.lock' | |
| ':!pnpm-lock.yaml' | |
| ':!package-lock.json' | |
| ':!yarn.lock' | |
| ':!**/CHANGELOG.md' | |
| ':!tmp/ship/**' | |
| ':!dist/**' | |
| ':!build/**' | |
| ':!.next/**' | |
| ':!.turbo/**' | |
| ':!node_modules/**' | |
| ':!.codex/**' | |
| ':!.agents/skills/pr-context/**' | |
| ':!specs/**' | |
| ':!reports/**' | |
| ':!tech-probes/**' | |
| ':!stories/**' | |
| ':!projects/**' | |
| ':!strategy/**' | |
| ) | |
| 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" | |
| SINCE_REVIEW_DIFF=$(git diff "$LAST_REVIEW_SHA".."$PR_HEAD" -- . "${DIFF_EXCLUDE[@]}") | |
| SINCE_REVIEW_DIFF_SIZE=${#SINCE_REVIEW_DIFF} | |
| SINCE_REVIEW_INLINE_THRESHOLD=30000 | |
| 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" | |
| 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 | |
| id: pr_comments | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| 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")))]') | |
| 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 | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| gh api repos/${{ github.repository }}/issues/${{ steps.pr.outputs.number }}/comments \ | |
| --jq '.[] | select((.user.login == "claude[bot]" or .user.login == "inkeep-internal-ci[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 | |
| 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: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 | |
| with: | |
| # Public client ID for inkeep-internal-ci (non-secret; supersedes the deprecated app-id input). | |
| client-id: Iv23liNt1mrrdgVKHn8K | |
| private-key: ${{ secrets.INTERNAL_CI_APP_PRIVATE_KEY }} | |
| owner: inkeep | |
| repositories: | | |
| open-knowledge | |
| team-skills | |
| - name: Clone team-skills (CI-only PR review agents) | |
| run: gh repo clone inkeep/team-skills /tmp/team-skills -- --depth 1 | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| - name: Run PR Review | |
| id: claude | |
| uses: anthropics/claude-code-action@e58dfa55559035499a4982426bb73605e8b5ad8e # v1.0.105 | |
| env: | |
| CLAUDE_CODE_DEBUG_LOGS_DIR: ${{ runner.temp }}/claude-debug | |
| DEBUG_CLAUDE_AGENT_SDK: "1" | |
| # Block first query until extraKnownMarketplaces install completes | |
| # so cross-plugin skill resolution (e.g., shared:interaction-polish) | |
| # is reliable. | |
| CLAUDE_CODE_SYNC_PLUGIN_INSTALL: "true" | |
| with: | |
| anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| github_token: ${{ steps.app-token.outputs.token }} | |
| allowed_bots: "inkeep-internal-ci[bot]" | |
| track_progress: true | |
| # Load the shared plugin via structured action inputs (install-plugins.ts), | |
| # not via a second --plugin-dir flag. claude-code-action's claude_args parser | |
| # collapses repeated --plugin-dir flags (last value wins), which silently | |
| # dropped the pr-review plugin and produced empty reviews on every PR. | |
| # Marketplace name "inkeep-team-skills" is from the marketplace.json at | |
| # the root of /tmp/team-skills, which the "Clone team-skills" step above | |
| # places on disk. | |
| plugin_marketplaces: | | |
| /tmp/team-skills | |
| plugins: | | |
| shared@inkeep-team-skills | |
| claude_args: | | |
| --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,Skill,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: Upload Debug Artifacts | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| 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 |