name: pr-review on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] issue_comment: types: [created] pull_request_review_comment: types: [created] concurrency: # PR conversation comments arrive as `issue_comment` events, so their PR number # is exposed as `github.event.issue.number`. Keep comment-triggered runs in a # separate group so skipped non-command comments do not cancel active reviews. group: pr-review-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }} cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} jobs: review: name: automation if: | github.event_name == 'pull_request_target' || (github.event_name == 'issue_comment' && github.event.issue.pull_request && github.event.comment.user.login != 'openchamber-bot[bot]' && (github.event.comment.body == '/oc-review' || startsWith(github.event.comment.body, '/oc-review ') || github.event.comment.body == '@openchamber-bot review' || startsWith(github.event.comment.body, '@openchamber-bot review '))) || (github.event_name == 'pull_request_review_comment' && github.event.comment.user.login != 'openchamber-bot[bot]' && (github.event.comment.body == '/oc-review' || startsWith(github.event.comment.body, '/oc-review ') || github.event.comment.body == '@openchamber-bot review' || startsWith(github.event.comment.body, '@openchamber-bot review '))) runs-on: ubuntu-latest permissions: contents: read pull-requests: write issues: write steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 1 - name: Resolve pull request context id: pr env: GH_TOKEN: ${{ github.token }} EVENT_PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} run: | pr_json="$(gh pr view "$EVENT_PR_NUMBER" --json number,url,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isDraft)" { echo "number=$(printf '%s' "$pr_json" | jq -r '.number')" echo "head_sha=$(printf '%s' "$pr_json" | jq -r '.headRefOid')" } >> "$GITHUB_OUTPUT" if [ "$(printf '%s' "$pr_json" | jq -r '.isDraft')" = "true" ]; then echo "draft=true" >> "$GITHUB_OUTPUT" exit 0 fi { echo "draft=false" echo "url=$(printf '%s' "$pr_json" | jq -r '.url')" echo "author=$(printf '%s' "$pr_json" | jq -r '.author.login')" echo "base_ref=$(printf '%s' "$pr_json" | jq -r '.baseRefName')" echo "head_ref=$(printf '%s' "$pr_json" | jq -r '.headRefName')" echo "head_repo_owner=$(printf '%s' "$pr_json" | jq -r '.headRepositoryOwner.login')" } >> "$GITHUB_OUTPUT" - name: Clear review status for draft if: steps.pr.outputs.draft == 'true' env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr.outputs.number }} run: | remove_args=() while IFS= read -r label; do case "$label" in review:*) remove_args+=(--remove-label "$label") ;; esac done < <(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name') if [ "${#remove_args[@]}" -gt 0 ]; then gh pr edit "$PR_NUMBER" "${remove_args[@]}" fi - name: Generate review app token id: app-token if: steps.pr.outputs.draft == 'false' uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 with: app-id: ${{ secrets.OC_REVIEW_APP_ID }} private-key: ${{ secrets.OC_REVIEW_APP_PRIVATE_KEY }} - name: Check review safety if: steps.pr.outputs.draft == 'false' id: safety env: GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ steps.pr.outputs.number }} run: | changed_sensitive_files="$(gh pr diff "$PR_NUMBER" --name-only | grep -E '^(AGENTS\.md|CONTRIBUTING\.md|\.agents/skills/|\.github/PULL_REQUEST_TEMPLATE\.md$|\.github/workflows/|\.opencode/agent/pr-review\.md$)' || true)" if [ -n "$changed_sensitive_files" ]; then { echo "safe=false" echo "changed_sensitive_files<> "$GITHUB_OUTPUT" exit 0 fi echo "safe=true" >> "$GITHUB_OUTPUT" - name: Throttle push-burst reviews id: throttle if: steps.pr.outputs.draft == 'false' && steps.safety.outputs.safe == 'true' env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr.outputs.number }} EVENT_NAME: ${{ github.event_name }} EVENT_ACTION: ${{ github.event.action }} run: | # Manual commands always run; only push-triggered re-reviews are throttled, # so a push burst cannot produce a review per push. if [ "$EVENT_NAME" != "pull_request_target" ] || [ "$EVENT_ACTION" != "synchronize" ]; then echo "skip=false" >> "$GITHUB_OUTPUT" exit 0 fi last_review_at="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ | jq -r '[.[] | select(.user.login == "openchamber-bot[bot]" and (.body | contains("").json | fromjson')"; then fail_automation "Review metadata is missing or malformed." fi reviewed_head="$(printf '%s' "$metadata" | jq -r '.head')" verdict="$(printf '%s' "$metadata" | jq -r '.verdict')" body="$(printf '%s' "$review_json" | jq -r '.body')" case "$verdict" in pass) review_label="review:ready" ;; needs-evidence) review_label="review:needs-evidence" ;; blocked) review_label="review:blocked" ;; human-review-required) review_label="review:human-required" ;; *) fail_automation "Review returned an unsupported verdict: $verdict" ;; esac if [ "$reviewed_head" != "$REVIEW_HEAD_SHA" ]; then fail_automation "Review metadata targets $reviewed_head, expected $REVIEW_HEAD_SHA." fi current_head="$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid')" if [ "$current_head" != "$REVIEW_HEAD_SHA" ]; then echo "PR HEAD moved from $REVIEW_HEAD_SHA to $current_head during review." >&2 exit 1 fi display_verdict="$(printf '%s' "$verdict" | tr '[:lower:]-' '[:upper:]_')" if ! printf '%s' "$body" | grep -Fq "**Verdict: $display_verdict**"; then fail_automation "Human-readable verdict does not match review metadata." fi if ! printf '%s' "$body" | grep -Fq "Reviewed HEAD: \`$REVIEW_HEAD_SHA\`"; then fail_automation "Review comment does not identify the expected HEAD." fi if ! printf '%s' "$body" | grep -Fq '**For the maintainer:**'; then fail_automation "Review comment does not contain the maintainer verdict line." fi expected_marker="" final_line="$(printf '%s\n' "$body" | awk 'NF { line=$0 } END { print line }')" if [ "$final_line" != "$expected_marker" ]; then fail_automation "Review metadata marker is missing, malformed, or not the final line." fi set_review_status "$review_label" if [ -n "$EYES_REACTION_ID" ]; then gh api \ --method DELETE \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "${REACTION_ENDPOINT}/${EYES_REACTION_ID}" gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "$REACTION_ENDPOINT" \ -f content='+1' >/dev/null fi { echo "### OpenChamber review verdict" echo echo "- HEAD: \`$REVIEW_HEAD_SHA\`" echo "- Verdict: \`$verdict\`" echo "- Status: \`$review_label\`" } >> "$GITHUB_STEP_SUMMARY" - name: Mark automation failure if: always() && steps.pr.outputs.draft == 'false' && steps.verdict.outcome != 'success' && steps.safety.outputs.safe != 'false' && steps.throttle.outputs.skip != 'true' env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr.outputs.number }} REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} run: | current_head="$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid')" if [ "$current_head" != "$REVIEW_HEAD_SHA" ]; then exit 0 fi remove_args=() while IFS= read -r label; do case "$label" in review:*) remove_args+=(--remove-label "$label") ;; esac done < <(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name') gh pr edit "$PR_NUMBER" "${remove_args[@]}" --add-label "review:automation-failed"