Skip to main content
Crashbytes logoCrashbytes
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Browse Articles
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Network
Theme
Browse Articles
Crashbytes logoCrashbytes

Expert insights on web development, technology trends, and programming best practices. Learn from real-world experiences and cutting-edge techniques that help you build better software.

Follow Us

Our Sites

  • 🔮 Predictions
  • 📰 Breaking News
  • 🎨 AI Art
  • 📖 Short Stories
  • View All →
  • Products →

Sitemap

  • Home
  • All Articles
  • Open Source
  • Services
  • About Us
  • Contact
  • Donate Compute

Popular Topics

  • Serverless
  • Cloud Architecture
  • DevOps
  • Kubernetes
  • Platform Engineering

Resources

  • Privacy Policy
  • Terms of Service
  • Sitemap
  • RSS Feed
  • PGP Key

Stay Updated

Get the latest articles, tutorials, and insights delivered to your inbox. Join our community of developers and never miss an update.

© 2021-2026 Crashbytes® by Blackhole Software, LLC. All rights reserved.
| Reg. U.S. Pat. & Tm. Off.

Made for the developer community

  1. Home
  2. /
  3. Articles
  4. /
  5. Build an AI PR Reviewer in C# — Part 3: CI/CD Pipelines, GitHub Actions, and GitLab CI
TutorialMarch 2, 202634 min read• By Michael Eakins

Build an AI PR Reviewer in C# — Part 3: CI/CD Pipelines, GitHub Actions, and GitLab CI

Learn how to deploy your AI PR reviewer as an automated CI/CD pipeline using GitHub Actions and GitLab CI. Part 3 covers platform abstraction with interfaces, the Octokit SDK for posting inline review comments, token chunking for large diffs, rate limiting, unit testing with xUnit, and complete YAML pipeline configurations.

Build an AI PR Reviewer in C# — Part 3: CI/CD Pipelines, GitHub Actions, and GitLab CI

Quick Takeaways

What you'll learn in this article

34 min read
Intermediate
  • 1

    PostReviewCommentAsync — Posts an inline comment on a specific file and line number. This is the high-value operation: when Claude identifies a bug on line 42 of UserService.cs, this method puts the comment right there in the diff view.

  • 2

    PostSummaryCommentAsync — Posts a general comment on the PR/MR. This serves as a fallback for non-JSON prompt versions and as the final summary (e.g., "Found 5 items: 2 critical, 2 warnings, 1 info").

  • 3

    GITHUBTOKEN — An automatically generated token with permissions scoped to the current workflow. We don't need to create a personal access token or a GitHub App — the workflow's permissions block controls what this token can access.

  • 4

    GITHUBREPOSITORY — The owner and repo name in owner/repo format (e.g., CrashBytes/ByteSizedExamples). We split this to get the individual parts Octokit needs.

  • 5

    GITHUBREF — For pull request events, this is refs/pull/<number>/merge. We parse out the PR number using string replacement.

Keep reading for detailed implementation, code examples, and real-world results

In Part 1, we built a C# console application that captures git diffs and parses unified diff format. In Part 2, we added AI by integrating AWS Bedrock's Converse API and evolving our prompts from basic feedback to structured JSON output with severity levels and line references.

Now it's time to make this thing automatic. In Part 3 — the final installment — we're turning our local tool into a fully automated CI/CD pipeline that runs on every pull request. When a developer opens a PR, our reviewer will analyze the diff, call Claude Haiku through AWS Bedrock, and post inline review comments directly on the PR. No manual intervention. No one needs to remember to run it.

This is where the project goes from "useful local tool" to "production infrastructure."

Part 3 Focus

CI/CD Integration

GitHub Actions + GitLab CI

↑ 3%new NuGet packages

We're covering a lot of ground in this tutorial. Platform abstraction with C# interfaces. The Octokit SDK for GitHub's API. Raw HTTP calls to GitLab's REST API. Token management for handling massive diffs that exceed model context windows. Rate limiting to stay within API quotas. Unit testing with xUnit. And complete YAML pipeline configurations for both GitHub Actions and GitLab CI.

If you've been following the series from Part 1, you already have the foundation. If you're jumping in fresh, you can clone the advanced branch and follow along — every file is there.

Part 1 — main branch

Git Diff Parser

Console app that captures git diffs, parses unified diff format, and displays structured summaries. Pure .NET 8 with zero dependencies.

Part 2 — intermediate branch

AWS Bedrock Integration

Send parsed diffs to Claude via Bedrock Converse API. Iterative prompt engineering from basic feedback to structured JSON output.

Part 3 — advanced branch (This Tutorial)

CI/CD Pipeline Integration

Run as GitHub Actions and GitLab CI pipeline. Post inline review comments directly on PRs and merge requests. Unit tests with xUnit.

The companion code lives in the ByteSizedExamples repository on GitHub. Check out the advanced branch to see everything we build in this tutorial.

What We're Adding in Part 3

Let's look at the new files we're introducing on top of the intermediate branch:

Bar chart data
categoryfiles
Platform Abstraction3
Services2
Tests3
CI/CD Configs2
Updated Files3
ai-pr-reviewer-csharp/
├── Platforms/
│   ├── IPlatform.cs           # Interface for PR commenting
│   ├── GitHubPlatform.cs      # GitHub API via Octokit
│   └── GitLabPlatform.cs      # GitLab API via HttpClient
├── Services/
│   ├── TokenManager.cs        # Token estimation and diff chunking
│   └── RateLimiter.cs         # API rate limiting with SemaphoreSlim
├── Tests/
│   ├── Tests.csproj           # xUnit test project
│   ├── DiffParserTests.cs     # Unit tests for diff parsing
│   └── TokenManagerTests.cs   # Unit tests for token management
├── .github/
│   └── workflows/
│       └── ai-pr-review.yml   # GitHub Actions workflow
├── .gitlab-ci.yml             # GitLab CI pipeline
├── AiPrReviewer.csproj        # Updated with Octokit + test exclusion
└── Program.cs                 # Updated orchestration with platform routing

That's 13 files — 10 new, 3 modified. Let's build them one at a time.

Prerequisites: Switching to the Advanced Branch

If you've been following along from Part 2, create the advanced branch from your current code:

git checkout -b advanced

If you're starting fresh, clone the repo and check out the branch:

git clone https://github.com/CrashBytes/ByteSizedExamples.git
cd ByteSizedExamples/ai-pr-reviewer-csharp
git checkout advanced

You'll need everything from Parts 1 and 2 already in place: the .csproj with AWS SDK packages, DiffParser.cs, BedrockClient.cs, the Models/ directory, and the Prompts/ directory. The advanced branch includes all of this.

Branch Progression

intermediate (Part 2)

NuGet packages2 (AWS SDK, DotNetEnv)
Source files6
CLI flags4 (repo, branch, prompt, model)
OutputConsole only
TestsNone

advanced (Part 3)

NuGet packages3 (+Octokit)
Source files13
CLI flags5 (+platform)
OutputConsole, GitHub, GitLab
Tests16 xUnit tests

Step 1: Updated Project Configuration

