From baefcd3dbe68990c0c8da9a5c77ed272e628bcae Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Fri, 16 Jan 2026 13:57:16 -0500 Subject: [PATCH 1/2] feat(step-registry): Add hypershift-review-agent workflow Add a new Prow workflow that automatically addresses review comments on PRs created by the jira-agent using Claude Code's /utils:address-reviews command from the ai-helpers repository. The workflow: - Queries GitHub for open PRs from hypershift-community fork - Identifies PRs with "Generated with Claude Code" attribution - Checks for unresolved review threads using GraphQL API - Runs /utils:address-reviews on each PR with pending reviews - Pushes changes back to the fork branch This is a companion workflow to the hypershift-jira-agent that creates the initial PRs. The review-agent runs 1 hour after the jira-agent to pick up any review comments on newly created PRs. Ref: CNTRLPLANE-2561 Co-Authored-By: Claude Opus 4.5 --- .../hypershift/openshift-hypershift-main.yaml | 13 + .../openshift-hypershift-main-periodics.yaml | 58 ++ .../openshift-hypershift-main-presubmits.yaml | 62 ++ .../hypershift/review-agent/OWNERS | 12 + .../hypershift/review-agent/README.md | 313 +++++++ ...rshift-review-agent-workflow.metadata.json | 19 + .../hypershift-review-agent-workflow.yaml | 16 + .../hypershift/review-agent/process/OWNERS | 12 + ...ypershift-review-agent-process-commands.sh | 837 ++++++++++++++++++ ...ift-review-agent-process-ref.metadata.json | 19 + .../hypershift-review-agent-process-ref.yaml | 51 ++ .../hypershift/review-agent/setup/OWNERS | 12 + .../hypershift-review-agent-setup-commands.sh | 10 + ...shift-review-agent-setup-ref.metadata.json | 19 + .../hypershift-review-agent-setup-ref.yaml | 34 + 15 files changed, 1487 insertions(+) create mode 100644 ci-operator/step-registry/hypershift/review-agent/OWNERS create mode 100644 ci-operator/step-registry/hypershift/review-agent/README.md create mode 100644 ci-operator/step-registry/hypershift/review-agent/hypershift-review-agent-workflow.metadata.json create mode 100644 ci-operator/step-registry/hypershift/review-agent/hypershift-review-agent-workflow.yaml create mode 100644 ci-operator/step-registry/hypershift/review-agent/process/OWNERS create mode 100644 ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-commands.sh create mode 100644 ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-ref.metadata.json create mode 100644 ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-ref.yaml create mode 100644 ci-operator/step-registry/hypershift/review-agent/setup/OWNERS create mode 100644 ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-commands.sh create mode 100644 ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-ref.metadata.json create mode 100644 ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-ref.yaml diff --git a/ci-operator/config/openshift/hypershift/openshift-hypershift-main.yaml b/ci-operator/config/openshift/hypershift/openshift-hypershift-main.yaml index fc4d5ef430c48..c1a0e70bc6bce 100644 --- a/ci-operator/config/openshift/hypershift/openshift-hypershift-main.yaml +++ b/ci-operator/config/openshift/hypershift/openshift-hypershift-main.yaml @@ -489,6 +489,19 @@ tests: env: JIRA_AGENT_MAX_ISSUES: "1" workflow: hypershift-jira-agent +- as: periodic-review-agent + cron: 0 8-23/3 * * * + steps: + env: + REVIEW_AGENT_BATCH_ONLY: "true" + REVIEW_AGENT_MAX_PRS: "10" + workflow: hypershift-review-agent +- always_run: false + as: review-agent-single-pr + optional: true + skip_if_only_changed: .* + steps: + workflow: hypershift-review-agent zz_generated_metadata: branch: main org: openshift diff --git a/ci-operator/jobs/openshift/hypershift/openshift-hypershift-main-periodics.yaml b/ci-operator/jobs/openshift/hypershift/openshift-hypershift-main-periodics.yaml index 04d5aff67ed23..2d0893c8c215d 100644 --- a/ci-operator/jobs/openshift/hypershift/openshift-hypershift-main-periodics.yaml +++ b/ci-operator/jobs/openshift/hypershift/openshift-hypershift-main-periodics.yaml @@ -205,3 +205,61 @@ periodics: - name: result-aggregator secret: secretName: result-aggregator +- agent: kubernetes + cluster: build06 + cron: 0 8-23/3 * * * + decorate: true + extra_refs: + - base_ref: main + org: openshift + repo: hypershift + labels: + ci.openshift.io/generator: prowgen + pj-rehearse.openshift.io/can-be-rehearsed: "true" + name: periodic-ci-openshift-hypershift-main-periodic-review-agent + spec: + containers: + - args: + - --gcs-upload-secret=/secrets/gcs/service-account.json + - --image-import-pull-secret=/etc/pull-secret/.dockerconfigjson + - --report-credentials-file=/etc/report/credentials + - --secret-dir=/secrets/ci-pull-credentials + - --target=periodic-review-agent + command: + - ci-operator + image: quay-proxy.ci.openshift.org/openshift/ci:ci_ci-operator_latest + imagePullPolicy: Always + name: "" + resources: + requests: + cpu: 10m + volumeMounts: + - mountPath: /secrets/ci-pull-credentials + name: ci-pull-credentials + readOnly: true + - mountPath: /secrets/gcs + name: gcs-credentials + readOnly: true + - mountPath: /secrets/manifest-tool + name: manifest-tool-local-pusher + readOnly: true + - mountPath: /etc/pull-secret + name: pull-secret + readOnly: true + - mountPath: /etc/report + name: result-aggregator + readOnly: true + serviceAccountName: ci-operator + volumes: + - name: ci-pull-credentials + secret: + secretName: ci-pull-credentials + - name: manifest-tool-local-pusher + secret: + secretName: manifest-tool-local-pusher + - name: pull-secret + secret: + secretName: registry-pull-credentials + - name: result-aggregator + secret: + secretName: result-aggregator diff --git a/ci-operator/jobs/openshift/hypershift/openshift-hypershift-main-presubmits.yaml b/ci-operator/jobs/openshift/hypershift/openshift-hypershift-main-presubmits.yaml index 0dd07aa70ea69..8e1692dc02d1f 100644 --- a/ci-operator/jobs/openshift/hypershift/openshift-hypershift-main-presubmits.yaml +++ b/ci-operator/jobs/openshift/hypershift/openshift-hypershift-main-presubmits.yaml @@ -1937,6 +1937,68 @@ presubmits: secret: secretName: result-aggregator trigger: (?m)^/test( | .* )reqserving-e2e-aws,?($|\s.*) + - agent: kubernetes + always_run: false + branches: + - ^main$ + - ^main- + cluster: build01 + context: ci/prow/review-agent-single-pr + decorate: true + labels: + ci.openshift.io/generator: prowgen + pj-rehearse.openshift.io/can-be-rehearsed: "true" + name: pull-ci-openshift-hypershift-main-review-agent-single-pr + optional: true + rerun_command: /test review-agent-single-pr + skip_if_only_changed: .* + spec: + containers: + - args: + - --gcs-upload-secret=/secrets/gcs/service-account.json + - --image-import-pull-secret=/etc/pull-secret/.dockerconfigjson + - --report-credentials-file=/etc/report/credentials + - --secret-dir=/secrets/ci-pull-credentials + - --target=review-agent-single-pr + command: + - ci-operator + image: quay-proxy.ci.openshift.org/openshift/ci:ci_ci-operator_latest + imagePullPolicy: Always + name: "" + resources: + requests: + cpu: 10m + volumeMounts: + - mountPath: /secrets/ci-pull-credentials + name: ci-pull-credentials + readOnly: true + - mountPath: /secrets/gcs + name: gcs-credentials + readOnly: true + - mountPath: /secrets/manifest-tool + name: manifest-tool-local-pusher + readOnly: true + - mountPath: /etc/pull-secret + name: pull-secret + readOnly: true + - mountPath: /etc/report + name: result-aggregator + readOnly: true + serviceAccountName: ci-operator + volumes: + - name: ci-pull-credentials + secret: + secretName: ci-pull-credentials + - name: manifest-tool-local-pusher + secret: + secretName: manifest-tool-local-pusher + - name: pull-secret + secret: + secretName: registry-pull-credentials + - name: result-aggregator + secret: + secretName: result-aggregator + trigger: (?m)^/test( | .* )review-agent-single-pr,?($|\s.*) - agent: kubernetes always_run: false branches: diff --git a/ci-operator/step-registry/hypershift/review-agent/OWNERS b/ci-operator/step-registry/hypershift/review-agent/OWNERS new file mode 100644 index 0000000000000..e39269bf55090 --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/OWNERS @@ -0,0 +1,12 @@ +approvers: +- bryan-cox +- csrwng +- celebdor +- enxebre +- sjenning +reviewers: +- bryan-cox +- csrwng +- celebdor +- enxebre +- sjenning diff --git a/ci-operator/step-registry/hypershift/review-agent/README.md b/ci-operator/step-registry/hypershift/review-agent/README.md new file mode 100644 index 0000000000000..2aac800967e7a --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/README.md @@ -0,0 +1,313 @@ +# HyperShift Review Agent Workflow + +Automated periodic job that addresses review comments on PRs created by the HyperShift Jira Agent using Claude Code. + +## Overview + +This workflow implements a fully automated system for addressing PR review comments: + +1. **Query**: Searches GitHub for open PRs from the hypershift-community fork that were created by the Jira Agent +2. **Filter**: Identifies PRs with unresolved review threads that need attention +3. **Process**: For each PR, runs the `/utils:address-reviews` command to analyze and address review comments +4. **Push**: Commits and pushes changes back to the PR branch + +## Data Flow Diagram + +```mermaid +flowchart TD + %% Trigger + Start([Cron Trigger
Daily 10:00 AM UTC]):::trigger --> PrePhase + + %% PRE-PHASE: Setup + subgraph PrePhase[PRE-PHASE: Setup] + direction TB + Verify[Verify Claude CLI
Test authentication]:::setup + end + + %% Secrets for Setup + Secret1[(Secret:
hypershift-team-claude-prow)]:::secret -.->|Read credentials| Verify + + %% TEST-PHASE: Process + PrePhase --> TestPhase + + subgraph TestPhase[TEST-PHASE: Process PRs] + direction TB + + QueryGitHub[Query GitHub API
Open PRs authored by
hypershift-jira-solve-ci App]:::process + + CheckPRs{PRs
Found?}:::decision + CheckMax{Processed <
MAX_PRS
Default: 10}:::decision + CheckReviews{Has Unresolved
Reviews?}:::decision + CheckSuccess{Processing
Successful?}:::decision + + ProcessPR[Run Claude Code CLI
/utils:address-reviews PR_NUMBER
--max-turns 50]:::ai + + LogSuccess[Log success
Push changes]:::success + LogSkip[Skip PR
No pending reviews]:::skip + LogFailure[Log failure
Will retry next run]:::failure + + RateLimit[Wait 60 seconds
Rate limiting]:::process + Summary[Print Summary
Processed/Skipped/Failed counts]:::process + + QueryGitHub --> CheckPRs + CheckPRs -->|No| Summary + CheckPRs -->|Yes| CheckMax + CheckMax -->|No| Summary + CheckMax -->|Yes| CheckReviews + CheckReviews -->|No| LogSkip + CheckReviews -->|Yes| ProcessPR + ProcessPR --> CheckSuccess + CheckSuccess -->|Yes| LogSuccess + CheckSuccess -->|No| LogFailure + LogSuccess --> RateLimit + LogSkip --> RateLimit + LogFailure --> RateLimit + RateLimit --> CheckMax + end + + %% External Systems + GitHubAPI[(GitHub API
openshift/hypershift)]:::external -.->|Return PRs & reviews| QueryGitHub + ClaudeAPI[(Claude API
via Vertex AI)]:::external -.->|Address comments| ProcessPR + + TestPhase --> End([Workflow Complete]):::trigger + Summary --> End + + %% Style Definitions + classDef trigger fill:#e1f5ff,stroke:#01579b,stroke-width:3px,color:#000 + classDef setup fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#000 + classDef process fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px,color:#000 + classDef decision fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#000 + classDef ai fill:#fce4ec,stroke:#880e4f,stroke-width:3px,color:#000 + classDef success fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000 + classDef failure fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000 + classDef skip fill:#f5f5f5,stroke:#757575,stroke-width:1px,color:#000 + classDef external fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000 + classDef secret fill:#ffebee,stroke:#b71c1c,stroke-width:2px,color:#000 +``` + +## Components + +### Workflow +- **File**: `hypershift-review-agent-workflow.yaml` +- **Description**: Defines the two-phase workflow (pre/test) + +### Steps + +#### 1. Setup (`hypershift-review-agent-setup`) +- Verifies Claude Code CLI availability +- Authenticates via Vertex AI + +#### 2. Process (`hypershift-review-agent-process`) +- Clones ai-helpers and hypershift repositories +- Queries GitHub API for agent-created PRs +- Runs `comment_analyzer.py` to identify comments needing attention (prevents duplicate responses) +- Runs `/utils:address-reviews` for each PR with pending reviews +- Implements rate limiting (60s between PRs) + +#### 3. Comment Analyzer (`comment_analyzer.py`) +- Python script that analyzes PR comments to prevent duplicate bot responses +- Fetches review threads and issue comments via GitHub API +- Compares timestamps to determine if bot already replied +- Outputs JSON list of threads/comments that need attention + +## Configuration + +### Secrets Required + +The workflow requires secrets in the `test-credentials` namespace: + +1. **`hypershift-team-claude-prow`** + - Key: `claude-prow` - GCP service account JSON for Vertex AI + - Key: `app-id` - GitHub App ID + - Key: `installation-id` - Installation ID for hypershift-community fork + - Key: `o-h-installation-id` - Installation ID for openshift/hypershift + - Key: `private-key` - GitHub App private key + - Mount path: `/var/run/claude-code-service-account` + +### Periodic Job + +Configured in `ci-operator/config/openshift/hypershift/openshift-hypershift-main.yaml`: + +```yaml +- as: periodic-review-agent + cron: 0 10 * * * # Daily at 10:00 AM UTC (1 hour after jira-agent) + steps: + env: + REVIEW_AGENT_MAX_PRS: "10" + workflow: hypershift-review-agent +``` + +### On-Demand Single PR Job + +An optional presubmit job allows processing a specific PR on-demand: + +```yaml +- always_run: false + as: review-agent-single-pr + optional: true + skip_if_only_changed: .* + steps: + workflow: hypershift-review-agent +``` + +**Usage**: Run `/test review-agent-single-pr` on any PR in openshift/hypershift. The job will process reviews for that specific PR using the `PULL_NUMBER` environment variable provided by Prow. + +This is useful for: +- Testing the review agent on a specific PR +- Debugging issues with review processing +- Manually triggering review processing without waiting for the periodic job + +### Environment Variables + +- **`REVIEW_AGENT_MAX_PRS`** (default: `10`) + - Maximum number of PRs to process per run + - Includes both processed and skipped PRs in the count + +- **`REVIEW_AGENT_TARGET_PR`** (optional) + - Explicit PR number to process + - If set, only this PR will be processed regardless of author + - Takes precedence over `PULL_NUMBER` + +- **`PULL_NUMBER`** (automatic in presubmit) + - Provided by Prow for presubmit jobs + - Used when `REVIEW_AGENT_TARGET_PR` is not set + +## PR Identification + +PRs are identified as agent-created using the GitHub App author filter: +- Open PRs authored by `app/hypershift-jira-solve-ci` + +This reliably identifies PRs created by the Jira Agent GitHub App, which is more robust than regex matching on PR body text. + +## How It Works + +### Non-Interactive Execution + +The workflow uses Claude Code CLI's non-interactive mode: + +```bash +claude -p "$PR_NUMBER. $REVIEW_CONTEXT" \ + --system-prompt "$SKILL_CONTENT" \ + --allowedTools "Bash Read Write Edit Grep Glob WebFetch" \ + --max-turns 50 \ + --output-format stream-json +``` + +### Comment Analysis and Duplicate Prevention + +The workflow uses a Python script (`comment_analyzer.py`) to analyze PR comments and prevent duplicate bot responses. This addresses the issue where the bot would respond multiple times to the same feedback. + +#### How It Works + +1. **Fetches all comments**: Uses GitHub's GraphQL API to retrieve review threads and issue comments +2. **Analyzes conversation timeline**: Sorts comments chronologically to understand conversation flow +3. **Identifies threads needing attention**: Only includes threads where: + - No bot reply exists, OR + - A human commented AFTER the last bot reply +4. **Filters already-addressed feedback**: Threads where the bot already replied and no human follow-up exists are skipped + +#### What Gets Processed + +A comment/thread needs attention when: + +| Condition | Action | +|-----------|--------| +| No bot reply in thread | Process (first response needed) | +| Human replied after bot's last comment | Process (follow-up needed) | +| Bot already replied, no human follow-up | Skip (already addressed) | +| Thread is resolved | Skip (marked complete by reviewer) | +| Thread is outdated (code changed) | Skip (likely addressed by code change) | + +#### What Counts as an Unresolved Review Thread + +A review thread is considered **unresolved** when: + +1. **Inline code comments**: A reviewer left a comment on a specific line of code in the "Files changed" tab, and no one has clicked "Resolve conversation" +2. **Review comments with suggestions**: Comments that include suggested code changes that haven't been resolved +3. **Threaded discussions**: Any reply chain started from a code review that remains open + +A review thread is **NOT** created by: + +- General PR comments (comments in the main "Conversation" tab that aren't attached to code) +- PR reviews that only contain an approval/request changes without inline comments +- Commit comments + +**Visual indicator**: In GitHub's UI, unresolved threads show an "Unresolved" label and a "Resolve conversation" button. Resolved threads are collapsed and show "Resolved". + +#### Response Rules + +When addressing feedback, the bot follows these rules: +1. **One response per feedback**: Never respond to the same feedback via both inline reply AND general PR comment +2. **Code changes only when requested**: Only modifies code when explicitly asked (imperative language like "change", "fix", "update") +3. **Explanations for questions**: Replies with explanation only for clarifying questions, without code changes + +### Rate Limiting + +- 60 seconds between processing each PR +- Maximum 50 agentic turns per PR +- Maximum PRs per run: configurable via `REVIEW_AGENT_MAX_PRS` +- Runs once daily at 10:00 AM UTC (1 hour after jira-agent) + +## Container Image + +Uses the `claude-ai-helpers` image from OpenShift CI containing: +- Claude Code CLI +- GitHub CLI (gh) +- jq, git, curl +- Required dependencies + +## Relationship to Jira Agent + +This workflow is a companion to the `hypershift-jira-agent` workflow: + +| Aspect | Jira Agent | Review Agent | +|--------|------------|--------------| +| Purpose | Create PRs from Jira issues | Address review comments on PRs | +| Schedule | Daily 9:00 AM UTC | Daily 10:00 AM UTC | +| Input | Jira issues with `issue-for-agent` label | PRs created by Jira Agent | +| Output | Draft PRs | Updated PR branches | +| Command | `/jira-solve` | `/utils:address-reviews` | + +## Monitoring + +### Success Indicators +- PRs processed successfully with changes pushed +- No authentication errors +- Review comments addressed + +### Failure Indicators +- Failed to authenticate with Claude API +- Failed to push changes (GitHub auth issues) +- Individual PR processing failures + +### Logs +Check Prow job logs for: +- GitHub query results +- Processing output for each PR +- Error messages + +## Troubleshooting + +### Issue: No PRs being processed +- Check that jira-agent has created PRs +- Verify PRs are open and authored by `app/hypershift-jira-solve-ci` + +### Issue: PRs skipped (no pending reviews) +- This is normal - PRs without unresolved review threads are skipped +- Check GitHub for actual review status + +### Issue: Authentication failures +- Verify secrets are mounted correctly +- Check API keys are valid and not expired +- Ensure GitHub App has required permissions + +### Issue: Push fails +- Check GitHub App installation permissions for fork +- Verify branch exists and is not protected + +## Future Enhancements + +- Slack notifications for addressed reviews +- Priority-based processing (older reviews first) +- Automatic retry for transient failures +- Metrics push to Prometheus diff --git a/ci-operator/step-registry/hypershift/review-agent/hypershift-review-agent-workflow.metadata.json b/ci-operator/step-registry/hypershift/review-agent/hypershift-review-agent-workflow.metadata.json new file mode 100644 index 0000000000000..47c1d3ad967c6 --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/hypershift-review-agent-workflow.metadata.json @@ -0,0 +1,19 @@ +{ + "path": "hypershift/review-agent/hypershift-review-agent-workflow.yaml", + "owners": { + "approvers": [ + "bryan-cox", + "csrwng", + "celebdor", + "enxebre", + "sjenning" + ], + "reviewers": [ + "bryan-cox", + "csrwng", + "celebdor", + "enxebre", + "sjenning" + ] + } +} \ No newline at end of file diff --git a/ci-operator/step-registry/hypershift/review-agent/hypershift-review-agent-workflow.yaml b/ci-operator/step-registry/hypershift/review-agent/hypershift-review-agent-workflow.yaml new file mode 100644 index 0000000000000..403152c067f21 --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/hypershift-review-agent-workflow.yaml @@ -0,0 +1,16 @@ +workflow: + as: hypershift-review-agent + steps: + pre: + - ref: hypershift-review-agent-setup + test: + - ref: hypershift-review-agent-process + documentation: |- + HyperShift Review Agent workflow for automated PR review comment handling. + + This workflow: + 1. Setup: Verifies Claude Code CLI is available and configures git credentials + 2. Process: Queries GitHub for agent-created PRs with pending reviews, runs /utils:address-reviews for each + + The workflow uses the /utils:address-reviews command from ai-helpers in non-interactive mode. + PRs are identified by being created from the hypershift-community fork with Claude Code attribution. diff --git a/ci-operator/step-registry/hypershift/review-agent/process/OWNERS b/ci-operator/step-registry/hypershift/review-agent/process/OWNERS new file mode 100644 index 0000000000000..e39269bf55090 --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/process/OWNERS @@ -0,0 +1,12 @@ +approvers: +- bryan-cox +- csrwng +- celebdor +- enxebre +- sjenning +reviewers: +- bryan-cox +- csrwng +- celebdor +- enxebre +- sjenning diff --git a/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-commands.sh b/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-commands.sh new file mode 100644 index 0000000000000..6f46e2c66774b --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-commands.sh @@ -0,0 +1,837 @@ +#!/bin/bash +set -euo pipefail + +echo "=== HyperShift Review Agent Process ===" + +# State file for sharing results with report step +STATE_FILE="${SHARED_DIR}/processed-prs.txt" + +# Clone ai-helpers repository (contains /utils:address-reviews command) +echo "Cloning ai-helpers repository..." +git clone https://github.com/openshift-eng/ai-helpers /tmp/ai-helpers + +# Clone HyperShift fork (we work on branches here) +echo "Cloning HyperShift repository..." +git clone https://github.com/hypershift-community/hypershift /tmp/hypershift + +# Copy address-reviews command to a stable location outside the git working tree +echo "Setting up Claude commands..." +cp /tmp/ai-helpers/plugins/utils/commands/address-reviews.md /tmp/address-reviews.md + +# Create comment analyzer script (used to filter already-addressed comments) +# This script is embedded inline to comply with step-registry file naming requirements +cat > /tmp/comment_analyzer.py << 'COMMENT_ANALYZER_EOF' +#!/usr/bin/env python3 +""" +Analyzes PR comments to determine which need bot attention. +Outputs JSON list of thread/comment IDs requiring response. + +This script prevents duplicate bot responses by analyzing conversation +timelines and identifying only threads where: +1. No bot reply exists, OR +2. A human commented AFTER the last bot reply +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from functools import lru_cache +from typing import Any + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +# Known bot accounts that should not trigger responses +BOT_ACCOUNTS = [ + "hypershift-jira-solve-ci[bot]", + "hypershift-jira-solve-ci", +] + +# Approved bots that ARE allowed to trigger responses +APPROVED_BOTS = [ + "coderabbitai", + "coderabbitai[bot]", +] + +# Cache for authorization results to minimize API calls +_auth_cache: dict[str, bool] = {} + + +def run_gh(args: list[str]) -> Any: + """Run gh CLI command and return JSON output.""" + result = subprocess.run( + ["gh"] + args, + capture_output=True, + text=True, + check=True + ) + return json.loads(result.stdout) if result.stdout.strip() else None + + +def is_bot(login: str) -> bool: + """Check if login is a known bot account.""" + if not login: + return False + return login in BOT_ACCOUNTS or login.endswith("[bot]") + + +def is_openshift_org_member(login: str) -> bool: + """Check if user is a member of the openshift GitHub org. + + Returns True if member, False if not or on error (fail-safe). + """ + try: + # gh api returns 204 No Content for members, 404 for non-members + result = subprocess.run( + ["gh", "api", f"orgs/openshift/members/{login}"], + capture_output=True, + text=True + ) + # 204 No Content means user is a member (exit code 0, empty response) + # 404 means not a member (exit code non-zero) + return result.returncode == 0 + except Exception as e: + print(f"Warning: Failed to check org membership for {login}: {e}", file=sys.stderr) + return False + + +def _parse_simple_yaml_list(content: str, key: str) -> list[str]: + """Simple YAML list parser for OWNERS files (fallback when PyYAML unavailable). + + Parses simple YAML like: + approvers: + - user1 + - user2 + """ + result = [] + in_key = False + for line in content.split('\n'): + stripped = line.strip() + if stripped.startswith(f"{key}:"): + in_key = True + continue + if in_key: + if stripped.startswith("- "): + result.append(stripped[2:].strip()) + elif stripped and not stripped.startswith("#") and ":" in stripped: + # New key started + in_key = False + return result + + +def _parse_simple_yaml_aliases(content: str) -> dict[str, list[str]]: + """Simple YAML aliases parser for OWNERS_ALIASES (fallback when PyYAML unavailable). + + Parses: + aliases: + alias-name: + - user1 + - user2 + """ + aliases: dict[str, list[str]] = {} + current_alias = None + in_aliases = False + + for line in content.split('\n'): + stripped = line.rstrip() + if stripped.startswith("aliases:"): + in_aliases = True + continue + if not in_aliases: + continue + + # Check indentation to determine structure + if stripped and not stripped.startswith(" ") and not stripped.startswith("\t"): + # No longer in aliases section + break + + stripped = stripped.strip() + if not stripped or stripped.startswith("#"): + continue + + if stripped.endswith(":") and not stripped.startswith("- "): + # New alias name + current_alias = stripped[:-1].strip() + aliases[current_alias] = [] + elif stripped.startswith("- ") and current_alias: + aliases[current_alias].append(stripped[2:].strip()) + + return aliases + + +@lru_cache(maxsize=1) +def get_owners_and_aliases() -> tuple[set[str], set[str]]: + """Fetch and parse OWNERS and OWNERS_ALIASES from hypershift repo. + + Returns tuple of (approvers set, reviewers set). + Uses lru_cache to only fetch once per run. + """ + approvers: set[str] = set() + reviewers: set[str] = set() + aliases: dict[str, list[str]] = {} + + # Fetch OWNERS_ALIASES first (aliases can be referenced in OWNERS) + try: + result = subprocess.run( + ["gh", "api", "-H", "Accept: application/vnd.github.raw", + "repos/openshift/hypershift/contents/OWNERS_ALIASES"], + capture_output=True, + text=True, + check=True + ) + if HAS_YAML: + aliases_data = yaml.safe_load(result.stdout) + if aliases_data and "aliases" in aliases_data: + aliases = aliases_data["aliases"] + else: + aliases = _parse_simple_yaml_aliases(result.stdout) + except Exception as e: + print(f"Warning: Failed to fetch OWNERS_ALIASES: {e}", file=sys.stderr) + + # Fetch OWNERS file + try: + result = subprocess.run( + ["gh", "api", "-H", "Accept: application/vnd.github.raw", + "repos/openshift/hypershift/contents/OWNERS"], + capture_output=True, + text=True, + check=True + ) + if HAS_YAML: + owners_data = yaml.safe_load(result.stdout) + if owners_data: + # Expand approvers (may include alias references) + for entry in owners_data.get("approvers", []): + if entry in aliases: + approvers.update(aliases[entry]) + else: + approvers.add(entry) + + # Expand reviewers (may include alias references) + for entry in owners_data.get("reviewers", []): + if entry in aliases: + reviewers.update(aliases[entry]) + else: + reviewers.add(entry) + else: + # Fallback parsing + for entry in _parse_simple_yaml_list(result.stdout, "approvers"): + if entry in aliases: + approvers.update(aliases[entry]) + else: + approvers.add(entry) + for entry in _parse_simple_yaml_list(result.stdout, "reviewers"): + if entry in aliases: + reviewers.update(aliases[entry]) + else: + reviewers.add(entry) + except Exception as e: + print(f"Warning: Failed to fetch OWNERS: {e}", file=sys.stderr) + + return approvers, reviewers + + +def is_in_owners_file(login: str) -> bool: + """Check if user is in OWNERS or OWNERS_ALIASES.""" + approvers, reviewers = get_owners_and_aliases() + login_lower = login.lower() + # GitHub usernames are case-insensitive, so check lowercase + return (login_lower in {a.lower() for a in approvers} or + login_lower in {r.lower() for r in reviewers}) + + +def is_authorized_author(login: str) -> bool: + """Check if author is authorized to trigger review agent responses. + + Authorized authors are: + 1. Approved bots (coderabbitai) + 2. Members of the openshift GitHub organization + 3. People listed in the OWNERS file (approvers or reviewers) + """ + if not login: + return False + + # Check cache first + if login in _auth_cache: + return _auth_cache[login] + + # 1. Check approved bots first (no API call needed) + if login in APPROVED_BOTS or login.lower() in {b.lower() for b in APPROVED_BOTS}: + _auth_cache[login] = True + print(f" Author '{login}' authorized: approved bot", file=sys.stderr) + return True + + # 2. Check OWNERS file (cached after first call) + if is_in_owners_file(login): + _auth_cache[login] = True + print(f" Author '{login}' authorized: in OWNERS file", file=sys.stderr) + return True + + # 3. Check openshift org membership + if is_openshift_org_member(login): + _auth_cache[login] = True + print(f" Author '{login}' authorized: openshift org member", file=sys.stderr) + return True + + # Not authorized + _auth_cache[login] = False + print(f" Author '{login}' NOT authorized: not in org, OWNERS, or approved bots", file=sys.stderr) + return False + + +def analyze_review_threads(pr_number: int) -> list[dict]: + """Analyze review threads and return those needing attention.""" + query = ''' + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100) { + nodes { + id + isResolved + isOutdated + comments(first: 100) { + nodes { + id + author { login } + createdAt + body + } + } + } + } + } + } + } + ''' + + result = run_gh([ + "api", "graphql", + "-f", f"query={query}", + "-f", "owner=openshift", + "-f", "repo=hypershift", + "-F", f"number={pr_number}" + ]) + + threads = result["data"]["repository"]["pullRequest"]["reviewThreads"]["nodes"] + needs_attention = [] + + for thread in threads: + # Skip resolved or outdated threads + if thread["isResolved"] or thread["isOutdated"]: + continue + + comments = sorted( + thread["comments"]["nodes"], + key=lambda c: c["createdAt"] + ) + + if not comments: + continue + + # Find last human and last bot comment + last_human = None + last_bot = None + + for comment in comments: + author = comment["author"]["login"] if comment["author"] else "unknown" + if is_bot(author): + last_bot = comment + else: + last_human = comment + + # Needs attention if no bot reply, or human commented after bot + if last_bot is None: + last_author = last_human["author"]["login"] if last_human and last_human["author"] else "unknown" + # Check if author is authorized + if not is_authorized_author(last_author): + continue + needs_attention.append({ + "type": "review_thread", + "id": thread["id"], + "last_human_comment": last_human["body"][:200] if last_human else None, + "last_human_author": last_author, + "reason": "no_bot_reply" + }) + elif last_human and last_human["createdAt"] > last_bot["createdAt"]: + last_author = last_human["author"]["login"] if last_human["author"] else "unknown" + # Check if author is authorized + if not is_authorized_author(last_author): + continue + needs_attention.append({ + "type": "review_thread", + "id": thread["id"], + "last_human_comment": last_human["body"][:200], + "last_human_author": last_author, + "reason": "human_followup_after_bot" + }) + + return needs_attention + + +def analyze_review_bodies(pr_number: int) -> list[dict]: + """Analyze review bodies (main text of reviews) and return those needing attention. + + Review bodies are separate from review threads (line-level comments) and issue comments. + A review body is the main text submitted when a reviewer submits their review. + """ + query = ''' + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviews(first: 100) { + nodes { + id + author { login } + body + state + submittedAt + } + } + } + } + } + ''' + + result = run_gh([ + "api", "graphql", + "-f", f"query={query}", + "-f", "owner=openshift", + "-f", "repo=hypershift", + "-F", f"number={pr_number}" + ]) + + reviews = result["data"]["repository"]["pullRequest"]["reviews"]["nodes"] + needs_attention = [] + + # Get issue comments to check if bot has replied to any review + try: + issue_comments = run_gh([ + "api", f"repos/openshift/hypershift/issues/{pr_number}/comments" + ]) + except subprocess.CalledProcessError: + issue_comments = [] + + # Find last bot comment time from issue comments + bot_comment_times = [] + if issue_comments: + for c in issue_comments: + if c["user"]["login"] in BOT_ACCOUNTS: + bot_comment_times.append(c["created_at"]) + last_bot_time = max(bot_comment_times) if bot_comment_times else None + + for review in reviews: + # Skip reviews without bodies or from bots + author = review["author"]["login"] if review["author"] else None + if not author or is_bot(author): + continue + + body = review.get("body", "").strip() + if not body: + continue + + # Check if author is authorized + if not is_authorized_author(author): + continue + + submitted_at = review["submittedAt"] + + # Needs attention if no bot reply, or review was submitted after last bot comment + if last_bot_time is None or submitted_at > last_bot_time: + needs_attention.append({ + "type": "review_body", + "id": review["id"], + "author": author, + "state": review["state"], + "body": body[:500], # Include more context for review bodies + "submitted_at": submitted_at, + "reason": "no_bot_reply" if last_bot_time is None else "review_after_bot_reply" + }) + + return needs_attention + + +def analyze_issue_comments(pr_number: int) -> list[dict]: + """Analyze issue comments (general PR comments) and return those needing attention.""" + try: + comments = run_gh([ + "api", f"repos/openshift/hypershift/issues/{pr_number}/comments" + ]) + except subprocess.CalledProcessError: + return [] + + if not comments: + return [] + + # Separate human and bot comments + human_comments = [c for c in comments if not is_bot(c["user"]["login"])] + bot_comments = [c for c in comments if c["user"]["login"] in BOT_ACCOUNTS] + + if not human_comments: + return [] + + # Find the last bot comment timestamp + last_bot_time = None + if bot_comments: + last_bot_time = max(c["created_at"] for c in bot_comments) + + needs_attention = [] + + # Find human comments after last bot reply + for comment in human_comments: + if last_bot_time is None or comment["created_at"] > last_bot_time: + author = comment["user"]["login"] + # Check if author is authorized + if not is_authorized_author(author): + continue + needs_attention.append({ + "type": "issue_comment", + "id": comment["id"], + "author": author, + "body": comment["body"][:200], + "created_at": comment["created_at"], + "reason": "no_bot_reply" if last_bot_time is None else "human_followup_after_bot" + }) + + return needs_attention + + +def main(): + if len(sys.argv) < 2: + print("Usage: comment_analyzer.py ", file=sys.stderr) + sys.exit(1) + + pr_number = int(sys.argv[1]) + + try: + review_threads = analyze_review_threads(pr_number) + review_bodies = analyze_review_bodies(pr_number) + issue_comments = analyze_issue_comments(pr_number) + except subprocess.CalledProcessError as e: + print(json.dumps({ + "error": f"Failed to query GitHub: {e.stderr}", + "pr_number": pr_number + })) + sys.exit(1) + except (KeyError, TypeError) as e: + print(json.dumps({ + "error": f"Failed to parse GitHub response: {str(e)}", + "pr_number": pr_number + })) + sys.exit(1) + + result = { + "pr_number": pr_number, + "needs_attention": review_threads + review_bodies + issue_comments, + "summary": { + "review_threads": len(review_threads), + "review_bodies": len(review_bodies), + "issue_comments": len(issue_comments), + "total": len(review_threads) + len(review_bodies) + len(issue_comments) + } + } + + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() +COMMENT_ANALYZER_EOF +chmod +x /tmp/comment_analyzer.py + +cd /tmp/hypershift + +# Configure git +git config user.name "OpenShift CI Bot" +git config user.email "ci-bot@redhat.com" + +# Add upstream remote for PR operations +git remote add upstream https://github.com/openshift/hypershift.git + +# Generate GitHub App installation token +echo "Generating GitHub App token..." + +GITHUB_APP_CREDS_DIR="/var/run/claude-code-service-account" +APP_ID_FILE="${GITHUB_APP_CREDS_DIR}/app-id" +INSTALLATION_ID_FILE="${GITHUB_APP_CREDS_DIR}/installation-id" +PRIVATE_KEY_FILE="${GITHUB_APP_CREDS_DIR}/private-key" +INSTALLATION_ID_UPSTREAM_FILE="${GITHUB_APP_CREDS_DIR}/o-h-installation-id" + +# Check if all required credentials exist +if [ ! -f "$APP_ID_FILE" ] || [ ! -f "$INSTALLATION_ID_FILE" ] || [ ! -f "$PRIVATE_KEY_FILE" ] || [ ! -f "$INSTALLATION_ID_UPSTREAM_FILE" ]; then + echo "GitHub App credentials not yet available in ${GITHUB_APP_CREDS_DIR}" + echo "Available files:" + ls -la "${GITHUB_APP_CREDS_DIR}/" || echo "Directory does not exist" + echo "" + echo "Waiting for Vault secretsync to complete. The following keys are required:" + echo " - app-id" + echo " - installation-id (for hypershift-community fork)" + echo " - o-h-installation-id (for openshift/hypershift upstream)" + echo " - private-key" + echo "" + echo "Exiting gracefully. Re-run once secrets are synced." + exit 0 +fi + +APP_ID=$(cat "$APP_ID_FILE") +INSTALLATION_ID_FORK=$(cat "$INSTALLATION_ID_FILE") +INSTALLATION_ID_UPSTREAM=$(cat "$INSTALLATION_ID_UPSTREAM_FILE") + +# Function to generate GitHub App token for a given installation ID +generate_github_token() { + local INSTALL_ID=$1 + local NOW + NOW=$(date +%s) + local IAT=$((NOW - 60)) + local EXP=$((NOW + 600)) + + local HEADER + HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n') + local PAYLOAD + PAYLOAD=$(echo -n "{\"iat\":${IAT},\"exp\":${EXP},\"iss\":\"${APP_ID}\"}" | base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n') + local SIGNATURE + SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -sign "$PRIVATE_KEY_FILE" | base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n') + local JWT="${HEADER}.${PAYLOAD}.${SIGNATURE}" + + curl -s -X POST \ + -H "Authorization: Bearer ${JWT}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/app/installations/${INSTALL_ID}/access_tokens" \ + | jq -r '.token' +} + +# Generate token for fork (hypershift-community/hypershift) - for pushing branches +echo "Generating GitHub App token for fork..." +GITHUB_TOKEN_FORK=$(generate_github_token "$INSTALLATION_ID_FORK") +if [ -z "$GITHUB_TOKEN_FORK" ] || [ "$GITHUB_TOKEN_FORK" = "null" ]; then + echo "ERROR: Failed to generate GitHub App token for fork" + exit 1 +fi +echo "Fork token generated successfully" + +# Generate token for upstream (openshift/hypershift) - for reading PRs and comments +echo "Generating GitHub App token for upstream..." +GITHUB_TOKEN_UPSTREAM=$(generate_github_token "$INSTALLATION_ID_UPSTREAM") +if [ -z "$GITHUB_TOKEN_UPSTREAM" ] || [ "$GITHUB_TOKEN_UPSTREAM" = "null" ]; then + echo "ERROR: Failed to generate GitHub App token for upstream" + exit 1 +fi +echo "Upstream token generated successfully" + +# Configure git to use the fork token for push operations via credential helper +git config --global credential.helper "!f() { echo username=x-access-token; echo password=${GITHUB_TOKEN_FORK}; }; f" + +# Export upstream token as GITHUB_TOKEN for gh CLI (used for PR operations) +export GITHUB_TOKEN="$GITHUB_TOKEN_UPSTREAM" +echo "GitHub App tokens configured successfully" + +# Configuration: maximum PRs to process per run (default: 10) +MAX_PRS=${REVIEW_AGENT_MAX_PRS:-10} +echo "Configuration: MAX_PRS=$MAX_PRS" + +# Check for target PR mode +# - REVIEW_AGENT_TARGET_PR: Explicit PR number override +# - PULL_NUMBER: Used for single-PR presubmit job (not in batch-only mode) +# - REVIEW_AGENT_BATCH_ONLY: Forces batch mode, ignores PULL_NUMBER (for periodic/rehearsal) +if [ "${REVIEW_AGENT_BATCH_ONLY:-}" = "true" ]; then + TARGET_PR="${REVIEW_AGENT_TARGET_PR:-}" +else + TARGET_PR="${REVIEW_AGENT_TARGET_PR:-${PULL_NUMBER:-}}" +fi + +if [ -n "$TARGET_PR" ]; then + echo "Target PR mode: Processing only PR #$TARGET_PR" + + # Fetch the specific PR details + PR_INFO=$(gh pr view "$TARGET_PR" \ + --repo openshift/hypershift \ + --json number,title,headRefName \ + --jq '"\(.number) \(.headRefName) \(.title)"' 2>/dev/null || echo "") + + if [ -z "$PR_INFO" ]; then + echo "ERROR: PR #$TARGET_PR not found or not accessible" + exit 1 + fi + + PRS="$PR_INFO" + MAX_PRS=1 +else + # Normal flow: Query GitHub for PRs created by jira-agent that need review attention + # Criteria: + # 1. Open PRs authored by the GitHub App (hypershift-jira-solve-ci) + # 2. Have pending review comments + echo "Batch mode: Querying GitHub for agent-created PRs with pending reviews..." + + # Get open PRs created by the jira-solve GitHub App + PRS=$(gh pr list \ + --repo openshift/hypershift \ + --state open \ + --author app/hypershift-jira-solve-ci \ + --json number,title,headRefName \ + --limit "$MAX_PRS" \ + --jq '.[] | "\(.number) \(.headRefName) \(.title)"') +fi + +if [ -z "$PRS" ]; then + echo "No agent-created PRs found matching criteria" + exit 0 +fi + +echo "Found PRs to check:" +echo "$PRS" | awk '{print " - PR #" $1 ": " $3}' + +# Counters for summary +PROCESSED_COUNT=0 +SKIPPED_COUNT=0 +FAILED_COUNT=0 +TOTAL_PROCESSED=0 + +# Process each PR +while IFS= read -r line; do + # Stop if we've reached the max PRs limit + if [ $TOTAL_PROCESSED -ge $MAX_PRS ]; then + echo "Reached maximum PRs limit ($MAX_PRS). Stopping." + break + fi + + PR_NUMBER=$(echo "$line" | awk '{print $1}') + BRANCH_NAME=$(echo "$line" | awk '{print $2}') + PR_TITLE=$(echo "$line" | cut -d' ' -f3-) + + echo "" + echo "==========================================" + echo "Checking: PR #$PR_NUMBER" + echo "Branch: $BRANCH_NAME" + echo "Title: $PR_TITLE" + echo "==========================================" + + # Capture timestamp early for consistent logging + TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + # Run comment analyzer to identify which comments actually need attention + # This filters out threads where the bot has already replied and no human follow-up exists + echo "Running comment analyzer for PR #$PR_NUMBER..." + set +e + # Capture stderr separately to preserve JSON output integrity + ANALYSIS_STDERR_FILE="/tmp/pr-${PR_NUMBER}-analysis-stderr.txt" + ANALYSIS_OUTPUT=$(python3 /tmp/comment_analyzer.py "$PR_NUMBER" 2>"$ANALYSIS_STDERR_FILE") + ANALYSIS_EXIT=$? + set -e + + # Log stderr (authorization decisions) for debugging + if [ -s "$ANALYSIS_STDERR_FILE" ]; then + echo "Authorization log for PR #$PR_NUMBER:" + cat "$ANALYSIS_STDERR_FILE" + fi + + if [ $ANALYSIS_EXIT -ne 0 ]; then + echo "Comment analyzer failed for PR #$PR_NUMBER: $ANALYSIS_OUTPUT" + echo "Stderr: $(cat "$ANALYSIS_STDERR_FILE" 2>/dev/null || echo 'none')" + FAILED_COUNT=$((FAILED_COUNT + 1)) + TOTAL_PROCESSED=$((TOTAL_PROCESSED + 1)) + echo "$PR_NUMBER $TIMESTAMP FAILED analyzer_error" >> "$STATE_FILE" + continue + fi + + # Extract summary counts from analysis + NEEDS_ATTENTION_COUNT=$(echo "$ANALYSIS_OUTPUT" | jq -r '.summary.total // 0') + REVIEW_THREADS=$(echo "$ANALYSIS_OUTPUT" | jq -r '.summary.review_threads // 0') + REVIEW_BODIES=$(echo "$ANALYSIS_OUTPUT" | jq -r '.summary.review_bodies // 0') + ISSUE_COMMENTS=$(echo "$ANALYSIS_OUTPUT" | jq -r '.summary.issue_comments // 0') + + if [ "$NEEDS_ATTENTION_COUNT" = "0" ] || [ -z "$NEEDS_ATTENTION_COUNT" ]; then + echo "No comments need attention for PR #$PR_NUMBER (bot already replied to all threads), skipping" + SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) + TOTAL_PROCESSED=$((TOTAL_PROCESSED + 1)) + continue + fi + + echo "Found $REVIEW_THREADS review threads, $REVIEW_BODIES review bodies, and $ISSUE_COMMENTS issue comments needing attention for PR #$PR_NUMBER" + + # Save analysis for Claude context + echo "$ANALYSIS_OUTPUT" > "/tmp/pr-${PR_NUMBER}-analysis.json" + + # Reset working directory and checkout the PR branch + echo "Checking out branch: $BRANCH_NAME" + git reset --hard HEAD + git clean -fd + git fetch origin "$BRANCH_NAME" + git checkout -B "$BRANCH_NAME" "origin/$BRANCH_NAME" + + # Run address-reviews command non-interactively + echo "Running: /utils:address-reviews $PR_NUMBER" + + # Load the skill content as system prompt + SKILL_CONTENT=$(cat /tmp/address-reviews.md) + + # Load the analysis results for context + NEEDS_ATTENTION_JSON=$(cat "/tmp/pr-${PR_NUMBER}-analysis.json") + + # Context for the review agent with filtered comments + REVIEW_CONTEXT="IMPORTANT: You are addressing review comments on PR #$PR_NUMBER in the openshift/hypershift repository. The PR was created from the hypershift-community fork. After making changes, push to the fork branch. Use 'git push origin $BRANCH_NAME' to push changes. The gh CLI is authenticated to openshift/hypershift for reading PR information. SECURITY: Do NOT run commands that reveal git credentials. + +CRITICAL - DUPLICATE PREVENTION: The following JSON contains ONLY the comments that need your attention. These are comments where either (1) you have not replied yet, or (2) a human has replied after your last response. ONLY address these specific comments. Ignore all other threads - they have already been addressed. + +COMMENTS NEEDING ATTENTION: +$NEEDS_ATTENTION_JSON + +RESPONSE RULES: +1. For each piece of feedback, choose ONE response mechanism only - never respond to the same feedback via both inline reply AND general PR comment +2. Only make code changes when explicitly requested (look for imperative language like 'change', 'fix', 'update', 'remove') +3. For questions or clarifications, reply with an explanation only - do not change code unless asked" + + set +e # Don't exit on error for individual PRs + echo "Starting Claude processing with streaming output..." + # Redirect stdin from /dev/null to prevent Claude from consuming the while loop's here-string input + RESULT=$(claude -p "$PR_NUMBER. $REVIEW_CONTEXT" \ + --system-prompt "$SKILL_CONTENT" \ + --allowedTools "Bash Read Write Edit Grep Glob WebFetch" \ + --max-turns 100 \ + --model "$CLAUDE_MODEL" \ + --verbose \ + --output-format stream-json \ + < /dev/null \ + 2>&1 | tee "/tmp/claude-pr-${PR_NUMBER}-output.json") + EXIT_CODE=$? + set -e + echo "Claude processing complete. Full output saved to /tmp/claude-pr-${PR_NUMBER}-output.json" + + if [ $EXIT_CODE -eq 0 ]; then + echo "Successfully processed PR #$PR_NUMBER" + echo "" + echo "--- Claude output for PR #$PR_NUMBER ---" + echo "$RESULT" | tail -50 + echo "--- End Claude output ---" + echo "" + PROCESSED_COUNT=$((PROCESSED_COUNT + 1)) + echo "$PR_NUMBER $TIMESTAMP SUCCESS" >> "$STATE_FILE" + else + echo "Failed to process PR #$PR_NUMBER" + echo "Error output (last 20 lines):" + echo "$RESULT" | tail -20 + FAILED_COUNT=$((FAILED_COUNT + 1)) + echo "$PR_NUMBER $TIMESTAMP FAILED" >> "$STATE_FILE" + fi + + # Increment total counter + TOTAL_PROCESSED=$((TOTAL_PROCESSED + 1)) + + # Rate limiting between PRs (60 seconds) + if [ $TOTAL_PROCESSED -lt $MAX_PRS ]; then + echo "Waiting 60 seconds before next PR..." + sleep 60 + fi + +done <<< "$PRS" + +echo "" +echo "=== Processing Summary ===" +echo "Processed: $PROCESSED_COUNT" +echo "Skipped (no pending reviews): $SKIPPED_COUNT" +echo "Failed: $FAILED_COUNT" +echo "==========================" diff --git a/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-ref.metadata.json b/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-ref.metadata.json new file mode 100644 index 0000000000000..e6659bdd64632 --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-ref.metadata.json @@ -0,0 +1,19 @@ +{ + "path": "hypershift/review-agent/process/hypershift-review-agent-process-ref.yaml", + "owners": { + "approvers": [ + "bryan-cox", + "csrwng", + "celebdor", + "enxebre", + "sjenning" + ], + "reviewers": [ + "bryan-cox", + "csrwng", + "celebdor", + "enxebre", + "sjenning" + ] + } +} \ No newline at end of file diff --git a/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-ref.yaml b/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-ref.yaml new file mode 100644 index 0000000000000..7086d20ec3de4 --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-ref.yaml @@ -0,0 +1,51 @@ +ref: + as: hypershift-review-agent-process + from: claude-ai-helpers + commands: hypershift-review-agent-process-commands.sh + env: + - name: CLAUDE_CODE_USE_VERTEX + default: "1" + documentation: |- + Enable Vertex AI for Claude Code. + - name: CLOUD_ML_REGION + default: "us-east5" + documentation: |- + Google Cloud region for Vertex AI. + - name: ANTHROPIC_VERTEX_PROJECT_ID + default: "itpc-gcp-hybrid-pe-eng-claude" + documentation: |- + Google Cloud project ID for Vertex AI authentication. + - name: GOOGLE_APPLICATION_CREDENTIALS + default: "/var/run/claude-code-service-account/claude-prow" + documentation: |- + Path to the Google Cloud service account JSON key file for Vertex AI authentication. + - name: REVIEW_AGENT_MAX_PRS + default: "10" + documentation: |- + Maximum number of PRs to process per run. Defaults to 10 for batch processing. + - name: REVIEW_AGENT_BATCH_ONLY + default: "" + documentation: |- + When set to "true", forces batch mode and ignores PULL_NUMBER. + Use this for periodic jobs to prevent rehearsal failures when + PULL_NUMBER refers to a release repo PR instead of a HyperShift PR. + - name: CLAUDE_MODEL + default: "claude-opus-4-5" + documentation: |- + Claude model to use for processing PR review comments. + resources: + requests: + cpu: 500m + memory: 1Gi + credentials: + - namespace: test-credentials + name: hypershift-team-claude-prow + mount_path: /var/run/claude-code-service-account + documentation: |- + Process step for the HyperShift Review Agent periodic job. + This step: + - Queries GitHub for PRs created by the jira-agent (from hypershift-community fork) + - Filters for PRs with unresolved review threads + - For each PR, runs the /utils:address-reviews command non-interactively + - Pushes changes back to the fork branch + - Uses Vertex AI for Claude authentication via GCP service account diff --git a/ci-operator/step-registry/hypershift/review-agent/setup/OWNERS b/ci-operator/step-registry/hypershift/review-agent/setup/OWNERS new file mode 100644 index 0000000000000..e39269bf55090 --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/setup/OWNERS @@ -0,0 +1,12 @@ +approvers: +- bryan-cox +- csrwng +- celebdor +- enxebre +- sjenning +reviewers: +- bryan-cox +- csrwng +- celebdor +- enxebre +- sjenning diff --git a/ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-commands.sh b/ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-commands.sh new file mode 100644 index 0000000000000..a393e3e27621a --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-commands.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail + +echo "=== HyperShift Review Agent Setup ===" + +# Verify Claude Code is available (Vertex AI authentication is handled via GOOGLE_APPLICATION_CREDENTIALS env var) +echo "Verifying Claude Code CLI..." +claude --version || { echo "ERROR: Claude Code CLI not found"; exit 1; } + +echo "Setup complete" diff --git a/ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-ref.metadata.json b/ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-ref.metadata.json new file mode 100644 index 0000000000000..b274fc921e952 --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-ref.metadata.json @@ -0,0 +1,19 @@ +{ + "path": "hypershift/review-agent/setup/hypershift-review-agent-setup-ref.yaml", + "owners": { + "approvers": [ + "bryan-cox", + "csrwng", + "celebdor", + "enxebre", + "sjenning" + ], + "reviewers": [ + "bryan-cox", + "csrwng", + "celebdor", + "enxebre", + "sjenning" + ] + } +} \ No newline at end of file diff --git a/ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-ref.yaml b/ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-ref.yaml new file mode 100644 index 0000000000000..22a5de370166a --- /dev/null +++ b/ci-operator/step-registry/hypershift/review-agent/setup/hypershift-review-agent-setup-ref.yaml @@ -0,0 +1,34 @@ +ref: + as: hypershift-review-agent-setup + from: claude-ai-helpers + commands: hypershift-review-agent-setup-commands.sh + env: + - name: CLAUDE_CODE_USE_VERTEX + default: "1" + documentation: |- + Enable Vertex AI for Claude Code. + - name: CLOUD_ML_REGION + default: "us-east5" + documentation: |- + Google Cloud region for Vertex AI. + - name: ANTHROPIC_VERTEX_PROJECT_ID + default: "itpc-gcp-hybrid-pe-eng-claude" + documentation: |- + Google Cloud project ID for Vertex AI authentication. + - name: GOOGLE_APPLICATION_CREDENTIALS + default: "/var/run/claude-code-service-account/claude-prow" + documentation: |- + Path to the Google Cloud service account JSON key file for Vertex AI authentication. + resources: + requests: + cpu: 100m + memory: 200Mi + credentials: + - namespace: test-credentials + name: hypershift-team-claude-prow + mount_path: /var/run/claude-code-service-account + documentation: |- + Setup step for the HyperShift Review Agent periodic job. + This step: + - Verifies Claude Code CLI is available + - Uses Vertex AI for Claude authentication via GCP service account From 1f38f09967d52435b83bd28eda26ed6dade7c349 Mon Sep 17 00:00:00 2001 From: Bryan Cox Date: Thu, 29 Jan 2026 21:29:27 -0500 Subject: [PATCH 2/2] fix(step-registry): hypershift-review-agent OWNERS parsing for filters format The authorization check was failing for users like jparrill who are listed in OWNERS_ALIASES but not recognized because the OWNERS file uses a filters-based format instead of simple top-level lists. Changes: - Replace get_owners_and_aliases() and is_in_owners_file() with a single get_all_authorized_users() function that builds one combined set - Add all users from all aliases in OWNERS_ALIASES upfront - Parse both simple format (top-level approvers/reviewers) and filters-based format (nested under filters key) - Simplify is_authorized_author() to check combined set then fallback to org membership Co-Authored-By: Claude Opus 4.5 --- ...ypershift-review-agent-process-commands.sh | 98 +++++++++---------- 1 file changed, 46 insertions(+), 52 deletions(-) diff --git a/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-commands.sh b/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-commands.sh index 6f46e2c66774b..7de6feb983786 100644 --- a/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-commands.sh +++ b/ci-operator/step-registry/hypershift/review-agent/process/hypershift-review-agent-process-commands.sh @@ -165,17 +165,23 @@ def _parse_simple_yaml_aliases(content: str) -> dict[str, list[str]]: @lru_cache(maxsize=1) -def get_owners_and_aliases() -> tuple[set[str], set[str]]: - """Fetch and parse OWNERS and OWNERS_ALIASES from hypershift repo. +def get_all_authorized_users() -> set[str]: + """Build a set of all authorized users. + + Collects into one set: + 1. Approved bots + 2. All usernames from all aliases in OWNERS_ALIASES + 3. Any direct usernames in OWNERS (both simple and filters-based formats) - Returns tuple of (approvers set, reviewers set). Uses lru_cache to only fetch once per run. """ - approvers: set[str] = set() - reviewers: set[str] = set() + authorized: set[str] = set() aliases: dict[str, list[str]] = {} - # Fetch OWNERS_ALIASES first (aliases can be referenced in OWNERS) + # 1. Add approved bots + authorized.update(APPROVED_BOTS) + + # 2. Fetch OWNERS_ALIASES - collect ALL users from ALL aliases try: result = subprocess.run( ["gh", "api", "-H", "Accept: application/vnd.github.raw", @@ -190,10 +196,14 @@ def get_owners_and_aliases() -> tuple[set[str], set[str]]: aliases = aliases_data["aliases"] else: aliases = _parse_simple_yaml_aliases(result.stdout) + + # Add all users from all aliases + for alias_name, members in aliases.items(): + authorized.update(members) except Exception as e: print(f"Warning: Failed to fetch OWNERS_ALIASES: {e}", file=sys.stderr) - # Fetch OWNERS file + # 3. Fetch OWNERS - collect any direct usernames (not alias references) try: result = subprocess.run( ["gh", "api", "-H", "Accept: application/vnd.github.raw", @@ -205,53 +215,42 @@ def get_owners_and_aliases() -> tuple[set[str], set[str]]: if HAS_YAML: owners_data = yaml.safe_load(result.stdout) if owners_data: - # Expand approvers (may include alias references) - for entry in owners_data.get("approvers", []): - if entry in aliases: - approvers.update(aliases[entry]) - else: - approvers.add(entry) - - # Expand reviewers (may include alias references) - for entry in owners_data.get("reviewers", []): - if entry in aliases: - reviewers.update(aliases[entry]) - else: - reviewers.add(entry) + # Helper to add entries (skip if it's an alias reference) + def add_entries(entries: list): + for entry in entries: + if entry not in aliases: # Direct username, not an alias + authorized.add(entry) + + # Simple format: top-level approvers/reviewers + add_entries(owners_data.get("approvers", [])) + add_entries(owners_data.get("reviewers", [])) + + # Filters-based format: nested under filters + if "filters" in owners_data: + for pattern, config in owners_data["filters"].items(): + if isinstance(config, dict): + add_entries(config.get("approvers", [])) + add_entries(config.get("reviewers", [])) else: - # Fallback parsing + # Fallback parsing (simple format only - filters format requires YAML) for entry in _parse_simple_yaml_list(result.stdout, "approvers"): - if entry in aliases: - approvers.update(aliases[entry]) - else: - approvers.add(entry) + if entry not in aliases: + authorized.add(entry) for entry in _parse_simple_yaml_list(result.stdout, "reviewers"): - if entry in aliases: - reviewers.update(aliases[entry]) - else: - reviewers.add(entry) + if entry not in aliases: + authorized.add(entry) except Exception as e: print(f"Warning: Failed to fetch OWNERS: {e}", file=sys.stderr) - return approvers, reviewers - - -def is_in_owners_file(login: str) -> bool: - """Check if user is in OWNERS or OWNERS_ALIASES.""" - approvers, reviewers = get_owners_and_aliases() - login_lower = login.lower() - # GitHub usernames are case-insensitive, so check lowercase - return (login_lower in {a.lower() for a in approvers} or - login_lower in {r.lower() for r in reviewers}) + return authorized def is_authorized_author(login: str) -> bool: """Check if author is authorized to trigger review agent responses. Authorized authors are: - 1. Approved bots (coderabbitai) - 2. Members of the openshift GitHub organization - 3. People listed in the OWNERS file (approvers or reviewers) + 1. Users in the combined set (approved bots + OWNERS + OWNERS_ALIASES) + 2. Members of the openshift GitHub organization (fallback) """ if not login: return False @@ -260,19 +259,14 @@ def is_authorized_author(login: str) -> bool: if login in _auth_cache: return _auth_cache[login] - # 1. Check approved bots first (no API call needed) - if login in APPROVED_BOTS or login.lower() in {b.lower() for b in APPROVED_BOTS}: - _auth_cache[login] = True - print(f" Author '{login}' authorized: approved bot", file=sys.stderr) - return True - - # 2. Check OWNERS file (cached after first call) - if is_in_owners_file(login): + # 1. Check combined set (bots + OWNERS + OWNERS_ALIASES) + authorized_users = get_all_authorized_users() + if login.lower() in {u.lower() for u in authorized_users}: _auth_cache[login] = True - print(f" Author '{login}' authorized: in OWNERS file", file=sys.stderr) + print(f" Author '{login}' authorized: in approved bots/OWNERS/OWNERS_ALIASES", file=sys.stderr) return True - # 3. Check openshift org membership + # 2. Fallback: check openshift org membership if is_openshift_org_member(login): _auth_cache[login] = True print(f" Author '{login}' authorized: openshift org member", file=sys.stderr)