diff --git a/.claude/agents/ci-watcher.md b/.claude/agents/ci-watcher.md index e8eac9bf..4e5bb322 100644 --- a/.claude/agents/ci-watcher.md +++ b/.claude/agents/ci-watcher.md @@ -24,30 +24,31 @@ The pull request is on the repository the branch was pushed to: git -C remote get-url origin ``` -Use `mcp__github__pull_request_read` with `method: "get_check_runs"` for the head commit's -checks. Where the `gh` CLI is installed, `gh pr checks --watch` and `gh run list --commit ` -followed by `gh run view --log-failed` give more detail; use them when they are there. - -## Find the run - -The pull request is a draft and stays one; the user takes it out of draft when they are ready -to review it. Checks run on drafts, so pushing the branch starts a round of them, and a round -is already queued or finished by the time you are invoked. +## Watch -Confirm you are reading the round for the commit you were asked about: +`.claude/scripts/ci-status.sh` does the waiting and the log archaeology. Run it and read what it +gives you: ```bash -git -C rev-parse +.claude/scripts/ci-status.sh / ``` -Compare that against the head SHA in the pull request data. A push takes a moment to register, -so poll `get_check_runs` until runs appear. If nothing has appeared after a few minutes, say so -and stop: a push that starts no checks is a setup problem no amount of waiting fixes. +It blocks until every check concludes, so **never write a polling loop with `sleep` in it** — a +foreground `sleep` does not run here. Give the call a long timeout; a full round is several +minutes and the tool caps at ten. -## Watch +Its exit code is the outcome, and it separates cases you would otherwise confuse: -Poll until every check reaches a conclusion. Give it a generous timeout — a full build plus the -test suite is slow, and a watch you cut short looks exactly like a failure. +| Exit | Means | What to do | +|---|---|---| +| `0` | Every check passed | Report success | +| `1` | A check failed | The log extract is on stdout; diagnose it | +| `3` | No checks ever started | A setup problem. Stop and say so | +| `4` | `gh` is not authenticated | A setup problem. Say `gh auth login` has not been run | +| `8` | Still pending when the watch ended | The call was cut short. Run it again | + +`3`, `4` and `8` are **not** CI failures. Reporting any of them as one sends an implementer +hunting for a defect that does not exist. Five workflows run on a pull request, reported by job name rather than by workflow name. Expect these: @@ -57,8 +58,7 @@ these: | `changes / detect` | Never. Every workflow gates on `detect-code-changes`, so there are five of these. | | `build-and-test` | The diff is documentation only | | `build-and-test-web` | The diff is documentation only | -| `Analyze (csharp)` | The diff is documentation only | -| `Analyze (javascript)` | The diff is documentation only | +| `Analyze (csharp)` and `Analyze (javascript)` | The diff is documentation only | | `build-api` | The diff is documentation only | | `build-web` | The diff is documentation only | | `build-devcontainer` | The diff does not touch `.devcontainer/` | @@ -72,8 +72,20 @@ not have caught locally shows up. ## On failure -Get the real error. The check run's `output` summary and annotations carry the diagnostic; -where `gh` is installed, the failed job's log carries more. +The script has already found the failing runs and printed the window of log ending at the +runner's `##[error]` marker. That window is where the diagnosis is, and reading it is the job. + +**The marker line is the symptom, not the cause.** It says things like `buildx failed with: +ERROR: ... exit code: 1`. The thing that actually broke — a version mismatch, a compiler +diagnostic, a failing assertion — sits in the lines above it. Work upwards until you find +something that explains the failure rather than restating it. + +Where the extract leaves you short of the cause, read further. The script keeps each failing +job's full log and prints its path, so open that file and search it rather than fetching another +copy. + +Say so in the report if it still does not explain the failure, and give the run URL. Do not fill +the gap with a cause the log does not support. Then read the code the failure points at. The repository's working tree is on whatever the user last checked out, so read the branch's version: diff --git a/.claude/scripts/ci-status.sh b/.claude/scripts/ci-status.sh new file mode 100755 index 00000000..dc368d9d --- /dev/null +++ b/.claude/scripts/ci-status.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Watch a pull request's checks to a conclusion and, where they failed, print the part of the +# log that explains why. +# +# This is the mechanical half of watching CI: waiting, reading exit codes, finding the failing +# runs, and cutting the noise out of their logs. Deciding what the error means belongs to +# whoever reads the output. +# +# usage: ci-status.sh {pr-number} [owner/repo] +# +# Exit codes are the caller's signal and are deliberately distinct: +# 0 every check passed +# 1 a check failed; the log extract is on stdout +# 3 no checks have started for the head commit +# 4 gh is not authenticated +# 8 checks were still pending when the watch ended +set -uo pipefail + +pr_number="${1:-}" +repo="${2:-}" + +if [[ -z "${pr_number}" ]]; then + echo "usage: ci-status.sh {pr-number} [owner/repo]" >&2 + exit 64 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "gh is not authenticated. Run 'gh auth login'." >&2 + exit 4 +fi + +gh_args=(--repo "${repo}") +[[ -z "${repo}" ]] && gh_args=() + +head_sha=$(gh pr view "${pr_number}" "${gh_args[@]}" --json headRefOid --jq .headRefOid 2>/dev/null) +if [[ -z "${head_sha}" ]]; then + echo "Could not read pull request ${pr_number}." >&2 + exit 64 +fi + +echo "Head commit: ${head_sha}" + +checks=$(gh pr checks "${pr_number}" "${gh_args[@]}" --watch 2>&1) +watch_status=$? + +# A pull request whose checks never started reports this rather than an empty table, and it +# means a setup problem rather than a slow queue. +if grep -qi "no checks reported" <<<"${checks}"; then + echo "No checks have started for ${head_sha}." + exit 3 +fi + +echo "${checks}" + +case "${watch_status}" in + 0) + echo + echo "All checks passed." + exit 0 + ;; + 8) + echo + echo "Checks still pending when the watch ended. Run again." + exit 8 + ;; +esac + +# Strip the "jobsteptimestamp " prefix gh puts on every log line, which is most of the +# width and none of the information. +strip_prefix() { + sed -E 's/^[^\t]*\t[^\t]*\t[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z //' +} + +# The runner marks the failure with ##[error], but that line is the symptom — "the build +# failed". The cause sits above it, so print a window ending at the marker. +extract_failure() { + local log="$1" + local marker + marker=$(grep -n '##\[error\]' "${log}" | head -1 | cut -d: -f1) + + if [[ -z "${marker}" ]]; then + echo " No ##[error] marker found. Last 40 lines:" + tail -40 "${log}" | strip_prefix | sed 's/^/ /' + return + fi + + local from=$(( marker - 60 )) + (( from < 1 )) && from=1 + sed -n "${from},$(( marker + 2 ))p" "${log}" | strip_prefix | sed 's/^/ /' +} + +echo +echo "=== Failing runs for ${head_sha} ===" + +failed_runs=$(gh run list --commit "${head_sha}" "${gh_args[@]}" \ + --status failure --json databaseId,workflowName --jq '.[] | "\(.databaseId)\t\(.workflowName)"') + +if [[ -z "${failed_runs}" ]]; then + echo "A check failed but no failing workflow run was found for the commit." + echo "The failure may belong to a check that is not an Actions run." + exit 1 +fi + +# The full logs are kept rather than cleaned up. The extract below is a window, and whoever +# reads it may need more; leaving the files behind means they open a file instead of going back +# to the network for a second copy. +log_dir=$(mktemp -d -t ci-status-XXXXXX) + +while IFS=$'\t' read -r run_id workflow_name; do + [[ -z "${run_id}" ]] && continue + echo + echo "--- ${workflow_name} (run ${run_id}) ---" + echo " https://github.com/${repo:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}/actions/runs/${run_id}" + echo + + log="${log_dir}/${run_id}.log" + if gh run view "${run_id}" "${gh_args[@]}" --log-failed >"${log}" 2>/dev/null && [[ -s "${log}" ]]; then + extract_failure "${log}" + echo + echo " Full log: ${log}" + else + echo " Could not read the failed log for run ${run_id}." + fi +done <<<"${failed_runs}" + +exit 1 diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md index d009c06c..2d547481 100644 --- a/.claude/skills/crypter-review/SKILL.md +++ b/.claude/skills/crypter-review/SKILL.md @@ -84,7 +84,7 @@ this pull request may not be theirs. Use the GitHub MCP server's `pull_request_review_write` with method `create` to open a pending review, `add_comment_to_pending_review` for each finding that names a file and a line **in the -diff**, then `submit_pending`. Where `gh` is installed, `gh pr review --comment` posts the body. +diff**, then `submit_pending`. The review body carries: diff --git a/.claude/skills/crypter-triage-review/SKILL.md b/.claude/skills/crypter-triage-review/SKILL.md index 6db73960..d390d577 100644 --- a/.claude/skills/crypter-triage-review/SKILL.md +++ b/.claude/skills/crypter-triage-review/SKILL.md @@ -72,8 +72,11 @@ summary. Every finding gets one of three outcomes, and none of them is silence. -**Does not hold** — reply on the thread with `add_reply_to_pull_request_comment`, or -`gh pr comment` where the finding has no thread. Give the evidence: what the code does instead, +**Does not hold** — reply on the thread with `add_reply_to_pull_request_comment`. Where the +finding has no thread to reply on, comment on the pull request itself with `add_issue_comment`, +quoting enough of the finding that the reply stands on its own. + +Give the evidence: what the code does instead, by file and line. Two or three sentences. Say it as a position, not a verdict — the person who raised it may know something the verifier could not see, and the thread is where that comes out.