First, let's update the .csproj to add the Octokit package for GitHub API access and exclude the test project from the main build:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>AiPrReviewer</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <Compile Remove="Tests\**" />
    <None Remove="Tests\**" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="AWSSDK.BedrockRuntime" Version="3.*" />
    <PackageReference Include="DotNetEnv" Version="3.*" />
    <PackageReference Include="Octokit" Version="13.*" />
  </ItemGroup>

  <ItemGroup>
    <None Update="Prompts\*.txt">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

</Project>

Two changes from Part 2. First, the new Octokit package reference — this is GitHub's official .NET SDK for interacting with the GitHub API. We're using version 13.* which supports all the PR review comment endpoints we need.

Second, the Compile Remove="Tests\**" and None Remove="Tests\**" directives. These tell MSBuild to exclude everything in the Tests/ directory from the main project compilation. The test project has its own .csproj that references the main project — a standard .NET pattern for keeping test code separate while still being able to access internal types.

Pie chart data
NameValue
AWSSDK.BedrockRuntime40
DotNetEnv20
Octokit40

Run a quick restore to pull down Octokit:

dotnet restore

Step 2: The Platform Abstraction — IPlatform Interface

Here's where C# really shines for this kind of project. We need to post review comments to different platforms — GitHub and GitLab today, potentially Bitbucket, Azure DevOps, or Gitea tomorrow. Rather than scattering platform-specific code throughout our application, we define a clean interface that every platform must implement.

This is a textbook application of the strategy pattern — one of the most practical design patterns for real-world code.

Create Platforms/IPlatform.cs:

namespace AiPrReviewer.Platforms;

public interface IPlatform
{
    Task PostReviewCommentAsync(string file, int line, string comment);
    Task PostSummaryCommentAsync(string summary);
}

Seven lines. That's the entire interface. Two methods:

  • PostReviewCommentAsync — Posts an inline comment on a specific file and line number. This is the high-value operation: when Claude identifies a bug on line 42 of UserService.cs, this method puts the comment right there in the diff view.
  • PostSummaryCommentAsync — Posts a general comment on the PR/MR. This serves as a fallback for non-JSON prompt versions and as the final summary (e.g., "Found 5 items: 2 critical, 2 warnings, 1 info").

Both methods return Task because every platform's API is asynchronous — we're making HTTP calls over the network. The Async suffix follows .NET convention for async methods.

Interface Methods

2

PostReviewComment + PostSummary

↑ 0%platform coupling

Notice what's not in the interface: authentication, configuration, API clients, or anything platform-specific. Each implementation handles its own setup. The caller never needs to know whether it's talking to GitHub, GitLab, or a mock for testing. This is dependency inversion in action — our high-level orchestration code depends on the abstraction, not the concrete implementations.

Advertisement

Step 3: GitHub Integration with Octokit

Now let's implement the GitHub platform. This is where Octokit earns its keep. Create Platforms/GitHubPlatform.cs:

using Octokit;

namespace AiPrReviewer.Platforms;

public class GitHubPlatform : IPlatform
{
    private readonly GitHubClient _client;
    private readonly string _owner;
    private readonly string _repo;
    private readonly int _prNumber;
    private readonly string _commitSha;

    public GitHubPlatform()
    {
        var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN")
            ?? throw new InvalidOperationException(
                "GITHUB_TOKEN environment variable is required.");

        var repoSlug = Environment.GetEnvironmentVariable("GITHUB_REPOSITORY")
            ?? throw new InvalidOperationException(
                "GITHUB_REPOSITORY environment variable is required.");

        var prRef = Environment.GetEnvironmentVariable("GITHUB_REF") ?? "";
        _commitSha = Environment.GetEnvironmentVariable("GITHUB_SHA") ?? "";

        var parts = repoSlug.Split('/');
        _owner = parts[0];
        _repo = parts[1];

        // Extract PR number from refs/pull/<number>/merge
        if (prRef.StartsWith("refs/pull/") && prRef.EndsWith("/merge"))
        {
            var numberStr = prRef
                .Replace("refs/pull/", "")
                .Replace("/merge", "");
            _prNumber = int.Parse(numberStr);
        }

        _client = new GitHubClient(
            new ProductHeaderValue("ai-pr-reviewer"))
        {
            Credentials = new Credentials(token)
        };
    }

    public async Task PostReviewCommentAsync(
        string file, int line, string comment)
    {
        try
        {
            var reviewComment = new PullRequestReviewCommentCreate(
                comment, _commitSha, file, line);
            await _client.PullRequest.ReviewComment
                .Create(_owner, _repo, _prNumber, reviewComment);
        }
        catch (Exception ex)
        {
            Console.ForegroundColor = ConsoleColor.Yellow;
            Console.WriteLine(
                $"Warning: Could not post inline comment on "
                + $"{file}:{line} — {ex.Message}");
            Console.ResetColor();
        }
    }

    public async Task PostSummaryCommentAsync(string summary)
    {
        await _client.Issue.Comment
            .Create(_owner, _repo, _prNumber, summary);
    }
}

Let's break this down in detail.

Constructor: Reading GitHub Actions Environment

When your code runs inside a GitHub Actions workflow, GitHub automatically injects several environment variables. We read four of them:

Bar chart data
variablepurpose
GITHUB_TOKEN100
GITHUB_REPOSITORY90
GITHUB_REF85
GITHUB_SHA80
  • GITHUB_TOKEN — An automatically generated token with permissions scoped to the current workflow. We don't need to create a personal access token or a GitHub App — the workflow's permissions block controls what this token can access.
  • GITHUB_REPOSITORY — The owner and repo name in owner/repo format (e.g., CrashBytes/ByteSizedExamples). We split this to get the individual parts Octokit needs.
  • GITHUB_REF — For pull request events, this is refs/pull/<number>/merge. We parse out the PR number using string replacement.
  • GITHUB_SHA — The merge commit SHA. Octokit needs this to attach inline comments to the correct commit in the PR.

Inline Comments vs Summary Comments

The PostReviewCommentAsync method uses Octokit's PullRequest.ReviewComment.Create endpoint. This posts a comment anchored to a specific file and line in the PR's diff view — the same kind of comment a human reviewer would leave when clicking the "+" button next to a line of code. When Claude's V3 prompt identifies a bug on line 42, the comment appears right there.

Notice the try/catch block. Inline comments can fail for several reasons: the file might have been renamed, the line number might not exist in the diff's hunk range, or the commit SHA might be stale. Rather than crashing the entire review, we log a warning and continue. One failed inline comment shouldn't prevent the other 15 from posting.

The PostSummaryCommentAsync method uses Issue.Comment.Create — the same endpoint used for regular PR comments. This posts a top-level comment visible in the PR's conversation tab. We use it for the final summary ("Found 5 items: 2 critical, 2 warnings, 1 info") and as a fallback when inline comments aren't possible.

Comment Types in GitHub PRs

Inline Review Comments

EndpointPullRequest.ReviewComment
LocationAnchored to file + line
VisibilityDiff view (Files changed tab)
RequiresCommit SHA, file path, line number
Best forSpecific code issues

