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
117 changes: 117 additions & 0 deletions .claude/hooks/coderabbit-merge-gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
#
# coderabbit-merge-gate.sh
#
# PreToolUse(Bash) hook. Gates `gh pr merge` on CodeRabbit having FINISHED its
# review of the PR's head commit ("review-complete" gate):
#
# - CodeRabbit commit status on head == success -> allow (surface findings)
# - status == pending -> BLOCK (still reviewing)
# - status == failure/error -> BLOCK (problem)
# - status absent / cannot verify -> BLOCK (not reviewed yet)
#
# A finished review lets the merge proceed even if it has actionable comments
# (that is the "resolve-all-issues" gate, deliberately not enabled here). The
# count is surfaced so it is not silently ignored. Every failure path fails
# SAFE to BLOCK -- it never silently allows a merge it could not verify.
#
# Blocking uses permissionDecision:"deny" rather than "ask" on purpose: this
# environment runs defaultMode:auto + skipAutoPermissionPrompt, which silently
# auto-approves "ask", making it a no-op. "deny" is a hard block. To override
# (e.g. CodeRabbit is down), edit/remove this hook in .claude/settings.local.json
# or run the merge yourself outside the agent.
#
# Authoritative signal is the `CodeRabbit` commit status (set by commit_status:
# true in .coderabbit.yaml): pending while reviewing, success when complete. A
# new push resets it to pending, so this is inherently staleness-proof.
#
# Reads the hook payload on stdin, emits a PreToolUse decision as JSON on stdout.

set -o pipefail

input="$(cat)"
cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null)"

# Fast path: only act when `gh pr merge` is an actual command, not a substring.
# Anchor it to a command boundary (line start, &&, ;, |, then, do) so it still
# catches compound forms (`cd x && gh pr merge 9`) but NOT mentions inside
# `echo "... gh pr merge ..."`, `git commit -m "... gh pr merge ..."`, or
# `rg "gh pr merge"`. Anything else passes untouched.
if ! printf '%s' "$cmd" | grep -qE '(^|&&|;|\||\bthen\b|\bdo\b)[[:space:]]*gh[[:space:]]+pr[[:space:]]+merge([[:space:]]|$)'; then
exit 0
fi

# Emit a hard "deny" decision (blocks the merge) and exit.
block() {
jq -nc --arg r "$1" \
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
exit 0
}

# Inject context for the model but do not block (normal permission flow continues).
note() {
jq -nc --arg c "$1" \
'{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$c}}'
exit 0
}

command -v gh >/dev/null 2>&1 || block "CodeRabbit gate: 'gh' not found — cannot verify CodeRabbit review. Confirm manually before merging."
command -v jq >/dev/null 2>&1 || exit 0 # jq missing: cannot build payload; do not block.

# Target repo: honor -R/--repo on the merge command, else the current repo.
repo="$(printf '%s' "$cmd" | grep -oE '(-R|--repo)[ =]+[^ ]+' | head -1 | sed -E 's/^(-R|--repo)[ =]+//')"
repo_args=()
[ -n "$repo" ] && repo_args=(--repo "$repo")

# PR number: prefer pull/<n> from a URL, then a bare integer argument, else the
# current branch's PR. (A bare-digit token avoids grabbing digits inside an
# owner name such as ".../mohamed-elkholy95/...".)
args="$(printf '%s' "$cmd" | sed -E 's/.*gh[[:space:]]+pr[[:space:]]+merge//')"
pr="$(printf '%s' "$args" | grep -oE 'pull/[0-9]+' | head -1 | grep -oE '[0-9]+')"
if [ -z "$pr" ]; then
pr="$(printf '%s' "$args" | tr ' ' '\n' | grep -xE '[0-9]+' | head -1)"
fi
if [ -z "$pr" ]; then
pr="$(gh pr view "${repo_args[@]}" --json number --jq '.number' 2>/dev/null)"
fi
[ -n "$pr" ] || block "CodeRabbit gate: could not determine the PR for this merge. Confirm CodeRabbit reviewed it, then merge."

# owner/repo for the commit-status API.
nwo="$repo"
[ -n "$nwo" ] || nwo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null)"
[ -n "$nwo" ] || block "CodeRabbit gate: could not resolve the repository for PR #$pr. Confirm CodeRabbit review, then merge."

