From 6bed023cc838c956fd16871984e3fccb06a9bb26 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Fri, 1 May 2026 10:55:45 -0700 Subject: [PATCH] ci(claude): extract inline workflow scripts to .github/scripts/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline `run: |` blocks in claude-review.yml and claude-mention.yml have grown past the point of being reviewable inside YAML — the log step alone was 142 lines. Pulls them out to standalone bash scripts: - .github/scripts/compose-review-scope.sh (was 36-line inline) - .github/scripts/find-prior-review-comment.sh (was 10-line inline) - .github/scripts/log-review-to-ai-review-log.sh (was 142-line inline) - .github/scripts/parse-claude-mention.sh (was 17-line inline) Each script documents its inputs, outputs, and the rationale for non-obvious mechanics inline. Workflows invoke via `bash .github/scripts/.sh` rather than direct path execution — sidesteps the +x bit being dropped on Windows checkouts and the "forgot to chmod the new script" footgun. The `#!/usr/bin/env bash` shebangs are now informational only. Stacked on top of #444 (marker-based review-comment edit), which introduces the "Find prior review comment" step and the marker'd log-step lookup. Will rebase to main once #444 merges. Tests for these scripts are deliberately out of scope — Nathan's preference per discussion. A separate PR will add coverage via `npm run test:workflows` (or similar) once we settle on the test runner shape. #417 (auth-gate work) carries its own inline scripts and is not touched here. When that branch updates, its scripts adopt the same .github/scripts/ structure as part of that PR's review fixup. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/scripts/compose-review-scope.sh | 50 +++++ .github/scripts/find-prior-review-comment.sh | 28 +++ .../scripts/log-review-to-ai-review-log.sh | 178 +++++++++++++++ .github/scripts/parse-claude-mention.sh | 37 +++ .github/workflows/claude-mention.yml | 18 +- .github/workflows/claude-review.yml | 210 +----------------- 6 files changed, 297 insertions(+), 224 deletions(-) create mode 100644 .github/scripts/compose-review-scope.sh create mode 100644 .github/scripts/find-prior-review-comment.sh create mode 100644 .github/scripts/log-review-to-ai-review-log.sh create mode 100644 .github/scripts/parse-claude-mention.sh diff --git a/.github/scripts/compose-review-scope.sh b/.github/scripts/compose-review-scope.sh new file mode 100644 index 000000000..87c02cee2 --- /dev/null +++ b/.github/scripts/compose-review-scope.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Compose the layered review scope from individual layer files into a +# single markdown blob, and emit it as the `composed` output via +# $GITHUB_OUTPUT. Driven by claude-review.yml's "Compose review scope +# from layers" step. +# +# Inputs: +# LAYERS — newline-separated layer names (e.g. "universal\nharper/v5") +# GITHUB_OUTPUT — path to the GitHub Actions output file +# +# Layer files live at .ai-review-prompts/.md (the path the +# `Clone review prompts` step checks out into). Missing layers emit +# a workflow warning and continue; an empty composed result fails +# the step (no review scope = no review discipline). +set -euo pipefail + +OUT=/tmp/composed-scope.md +: > "$OUT" +while IFS= read -r raw_layer; do + # Trim whitespace around each layer name. + layer="$(printf '%s' "$raw_layer" | awk '{$1=$1;print}')" + [ -z "$layer" ] && continue + file=".ai-review-prompts/${layer}.md" + if [ ! -f "$file" ]; then + echo "::warning::Review layer '$layer' not found at $file; skipping." + continue + fi + { + cat "$file" + printf '\n\n' + } >> "$OUT" +done <<< "${LAYERS:-}" + +BYTES=$(wc -c < "$OUT") +echo "Composed ${BYTES} bytes from review layers" +if [ "$BYTES" -eq 0 ]; then + echo "::error::Composed review scope is empty — all layers missing or unreadable." + exit 1 +fi + +# Random heredoc delimiter — collision-proof against any content a +# future layer file might include. $GITHUB_OUTPUT uses heredoc +# syntax; a fixed marker could be forged (or coincidentally appear) +# in layer content and corrupt the output. +DELIM="EOF_$(openssl rand -hex 16)" +{ + echo "composed<<${DELIM}" + cat "$OUT" + echo "${DELIM}" +} >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/find-prior-review-comment.sh b/.github/scripts/find-prior-review-comment.sh new file mode 100644 index 000000000..1ace4230e --- /dev/null +++ b/.github/scripts/find-prior-review-comment.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Find the prior `claude-review:v1`-marker'd top-level review comment +# on a PR (if any) and write its integer database ID to +# $GITHUB_OUTPUT under key `id`. Empty when no prior exists. +# +# Why marker-based lookup: `--edit-last` filters by authenticated +# identity (`claude[bot]`) only — so after a `@claude` mention, the +# most recent claude[bot] comment is the mention response, and +# `--edit-last` clobbers it. Every review comment starts with +# ``; mention responses never carry the +# marker, so this lookup targets only the review comment. +# +# Inputs: +# GH_TOKEN — token with `pull-requests: read` +# GITHUB_REPOSITORY — owner/repo (auto-set by GitHub Actions) +# PR_NUMBER — pull request number +# GITHUB_OUTPUT — output file path +set -uo pipefail + +EXISTING_ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --jq '[.[] | select(.user.login == "claude[bot]") | select(.body | startswith(""))] | last | .id // empty') + +if [ -n "$EXISTING_ID" ]; then + echo "Prior review comment: $EXISTING_ID" +else + echo "No prior review comment found — agent will post fresh." +fi +echo "id=${EXISTING_ID}" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/log-review-to-ai-review-log.sh b/.github/scripts/log-review-to-ai-review-log.sh new file mode 100644 index 000000000..2aac0db3a --- /dev/null +++ b/.github/scripts/log-review-to-ai-review-log.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Log this run's PR review to the central HarperFast/ai-review-log +# tracker — finds the per-PR issue by stable title prefix and +# appends a comment, or creates a new issue if none exists. Driven +# by claude-review.yml's "Log review to ai-review-log" step. +# +# Best-effort: never fails the job. A missing `AI_REVIEW_LOG_TOKEN` +# secret, an absent claude review comment, or a stale comment all +# exit cleanly with a notice/warning rather than failing. +# +# Inputs: +# GH_TOKEN — token with `pull-requests: read` +# AI_REVIEW_LOG_TOKEN — fine-grained PAT scoped to ai-review-log +# with `issues: write` (optional — missing +# skips logging with a warning) +# PR_NUMBER — pull request number +# PR_URL — html URL of the PR +# REVIEW_STATUS — outcome of the Claude review step +# (success / failure / cancelled / etc.) +# REPO_SHORT — short repo name (e.g. "harper") +# GITHUB_REPOSITORY — owner/repo of the PR's repo +# GITHUB_RUN_ID — current Actions run ID (for staleness +# guard) +# RUNNER_TEMP — runner temp dir (where the agent's +# optional run-notes file lives) +set -uo pipefail + +if [ -z "${AI_REVIEW_LOG_TOKEN:-}" ]; then + echo "::warning::AI_REVIEW_LOG_TOKEN secret not set; skipping log entry." + exit 0 +fi + +# When this workflow job started. Used to filter out stale Claude +# review comments from previous runs so a cancelled in-flight run +# (e.g. from a force-push) doesn't re-log a prior run's content as +# a fresh finding. +JOB_STARTED=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq '.run_started_at // empty') + +# Fetch the marker'd review comment via raw API. We can't use +# `gh pr view --json comments` because (a) it doesn't expose +# `updated_at` (which we need below for the staleness guard now +# that comments are edited in place), and (b) we need the marker +# filter to ignore `@claude` mention responses that share the +# `claude[bot]` identity. +CLAUDE_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --jq '[.[] | select(.user.login == "claude[bot]") | select(.body | startswith(""))] | last // empty') + +if [ -z "$CLAUDE_JSON" ] || [ "$CLAUDE_JSON" = "null" ]; then + echo "No marker'd Claude review comment found on PR #$PR_NUMBER (review_status=$REVIEW_STATUS); skipping log." + exit 0 +fi + +CLAUDE_BODY=$(printf '%s' "$CLAUDE_JSON" | jq -r '.body // empty') +# Prefer updated_at (reflects the most recent edit) over created_at +# (frozen at original post time) — comments are now edited in place +# across runs. +CLAUDE_AT=$(printf '%s' "$CLAUDE_JSON" | jq -r '.updated_at // .created_at // empty') + +if [ -z "$CLAUDE_BODY" ]; then + echo "Claude review comment had empty body; skipping log." + exit 0 +fi + +# ISO-8601 lexicographic compare — both are UTC timestamps in the +# same shape, so string comparison is sound. +if [ -n "$JOB_STARTED" ] && [ -n "$CLAUDE_AT" ] && [ "$CLAUDE_AT" \< "$JOB_STARTED" ]; then + echo "::notice::Latest Claude review comment update ($CLAUDE_AT) predates this job's start ($JOB_STARTED); skipping to avoid re-logging stale content." + exit 0 +fi + +# Title: count findings (lines starting with `### `). The +# "no blockers" branch matches the sentinel phrase anywhere in the +# body — the concise prompt's `Reviewed; no blockers found.` doesn't +# start with "no blockers", so an anchored regex would miss it. +# Anywhere-match is safe because the phrase is a deliberate output +# from the prompt. +if printf '%s' "$CLAUDE_BODY" | grep -qi 'no blockers found'; then + COUNT_PART="no blockers" +else + FINDING_COUNT=$(printf '%s\n' "$CLAUDE_BODY" | grep -c '^### [0-9]' || true) + COUNT_PART="${FINDING_COUNT} finding(s) — triage pending" +fi + +if [ "$REVIEW_STATUS" = "success" ]; then + TITLE="[$REPO_SHORT] PR #$PR_NUMBER: $COUNT_PART" +else + TITLE="[$REPO_SHORT] PR #$PR_NUMBER: $COUNT_PART (review $REVIEW_STATUS — may be incomplete)" +fi + +BODY=$(printf '**Source:** %s\n**Repo:** %s\n**PR:** #%s\n**Model:** claude-sonnet-4-6\n**Phase:** baseline\n**Review job status:** %s\n**Date:** %s\n\n---\n\n%s\n' \ + "$PR_URL" "$REPO_SHORT" "$PR_NUMBER" "$REVIEW_STATUS" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$CLAUDE_BODY") + +# Structured run notes from the agent (optional). This is the +# channel that keeps verbose context off the PR — the agent writes +# to a fixed path under $RUNNER_TEMP, and we append here so the log +# issue gets the full picture while the PR comment stays concise. +# Absent file is fine; means the run had nothing structured to +# capture. +NOTES_FILE="${RUNNER_TEMP:-/tmp}/claude-review-notes.md" +if [ -f "$NOTES_FILE" ]; then + NOTES_CONTENT=$(cat "$NOTES_FILE") + BODY=$(printf '%s\n\n---\n\n%s\n' "$BODY" "$NOTES_CONTENT") + echo "Appended $(wc -c < "$NOTES_FILE") bytes of run notes from $NOTES_FILE" +else + echo "No run notes file at $NOTES_FILE — skipping notes append" +fi + +# One ai-review-log issue per PR. Stable prefix `[] PR #:` +# lets us look up an existing issue for this PR across runs even +# though the count/status portion past the colon changes per run. +# List API (not search) is used because search is eventually- +# consistent — a same-day second review run might fire before the +# first issue is indexed. +TITLE_PREFIX="[$REPO_SHORT] PR #$PR_NUMBER:" + +EXISTING_NUMBER=$(curl -sS \ + -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/HarperFast/ai-review-log/issues?labels=repo:$REPO_SHORT&state=all&per_page=100&sort=created&direction=desc" \ + | jq -r --arg prefix "$TITLE_PREFIX" \ + '[.[] | select(.title | startswith($prefix))] | first | .number // empty') + +if [ -n "$EXISTING_NUMBER" ] && [ "$EXISTING_NUMBER" != "null" ]; then + # Existing issue: append a comment, refresh the title to reflect + # this run's status. Title refresh is best-effort — we still + # report success on the comment alone. + COMMENT_PAYLOAD=$(jq -nc --arg body "$BODY" '{body: $body}') + HTTP_C=$(curl -sS -o /tmp/ai-log-comment-resp.json -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/HarperFast/ai-review-log/issues/$EXISTING_NUMBER/comments" \ + -d "$COMMENT_PAYLOAD") + + PATCH_PAYLOAD=$(jq -nc --arg title "$TITLE" '{title: $title}') + HTTP_T=$(curl -sS -o /tmp/ai-log-patch-resp.json -w '%{http_code}' -X PATCH \ + -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/HarperFast/ai-review-log/issues/$EXISTING_NUMBER" \ + -d "$PATCH_PAYLOAD") + + if [ "$HTTP_C" -ge 200 ] && [ "$HTTP_C" -lt 300 ]; then + COMMENT_URL=$(jq -r '.html_url' /tmp/ai-log-comment-resp.json) + echo "Logged review as comment on existing issue: $COMMENT_URL" + else + echo "::warning::ai-review-log comment POST failed (HTTP $HTTP_C):" + cat /tmp/ai-log-comment-resp.json + fi + + if [ "$HTTP_T" -lt 200 ] || [ "$HTTP_T" -ge 300 ]; then + echo "::warning::ai-review-log title PATCH failed (HTTP $HTTP_T):" + cat /tmp/ai-log-patch-resp.json + fi +else + # No existing issue for this PR — create one. + CREATE_PAYLOAD=$(jq -nc \ + --arg title "$TITLE" \ + --arg repo_label "repo:$REPO_SHORT" \ + --arg body "$BODY" \ + '{title: $title, body: $body, labels: [$repo_label, "verdict:pending", "phase:baseline"]}') + + HTTP=$(curl -sS -o /tmp/ai-log-resp.json -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/HarperFast/ai-review-log/issues \ + -d "$CREATE_PAYLOAD") + + if [ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ]; then + ISSUE_URL=$(jq -r '.html_url' /tmp/ai-log-resp.json) + echo "Logged review to new issue: $ISSUE_URL" + else + echo "::warning::ai-review-log POST failed (HTTP $HTTP):" + cat /tmp/ai-log-resp.json + fi +fi diff --git a/.github/scripts/parse-claude-mention.sh b/.github/scripts/parse-claude-mention.sh new file mode 100644 index 000000000..30149fa65 --- /dev/null +++ b/.github/scripts/parse-claude-mention.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Decide whether to proceed with an `@claude` mention and which model +# to use, based on the comment body. Driven by claude-mention.yml's +# "Parse mention" step. +# +# Rules (the precision gate; the job-level `if:` is a cheap +# pre-filter that only checks substring containment): +# 1. `@claude` must be the FIRST non-whitespace token (word- +# boundary after) — rules out `@claudette`, inline prose +# mentions ("saw @claude's fix"), and quoted replies +# (`> @claude ...`) where the reply is addressing a human. +# 2. Case-insensitive word-boundary `deep` anywhere in the body +# escalates to Opus. Sonnet is the default. +# +# Inputs: +# BODY — comment body (verbatim) +# GITHUB_OUTPUT — output file path +# +# Outputs (to $GITHUB_OUTPUT): +# proceed=true|false +# model=claude-opus-4-7|claude-sonnet-4-6 (only when proceed=true) +set -uo pipefail + +if ! printf '%s' "$BODY" | grep -Pqz '\A\s*@claude\b'; then + echo "Comment does not start with @claude; skipping." + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 +fi + +if printf '%s' "$BODY" | grep -Piq '\bdeep\b'; then + echo "model=claude-opus-4-7" >> "$GITHUB_OUTPUT" + echo "Selected claude-opus-4-7 (deep requested)" +else + echo "model=claude-sonnet-4-6" >> "$GITHUB_OUTPUT" + echo "Selected claude-sonnet-4-6 (default)" +fi +echo "proceed=true" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/claude-mention.yml b/.github/workflows/claude-mention.yml index 52e3af99a..afccd30be 100644 --- a/.github/workflows/claude-mention.yml +++ b/.github/workflows/claude-mention.yml @@ -58,23 +58,7 @@ jobs: id: mention env: BODY: ${{ github.event.comment.body }} - run: | - set -uo pipefail - - if ! printf '%s' "$BODY" | grep -Pqz '\A\s*@claude\b'; then - echo "Comment does not start with @claude; skipping." - echo "proceed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if printf '%s' "$BODY" | grep -Piq '\bdeep\b'; then - echo "model=claude-opus-4-7" >> "$GITHUB_OUTPUT" - echo "Selected claude-opus-4-7 (deep requested)" - else - echo "model=claude-sonnet-4-6" >> "$GITHUB_OUTPUT" - echo "Selected claude-sonnet-4-6 (default)" - fi - echo "proceed=true" >> "$GITHUB_OUTPUT" + run: bash .github/scripts/parse-claude-mention.sh - name: Clone shared Harper skills # Pinned to a SHA so agent behavior is reproducible across runs — diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index a764e6aaa..14d26a178 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -73,65 +73,14 @@ jobs: id: scope env: LAYERS: ${{ env.REVIEW_LAYERS }} - run: | - set -euo pipefail - OUT=/tmp/composed-scope.md - : > "$OUT" - while IFS= read -r raw_layer; do - # Trim whitespace around each layer name - layer="$(printf '%s' "$raw_layer" | awk '{$1=$1;print}')" - [ -z "$layer" ] && continue - file=".ai-review-prompts/${layer}.md" - if [ ! -f "$file" ]; then - echo "::warning::Review layer '$layer' not found at $file; skipping." - continue - fi - { - cat "$file" - printf '\n\n' - } >> "$OUT" - done <<< "$LAYERS" - - BYTES=$(wc -c < "$OUT") - echo "Composed ${BYTES} bytes from review layers" - if [ "$BYTES" -eq 0 ]; then - echo "::error::Composed review scope is empty — all layers missing or unreadable." - exit 1 - fi - - # Random heredoc delimiter — collision-proof against any content - # a future layer file might include. $GITHUB_OUTPUT uses heredoc - # syntax; a fixed marker could be forged (or coincidentally - # appear) in layer content and corrupt the output. - DELIM="EOF_$(openssl rand -hex 16)" - { - echo "composed<<${DELIM}" - cat "$OUT" - echo "${DELIM}" - } >> "$GITHUB_OUTPUT" + run: bash .github/scripts/compose-review-scope.sh - name: Find prior review comment - # `--edit-last` (the previous mechanism) filters by authenticated - # identity (`claude[bot]`) only — so after a `@claude` mention, - # the most recent claude[bot] comment is the mention response - # and `--edit-last` clobbers it. Marker-based lookup fixes that: - # every review comment starts with ``, - # which mention responses never carry. Mention responses are - # safe; only the marker'd review comment is editable here. id: prior_review env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -uo pipefail - EXISTING_ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ - --jq '[.[] | select(.user.login == "claude[bot]") | select(.body | startswith(""))] | last | .id // empty') - if [ -n "$EXISTING_ID" ]; then - echo "Prior review comment: $EXISTING_ID" - else - echo "No prior review comment found — agent will post fresh." - fi - echo "id=${EXISTING_ID}" >> "$GITHUB_OUTPUT" + run: bash .github/scripts/find-prior-review-comment.sh - name: Claude review id: claude-review @@ -431,157 +380,4 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} REVIEW_STATUS: ${{ steps.claude-review.outcome }} REPO_SHORT: ${{ github.event.repository.name }} - run: | - set -uo pipefail - - if [ -z "${AI_REVIEW_LOG_TOKEN:-}" ]; then - echo "::warning::AI_REVIEW_LOG_TOKEN secret not set; skipping log entry." - exit 0 - fi - - # When this workflow job started. Used to filter out stale Claude - # review comments from previous runs so a cancelled in-flight - # run (e.g. from a force-push) doesn't re-log a prior run's - # content as a fresh finding. - JOB_STARTED=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq '.run_started_at // empty') - - # Fetch the marker'd review comment via raw API. We can't use - # `gh pr view --json comments` because (a) it doesn't expose - # `updated_at` (which we need below for the staleness guard - # now that comments are edited in place), and (b) we need the - # marker filter to ignore `@claude` mention responses that - # share the `claude[bot]` identity. - CLAUDE_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ - --jq '[.[] | select(.user.login == "claude[bot]") | select(.body | startswith(""))] | last // empty') - - if [ -z "$CLAUDE_JSON" ] || [ "$CLAUDE_JSON" = "null" ]; then - echo "No marker'd Claude review comment found on PR #$PR_NUMBER (review_status=$REVIEW_STATUS); skipping log." - exit 0 - fi - - CLAUDE_BODY=$(printf '%s' "$CLAUDE_JSON" | jq -r '.body // empty') - # Prefer updated_at (reflects the most recent edit) over - # created_at (frozen at original post time) — comments are - # now edited in place across runs. - CLAUDE_AT=$(printf '%s' "$CLAUDE_JSON" | jq -r '.updated_at // .created_at // empty') - - if [ -z "$CLAUDE_BODY" ]; then - echo "Claude review comment had empty body; skipping log." - exit 0 - fi - - # ISO-8601 lexicographic compare — both are UTC timestamps in the - # same shape, so string comparison is sound. - if [ -n "$JOB_STARTED" ] && [ -n "$CLAUDE_AT" ] && [ "$CLAUDE_AT" \< "$JOB_STARTED" ]; then - echo "::notice::Latest Claude review comment update ($CLAUDE_AT) predates this job's start ($JOB_STARTED); skipping to avoid re-logging stale content." - exit 0 - fi - - # Title: count findings (lines starting with `### `). "No blockers" case has none. - # Match the phrase anywhere in the body — the new concise prompt asks for - # `Reviewed; no blockers found.`, which doesn't start with "no blockers" - # and so wouldn't match an anchored regex. Anywhere-match is safe because - # `no blockers found` is a deliberate sentinel phrase from the prompt. - if printf '%s' "$CLAUDE_BODY" | grep -qi 'no blockers found'; then - COUNT_PART="no blockers" - else - FINDING_COUNT=$(printf '%s\n' "$CLAUDE_BODY" | grep -c '^### [0-9]' || true) - COUNT_PART="${FINDING_COUNT} finding(s) — triage pending" - fi - - if [ "$REVIEW_STATUS" = "success" ]; then - TITLE="[$REPO_SHORT] PR #$PR_NUMBER: $COUNT_PART" - else - TITLE="[$REPO_SHORT] PR #$PR_NUMBER: $COUNT_PART (review $REVIEW_STATUS — may be incomplete)" - fi - - BODY=$(printf '**Source:** %s\n**Repo:** %s\n**PR:** #%s\n**Model:** claude-sonnet-4-6\n**Phase:** baseline\n**Review job status:** %s\n**Date:** %s\n\n---\n\n%s\n' \ - "$PR_URL" "$REPO_SHORT" "$PR_NUMBER" "$REVIEW_STATUS" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$CLAUDE_BODY") - - # Structured run notes from the agent (optional). This is - # the channel that keeps verbose context off the PR — the - # agent writes to a fixed path under $RUNNER_TEMP, and we - # append here so the log issue gets the full picture while - # the PR comment stays concise. Absent file is fine; means - # the run had nothing structured to capture. - NOTES_FILE="${RUNNER_TEMP:-/tmp}/claude-review-notes.md" - if [ -f "$NOTES_FILE" ]; then - NOTES_CONTENT=$(cat "$NOTES_FILE") - BODY=$(printf '%s\n\n---\n\n%s\n' "$BODY" "$NOTES_CONTENT") - echo "Appended $(wc -c < "$NOTES_FILE") bytes of run notes from $NOTES_FILE" - else - echo "No run notes file at $NOTES_FILE — skipping notes append" - fi - - # One ai-review-log issue per PR. Stable prefix - # `[] PR #:` lets us look up an existing issue for - # this PR across runs even though the count/status portion - # past the colon changes per run. List API (not search) is - # used because search is eventually-consistent — a same-day - # second review run might fire before the first issue is - # indexed. - TITLE_PREFIX="[$REPO_SHORT] PR #$PR_NUMBER:" - - EXISTING_NUMBER=$(curl -sS \ - -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/HarperFast/ai-review-log/issues?labels=repo:$REPO_SHORT&state=all&per_page=100&sort=created&direction=desc" \ - | jq -r --arg prefix "$TITLE_PREFIX" \ - '[.[] | select(.title | startswith($prefix))] | first | .number // empty') - - if [ -n "$EXISTING_NUMBER" ] && [ "$EXISTING_NUMBER" != "null" ]; then - # Existing issue: append a comment, refresh the title to - # reflect this run's status. Title refresh is best-effort — - # we still report success on the comment alone. - COMMENT_PAYLOAD=$(jq -nc --arg body "$BODY" '{body: $body}') - HTTP_C=$(curl -sS -o /tmp/ai-log-comment-resp.json -w '%{http_code}' -X POST \ - -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/HarperFast/ai-review-log/issues/$EXISTING_NUMBER/comments" \ - -d "$COMMENT_PAYLOAD") - - PATCH_PAYLOAD=$(jq -nc --arg title "$TITLE" '{title: $title}') - HTTP_T=$(curl -sS -o /tmp/ai-log-patch-resp.json -w '%{http_code}' -X PATCH \ - -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/HarperFast/ai-review-log/issues/$EXISTING_NUMBER" \ - -d "$PATCH_PAYLOAD") - - if [ "$HTTP_C" -ge 200 ] && [ "$HTTP_C" -lt 300 ]; then - COMMENT_URL=$(jq -r '.html_url' /tmp/ai-log-comment-resp.json) - echo "Logged review as comment on existing issue: $COMMENT_URL" - else - echo "::warning::ai-review-log comment POST failed (HTTP $HTTP_C):" - cat /tmp/ai-log-comment-resp.json - fi - - if [ "$HTTP_T" -lt 200 ] || [ "$HTTP_T" -ge 300 ]; then - echo "::warning::ai-review-log title PATCH failed (HTTP $HTTP_T):" - cat /tmp/ai-log-patch-resp.json - fi - else - # No existing issue for this PR — create one. - CREATE_PAYLOAD=$(jq -nc \ - --arg title "$TITLE" \ - --arg repo_label "repo:$REPO_SHORT" \ - --arg body "$BODY" \ - '{title: $title, body: $body, labels: [$repo_label, "verdict:pending", "phase:baseline"]}') - - HTTP=$(curl -sS -o /tmp/ai-log-resp.json -w '%{http_code}' -X POST \ - -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - https://api.github.com/repos/HarperFast/ai-review-log/issues \ - -d "$CREATE_PAYLOAD") - - if [ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ]; then - ISSUE_URL=$(jq -r '.html_url' /tmp/ai-log-resp.json) - echo "Logged review to new issue: $ISSUE_URL" - else - echo "::warning::ai-review-log POST failed (HTTP $HTTP):" - cat /tmp/ai-log-resp.json - fi - fi + run: bash .github/scripts/log-review-to-ai-review-log.sh