Summary Comments

EndpointIssue.Comment
LocationPR conversation thread
VisibilityConversation tab
RequiresJust the comment body
Best forOverall summaries, statistics

Why Octokit Instead of Raw HTTP?

You could absolutely use HttpClient to call GitHub's REST API directly — and we'll do exactly that for GitLab in the next section. But Octokit gives us several advantages for GitHub specifically:

  1. Typed models — PullRequestReviewCommentCreate enforces the correct shape at compile time. No guessing which JSON fields are required.
  2. Authentication handling — Setting Credentials once covers token rotation, header formatting, and retry logic.
  3. API versioning — Octokit tracks GitHub's API versions so you don't have to worry about deprecation headers.
  4. Pagination — If we ever need to list existing comments before posting, Octokit handles cursor-based pagination automatically.

For GitHub, the SDK is mature and well-maintained. For GitLab, the official .NET SDK is less complete, so raw HTTP is the pragmatic choice.

Step 4: GitLab Integration with HttpClient

Create Platforms/GitLabPlatform.cs:

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

namespace AiPrReviewer.Platforms;

public class GitLabPlatform : IPlatform
{
    private readonly HttpClient _http;
    private readonly string _projectId;
    private readonly string _mrIid;
    private readonly string _commitSha;

    public GitLabPlatform()
    {
        var token = Environment.GetEnvironmentVariable("GITLAB_TOKEN")
            ?? throw new InvalidOperationException(
                "GITLAB_TOKEN environment variable is required.");

        _projectId = Environment.GetEnvironmentVariable("CI_PROJECT_ID")
            ?? throw new InvalidOperationException(
                "CI_PROJECT_ID environment variable is required.");

        _mrIid = Environment.GetEnvironmentVariable("CI_MERGE_REQUEST_IID")
            ?? throw new InvalidOperationException(
                "CI_MERGE_REQUEST_IID environment variable is required.");

        _commitSha = Environment.GetEnvironmentVariable("CI_COMMIT_SHA")
            ?? "";

        var baseUrl = Environment.GetEnvironmentVariable("CI_SERVER_URL")
            ?? "https://gitlab.com";

        _http = new HttpClient
        {
            BaseAddress = new Uri($"{baseUrl}/api/v4/")
        };
        _http.DefaultRequestHeaders.Add("PRIVATE-TOKEN", token);
        _http.DefaultRequestHeaders.Accept.Add(
            new MediaTypeWithQualityHeaderValue("application/json"));
    }

    public async Task PostReviewCommentAsync(
        string file, int line, string comment)
    {
        try
        {
            var payload = new
            {
                body = comment,
                position = new
                {
                    base_sha = _commitSha,
                    start_sha = _commitSha,
                    head_sha = _commitSha,
                    position_type = "text",
                    new_path = file,
                    new_line = line
                }
            };

            var json = JsonSerializer.Serialize(payload);
            var content = new StringContent(
                json, Encoding.UTF8, "application/json");

            var response = await _http.PostAsync(
                $"projects/{_projectId}/merge_requests/{_mrIid}/discussions",
                content);

            response.EnsureSuccessStatusCode();
        }
        catch (Exception ex)
        {
            Console.ForegroundColor = ConsoleColor.Yellow;
            Console.WriteLine(
                $"Warning: Could not post inline comment on "
                + $"{file}:{line} — {ex.Message}");
            Console.ResetColor();
        }
    }

    public async Task PostSummaryCommentAsync(string summary)
    {
        var payload = new { body = summary };
        var json = JsonSerializer.Serialize(payload);
        var content = new StringContent(
            json, Encoding.UTF8, "application/json");

        var response = await _http.PostAsync(
            $"projects/{_projectId}/merge_requests/{_mrIid}/notes",
            content);

        response.EnsureSuccessStatusCode();
    }
}

GitLab's CI Environment Variables

GitLab CI injects its own set of environment variables, different from GitHub's:

Bar chart data
variablepurpose
GITLAB_TOKEN100
CI_PROJECT_ID95
CI_MERGE_REQUEST_IID90
CI_COMMIT_SHA85
CI_SERVER_URL70
  • GITLAB_TOKEN — Unlike GitHub, GitLab doesn't auto-generate a scoped token for CI jobs. You need to create a project or personal access token with api scope and store it as a CI/CD variable.
  • CI_PROJECT_ID — The numeric project ID (not the namespace/name slug). GitLab's API uses this for all project-scoped endpoints.
  • CI_MERGE_REQUEST_IID — The merge request's internal ID within the project. Note: IID (internal ID), not ID (global ID). This is a common gotcha.
  • CI_COMMIT_SHA — The current commit SHA. Used to anchor inline discussion comments to the correct position.
  • CI_SERVER_URL — Defaults to https://gitlab.com but supports self-hosted instances. Your company's GitLab might be at https://gitlab.internal.company.com.

Inline Comments as Discussions

GitLab's API for inline comments is more complex than GitHub's. Instead of a dedicated review comment endpoint, GitLab uses discussions — threaded conversations that can be anchored to specific diff positions. The position object tells GitLab exactly where to place the comment:

{
  "body": "Bug found here",
  "position": {
    "base_sha": "abc123",
    "start_sha": "abc123",
    "head_sha": "abc123",
    "position_type": "text",
    "new_path": "src/UserService.cs",
    "new_line": 42
  }
}

The three SHA fields (base_sha, start_sha, head_sha) define the diff range that the comment applies to. For simplicity, we're using the same commit SHA for all three — this works for single-commit MRs. For multi-commit MRs with complex rebase history, you'd need to calculate the actual base and start SHAs from the merge request metadata.

Summary comments use the simpler /notes endpoint, which is GitLab's equivalent of GitHub's issue comments.

GitLab API Endpoints

2

discussions (inline) + notes (summary)

↑ 0%external SDK dependencies

Step 5: Token Management — Handling Large Diffs

Real-world PRs aren't always neat 50-line changes. Sometimes a developer refactors an entire module, renames files across 30 directories, or adds a massive migration. These diffs can easily exceed Claude Haiku's context window.

Our TokenManager handles this by estimating token counts and splitting large diffs into manageable chunks. Create Services/TokenManager.cs:

namespace AiPrReviewer.Services;

public class TokenManager
{
    // Approximate tokens per character for code (conservative estimate)
    private const double TokensPerChar = 0.3;

    public int MaxTokens { get; }

    public TokenManager(int maxTokens = 100000)
    {
        MaxTokens = maxTokens;
    }

    public int EstimateTokens(string text)
    {
        if (string.IsNullOrEmpty(text))
            return 0;

        return (int)Math.Ceiling(text.Length * TokensPerChar);
    }

    public bool ExceedsLimit(string text)
    {
        return EstimateTokens(text) > MaxTokens;
    }