# Head commit of the PR.
sha="$(gh pr view "$pr" "${repo_args[@]}" --json commits --jq '.commits[-1].oid' 2>/dev/null)"
[ -n "$sha" ] || block "CodeRabbit gate: could not read PR #$pr head commit. Confirm CodeRabbit review, then merge."

# CodeRabbit commit status on the head commit.
cr_state="$(gh api "repos/$nwo/commits/$sha/status" \
--jq '.statuses[] | select(.context=="CodeRabbit") | .state' 2>/dev/null | head -1)"

# Latest "Actionable comments posted: N" from CodeRabbit's completion comment.
actionable="$(gh pr view "$pr" "${repo_args[@]}" --json comments --jq '
[ .comments[]
| select(.author.login=="coderabbitai")
| select(.body | contains("coderabbit-review-completion-marker"))
| (.body | capture("Actionable comments posted: (?<n>[0-9]+)").n)
] | last // "unknown"' 2>/dev/null)"

case "$cr_state" in
success)
if [ "$actionable" = "0" ] || [ "$actionable" = "unknown" ] || [ -z "$actionable" ]; then
note "CodeRabbit review complete on PR #$pr (no actionable comments). Proceeding."
else
note "CodeRabbit review complete on PR #$pr with $actionable actionable comment(s). Review-complete gate allows the merge — confirm those were addressed/resolved before merging."
fi
;;
pending)
block "CodeRabbit is still reviewing the latest commit on PR #$pr (status: pending). Wait for the review to finish before merging."
;;
failure|error)
block "CodeRabbit commit status on PR #$pr is '$cr_state'. Investigate and resolve before merging."
;;
*)
block "No CodeRabbit review found on PR #$pr's head commit ($sha). CodeRabbit may not have reviewed this push yet (or is not enabled here). Confirm before merging."
;;
esac
158 changes: 72 additions & 86 deletions .github/workflows/dispatch-pythinker-home-sync.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
name: Dispatch pythinker-home sync

# Triggers a pythinker-home website sync when the install scripts or README
# change on main. Release promotion and the post-release sync live in
# promote-release.yml: a GITHUB_TOKEN-created release never fires a workflow,
# so the old `release: published` trigger here was dead code that never ran the
# wait-for-assets / mark-latest steps.
# change on main. This is a best-effort latency optimization, NOT the source of
# truth: pythinker-home re-syncs on its own daily cron (sync-upstream-products
# @ 04:17 UTC) using its own GITHUB_TOKEN, so a failed dispatch here never
# leaves the website stale. The job therefore degrades gracefully (warns +
# alerts, exits 0) instead of red-lining main when the org-owned
# pythinker-release-bot App is missing, uninstalled, or rotated.
#
# Release promotion's post-release sync lives in promote-release.yml: a
# GITHUB_TOKEN-created release never fires a workflow, so the old
# `release: published` trigger here was dead code that never ran.

on:
workflow_dispatch:
Expand All @@ -28,99 +34,79 @@ jobs:
steps:
# Mint a short-lived installation token for the org-owned
# pythinker-release-bot App (Contents: write on pythinker-home only).
# Replaces a personal PAT: org-owned (survives member/org changes),
# ~1h TTL, minted fresh each run, scoped to the single private site repo.
# `continue-on-error` is deliberate: a missing/rotated App must not fail
# the run — the next step detects the empty token and degrades gracefully.
- name: Mint GitHub App token for pythinker-home
id: app-token
env:
APP_ID: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY }}
DISPATCH_OWNER: ${{ env.DISPATCH_OWNER }}
DISPATCH_REPO: ${{ env.DISPATCH_REPO }}
run: |
set -euo pipefail
if [ -z "${APP_ID:-}" ] || [ -z "${APP_PRIVATE_KEY:-}" ]; then
echo "::error::Missing PYTHINKER_RELEASE_BOT_APP_ID or PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY. Configure the org-owned pythinker-release-bot App and install it on ${DISPATCH_OWNER}/${DISPATCH_REPO} with Contents: Read and write." >&2
exit 1
fi
continue-on-error: true
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2.2.2
with:
app-id: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY }}
owner: ${{ env.DISPATCH_OWNER }}
repositories: ${{ env.DISPATCH_REPO }}
permission-contents: write

b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
key_file=$(mktemp)
trap 'rm -f "$key_file"' EXIT
printf '%s\n' "$APP_PRIVATE_KEY" > "$key_file"
chmod 600 "$key_file"

