Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 33 additions & 21 deletions .claude/agents/ci-watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,30 +24,31 @@ The pull request is on the repository the branch was pushed to:
git -C <repo> 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 <sha>`
followed by `gh run view <run-id> --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 <repo> rev-parse <branch>
.claude/scripts/ci-status.sh <pr-number> <owner>/<repo>
```

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:
Expand All @@ -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/` |
Expand All @@ -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:
Expand Down
126 changes: 126 additions & 0 deletions .claude/scripts/ci-status.sh
Original file line number Diff line number Diff line change
@@ -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 "job<TAB>step<TAB>timestamp " 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
2 changes: 1 addition & 1 deletion .claude/skills/crypter-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
7 changes: 5 additions & 2 deletions .claude/skills/crypter-triage-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading