This commit adds a new infrastructure for CI-based testing
that can leverage Anthropic AI's Claude sonnet model to assist
when doing code reviews.  The eventual goal is for the 0-day
Robot to run this automatically for each series that comes
into the mailing list.

Rather than relying on the GitHub infrastructure, this
provides a few base tools for doing reviews as well as a
prompting infrastructure that we can extend for domain
specific hints to the LLM for analysis.  The prompt is
most useful for inline diffs, mimicing a real interactive
development session on the mailing list.  This can help
to do a few rounds of internal commenting and development
before submitting to the list (using your own API key),
and then run a final pass based on the upstream.

This is a first cut of the functionality.  A good chunk
of it was generated using Claude LLM, but there is also
a heavy amount of manual editing involved as well.

A first pass run has been posted to the list against
the series found at:

https://mail.openvswitch.org/pipermail/ovs-dev/2025-November/427714.html

This can give a good idea of what currently is possible.

Signed-off-by: Aaron Conole <[email protected]>
---
 .ci/ai_review/README.md                  | 197 +++++++++++
 .ci/ai_review/prompts/review-start.md    | 110 ++++++
 .ci/ai_review/requirements.txt           |   1 +
 .ci/ai_review/review.py                  | 411 +++++++++++++++++++++++
 .ci/ai_review/run_code_review_session.sh | 302 +++++++++++++++++
 5 files changed, 1021 insertions(+)
 create mode 100644 .ci/ai_review/README.md
 create mode 100644 .ci/ai_review/prompts/review-start.md
 create mode 100644 .ci/ai_review/requirements.txt
 create mode 100755 .ci/ai_review/review.py
 create mode 100755 .ci/ai_review/run_code_review_session.sh

diff --git a/.ci/ai_review/README.md b/.ci/ai_review/README.md
new file mode 100644
index 0000000000..497fbc745a
--- /dev/null
+++ b/.ci/ai_review/README.md
@@ -0,0 +1,197 @@
+# AI Review Suite for OVS
+
+This directory contains tools for conducting AI-powered code reviews of OVS 
+patches using Claude (Anthropic API).
+
+## Setup
+
+1. Install the required Python packages:
+   ```bash
+   pip install -r .ci/ai_review/requirements.txt
+   ```
+
+   Or install directly:
+   ```bash
+   pip install anthropic
+   ```
+
+2. Set your Anthropic API key:
+   ```bash
+   export ANTHROPIC_API_KEY='your-api-key-here'
+   ```
+
+## Usage
+
+### Batch Review Multiple Commits (Recommended)
+
+The easiest way to review multiple commits or email patches is using the
+`run_code_review_session.sh` script:
+
+```bash
+# Review last 3 commits
+.ci/ai_review/run_code_review_session.sh HEAD~2 HEAD~1 HEAD
+
+# Review a range of commits from a branch
+.ci/ai_review/run_code_review_session.sh $(git rev-list main..feature-branch)
+
+# Review specific commits
+.ci/ai_review/run_code_review_session.sh abc123 def456 789ghi
+
+# Review patch files from email (with automatic email reply formatting)
+.ci/ai_review/run_code_review_session.sh patch1.patch patch2.patch
+
+# Custom output directory
+.ci/ai_review/run_code_review_session.sh HEAD~2 HEAD~1 HEAD \
+    --output-dir /tmp/reviews
+```
+
+The script will:
+1. Generate patches for each commit using `git format-patch` (or use provided
+patch files)
+2. Reset the tree to each commit's parent (for git commits)
+3. Run the AI review
+4. **If patch has email headers**: Format output as email reply with proper 
+To:, Subject:, In-Reply-To:, and References: headers
+5. Save outputs as `message_0001`, `message_0002`, etc.
+6. Restore your original git state when done
+
+#### Email Header Support
+
+When reviewing patches from email (e.g., from a mailing list), the script 
+automatically:
+- Detects email headers (Message-ID, From, Subject, etc.)
+- Formats the review output as a proper email reply:
+  - `To:` set to the original patch author (From:)
+  - `Subject:` prefixed with "Re:"
+  - `In-Reply-To:` set to original Message-ID
+  - `References:` includes the original Message-ID and any existing references
+
+This makes it easy to send the review back to the mailing list as a proper 
+threaded reply.
+
+### Basic Review (Single Patch)
+
+To review a patch file directly:
+
+```bash
+.ci/ai_review/review.py path/to/patch.patch
+```
+
+This will output the review to stdout.
+
+### Save Review to File
+
+```bash
+.ci/ai_review/review.py path/to/patch.patch --output review.txt
+```
+
+### Custom Prompt
+
+By default, the script uses the `review-start` prompt. To use a different 
+prompt:
+
+```bash
+.ci/ai_review/review.py path/to/patch.patch --prompt custom-prompt
+```
+
+(This will look for `.ci/ai_review/prompts/custom-prompt.md`)
+
+### Advanced Options
+
+```bash
+.ci/ai_review/review.py \
+  path/to/patch.patch \
+  --prompt review-start \
+  --model claude-sonnet-4-20250514 \
+  --max-tokens 16000 \
+  --output review.txt
+```
+
+### Including Additional Context
+
+If the prompt references other scripts or files that Claude needs access to, 
you
+can provide them as context:
+
+```bash
+.ci/ai_review/review.py path/to/patch.patch \
+  --context lib/odp-util.c \
+  --context utilities/checkpatch.py \
+  --output review.txt
+```
+
+You can specify `--context` multiple times to include multiple files. These 
+files will be included in the API call so Claude can reference them during the
+review.
+
+### Disable Git Context
+
+By default, the script automatically extracts git context (current branch, 
+recent commits, commit SHA from patch). To disable this:
+
+```bash
+.ci/ai_review/review.py path/to/patch.patch --no-git-context
+```
+
+## Command Line Options
+
+- `patch_file` (required): Path to the patch file to review
+- `--prompt PROMPT`: Name of the prompt file to use (default: review-start)
+- `--model MODEL`: Claude model to use (default: claude-sonnet-4-20250514)
+- `--max-tokens N`: Maximum tokens for response (default: 16000)
+- `--output FILE`: Output file for the review (default: stdout)
+- `--context FILE`: Additional context file to include (can be used multiple
+times)
+- `--no-git-context`: Disable automatic git context extraction
+
+## Prompts
+
+Review prompts are stored in `.ci/ai_review/prompts/` as Markdown files. The 
+script will automatically load the specified prompt and combine it with the
+patch content before sending to Claude.
+
+To create a new prompt, add a new `.md` file to the prompts directory.
+
+### Context Files for Prompts
+
+If your prompt references scripts, tools, or specific source files that Claude 
+should have access to, you have two options:
+
+#### Option 1: Automatic Loading with @ref: (Recommended)
+
+Add `@ref:filename` references directly in your prompt file. The script will 
+automatically detect and load these files:
+
+```markdown
+<!-- In .ci/ai_review/prompts/review-start.md -->
+
+Please review this patch according to the coding standards in 
@ref:CODING_STYLE.md
+and compare against the reference implementation in @ref:lib/common-functions.c
+
+When checking for memory leaks, refer to @ref:memory-patterns.md
+```
+
+The script will automatically:
+- Detect all `@ref:filename` patterns in the prompt
+- Load the referenced files (searches in prompts directory first, then relative
+paths)
+- Include them in the context sent to Claude
+
+#### Option 2: Manual Context with --context Flag
+
+You can also provide context files manually using the `--context` flag:
+
+```bash
+.ci/ai_review/review.py patch.patch \
+  --context CODING_STYLE.md \
+  --context lib/common-functions.c
+```
+
+**Note:** Both methods can be combined. Files specified with `@ref:` in prompts
+will be merged with files specified via `--context` flags.
+
+## Requirements
+
+- Python 3.6 or later
+- anthropic Python package
+- Valid Anthropic API key
+- Git repository (script auto-detects git root)
diff --git a/.ci/ai_review/prompts/review-start.md 
b/.ci/ai_review/prompts/review-start.md
new file mode 100644
index 0000000000..55d2dea7ae
--- /dev/null
+++ b/.ci/ai_review/prompts/review-start.md
@@ -0,0 +1,110 @@
+Produce a report of regressions found based on this template.
+
+- Reviews should be in plain text only.  Do not use markdown, special
+characters, emoji-alikes.  Only plain text is suitable for the ovs-dev mailing
+list.
+
+- Any long lines present in the unified diff should be preserved, but any
+summary, comments, or questions you add should be wrapped at 79 characters.
+
+- Never mention line numbers when referencing code locations.  Instead
+use the function name and also call chain if it makes it more clear.  Avoid
+complex paragraphs, and instead use call chains fA()->fB() to explain.
+
+- Always end the report with a blank line.
+
+- The report must be conversational with undramatic wording, fit for sending
+as a reply to the patch being analyzed to the ovs-dev mailing list.
+
+- Explain any regressions as questions about the code, but do not mention
+the authors.  Don't say "Did you do X?" but rather say, "Can this X?" or
+"Does this code X?"
+
+- Vary question phrasing.  Do not always start all questions in the same
+manner.
+
+- Ask your question specifically about the sources you're referencing.
+  - If you suspect a leak, ask specifically about the resource being leaked.
+    "Does this code leak this thing?"
+  - Don't say "Does this loop have a bounds checking issue?"  Name the variable
+    you think is overflowing: "Does this code overflow xyz[]"
+
+- Don't make long paragraphs, ask short questions backed up by code snippets, 
+or call chains.
+
+- Ensure that the code follows the official coding style guide found in
+https://github.com/openvswitch/ovs/blob/main/Documentation/internals/contributing/coding-style.rst
+
+- Verify that the ​​commit subject and message comply with the project's 
+submission guide found in
+https://github.com/openvswitch/ovs/blob/main/Documentation/internals/contributing/submitting-patches.rst
+
+- For dynamic string management, confirm that functions like `ds_init()` are
+not being called repeatedly in a loop when `ds_clear()` should be used to
+reuse the buffer.
+
+- Verify proper use of `ds_init()`, `ds_clear()`, and `ds_destroy()` (no
+redundant init, no leaks inside loops).
+
+- Check that all dynamically allocated resources (`xmalloc()`, `json_*()`, 
etc.)
+are properly freed or reused.
+
+- Portability: Verify that the patch does not rely on undefined or 
+platform-specific behavior.
+
+- Error Handling: Ensure proper error detection, logging, and cleanup on
+failure.
+
+- Readability & Maintainability: Evaluate naming, comments, and modularity.
+
+- Be sure to wrap all comments at 79 characters.
+
+- Make sure to check for whitespace errors (things like aligned whitespace at
+the start of a line, and incorrect whitespace in includes).
+
+- Check for common mistake patters such as calling `strcmp` with NULL.
+
+Create a TodoWrite for these items, all of which your report should include:
+
+- [ ] git sha of the commit
+- [ ] Author: line from the commit
+- [ ] One line subject of the commit
+
+- [ ] A brief summary of the commit.  Use the full commit message if the bug is
+in the commit message itself.
+
+- [ ] A unified diff of the commit, quoted as though it's in an email reply.
+  - [ ] The diff must not be generated from existing context.
+  - [ ] You must regenerate the diff by calling out to semcode's commit 
+    function,
+    using git log, or re-reading any patch files you were asked to review.
+  - [ ] You must ensure the quoted portions of the diff exactly match the
+    original commit or patch.
+
+- [ ] Place your questions about the regressions you found alongside the code
+  in the diff that introduced them.  Do not put the quoting '> ' characters in
+  front of your new text.
+- [ ] Place your questions as close as possible to the buggy section of code.
+
+- [ ] Snip portions of the quoted content unrelated to your review
+  - [ ] Create a TodoWrite with every hunk in the diff.  Check every hunk
+        to see if it is relevant to the review comments.
+  - [ ] ensure diff headers are retained for the files owning any hunks keep
+  - [ ] Replace any content you snip with [ ... ]
+  - [ ] Never include diff headers for entirely snipped files
+  - [ ] snip entire files unrelated to the review comments
+  - [ ] snip entire hunks from quoted files if they unrelated to the review
+  - [ ] snip entire functions from the quoted hunks unrelated to the review
+  - [ ] snip any portions of large functions from quoted hunks if unrelated to
+        the review
+  - [ ] ensure you only keep enough quoted material for the review to make 
sense
+  - [ ] snip trailing hunks and files after your last review comments unless
+        you need them for the review to make sense
+  - [ ] The review should contain only the portions of hunks needed to explain 
the review's concerns.
+
+Use the following sample as a guideline:
+
+```
+
+```
+
diff --git a/.ci/ai_review/requirements.txt b/.ci/ai_review/requirements.txt
new file mode 100644
index 0000000000..5df62e6a29
--- /dev/null
+++ b/.ci/ai_review/requirements.txt
@@ -0,0 +1 @@
+anthropic>=0.39.0
diff --git a/.ci/ai_review/review.py b/.ci/ai_review/review.py
new file mode 100755
index 0000000000..3efc31132f
--- /dev/null
+++ b/.ci/ai_review/review.py
@@ -0,0 +1,411 @@
+#!/usr/bin/env python3
+"""
+AI-powered code review script for OVS patches.
+
+This script uses Claude (Anthropic API) to review git patches based on
+prompts defined in .ci/ai_review/prompts/.
+"""
+
+import argparse
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+from typing import Optional, List, Dict, Set
+
+try:
+    import anthropic
+except ImportError:
+    print("Error: anthropic package not installed.", file=sys.stderr)
+    print("Please install it with: pip install anthropic", file=sys.stderr)
+    sys.exit(1)
+
+
+def find_git_root() -> Path:
+    """Find the root of the git repository."""
+    current = Path.cwd()
+    while current != current.parent:
+        if (current / ".git").exists():
+            return current
+        current = current.parent
+    raise RuntimeError("Not in a git repository")
+
+
+def find_referenced_files(content: str, prompts_dir: Path) -> Set[Path]:
+    """
+    Find all files referenced in the content using @ref:filename pattern.
+
+    Args:
+        content: The text content to scan for references
+        prompts_dir: Directory where prompt files are located
+
+    Returns:
+        Set of Path objects for referenced files
+    """
+    # Pattern to match @ref:filename.md or @ref:path/to/file.ext
+    pattern = r'@ref:([^\s\)]+)'
+    matches = re.findall(pattern, content)
+
+    referenced_files = set()
+    for match in matches:
+        # Try relative to prompts directory first
+        ref_path = prompts_dir / match
+        if ref_path.exists():
+            referenced_files.add(ref_path)
+        else:
+            # Try as absolute or relative to git root
+            ref_path = Path(match)
+            if ref_path.exists():
+                referenced_files.add(ref_path)
+            else:
+                print(f"Warning: Referenced file not found: {match}", 
file=sys.stderr)
+
+    return referenced_files
+
+
+def read_prompt_file(prompts_dir: Path, prompt_name: str) -> tuple[str, 
Set[Path]]:
+    """
+    Read a prompt file from the prompts directory and find referenced files.
+
+    Args:
+        prompts_dir: Directory where prompt files are located
+        prompt_name: Name of the prompt (without .md extension)
+
+    Returns:
+        Tuple of (prompt_content, set_of_referenced_files)
+    """
+    prompt_file = prompts_dir / f"{prompt_name}.md"
+    if not prompt_file.exists():
+        raise FileNotFoundError(f"Prompt file not found: {prompt_file}")
+
+    with open(prompt_file, 'r', encoding='utf-8') as f:
+        content = f.read()
+
+    # Find all referenced files
+    referenced_files = find_referenced_files(content, prompts_dir)
+
+    return content, referenced_files
+
+
+def read_patch_file(patch_path: Path) -> str:
+    """Read the patch file contents."""
+    if not patch_path.exists():
+        raise FileNotFoundError(f"Patch file not found: {patch_path}")
+
+    with open(patch_path, 'r', encoding='utf-8') as f:
+        return f.read()
+
+
+def get_api_key() -> str:
+    """Get the Anthropic API key from environment."""
+    api_key = os.environ.get("ANTHROPIC_API_KEY")
+    if not api_key:
+        raise RuntimeError(
+            "ANTHROPIC_API_KEY environment variable not set.\n"
+            "Please set it with: export ANTHROPIC_API_KEY='your-api-key'"
+        )
+    return api_key
+
+
+def run_git_command(git_root: Path, command: List[str]) -> Optional[str]:
+    """
+    Run a git command and return its output.
+
+    Args:
+        git_root: Root of the git repository
+        command: Git command as list (e.g., ['log', '--oneline', '-1'])
+
+    Returns:
+        Command output as string, or None if command fails
+    """
+    try:
+        result = subprocess.run(
+            ['git'] + command,
+            cwd=git_root,
+            capture_output=True,
+            text=True,
+            timeout=30
+        )
+        if result.returncode == 0:
+            return result.stdout.strip()
+        else:
+            print(f"Git command failed: {' '.join(command)}", file=sys.stderr)
+            print(f"Error: {result.stderr}", file=sys.stderr)
+            return None
+    except subprocess.TimeoutExpired:
+        print(f"Git command timed out: {' '.join(command)}", file=sys.stderr)
+        return None
+    except Exception as e:
+        print(f"Error running git command: {e}", file=sys.stderr)
+        return None
+
+
+def extract_git_context(git_root: Path, patch_content: str) -> Dict[str, str]:
+    """
+    Extract git repository context that might be useful for the review.
+
+    Args:
+        git_root: Root of the git repository
+        patch_content: The patch content (may contain commit info)
+
+    Returns:
+        Dictionary with git context information
+    """
+    context = {}
+
+    # Try to extract commit SHA from patch if it's a git format-patch style
+    lines = patch_content.split('\n')
+    for line in lines[:50]:  # Check first 50 lines
+        if line.startswith('From '):
+            parts = line.split()
+            if len(parts) >= 2 and len(parts[1]) >= 7:
+                context['commit_sha'] = parts[1][:40]  # Full or short SHA
+                break
+
+    # Get current branch info
+    branch = run_git_command(git_root, ['rev-parse', '--abbrev-ref', 'HEAD'])
+    if branch:
+        context['current_branch'] = branch
+
+    # Get recent commits for context
+    recent_log = run_git_command(git_root, ['log', '--oneline', '-5'])
+    if recent_log:
+        context['recent_commits'] = recent_log
+
+    return context
+
+
+def read_context_files(context_paths: List[Path]) -> Dict[str, str]:
+    """
+    Read additional context files (e.g., related source files, scripts).
+
+    Args:
+        context_paths: List of file paths to include as context
+
+    Returns:
+        Dictionary mapping file paths to their contents
+    """
+    context_files = {}
+
+    for path in context_paths:
+        if not path.exists():
+            print(f"Warning: Context file not found: {path}", file=sys.stderr)
+            continue
+
+        try:
+            with open(path, 'r', encoding='utf-8') as f:
+                content = f.read()
+                context_files[str(path)] = content
+                print(f"Loaded context file: {path}", file=sys.stderr)
+        except Exception as e:
+            print(f"Warning: Could not read {path}: {e}", file=sys.stderr)
+
+    return context_files
+
+
+def conduct_review(
+    patch_content: str,
+    prompt_content: str,
+    git_context: Dict[str, str] = None,
+    context_files: Dict[str, str] = None,
+    model: str = "claude-sonnet-4-20250514",
+    max_tokens: int = 16000
+) -> str:
+    """
+    Conduct a code review using Claude API.
+
+    Args:
+        patch_content: The patch file content to review
+        prompt_content: The review instructions/prompt
+        git_context: Git repository context (commit info, branches, etc.)
+        context_files: Additional context files (scripts, related sources)
+        model: The Claude model to use
+        max_tokens: Maximum tokens for the response
+
+    Returns:
+        The review text from Claude
+    """
+    api_key = get_api_key()
+    client = anthropic.Anthropic(api_key=api_key)
+
+    # Construct the full message with all context
+    message_parts = [prompt_content]
+
+    # Add git context if available
+    if git_context:
+        message_parts.append("\n## Git Repository Context\n")
+        for key, value in git_context.items():
+            message_parts.append(f"{key}: {value}\n")
+
+    # Add context files if provided
+    if context_files:
+        message_parts.append("\n## Additional Context Files\n")
+        for file_path, content in context_files.items():
+            message_parts.append(f"\n### File: {file_path}\n")
+            message_parts.append(f"```\n{content}\n```\n")
+
+    # Add the patch to review
+    message_parts.append("\n## Patch to Review\n")
+    message_parts.append(patch_content)
+
+    message_content = "".join(message_parts)
+
+    print(f"Starting review with model: {model}", file=sys.stderr)
+    print(f"Patch size: {len(patch_content)} characters", file=sys.stderr)
+    if git_context:
+        print(f"Git context items: {len(git_context)}", file=sys.stderr)
+    if context_files:
+        print(f"Context files: {len(context_files)}", file=sys.stderr)
+
+    try:
+        message = client.messages.create(
+            model=model,
+            max_tokens=max_tokens,
+            messages=[
+                {
+                    "role": "user",
+                    "content": message_content
+                }
+            ]
+        )
+
+        # Extract the text response
+        response_text = ""
+        for block in message.content:
+            if block.type == "text":
+                response_text += block.text
+
+        return response_text
+
+    except anthropic.APIError as e:
+        print(f"Anthropic API error: {e}", file=sys.stderr)
+        raise
+    except Exception as e:
+        print(f"Unexpected error during review: {e}", file=sys.stderr)
+        raise
+
+
+def main():
+    """Main entry point for the review script."""
+    parser = argparse.ArgumentParser(
+        description="Conduct AI-powered code review of OVS patches using 
Claude"
+    )
+    parser.add_argument(
+        "patch_file",
+        type=Path,
+        help="Path to the patch file to review"
+    )
+    parser.add_argument(
+        "--prompt",
+        type=str,
+        default="review-start",
+        help="Name of the prompt to use (without .md extension). Default: 
review-start"
+    )
+    parser.add_argument(
+        "--model",
+        type=str,
+        #default="claude-sonnet-4-20250929-v1",
+        default="claude-sonnet-4-20250514",
+        help="Claude model to use. Default: claude-sonnet-4-20250514"
+    )
+    parser.add_argument(
+        "--max-tokens",
+        type=int,
+        default=16000,
+        help="Maximum tokens for the response. Default: 16000"
+    )
+    parser.add_argument(
+        "--output",
+        type=Path,
+        help="Output file for the review (default: stdout)"
+    )
+    parser.add_argument(
+        "--context",
+        type=Path,
+        action="append",
+        dest="context_files",
+        help="Additional context files to include (can be specified multiple 
times)"
+    )
+    parser.add_argument(
+        "--no-git-context",
+        action="store_true",
+        help="Disable automatic git context extraction"
+    )
+
+    args = parser.parse_args()
+
+    try:
+        # Find git root and construct paths
+        git_root = find_git_root()
+        prompts_dir = git_root / ".ci" / "ai_review" / "prompts"
+
+        if not prompts_dir.exists():
+            print(f"Error: Prompts directory not found: {prompts_dir}", 
file=sys.stderr)
+            sys.exit(1)
+
+        # Read the prompt and find referenced files
+        print(f"Reading prompt: {args.prompt}", file=sys.stderr)
+        prompt_content, referenced_files = read_prompt_file(prompts_dir, 
args.prompt)
+
+        if referenced_files:
+            print(f"Found {len(referenced_files)} referenced file(s) in 
prompt:", file=sys.stderr)
+            for ref_file in referenced_files:
+                print(f"  - {ref_file}", file=sys.stderr)
+
+        print(f"Reading patch: {args.patch_file}", file=sys.stderr)
+        patch_content = read_patch_file(args.patch_file)
+
+        # Extract git context unless disabled
+        git_context = None
+        if not args.no_git_context:
+            print("Extracting git context...", file=sys.stderr)
+            git_context = extract_git_context(git_root, patch_content)
+
+        # Merge referenced files with explicitly provided context files
+        all_context_paths = list(referenced_files)
+        if args.context_files:
+            all_context_paths.extend(args.context_files)
+
+        # Read all context files
+        context_files = None
+        if all_context_paths:
+            print(f"Loading {len(all_context_paths)} total context 
file(s)...", file=sys.stderr)
+            context_files = read_context_files(all_context_paths)
+
+        # Conduct the review
+        review_result = conduct_review(
+            patch_content=patch_content,
+            prompt_content=prompt_content,
+            git_context=git_context,
+            context_files=context_files,
+            model=args.model,
+            max_tokens=args.max_tokens
+        )
+
+        # Output the result
+        if args.output:
+            with open(args.output, 'w', encoding='utf-8') as f:
+                f.write(review_result)
+            print(f"Review written to: {args.output}", file=sys.stderr)
+        else:
+            print(review_result)
+
+        print("\nReview completed successfully!", file=sys.stderr)
+
+    except FileNotFoundError as e:
+        print(f"Error: {e}", file=sys.stderr)
+        sys.exit(1)
+    except RuntimeError as e:
+        print(f"Error: {e}", file=sys.stderr)
+        sys.exit(1)
+    except KeyboardInterrupt:
+        print("\nReview interrupted by user", file=sys.stderr)
+        sys.exit(130)
+    except Exception as e:
+        print(f"Unexpected error: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/.ci/ai_review/run_code_review_session.sh 
b/.ci/ai_review/run_code_review_session.sh
new file mode 100755
index 0000000000..333b9a5510
--- /dev/null
+++ b/.ci/ai_review/run_code_review_session.sh
@@ -0,0 +1,302 @@
+#!/bin/bash
+# Script to review multiple git commits using AI
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REVIEW_SCRIPT="$SCRIPT_DIR/review.py"
+GIT_ROOT="$(git rev-parse --show-toplevel)"
+WORK_DIR="$GIT_ROOT/.ci/ai_review/reviews"
+
+# Check if API key is set
+if [ -z "$ANTHROPIC_API_KEY" ]; then
+    echo "Error: ANTHROPIC_API_KEY environment variable not set"
+    echo "Please set it with: export ANTHROPIC_API_KEY='your-api-key'"
+    exit 1
+fi
+
+# Function to extract email header
+extract_header() {
+    local file="$1"
+    local header="$2"
+    # Extract header value, handling multi-line headers
+    awk -v header="$header:" '
+        tolower($0) ~ "^" tolower(header) {
+            sub(/^[^:]+: */, "")
+            value = $0
+            # Handle continuation lines (starting with whitespace)
+            while (getline > 0 && /^[ \t]/) {
+                sub(/^[ \t]+/, " ")
+                value = value $0
+            }
+            print value
+            exit
+        }
+    ' "$file"
+}
+
+# Function to check if patch has email headers
+has_email_headers() {
+    local file="$1"
+    grep -q "^Message-ID:" "$file" || grep -q "^Message-Id:" "$file"
+}
+
+# Function to generate email headers for reply
+generate_email_headers() {
+    local to="$1"
+    local subject="$2"
+    local message_id="$3"
+    local references="$4"
+
+    # Generate To: header
+    if [ -n "$to" ]; then
+        echo "To: $to"
+    fi
+
+    # Generate Subject: header
+    if [ -n "$subject" ]; then
+        # Remove any existing Re: prefix and add our own
+        subject=$(echo "$subject" | sed 's/^[Rr][Ee]: *//')
+        echo "Subject: Re: $subject"
+    fi
+
+    # Generate In-Reply-To: header
+    if [ -n "$message_id" ]; then
+        echo "In-Reply-To: $message_id"
+    fi
+
+    # Generate References: header
+    if [ -n "$message_id" ]; then
+        # Add the original Message-ID to references
+        if [ -n "$references" ]; then
+            # Append to existing references
+            echo "References: $references $message_id"
+        else
+            echo "References: $message_id"
+        fi
+    elif [ -n "$references" ]; then
+        echo "References: $references"
+    fi
+
+    echo ""
+}
+
+usage() {
+    cat <<EOF
+Usage: $0 <commit1> <commit2> [commit3 ...]
+Usage: $0 <patch or mbox file>
+
+Review multiple git commits using AI-powered code review.
+
+Arguments:
+  commit1 commit2 ...   Git commit IDs to review (can be SHAs, branches, tags,
+                         etc.)
+  patch or mbox file... Patch file or MBOX file.
+
+Options:
+  --output-dir DIR     Directory to save reviews (default: 
.ci/ai_review/reviews)
+  --prompt PROMPT      Prompt to use (default: review-start)
+  --help               Show this help message
+
+Examples:
+  # Review last 3 commits
+  $0 HEAD~2 HEAD~1 HEAD
+
+  # Review a range of commits
+  $0 \$(git rev-list main..feature-branch)
+
+  # Review specific commits
+  $0 abc123 def456 789ghi
+
+Output:
+  Reviews will be saved in the output directory as:
+    message_0001  (review of first commit)
+    message_0002  (review of second commit)
+    ...
+EOF
+    exit 0
+}
+
+# Parse arguments
+OUTPUT_DIR=""
+PROMPT="review-start"
+COMMITS=()
+
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        --help|-h)
+            usage
+            ;;
+        --output-dir)
+            OUTPUT_DIR="$2"
+            shift 2
+            ;;
+        --prompt)
+            PROMPT="$2"
+            shift 2
+            ;;
+        *)
+            COMMITS+=("$1")
+            shift
+            ;;
+    esac
+done
+
+# Check if commits were provided
+if [ ${#COMMITS[@]} -eq 0 ]; then
+    echo "Error: No commits specified"
+    echo ""
+    usage
+fi
+
+# Set default output directory if not specified
+if [ -z "$OUTPUT_DIR" ]; then
+    OUTPUT_DIR="$WORK_DIR"
+fi
+
+# Create output directory
+mkdir -p "$OUTPUT_DIR"
+
+echo "==================================="
+echo "AI Review Tool - Batch Mode"
+echo "==================================="
+echo "Git Root: $GIT_ROOT"
+echo "Output Directory: $OUTPUT_DIR"
+echo "Prompt: $PROMPT"
+echo "Number of commits: ${#COMMITS[@]}"
+echo ""
+
+# Save current branch/HEAD for restoration
+ORIGINAL_HEAD=$(git rev-parse HEAD)
+ORIGINAL_BRANCH=$(git symbolic-ref -q HEAD || echo "")
+
+# Function to restore git state
+restore_git_state() {
+    echo ""
+    echo "Restoring git state..."
+    if [ -n "$ORIGINAL_BRANCH" ]; then
+        git checkout -q "${ORIGINAL_BRANCH#refs/heads/}"
+    else
+        git checkout -q "$ORIGINAL_HEAD"
+    fi
+}
+
+# Set trap to restore state on exit
+trap restore_git_state EXIT
+
+# Process each commit
+counter=1
+for commit in "${COMMITS[@]}"; do
+    # Format counter with leading zeros (0001, 0002, etc.)
+    counter_formatted=$(printf "%04d" $counter)
+    PATCH_FILE="$OUTPUT_DIR/patch_${counter_formatted}.patch"
+
+    if [ ! -f "$commit" ]; then
+        echo "==================================="
+        echo "Processing commit $counter_formatted: $commit"
+        echo "==================================="
+
+        # Verify commit exists
+        if ! git rev-parse --verify "$commit" >/dev/null 2>&1; then
+            echo "Error: Invalid commit: $commit"
+            echo "Skipping..."
+            echo ""
+            counter=$((counter + 1))
+            continue
+        fi
+
+        # Get full commit SHA
+        COMMIT_SHA=$(git rev-parse "$commit")
+        echo "Commit SHA: $COMMIT_SHA"
+
+        # Reset tree to COMMIT~1
+        PARENT_COMMIT="${COMMIT_SHA}~1"
+
+        # Check if parent exists (not initial commit)
+        if git rev-parse --verify "$PARENT_COMMIT" >/dev/null 2>&1; then
+            echo "Checking out parent: $PARENT_COMMIT"
+            git checkout -q "$PARENT_COMMIT"
+        else
+            echo "This is the initial commit, checking out commit itself"
+            git checkout -q "$COMMIT_SHA"
+        fi
+
+        # Generate patch file
+        echo "Generating patch: $PATCH_FILE"
+        git format-patch -1 "$COMMIT_SHA" --stdout > "$PATCH_FILE"
+    else
+        echo "==================================="
+        echo "Processing Patch $counter_formatted: $commit"
+        echo "==================================="
+
+        cp "$commit" "$PATCH_FILE"
+    fi
+
+    # Run review to temporary file first
+    TEMP_REVIEW=$(mktemp)
+    echo "Running AI review..."
+    echo "Output: $OUTPUT_FILE"
+
+    if "$REVIEW_SCRIPT" "$PATCH_FILE" --prompt "$PROMPT" --output 
"$TEMP_REVIEW"; then
+        echo "✓ Review completed successfully"
+
+        # Check if patch has email headers
+        if has_email_headers "$PATCH_FILE"; then
+            echo "  Detected email headers, formatting as reply..."
+
+            # Extract email headers
+            FROM=$(extract_header "$PATCH_FILE" "From")
+            SUBJECT=$(extract_header "$PATCH_FILE" "Subject")
+            MESSAGE_ID=$(extract_header "$PATCH_FILE" "Message-ID")
+            [ -z "$MESSAGE_ID" ] && MESSAGE_ID=$(extract_header "$PATCH_FILE" 
"Message-Id")
+            REFERENCES=$(extract_header "$PATCH_FILE" "References")
+
+            # Create output with email headers
+            OUTPUT_FILE="$OUTPUT_DIR/message_${counter_formatted}"
+            {
+                generate_email_headers "$FROM" "$SUBJECT" "$MESSAGE_ID" 
"$REFERENCES"
+                cat "$TEMP_REVIEW"
+            } > "$OUTPUT_FILE"
+
+            echo "  To: $FROM"
+            echo "  Subject: Re: $(echo "$SUBJECT" | sed 's/^[Rr][Ee]: *//')"
+        else
+            # No email headers, just copy the review
+            OUTPUT_FILE="$OUTPUT_DIR/message_${counter_formatted}"
+            cp "$TEMP_REVIEW" "$OUTPUT_FILE"
+        fi
+
+        rm -f "$TEMP_REVIEW"
+
+        # Show summary
+        REVIEW_SIZE=$(wc -l < "$OUTPUT_FILE")
+        echo "  Review size: $REVIEW_SIZE lines"
+    else
+        echo "✗ Review failed"
+        rm -f "$TEMP_REVIEW"
+        echo "  Check $OUTPUT_FILE for details"
+    fi
+
+    if [ -f "$commit" ]; then
+        echo "✓ Advancing tree by running [ git am \"$commit\" ]..."
+        git am "$commit"
+    fi
+
+    echo ""
+    counter=$((counter + 1))
+done
+
+# Restore git state (will also be called by trap)
+restore_git_state
+
+echo "==================================="
+echo "All reviews completed!"
+echo "==================================="
+echo "Output directory: $OUTPUT_DIR"
+echo "Files generated:"
+ls -1 "$OUTPUT_DIR"/message_* 2>/dev/null | while read -r file; do
+    echo "  - $(basename "$file")"
+done
+echo ""
+echo "To view a review:"
+echo "  cat $OUTPUT_DIR/message_0001"
-- 
2.51.0

_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev

Reply via email to