now=$(date +%s)
header=$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)
payload=$(jq -nc --argjson iat "$((now - 60))" --argjson exp "$((now + 540))" --arg iss "$APP_ID" '{iat:$iat,exp:$exp,iss:$iss}' | b64url)
unsigned="${header}.${payload}"
signature=$(printf '%s' "$unsigned" | openssl dgst -sha256 -sign "$key_file" | b64url)
jwt="${unsigned}.${signature}"

installation_id=$(curl --fail-with-body -sS \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${jwt}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/installation" \
| jq -er '.id')
token=$(jq -nc --arg repo "$DISPATCH_REPO" '{repositories:[$repo],permissions:{contents:"write"}}' \
| curl --fail-with-body -sS \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${jwt}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/app/installations/${installation_id}/access_tokens" \
-d @- \
| jq -er '.token')
echo "::add-mask::$token"
echo "token=$token" >> "$GITHUB_OUTPUT"

- name: Trigger pythinker-home sync
- name: Trigger pythinker-home sync (best-effort)
env:
DISPATCH_TOKEN: ${{ steps.app-token.outputs.token }}
TOKEN_OUTCOME: ${{ steps.app-token.outcome }}
SOURCE_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.sha }}
DISPATCH_OWNER: ${{ env.DISPATCH_OWNER }}
DISPATCH_REPO: ${{ env.DISPATCH_REPO }}
run: |
set -euo pipefail
if [ -z "${DISPATCH_TOKEN:-}" ]; then
echo "::error::No dispatch token: the pythinker-release-bot App token mint produced an empty value. Confirm PYTHINKER_RELEASE_BOT_APP_ID and PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY org secrets are set and the App is installed on ${DISPATCH_OWNER}/${DISPATCH_REPO} with Contents: Read and write." >&2
exit 1
fi
payload=$(jq -n \
--arg source_repo "$SOURCE_REPO" \
--arg tag "$RELEASE_TAG" \
'{"event_type":"sync-pythinker-products","client_payload":{"source_repo":$source_repo,"tag":$tag}}')
curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DISPATCH_TOKEN" \
"https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/dispatches" \
-d "$payload"

notify-failure:
name: Notify on failure
runs-on: ubuntu-latest
needs: dispatch
if: failure()
steps:
- name: Post Slack alert
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPO: ${{ github.repository }}
TRIGGER: ${{ github.event_name }}
run: |
if [ -z "$SLACK_WEBHOOK_URL" ]; then
# No `set -e`: anticipated failures degrade gracefully (warn + alert +
# exit 0) so a broken App never red-lines main. The daily cron in
# pythinker-home is the real sync guarantee.
set -uo pipefail

degrade() {
reason="$1"
echo "::warning title=pythinker-home sync skipped::${reason}"
{
echo "### :warning: pythinker-home sync dispatch skipped"
echo ""
echo "${reason}"
echo ""
echo "**Non-blocking:** pythinker-home re-syncs on its daily cron (\`sync-upstream-products\` @ 04:17 UTC) using its own token, so the website is not stale."
echo ""
echo "**Restore the fast path:** recreate/install the **pythinker-release-bot** App on \`${DISPATCH_OWNER}\` with *Contents: write* on \`${DISPATCH_REPO}\`, then update the \`PYTHINKER_RELEASE_BOT_APP_ID\` and \`PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY\` secrets."
} >> "$GITHUB_STEP_SUMMARY"
if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
alert=$(jq -n --arg run_url "$RUN_URL" --arg repo "$SOURCE_REPO" --arg reason "$reason" \
'{"text":":warning: *pythinker-home sync dispatch skipped (non-blocking)*","attachments":[{"color":"warning","fields":[{"title":"Repo","value":$repo,"short":true},{"title":"Reason","value":$reason,"short":false},{"title":"Run","value":"<\($run_url)|View logs>","short":false}]}]}')
curl -sS -X POST -H "Content-Type: application/json" -d "$alert" "$SLACK_WEBHOOK_URL" || true
fi
exit 0
}