    public List<string> ChunkDiff(string diff, int maxChunkTokens = 40000)
    {
        var chunks = new List<string>();
        var files = diff.Split(
            "diff --git", StringSplitOptions.RemoveEmptyEntries);

        var currentChunk = "";
        foreach (var file in files)
        {
            var fileContent = "diff --git" + file;
            var combinedTokens = EstimateTokens(
                currentChunk + fileContent);

            if (combinedTokens > maxChunkTokens
                && currentChunk.Length > 0)
            {
                chunks.Add(currentChunk);
                currentChunk = fileContent;
            }
            else
            {
                currentChunk += fileContent;
            }
        }

        if (currentChunk.Length > 0)
            chunks.Add(currentChunk);

        return chunks;
    }
}

The Token Estimation Strategy

We're using a simple heuristic: approximately 0.3 tokens per character for code. This is deliberately conservative. The actual ratio varies by language and content — English prose averages around 0.25 tokens per character, while code with lots of symbols and short identifiers runs closer to 0.35. By rounding up with Math.Ceiling, we ensure we never accidentally exceed the model's context window.

Pie chart data
NameValue
English prose25
Code (average)30
Code (symbol-heavy)35
Safety margin10

Is this perfect? No. A proper tokenizer (like the one in the tiktoken Python library) would give exact counts. But for our purposes — deciding whether to split a diff into chunks — the estimate is more than good enough. The cost of overestimating is sending slightly smaller chunks; the cost of underestimating is a failed API call. We'll take the conservative route.

The Chunking Algorithm

The ChunkDiff method splits diffs at file boundaries. Here's the key insight: we split on "diff --git" markers, which means each chunk contains one or more complete file diffs. We never split mid-file. This matters because Claude needs the full context of a file's changes to give useful feedback — a partial diff would lead to confused or incorrect review comments.

The default maxChunkTokens is 40,000 — well under Claude Haiku's 200K context window. This leaves room for the system prompt, the user message framing, and the model's output tokens. Being generous with headroom prevents edge cases where a slightly-over-limit request gets rejected.

Area chart data
chunkSizeresponseQualityspeed
10K9598
20K9695
40K9488
80K9072
150K8250

In practice, most PRs fit in a single chunk. The chunking logic exists for the 10-15% of PRs that don't — and those are often the PRs that need the most thorough review.

Step 6: Rate Limiting with SemaphoreSlim

When you're chunking large diffs into multiple API calls, you need to be respectful of API rate limits. AWS Bedrock has per-model throttling, and hammering the API with 10 concurrent requests will get you throttled quickly.

Create Services/RateLimiter.cs:

namespace AiPrReviewer.Services;

public class RateLimiter
{
    private readonly int _maxRequestsPerMinute;
    private readonly Queue<DateTime> _requestTimes = new();
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    public RateLimiter(int maxRequestsPerMinute = 10)
    {
        _maxRequestsPerMinute = maxRequestsPerMinute;
    }

