Add optional skills-mode improve and verify stages #209
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
| # Drop into your repo at .github/workflows/ai-review.yml. | |
| # Posts a signed webhook to the AI Review Runner on: | |
| # 1. PR opened from the same repo — automatic first review. | |
| # 2. A trusted user posts one of these slash commands in a PR comment: | |
| # /code-review → review only | |
| # /fix → apply fixes for the latest review verdict | |
| # /code-review-and-fix → review then fix (chained) | |
| # | |
| # Required secrets in the repo (or org): | |
| # RUNNER_HMAC_SECRET — same value the runner has at RUNNER_HMAC_SECRET / _FILE. | |
| # RUNNER_URL — e.g. https://runner.example.com/review | |
| # RUNNER_GH_PAT — fine-grained PAT with: | |
| # Contents: Read (review-only repos) | |
| # Contents: Read & Write (when /fix is enabled — push to PR branch) | |
| # Pull requests: Read & Write | |
| # Issues: Read & Write | |
| # Metadata: Read | |
| # See README / DEPLOY.md for why GITHUB_TOKEN won't work | |
| # (it's revoked when this workflow job ends, but the | |
| # runner needs the token 30–180 s later.) | |
| # RUNNER_TRUSTED_USERS — JSON array string of GitHub logins allowed to re-trigger | |
| # via slash commands. Example: `["lee-to","alice"]`. | |
| # If unset / empty, slash-command triggers are blocked | |
| # and only same-repo `pull_request: opened` events | |
| # start a review. | |
| name: AI Review | |
| on: | |
| pull_request: | |
| types: [opened] | |
| issue_comment: | |
| types: [created] | |
| permissions: | |
| pull-requests: write | |
| contents: read | |
| issues: read | |
| jobs: | |
| ai-review: | |
| # Trigger on: | |
| # - same-repo PR opened (automatic first review) | |
| # - issue_comment on a PR, by a trusted user, containing one of the | |
| # slash commands. Order in the if() matters only for documentation; | |
| # `contains()` is unordered. | |
| # Job-level `if:` cannot read `secrets`, so trusted-users whitelist is | |
| # enforced in the bash step below. Here we only filter by event type + | |
| # slash command structure; non-trusted senders fall through to a no-op | |
| # exit in the step (workflow run still appears in Actions, but skipped). | |
| if: | | |
| (github.event_name == 'pull_request' && | |
| github.event.pull_request.head.repo.full_name == github.repository) || | |
| (github.event_name == 'issue_comment' && | |
| github.event.issue.pull_request != null && | |
| github.event.sender.type != 'Bot' && | |
| (contains(github.event.comment.body, '/code-review-and-fix') || | |
| contains(github.event.comment.body, '/code-review') || | |
| contains(github.event.comment.body, '/fix'))) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Send signed webhook | |
| env: | |
| RUNNER_URL: ${{ secrets.RUNNER_URL }} | |
| RUNNER_HMAC_SECRET: ${{ secrets.RUNNER_HMAC_SECRET }} | |
| # PAT, NOT secrets.GITHUB_TOKEN — that one expires when the job | |
| # finishes, but the runner needs the token 30–180 s later. | |
| GH_TOKEN: ${{ secrets.RUNNER_GH_PAT }} | |
| # JSON-array string, e.g. ["lee-to","alice"]. Empty/unset → no slash | |
| # commands accepted; only same-repo `pull_request: opened` auto-fires. | |
| TRUSTED_USERS: ${{ secrets.RUNNER_TRUSTED_USERS }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| REPO="${GITHUB_REPOSITORY}" | |
| if [ -z "${RUNNER_URL:-}" ] || [ -z "${RUNNER_HMAC_SECRET:-}" ] || [ -z "${GH_TOKEN:-}" ]; then | |
| echo "::warning::AI Review runner secrets are unavailable; skipping webhook dispatch" | |
| exit 0 | |
| fi | |
| # PR number + head_sha + action differ between event types. | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then | |
| echo "::notice::automatic review skipped for fork PR; a trusted maintainer can comment /code-review to run it" | |
| exit 0 | |
| fi | |
| PR_NUMBER="${{ github.event.pull_request.number }}" | |
| HEAD_SHA="${{ github.event.pull_request.head.sha }}" | |
| ACTION="review" | |
| else | |
| # issue_comment path — enforce trusted-users whitelist here | |
| # (job-level `if:` can't read secrets in GitHub Actions). | |
| SENDER="${{ github.event.sender.login }}" | |
| TRUSTED_JSON="${TRUSTED_USERS:-[]}" | |
| if ! echo "${TRUSTED_JSON}" | jq -e --arg u "${SENDER}" 'index($u) != null' >/dev/null 2>&1; then | |
| echo "::notice::user '${SENDER}' is not in RUNNER_TRUSTED_USERS — skipping" | |
| exit 0 | |
| fi | |
| PR_NUMBER="${{ github.event.issue.number }}" | |
| HEAD_SHA=$(gh pr view "${PR_NUMBER}" \ | |
| --repo "${REPO}" \ | |
| --json headRefOid -q .headRefOid) | |
| # Map slash → action. Check the LONGEST match first so | |
| # `/code-review` doesn't shadow `/code-review-and-fix`. | |
| COMMENT="${{ github.event.comment.body }}" | |
| if [[ "$COMMENT" == *"/code-review-and-fix"* ]]; then | |
| ACTION="review-and-fix" | |
| elif [[ "$COMMENT" == *"/fix"* ]]; then | |
| ACTION="fix" | |
| elif [[ "$COMMENT" == *"/code-review"* ]]; then | |
| ACTION="review" | |
| else | |
| # `if:` already filtered, but stay defensive. | |
| echo "::warning::no slash command matched; skipping" | |
| exit 0 | |
| fi | |
| fi | |
| missing=() | |
| [ -n "${RUNNER_URL:-}" ] || missing+=("RUNNER_URL") | |
| [ -n "${RUNNER_HMAC_SECRET:-}" ] || missing+=("RUNNER_HMAC_SECRET") | |
| [ -n "${GH_TOKEN:-}" ] || missing+=("RUNNER_GH_PAT") | |
| if [ "${#missing[@]}" -gt 0 ]; then | |
| echo "::error::missing required secret(s): ${missing[*]}" | |
| exit 1 | |
| fi | |
| TS="$(date +%s)" | |
| # Build payload — keep this single-line JSON; the runner verifies HMAC | |
| # over `<ts>.<rawBody>` byte-for-byte. | |
| BODY="$(jq -nc \ | |
| --arg action "${ACTION}" \ | |
| --arg repo "${REPO}" \ | |
| --argjson pr ${PR_NUMBER} \ | |
| --arg sha "${HEAD_SHA}" \ | |
| --arg tok "${GH_TOKEN}" \ | |
| --argjson ts ${TS} \ | |
| '{action: $action, repo: $repo, pr_number: $pr, head_sha: $sha, gh_token: $tok, ts: $ts}')" | |
| # X-Signature = sha256= + HMAC( "<ts>." + BODY ) | |
| SIG="sha256=$(printf '%s.%s' "${TS}" "${BODY}" \ | |
| | openssl dgst -sha256 -hmac "${RUNNER_HMAC_SECRET}" -hex \ | |
| | awk '{print $2}')" | |
| # POST and capture status; fail the workflow on >= 400 from the runner. | |
| HTTP_CODE=$(curl -sS -o /tmp/resp.json -w '%{http_code}' \ | |
| -X POST "${RUNNER_URL}" \ | |
| -H "Content-Type: application/json" \ | |
| -H "X-Signature: ${SIG}" \ | |
| -H "X-Timestamp: ${TS}" \ | |
| --data-raw "${BODY}") | |
| echo "runner http: ${HTTP_CODE} (action=${ACTION})" | |
| cat /tmp/resp.json || true | |
| if [ "${HTTP_CODE}" -ge 400 ]; then | |
| echo "::error::Runner returned ${HTTP_CODE}" | |
| exit 1 | |
| fi |