if [ "${TOKEN_OUTCOME}" != "success" ] || [ -z "${DISPATCH_TOKEN:-}" ]; then
degrade "Could not mint a pythinker-release-bot App token (token step outcome: ${TOKEN_OUTCOME}). The App is likely missing/uninstalled on ${DISPATCH_OWNER}, or its credentials are stale."
fi
payload=$(jq -n \
--arg run_url "$RUN_URL" \
--arg repo "$REPO" \
--arg trigger "$TRIGGER" \
'{"text":":red_circle: *Dispatch pythinker-home sync failed*","attachments":[{"color":"danger","fields":[{"title":"Repo","value":$repo,"short":true},{"title":"Trigger","value":$trigger,"short":true},{"title":"Run","value":"<\($run_url)|View logs>","short":false}]}]}')
curl --fail-with-body -X POST \
-H "Content-Type: application/json" \
-d "$payload" \
"$SLACK_WEBHOOK_URL"

payload=$(jq -n --arg source_repo "$SOURCE_REPO" --arg tag "$RELEASE_TAG" \
'{"event_type":"sync-pythinker-products","client_payload":{"source_repo":$source_repo,"tag":$tag}}')

# repository_dispatch returns 204 on success. Retry transient errors;
# a persistent failure degrades (the cron still backstops the sync).
resp=$(mktemp)
code="000"
for attempt in 1 2 3; do
code=$(curl -sS -o "$resp" -w '%{http_code}' \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DISPATCH_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/dispatches" \
-d "$payload") || code="000"
if [ "$code" = "204" ]; then
echo "Dispatched sync-pythinker-products to ${DISPATCH_OWNER}/${DISPATCH_REPO} (HTTP 204)."
exit 0
fi
echo "Dispatch attempt ${attempt} returned HTTP ${code}: $(head -c 200 "$resp")"
[ "$attempt" -lt 3 ] && sleep $((attempt * 3)) || true
done
degrade "repository_dispatch to ${DISPATCH_OWNER}/${DISPATCH_REPO} failed after 3 attempts (last HTTP ${code}): $(head -c 200 "$resp")"
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ GitHub Releases page; `0.8.0` is the new starting line.
## Unreleased

- **Release promotion no longer stalls when the Homebrew tap is broken.** The `promote-release` workflow now gates only on platform assets and PyPI; a lagging or broken Homebrew tap emits a warning annotation and step summary note but no longer blocks the GitHub Release from reaching Latest.
- **Calmer, theme-aligned TUI rendering.** Transcript, recap, and tool-header output now use theme-standardized activity colors instead of hardcoded values, and Markdown tables render as a bordered grid (wide tables no longer collapse into a stacked-record list).
- **Auto-mode tool approval fails closed when unattended.** In auto/non-interactive runs, an action that still needs approval under the active safe-mode/trust policy is denied with guidance instead of waiting indefinitely for an absent user, and outside-workspace writes are never auto-approved. A new `auto_deliberate_destructive_actions` setting can additionally bounce destructive auto-approved actions once for deliberation before they run.
- **`pythinker review` validates finding evidence.** Reviewflow assembles prompts from a shared security-knowledge manifest and validates findings, handling invalid ones without failing the whole review.

## 0.30.0 (2026-06-02)

Expand Down
6 changes: 4 additions & 2 deletions packages/pythinker-review/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pythinker-review show-finding <finding-id>
pythinker-review init
pythinker-review map
pythinker-review review --limit 3 --jobs 3
pythinker-review review --limit 3 --jobs 3 --prompt-file review-guidance.md --rate-limit-per-minute 6 # optional guidance/rate cap
pythinker-review report --status open
pythinker-review show --finding <finding-id>
pythinker-review triage --finding <finding-id> --status false-positive
Expand Down Expand Up @@ -100,8 +101,9 @@ The stateful Reviewflow workflow writes `.pythinker-review-flow/` by default:

Phase 1 now ports the highest-value behavior from the mounted blackbox repos:

- Reviewflow-style evidence validation rejects findings outside the reviewed chunk/feature, unsafe
paths, stale line ranges, or non-matching evidence snippets.
- Reviewflow-style evidence validation uses line-numbered prompt manifests and rejects findings
outside the reviewed chunk/feature, unsafe paths, omitted/truncated line ranges, or non-matching
evidence snippets; invalid sibling findings are recorded as drops without failing the whole run.
- Reviewflow pure-Python stateful commands cover `init`, `map`, `status`, `review`, `ci`,
`report`, `show --finding`, `next`, `triage`, `revalidate`, `fix`, `open-pr`, `doctor`, and
`clean-locks`.
Expand Down
Loading
Loading