    public async Task WaitAsync()
    {
        await _semaphore.WaitAsync();
        try
        {
            var now = DateTime.UtcNow;
            var windowStart = now.AddMinutes(-1);

            // Remove expired entries
            while (_requestTimes.Count > 0
                && _requestTimes.Peek() < windowStart)
                _requestTimes.Dequeue();

            // Wait if at limit
            if (_requestTimes.Count >= _maxRequestsPerMinute)
            {
                var oldest = _requestTimes.Peek();
                var waitTime = oldest.AddMinutes(1) - now;
                if (waitTime > TimeSpan.Zero)
                {
                    Console.WriteLine(
                        $"Rate limit reached. "
                        + $"Waiting {waitTime.TotalSeconds:F1}s...");
                    await Task.Delay(waitTime);
                }
            }

            _requestTimes.Enqueue(DateTime.UtcNow);
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

How the Sliding Window Works

This is a classic sliding window rate limiter. The Queue<DateTime> stores timestamps of recent requests. Before each new request, we:

  1. Prune expired entries — Remove timestamps older than 1 minute from the front of the queue. Since requests are enqueued chronologically, the oldest is always at the front.
  2. Check the count — If we've made maxRequestsPerMinute requests within the last minute, calculate how long until the oldest request expires and await Task.Delay for that duration.
  3. Record the request — Enqueue the current timestamp.

The SemaphoreSlim ensures thread safety. Even though our current code processes chunks sequentially, the rate limiter is safe for concurrent use — if you later switch to Parallel.ForEachAsync for chunk processing, the limiter still works correctly.

Default Rate Limit

10 req/min

Configurable via constructor

↑ 6%seconds avg wait per request

The default of 10 requests per minute is conservative for AWS Bedrock. Most accounts can handle significantly more. But in a CI/CD context, you might have dozens of PRs triggering reviews simultaneously across different repos — staying under the limit per-instance ensures the aggregate load stays manageable.

Step 7: Updated Program.cs — The Orchestrator

Now let's wire everything together. The updated Program.cs adds the --platform flag, integrates TokenManager and RateLimiter, and routes V3 JSON output to inline comments:

using System.Text.Json;
using DotNetEnv;
using AiPrReviewer.Platforms;
using AiPrReviewer.Services;

namespace AiPrReviewer;

class Program
{
    static async Task<int> Main(string[] args)
    {
        var repoPath = GetArgValue(args, "--repo")
            ?? Directory.GetCurrentDirectory();
        var branch = GetArgValue(args, "--branch") ?? "main";
        var promptVersion = GetArgValue(args, "--prompt") ?? "v3";
        var modelId = GetArgValue(args, "--model");
        var platform = GetArgValue(args, "--platform");

        if (args.Contains("--help") || args.Contains("-h"))
        {
            PrintUsage();
            return 0;
        }

        // Load .env file if present
        var envPath = Path.Combine(
            Directory.GetCurrentDirectory(), ".env");
        if (File.Exists(envPath))
            Env.Load(envPath);

        Console.WriteLine("========================================");
        Console.WriteLine("  AI PR Reviewer — CrashBytes");
        Console.WriteLine("  Part 3: CI/CD Integration");
        Console.WriteLine("========================================");
        Console.WriteLine();

        // ... validation code from Part 2 ...

The key new section is the chunking and platform routing loop:

        // Chunk large diffs
        var tokenManager = new TokenManager();
        var rateLimiter = new RateLimiter();
        var chunks = tokenManager.ChunkDiff(parsed.RawDiff);

        if (chunks.Count > 1)
            Console.WriteLine(
                $"Large diff detected — split into "
                + $"{chunks.Count} chunks.");

        Console.WriteLine("Sending diff to AWS Bedrock...");

        var bedrock = new BedrockClient();
        var allResponses = new List<string>();

        for (int i = 0; i < chunks.Count; i++)
        {
            if (chunks.Count > 1)
                Console.WriteLine(
                    $"Processing chunk {i + 1}/{chunks.Count}...");

            await rateLimiter.WaitAsync();
            var review = await bedrock.ReviewDiffAsync(
                chunks[i], promptVersion, modelId);

            Console.WriteLine(
                $"  Input tokens:  {review.InputTokens:N0}");
            Console.WriteLine(
                $"  Output tokens: {review.OutputTokens:N0}");
            allResponses.Add(review.RawResponse);
        }

        var fullReview = string.Join("\n\n", allResponses);

For each chunk, we call rateLimiter.WaitAsync() before sending the request, then aggregate all responses into a single review string.

Platform Routing with Pattern Matching

The PostToPlatform method uses C#'s switch expression to resolve the correct platform implementation:

    static async Task PostToPlatform(
        string platformName, string review, string promptVersion)
    {
        IPlatform target = platformName.ToLower() switch
        {
            "github" => new GitHubPlatform(),
            "gitlab" => new GitLabPlatform(),
            _ => throw new ArgumentException(
                $"Unknown platform: {platformName}. "
                + "Use 'github' or 'gitlab'.")
        };

        // For V3 (JSON), try to post inline comments
        if (promptVersion == "v3")
        {
            try
            {
                var items = JsonSerializer
                    .Deserialize<List<ReviewItem>>(review);
                if (items != null && items.Count > 0)
                {
                    foreach (var item in items)
                    {
                        if (item.Line > 0
                            && !string.IsNullOrEmpty(item.File))
                        {
                            var comment =
                                $"**[{item.Severity?.ToUpper()}]** "
                                + $"{item.Category}\n\n{item.Message}";
                            if (!string.IsNullOrEmpty(item.Suggestion))
                                comment += $"\n\n**Suggestion:** "
                                    + item.Suggestion;

                            await target.PostReviewCommentAsync(
                                item.File, item.Line, comment);
                        }
                    }

                    // Post summary with counts
                    var criticalCount = items
                        .Count(i => i.Severity == "critical");
                    var warningCount = items
                        .Count(i => i.Severity == "warning");
                    var infoCount = items
                        .Count(i => i.Severity == "info");

                    var summary =
                        "## AI PR Review — CrashBytes\n\n"
                        + $"Found **{items.Count}** items: "
                        + $"🔴 {criticalCount} critical, "
                        + $"⚠️ {warningCount} warnings, "
                        + $"ℹ️ {infoCount} info\n\n"
                        + "*Powered by AWS Bedrock + Claude*";

                    await target.PostSummaryCommentAsync(summary);
                    return;
                }
            }
            catch (JsonException)
            {
                // Fall through to summary comment
            }
        }

        // Fallback: post as summary comment
        var fallbackSummary =
            $"## AI PR Review — CrashBytes\n\n{review}\n\n"
            + "*Powered by AWS Bedrock + Claude*";
        await target.PostSummaryCommentAsync(fallbackSummary);
    }
Parse V3 JSON response100.0%
Post inline comments per item100.0%
Calculate severity counts100.0%
Post summary comment100.0%
Fallback for non-JSON100.0%

This is where all three parts of the series converge. The V3 prompt from Part 2 produces JSON with file, line, severity, category, message, and suggestion fields. Program.cs deserializes that JSON, iterates through each item, and calls PostReviewCommentAsync for items with valid file and line references. Then it posts a summary with severity counts.

If the JSON deserialization fails — maybe the model returned malformed JSON, or we used the V1/V2 prompt — we gracefully fall through to posting the entire review as a summary comment. No crash, no lost feedback.

The file-scoped ReviewItem Class

At the bottom of Program.cs, you'll find a file class:

file class ReviewItem
{
    public string? File { get; set; }
    public int Line { get; set; }
    public string? Severity { get; set; }
    public string? Category { get; set; }
    public string? Message { get; set; }
    public string? Suggestion { get; set; }
}

The file keyword is a C# 11 feature that restricts the class's visibility to the current source file. ReviewItem is an internal deserialization model — it doesn't belong in a Models/ directory or a public API. File-scoped types keep implementation details private without cluttering your namespace.

Step 8: Unit Testing with xUnit

Production code needs tests. We're using xUnit — the most popular testing framework for .NET, and the framework Microsoft uses internally for testing .NET itself. As AI transforms software testing, having a solid test foundation becomes even more critical — you need tests to validate that AI-generated suggestions don't introduce regressions.

Test Project Setup

Create Tests/Tests.csproj:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>AiPrReviewer.Tests</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
    <PackageReference Include="xunit" Version="2.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\AiPrReviewer.csproj" />
  </ItemGroup>

</Project>

Test Project vs Main Project

Tests.csproj

IsTestProjecttrue
IsPackablefalse
ReferencesMain project + xUnit
OutputTest assembly (not executable)
Runs viadotnet test

AiPrReviewer.csproj

OutputTypeExe
IsPackabledefault (true)
ReferencesNuGet packages only
OutputConsole executable
Runs viadotnet run

Three xUnit packages:

  • Microsoft.NET.Test.Sdk — The test runner infrastructure that dotnet test uses to discover and execute tests.
  • xunit — The core framework providing [Fact], [Theory], and the Assert API.
  • xunit.runner.visualstudio — Adapts xUnit's test discovery to VS Code's Test Explorer and the dotnet test command.

The <ProjectReference> element links the test project back to the main project, giving tests access to DiffParser, TokenManager, and all other public types.

DiffParser Tests

Create Tests/DiffParserTests.cs:

using Xunit;

namespace AiPrReviewer.Tests;

public class DiffParserTests
{
    private const string SampleDiff = @"diff --git a/src/Program.cs b/src/Program.cs
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -1,5 +1,7 @@
 using System;
+using System.Collections.Generic;

 namespace MyApp
 {
+    // Added a new class
     class Program
@@ -10,3 +12,4 @@
         Console.WriteLine(""Hello"");
+        Console.WriteLine(""World"");
     }
 }
diff --git a/src/NewFile.cs b/src/NewFile.cs
--- /dev/null
+++ b/src/NewFile.cs
@@ -0,0 +1,5 @@
+namespace MyApp;
+
+public class NewFile
+{
+}
diff --git a/src/Deleted.cs b/src/Deleted.cs
--- a/src/Deleted.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace MyApp;
-
-public class Deleted { }";

    [Fact]
    public void Parse_ExtractsCorrectFileCount()
    {
        var result = DiffParser.Parse(SampleDiff);
        Assert.Equal(3, result.Files.Count);
    }

    [Fact]
    public void Parse_IdentifiesNewFile()
    {
        var result = DiffParser.Parse(SampleDiff);
        var newFile = result.Files
            .First(f => f.NewPath == "src/NewFile.cs");
        Assert.True(newFile.IsNew);
        Assert.False(newFile.IsDeleted);
    }

    [Fact]
    public void Parse_IdentifiesDeletedFile()
    {
        var result = DiffParser.Parse(SampleDiff);
        var deleted = result.Files
            .First(f => f.OldPath == "src/Deleted.cs");
        Assert.True(deleted.IsDeleted);
        Assert.False(deleted.IsNew);
    }

    [Fact]
    public void Parse_CountsAdditionsAndDeletions()
    {
        var result = DiffParser.Parse(SampleDiff);
        var modified = result.Files
            .First(f => f.NewPath == "src/Program.cs");
        Assert.Equal(3, modified.Additions);
        Assert.Equal(0, modified.Deletions);
    }

    [Fact]
    public void Parse_CountsHunks()
    {
        var result = DiffParser.Parse(SampleDiff);
        var modified = result.Files
            .First(f => f.NewPath == "src/Program.cs");
        Assert.Equal(2, modified.HunkCount);
    }

    [Fact]
    public void Parse_CalculatesTotals()
    {
        var result = DiffParser.Parse(SampleDiff);
        Assert.Equal(8, result.TotalAdditions);
        Assert.Equal(3, result.TotalDeletions);
        Assert.Equal(1, result.NewFiles);
        Assert.Equal(1, result.DeletedFiles);
        Assert.Equal(1, result.ModifiedFiles);
    }

    [Fact]
    public void Parse_EmptyDiff_ReturnsEmptyResult()
    {
        var result = DiffParser.Parse("");
        Assert.Empty(result.Files);
        Assert.Equal(0, result.TotalAdditions);
    }

    [Fact]
    public void TruncateDiff_ShortDiff_ReturnsUnchanged()
    {
        var shortDiff = "some short diff";
        Assert.Equal(shortDiff, DiffParser.TruncateDiff(shortDiff));
    }

    [Fact]
    public void TruncateDiff_LongDiff_Truncates()
    {
        var longDiff = new string('x', 60000) + "\nmore content";
        var truncated = DiffParser.TruncateDiff(longDiff, 50000);
        Assert.True(truncated.Length <= 50100);
        Assert.Contains(
            "[... diff truncated due to size ...]", truncated);
    }
}

These nine tests cover the core diff parsing logic we built in Part 1:

Bar chart data
testcategory
File count100
New file detection100
Deleted file detection100
Addition counting100
Deletion counting100
Hunk counting100
Total calculations100
Empty diff handling100
Truncation behavior100

The SampleDiff constant is a realistic multi-file diff containing a modified file (with two hunks), a new file, and a deleted file. This single fixture exercises all the parsing paths we care about.

Notice the test naming convention: MethodName_Scenario_ExpectedResult. This makes test output immediately readable when a test fails — you see Parse_IdentifiesDeletedFile in the failure report, not Test7.

TokenManager Tests

Create Tests/TokenManagerTests.cs:

using Xunit;
using AiPrReviewer.Services;

namespace AiPrReviewer.Tests;

public class TokenManagerTests
{
    [Fact]
    public void EstimateTokens_EmptyString_ReturnsZero()
    {
        var tm = new TokenManager();
        Assert.Equal(0, tm.EstimateTokens(""));
    }

    [Fact]
    public void EstimateTokens_NullString_ReturnsZero()
    {
        var tm = new TokenManager();
        Assert.Equal(0, tm.EstimateTokens(null!));
    }

    [Fact]
    public void EstimateTokens_ReturnsPositiveValue()
    {
        var tm = new TokenManager();
        var tokens = tm.EstimateTokens(
            "Hello, world! This is a test string.");
        Assert.True(tokens > 0);
    }

    [Fact]
    public void ExceedsLimit_ShortText_ReturnsFalse()
    {
        var tm = new TokenManager(100000);
        Assert.False(tm.ExceedsLimit("short text"));
    }

    [Fact]
    public void ExceedsLimit_VeryLongText_ReturnsTrue()
    {
        var tm = new TokenManager(10);
        var longText = new string('a', 1000);
        Assert.True(tm.ExceedsLimit(longText));
    }

    [Fact]
    public void ChunkDiff_SmallDiff_ReturnsSingleChunk()
    {
        var tm = new TokenManager();
        var diff = "diff --git a/file.cs b/file.cs\n+hello\n";
        var chunks = tm.ChunkDiff(diff);
        Assert.Single(chunks);
    }

    [Fact]
    public void ChunkDiff_LargeDiff_ReturnsMultipleChunks()
    {
        var tm = new TokenManager();
        var fileDiff = "diff --git a/file{0}.cs b/file{0}.cs\n"
            + new string('+', 50000) + "\n";

        var largeDiff = "";
        for (int i = 0; i < 5; i++)
            largeDiff += string.Format(fileDiff, i);

        var chunks = tm.ChunkDiff(largeDiff, 40000);
        Assert.True(chunks.Count > 1);
    }

    [Fact]
    public void ChunkDiff_EmptyDiff_ReturnsSingleEmptyChunk()
    {
        var tm = new TokenManager();
        var chunks = tm.ChunkDiff("");
        Assert.Empty(chunks);
    }
}

Run the tests:

cd Tests
dotnet test

You should see all 16 tests passing:

Passed!  - Failed:     0, Passed:    16, Skipped:     0, Total:    16

Test Coverage

16 Tests

9 DiffParser + 7 TokenManager

↑ 100%% pass rate
Advertisement

Step 9: GitHub Actions Workflow

This is the payoff. Create .github/workflows/ai-pr-review.yml:

name: AI PR Review

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read
  pull-requests: write

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup .NET 8
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Restore dependencies
        working-directory: ai-pr-reviewer-csharp
        run: dotnet restore

      - name: Run AI PR Review
        working-directory: ai-pr-reviewer-csharp
        run: >-
          dotnet run -- --branch ${{ github.event.pull_request.base.ref }}
          --platform github
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: us-east-1
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

If you've worked with GitHub Actions multi-cloud pipelines, this structure will look familiar. Let's walk through every decision.

Trigger Configuration

on:
  pull_request:
    types: [opened, synchronize, reopened]

Three event types:

  • opened — A new PR is created. Run the initial review.
  • synchronize — New commits are pushed to an existing PR. Re-review the updated diff.
  • reopened — A previously closed PR is reopened. Review again in case the base branch has changed.

We're not triggering on edited (title/description changes) or labeled (label changes) — those don't affect the code diff and would waste API calls.

Permissions Block

permissions:
  contents: read
  pull-requests: write

This is critical for security. We're using the principle of least privilege:

GITHUB_TOKEN Permissions

What We Need

contents: readRead repo code + diff
pull-requests: writePost PR comments

What We Do NOT Need

contents: writeNever push code
issues: writeNot creating issues
actions: writeNot modifying workflows
packages: writeNot publishing packages

If your repository has the default GITHUB_TOKEN permissions set to "Read repository contents and packages permissions" (the restrictive default GitHub now recommends), you must include this permissions block. Without pull-requests: write, the Octokit calls to post comments will fail with a 403 error.

Checkout with Full History

- uses: actions/checkout@v4
  with:
    fetch-depth: 0

The fetch-depth: 0 flag is essential. By default, actions/checkout performs a shallow clone (depth 1) for speed. But our tool runs git diff <branch>...HEAD, which requires the full commit history to compute the merge base between the feature branch and the target branch. A shallow clone would cause the git diff command to fail.

AWS Secrets

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  AWS_REGION: us-east-1

These come from your repository's Settings > Secrets and variables > Actions page. The AWS credentials need IAM permissions for bedrock:InvokeModel on the Claude Haiku model in the specified region.

Add AWS_ACCESS_KEY_ID to repo secrets100.0%
Add AWS_SECRET_ACCESS_KEY to repo secrets100.0%
Enable Claude Haiku in Bedrock console100.0%
Set permissions block in workflow100.0%
Push workflow to repo100.0%

Step 10: GitLab CI Pipeline

Create .gitlab-ci.yml in the project root:

ai-pr-review:
  image: mcr.microsoft.com/dotnet/sdk:8.0
  stage: test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  script:
    - cd ai-pr-reviewer-csharp
    - dotnet restore
    - dotnet run -- --branch $CI_MERGE_REQUEST_TARGET_BRANCH_NAME --platform
      gitlab
  variables:
    AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
    AWS_REGION: us-east-1
    GITLAB_TOKEN: $GITLAB_TOKEN

Key Differences from GitHub Actions

GitHub Actions vs GitLab CI

GitHub Actions

Config location.github/workflows/*.yml
Runnerruns-on: ubuntu-latest
Triggeron: pull_request
Base branchgithub.event.pull_request.base.ref
Auth tokenAuto-generated GITHUB_TOKEN
Secrets${{ secrets.NAME }}

GitLab CI

Config location.gitlab-ci.yml (root)
Runnerimage: dotnet/sdk:8.0
Triggerrules: merge_request_event
Base branchCI_MERGE_REQUEST_TARGET_BRANCH_NAME
Auth tokenManual GITLAB_TOKEN variable
Secrets$VARIABLE_NAME

GitLab CI uses a Docker image directly (mcr.microsoft.com/dotnet/sdk:8.0) instead of a setup step. The rules block with $CI_PIPELINE_SOURCE == "merge_request_event" is GitLab's equivalent of GitHub's on: pull_request — it ensures the job only runs on merge request pipelines, not on push pipelines or scheduled pipelines.

The variables section maps CI/CD variables (configured in Settings > CI/CD > Variables) to environment variables available in the job. Unlike GitHub Actions where GITHUB_TOKEN is automatically available, you need to manually create a GITLAB_TOKEN with api scope.

Setting Up GitLab CI Variables

  1. Navigate to Settings > CI/CD > Variables
  2. Add AWS_ACCESS_KEY_ID (masked, not protected unless you only run on protected branches)
  3. Add AWS_SECRET_ACCESS_KEY (masked)
  4. Add GITLAB_TOKEN — create a project access token with api scope at Settings > Access Tokens

Mark all three as "masked" so they don't appear in job logs.

Running the Complete Pipeline

Let's trace the entire flow from PR creation to review comments. Understanding the end-to-end pipeline helps you debug issues when something goes wrong.

Step 1

Developer Opens PR

A developer pushes a branch and opens a pull request against main. GitHub/GitLab fires a webhook event.

Step 2

CI Pipeline Triggers

GitHub Actions or GitLab CI picks up the event, spins up a runner, and checks out the repository with full history.

Step 3

dotnet run Executes

Our tool runs git diff against the base branch, parses the output, and estimates token count. Large diffs get chunked.

Step 4

AWS Bedrock Review

Each chunk is sent to Claude Haiku through the Converse API with the V3 production prompt. Rate limiter prevents throttling.

Step 5

Comments Posted

V3 JSON response is deserialized. Inline comments go on specific files and lines. Summary comment shows severity counts.

Testing Locally Before CI

You can test the platform routing locally without actually posting to GitHub or GitLab. Set the required environment variables to dummy values:

# Test GitHub platform initialization
export GITHUB_TOKEN=test
export GITHUB_REPOSITORY=owner/repo
export GITHUB_REF=refs/pull/1/merge
export GITHUB_SHA=abc123

dotnet run -- --branch main --platform github

This will fail at the API call stage (the token is invalid), but it verifies that the platform initialization, diff parsing, chunking, and Bedrock integration all work correctly. The actual API call failure will show as a descriptive error message.

For a full local test without the platform flag:

dotnet run -- --repo /path/to/any/git/repo --branch main --prompt v3

This runs the complete pipeline and outputs the review to the console — the same output that would be posted as comments in CI.

Cost Analysis: What Does This Actually Cost?

Running AI-powered code review on every PR has a cost. Let's break it down.

Bar chart data
componentmonthlyCost
GitHub Actions Runner0
AWS Bedrock (Haiku)8
GitLab CI Runner0
Total Infrastructure8

GitHub Actions: Free for public repos, 2,000 minutes/month free for private repos. Each PR review takes 30-60 seconds of runner time. At 200 PRs/month, that's about 100-200 minutes — well within the free tier.

GitLab CI: 400 compute minutes/month free on GitLab.com shared runners. Same math applies.

AWS Bedrock (Claude 3.5 Haiku): The real cost is here. At $0.80 per million input tokens and $4.00 per million output tokens:

Line chart data
prsPerMonthcost
502
1004
2008
50020
100040

A typical PR review uses 5,000-15,000 input tokens (the diff + system prompt) and 500-2,000 output tokens (the review). That's roughly $0.01-0.04 per review. At 200 PRs/month — a mid-size team's output — you're looking at $2-8/month. For context, that's less than one hour of a developer's time spent manually reviewing code that the AI could have caught first.

Troubleshooting Common Issues

GitHub Actions: 403 Forbidden on Comment Post

Error: POST /repos/{owner}/{repo}/pulls/{number}/comments
returned 403 (Resource not accessible by integration)

Fix: Add the permissions block to your workflow:

permissions:
  contents: read
  pull-requests: write

Also check Settings > Actions > General > Workflow permissions and ensure "Read and write permissions" is selected (or use the explicit permissions block which overrides this setting).

GitLab CI: 401 Unauthorized

Error: HTTP 401 from GitLab API

Fix: Verify your GITLAB_TOKEN has api scope. Project access tokens with only read_repository scope can't post comments. Create a new token at Settings > Access Tokens with the api scope checked.

git diff Fails in CI

Error: Branch 'main' does not exist

Fix for GitHub Actions: Ensure fetch-depth: 0 in the checkout step. Without full history, the main branch reference doesn't exist in the shallow clone.

Fix for GitLab CI: GitLab's default clone strategy includes branch references, but if you're using a custom GIT_STRATEGY or GIT_DEPTH, set GIT_DEPTH: 0 in your variables.

Large Diffs Cause Bedrock Timeout

If a PR has thousands of changed lines, the API call might time out.

Fix: The TokenManager handles this by chunking, but you can also reduce the chunk size:

var tokenManager = new TokenManager();
var chunks = tokenManager.ChunkDiff(parsed.RawDiff, 20000);

Smaller chunks mean more API calls but faster individual responses.

Rate Limiting Errors from Bedrock

ThrottlingException: Rate exceeded

Fix: Lower the rate limiter's max requests:

var rateLimiter = new RateLimiter(maxRequestsPerMinute: 5);

Also check your AWS account's Bedrock service quotas in the AWS console. You may need to request a quota increase for the Claude model.

Security Considerations

Running AI on your codebase in CI raises legitimate security questions. Here's how this implementation addresses them.

Bar chart data
concernmitigation
Secrets in diffs95
Token permissions100
Data residency85
Supply chain90

Secrets in diffs: If a developer accidentally commits an API key, the diff containing that key gets sent to AWS Bedrock. Claude might even flag it in the review (which is good). But the key still transited through the API. Mitigation: use a pre-commit hook or GitHub's secret scanning to catch secrets before they enter the diff.

Token permissions: The GITHUB_TOKEN has write access to pull requests. If the workflow is compromised, an attacker could post misleading comments but couldn't push code, modify branches, or access other repos. The permissions block limits blast radius.

Data residency: AWS Bedrock processes data in the region you specify. If your organization has data residency requirements, choose an appropriate AWS_REGION. Unlike the public Claude API, Bedrock runs on AWS infrastructure within your selected region.

Supply chain: We use three NuGet packages. Pin your versions in production (replace 3.* with specific versions like 3.7.404.2) and use dotnet restore --locked-mode with a packages.lock.json to prevent unexpected dependency changes.

The Complete Architecture

Let's step back and look at what we've built across all three parts:

Pie chart data
NameValue
Part 1: Git Diff Parsing25
Part 2: AWS Bedrock + Prompts35
Part 3: CI/CD + Platform40

From a single Program.cs in Part 1 to a 13-file production system. The architecture follows clean separation of concerns:

  • DiffParser handles git interaction and diff parsing (Part 1)
  • BedrockClient handles AI model communication (Part 2)
  • Prompts/ contains versioned prompt templates (Part 2)
  • Platforms/ abstracts PR commenting behind an interface (Part 3)
  • Services/ provides cross-cutting concerns: token management and rate limiting (Part 3)
  • Tests/ validates the core logic (Part 3)
  • CI/CD configs tie everything into automated pipelines (Part 3)

Each layer has a single responsibility. Adding a new platform (Bitbucket, Azure DevOps) means implementing one interface — no changes to the AI or parsing layers. Switching from Claude Haiku to a different model means changing one string constant in BedrockClient.cs — no changes to the platform or parsing layers.

This is what composable architecture looks like in practice. Not a theoretical design pattern discussion — actual working code that demonstrates the value of interfaces, dependency inversion, and separation of concerns.

What Could Come Next

We've built a complete, working AI PR reviewer that runs automatically. But there's always more to build. Here are some ideas if you want to extend the project:

Bar chart data
extensioneffort
Bitbucket Platform30
Review Caching45
Custom Rules Config50
Dashboard / Metrics70
Model Comparison40
  • Bitbucket integration — Implement IPlatform for Bitbucket's REST API. The interface makes this a one-file addition.
  • Review caching — Store review results keyed by diff hash. Skip the Bedrock call if we've already reviewed the same changes.
  • Custom rules configuration — A .ai-review.yml in the repo root that lets teams customize severity thresholds, ignored paths, and review focus areas.
  • Metrics dashboard — Track review counts, common issue categories, and time saved across your organization.
  • Model comparison — Run the same diff through multiple models and compare results. The --model flag already supports this for manual testing.

As AI continues reshaping DevOps, automated code review is becoming table stakes for engineering teams. The tool we built here is a production-quality starting point — not a toy demo.

Series Recap

Over three tutorials, we built a complete AI-powered PR reviewer from scratch:

Part 1

Foundation

Created a C# console app with pure .NET 8. Learned Process execution, unified diff parsing, LINQ, and CLI argument handling. Zero external dependencies.

Part 2

Intelligence

Added AWS Bedrock integration with the Converse API. Evolved prompts from basic (V1) to structured categories (V2) to production-grade JSON output (V3). Introduced NuGet, .env management, and class extraction.

Part 3

Automation

Deployed to CI/CD with GitHub Actions and GitLab CI. Built platform abstraction with interfaces, token chunking for large diffs, rate limiting, and 16 unit tests with xUnit.

The companion code for the entire series lives in the ByteSizedExamples repository. Each branch represents a complete, buildable, testable version of the project at that stage.

# Part 1: Git diff parsing only
git checkout main

# Part 2: + AWS Bedrock and prompt engineering
git checkout intermediate

# Part 3: + CI/CD, platforms, services, and tests
git checkout advanced

Clone it, fork it, extend it. The code is MIT licensed and designed to be a starting point — not an endpoint.

If you found this series valuable, check out these related articles on CrashBytes:

  • Multi-Cloud CI/CD with GitHub Actions for expanding your pipeline strategies
  • AI-Driven Code Review: Transforming Software Quality for the broader landscape of AI in code review
  • Advanced API Rate Limiting Patterns for deeper rate limiting strategies beyond what we covered here

Happy building.

Advertisement

Was this article helpful?

Your feedback helps us improve our content and create more valuable resources

We appreciate honest feedback - it helps us serve you better

Work with us

This analysis is what we do for clients

CrashBytes consults on enterprise AI strategy and implementation, builds custom web and mobile software, and places senior engineers on corp-to-corp engagements.

See Services

Enjoyed this? Get the next one.

Join developers getting CrashBytes articles, tutorials, and predictions in their inbox. No spam, unsubscribe anytime.

Related Topics

C#TutorialCI/CDGitHub ActionsGitLab CIAIDeveloper ToolsTesting
Back to Articles
← PreviousAI's Infrastructure Reckoning: How the $650 Billion AI Boom Is Making Everything More ExpensiveNext →The AI Backlash Goes Mainstream — From London Streets to Boardroom Anxiety

From across the CrashBytes network

More than the blog — predictions, news, fiction, and AI art.

PredictionCustom AI Chips Reach Commodity Status by Q4 2027: Cloud Provider Competition Drives Democratization
NewsWeek In Review July 19-25, 2026 - The Week The Money Moved To The Metering Layer
Short StoryThe Answer Key
AI ArtThe Room That Remembers

Continue Your Learning Journey

Explore more articles related to Tutorial and expand your knowledge.

📄Tutorial

Build an AI PR Reviewer in C# — Part 1: Git Diff Parser From Scratch

Learn how to build a C# console application that captures and parses git diffs using pure .NET 8. Part 1 of a 3-part YouTube tutorial series covering Process execution, unified diff parsing, and CLI tool development with zero external dependencies.

26 min readRead more
📄Tutorial

Building Your First MCP Server in TypeScript From Scratch

A complete hands-on tutorial for building a production-ready Model Context Protocol server in TypeScript. Learn to create tools, resources, and prompts, then connect your server to Claude Desktop, VS Code, and Cursor.

30 min readRead more
📄AI/ML

AI Code Review Tools in 2026: A Practical Comparison Guide

AI code review has moved from experimental novelty to production infrastructure. This practical guide compares the leading tools — CodeRabbit, GitHub Copilot, Qodo, Sourcery, Amazon CodeGuru, and Snyk Code — across real evaluation criteria: detection accuracy, false positive rates, CI/CD integration, pricing models, and the specific review scenarios where each tool excels or falls short.

12 min readRead more
📄AI/ML

Rethinking Engineering: How AI Is Empowering Developers, Not Replacing Them

The conversation about AI in software engineering fixates on productivity metrics and job displacement. The real transformation is more personal. AI is changing what it means to be a developer by eliminating cognitive drudgery, accelerating skill development, and reshaping career trajectories in ways the industry hasn't fully reckoned with.

11 min readRead more