Quick Takeaways
What you'll learn in this article
- 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
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.
Git Diff Parser
Console app that captures git diffs, parses unified diff format, and displays structured summaries. Pure .NET 8 with zero dependencies.
AWS Bedrock Integration
Send parsed diffs to Claude via Bedrock Converse API. Iterative prompt engineering from basic feedback to structured JSON output.
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:
| category | files |
|---|---|
| Platform Abstraction | 3 |
| Services | 2 |
| Tests | 3 |
| CI/CD Configs | 2 |
| Updated Files | 3 |
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)
advanced (Part 3)
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.
| Name | Value |
|---|---|
| AWSSDK.BedrockRuntime | 40 |
| DotNetEnv | 20 |
| Octokit | 40 |
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
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.
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:
| variable | purpose |
|---|---|
| GITHUB_TOKEN | 100 |
| GITHUB_REPOSITORY | 90 |
| GITHUB_REF | 85 |
| GITHUB_SHA | 80 |
- 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
Summary Comments
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:
- Typed models — PullRequestReviewCommentCreate enforces the correct shape at compile time. No guessing which JSON fields are required.
- Authentication handling — Setting Credentials once covers token rotation, header formatting, and retry logic.
- API versioning — Octokit tracks GitHub's API versions so you don't have to worry about deprecation headers.
- 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:
| variable | purpose |
|---|---|
| GITLAB_TOKEN | 100 |
| CI_PROJECT_ID | 95 |
| CI_MERGE_REQUEST_IID | 90 |
| CI_COMMIT_SHA | 85 |
| CI_SERVER_URL | 70 |
- 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)
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.
| Name | Value |
|---|---|
| English prose | 25 |
| Code (average) | 30 |
| Code (symbol-heavy) | 35 |
| Safety margin | 10 |
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.
| chunkSize | responseQuality | speed |
|---|---|---|
| 10K | 95 | 98 |
| 20K | 96 | 95 |
| 40K | 94 | 88 |
| 80K | 90 | 72 |
| 150K | 82 | 50 |
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:
- 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.
- 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.
- 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
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);
}
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
AiPrReviewer.csproj
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:
| test | category |
|---|---|
| File count | 100 |
| New file detection | 100 |
| Deleted file detection | 100 |
| Addition counting | 100 |
| Deletion counting | 100 |
| Hunk counting | 100 |
| Total calculations | 100 |
| Empty diff handling | 100 |
| Truncation behavior | 100 |
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
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
What We Do NOT Need
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.
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
GitLab CI
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
- Navigate to Settings > CI/CD > Variables
- Add AWS_ACCESS_KEY_ID (masked, not protected unless you only run on protected branches)
- Add AWS_SECRET_ACCESS_KEY (masked)
- 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.
Developer Opens PR
A developer pushes a branch and opens a pull request against main. GitHub/GitLab fires a webhook event.
CI Pipeline Triggers
GitHub Actions or GitLab CI picks up the event, spins up a runner, and checks out the repository with full history.
dotnet run Executes
Our tool runs git diff against the base branch, parses the output, and estimates token count. Large diffs get chunked.
AWS Bedrock Review
Each chunk is sent to Claude Haiku through the Converse API with the V3 production prompt. Rate limiter prevents throttling.
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.
| component | monthlyCost |
|---|---|
| GitHub Actions Runner | 0 |
| AWS Bedrock (Haiku) | 8 |
| GitLab CI Runner | 0 |
| Total Infrastructure | 8 |
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:
| prsPerMonth | cost |
|---|---|
| 50 | 2 |
| 100 | 4 |
| 200 | 8 |
| 500 | 20 |
| 1000 | 40 |
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.
| concern | mitigation |
|---|---|
| Secrets in diffs | 95 |
| Token permissions | 100 |
| Data residency | 85 |
| Supply chain | 90 |
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:
| Name | Value |
|---|---|
| Part 1: Git Diff Parsing | 25 |
| Part 2: AWS Bedrock + Prompts | 35 |
| Part 3: CI/CD + Platform | 40 |
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:
| extension | effort |
|---|---|
| Bitbucket Platform | 30 |
| Review Caching | 45 |
| Custom Rules Config | 50 |
| Dashboard / Metrics | 70 |
| Model Comparison | 40 |
- 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:
Foundation
Created a C# console app with pure .NET 8. Learned Process execution, unified diff parsing, LINQ, and CLI argument handling. Zero external dependencies.
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.
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.

