Skip to content

feat: Retry for security analysis#121

Closed
dkorecko wants to merge 1 commit intomainfrom
feature/retry-security-analysis
Closed

feat: Retry for security analysis#121
dkorecko wants to merge 1 commit intomainfrom
feature/retry-security-analysis

Conversation

@dkorecko
Copy link
Copy Markdown
Owner

@dkorecko dkorecko commented Feb 10, 2026

Summary by CodeRabbit

  • Tests

    • Added unit tests validating security check retry behavior for improved reliability
  • Improvements

    • Enhanced security scanning with automatic retry logic for transient failures
    • Strengthened error handling and diagnostic logging

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 10, 2026

📝 Walkthrough

Walkthrough

The pull request adds unit tests for a new security check retry mechanism in VersionService and refactors the service to implement per-attempt error handling with improved logging. The GetClient() method is now protected and virtual to support test mocking. Test data constants are extended to support the new test cases.

Changes

Cohort / File(s) Summary
Test Infrastructure
PatchPanda.Units/Services/VersionServiceTests.cs, PatchPanda.Units/TestData.cs
New unit test file with SecurityCheck_Retries_On_Failure test method that validates retry logic across multiple AI analysis attempts. Extended TestData class with five new constant string fields (SAFE_ANALYSIS, AI_ERROR, PATCH_CONTENT, DUMMY_URL, BODY) used by tests.
Service Refactoring
PatchPanda.Web/Services/VersionService.cs
Modified GetClient() from private GitHubClient to protected virtual IGitHubClient to enable test mocking. Implemented per-attempt error handling in security scanning loop with granular try-catch blocks around GitHub client and AI service calls. Enhanced logging distinguishes between intermediate and final attempt failures. Extracts baseTag explicitly from matched release before diff computation.

Sequence Diagrams

sequenceDiagram
    participant Test as Test
    participant VS as VersionService
    participant GH as GitHub Client
    participant AI as AI Service
    
    Test->>VS: Call AnalyzeDiff (Attempt 1)
    VS->>GH: Retrieve release & commits
    VS->>AI: Analyze diff
    AI-->>VS: Returns null (failure)
    VS->>VS: Log warning, continue retry
    
    Test->>VS: Call AnalyzeDiff (Attempt 2)
    VS->>GH: Retrieve release & commits
    VS->>AI: Analyze diff
    AI-->>VS: Throws exception
    VS->>VS: Log warning, continue retry
    
    Test->>VS: Call AnalyzeDiff (Attempt 3)
    VS->>GH: Retrieve release & commits
    VS->>AI: Analyze diff
    AI-->>VS: Returns valid analysis
    VS->>VS: Populate SecurityAnalysis & break
    VS-->>Test: Returns result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A test hops in with mocks so clever,
Retrying the scan, now and forever,
When AI stumbles, the loop tries again,
Till success arrives—safe patch, dear friend! 🐾

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'feat: Retry for security analysis' directly describes the main change: adding retry logic to the security analysis process in VersionService.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/retry-security-analysis

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@PatchPanda.Units/Services/VersionServiceTests.cs`:
- Around line 64-69: Add a new unit test in VersionServiceTests that simulates
all AI retries failing: setup _aiService.SetupSequence(x =>
x.AnalyzeDiff(It.IsAny<string>())) to ThrowsAsync(new
Exception(TestData.AI_ERROR)) for every retry attempt, call the same
VersionService method under test that triggers AnalyzeDiff (the existing test's
invocation), and assert the returned result has no SecurityAnalysis (e.g.,
SecurityAnalysis property is null or default) and that the mocked logger (the
test's logger mock) received an error-level log about the final failure; use the
same TestData.AI_ERROR and SecurityAnalysisResult type references so the test
mirrors existing patterns.

In `@PatchPanda.Web/Services/VersionService.cs`:
- Around line 177-180: The current logic uses
app.GitHubVersionRegex.TrimStart('^','v').TrimEnd('$') which can remove multiple
leading characters (e.g., an initial "v" inside a word) and over-trim; update
the transformation that builds adjustedRegex (used by Regex.Match) to remove
only a single leading '^' if present, then only a single leading 'v' if present,
and only a single trailing '$' if present (use explicit StartsWith/EndsWith
checks or equivalent string slicing) so the semantic pattern inside
app.GitHubVersionRegex is preserved.
- Around line 193-254: The retry loop in VersionService (the for loop that calls
GetClient() and _aiService.AnalyzeDiff for notSeenNewVersion) has no pause
between attempts; add a small delay between retries (e.g., Task.Delay with
exponential backoff or a fixed delay based on the attempt index i) inside the
catch and/or after a null analysis before the next iteration so transient
failures are less likely to repeat immediately—use the existing loop variable i
to compute backoff (for example delay = TimeSpan.FromSeconds(Math.Pow(2, i)) or
a fixed ms) and await the delay before continuing to the next attempt.
- Around line 193-209: The GitHub Compare call
(client.Repository.Commit.Compare) is being invoked on every retry inside the
loop (for i = 1..Constants.Limits.MAX_OLLAMA_ATTEMPTS) which wastes API rate
limits; move the call that computes diff (the call to
client.Repository.Commit.Compare and the construction of textToAnalyze from
diff.Files.Select(...)) out of the retry loop so it runs once before the retry
attempts, then inside the loop only retry the flaky AI analysis using
textToAnalyze and not make additional Compare calls; locate GetClient(),
client.Repository.Commit.Compare, textToAnalyze, MAX_OLLAMA_ATTEMPTS and
notSeenNewVersion to implement this change.
- Around line 184-187: The substring regex allows false positives (e.g.,
"v21.0.0" matching "1.0.0"); update the selection so it matches the whole tag
instead of containing the version. Replace the predicate used to compute
currentRelease (the lambda over allReleases and r.TagName) to either compare
strings exactly (r.TagName == versionMatch.Value or string.Equals) or anchor the
escaped regex like Regex.IsMatch(r.TagName, "^" +
Regex.Escape(versionMatch.Value) + "$") so only exact tag matches are selected.

Comment on lines +64 to +69

// Fail twice, then succeed
_aiService.SetupSequence(x => x.AnalyzeDiff(It.IsAny<string>()))
.ThrowsAsync(new Exception(TestData.AI_ERROR))
.ThrowsAsync(new Exception(TestData.AI_ERROR))
.ReturnsAsync(new SecurityAnalysisResult { Analysis = TestData.SAFE_ANALYSIS, IsSuspectedMalicious = false });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Potential test gap: consider testing when all retries are exhausted.

The test covers the happy path (fail twice, succeed on third). Consider adding a complementary test where all attempts fail, verifying that the result has no SecurityAnalysis set and that the final error is logged.

🤖 Prompt for AI Agents
In `@PatchPanda.Units/Services/VersionServiceTests.cs` around lines 64 - 69, Add a
new unit test in VersionServiceTests that simulates all AI retries failing:
setup _aiService.SetupSequence(x => x.AnalyzeDiff(It.IsAny<string>())) to
ThrowsAsync(new Exception(TestData.AI_ERROR)) for every retry attempt, call the
same VersionService method under test that triggers AnalyzeDiff (the existing
test's invocation), and assert the returned result has no SecurityAnalysis
(e.g., SecurityAnalysis property is null or default) and that the mocked logger
(the test's logger mock) received an error-level log about the final failure;
use the same TestData.AI_ERROR and SecurityAnalysisResult type references so the
test mirrors existing patterns.

Comment on lines +177 to +180
// Resolve the correct tag for the current version to ensure comparison works
// We extract the semantic version portion from the app version and use it to find the source tag
var adjustedRegex = app.GitHubVersionRegex.TrimStart('^', 'v').TrimEnd('$');
var versionMatch = Regex.Match(app.Version, adjustedRegex);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

TrimStart('^', 'v') may over-trim regexes starting with v-words.

TrimStart removes all leading characters present in the char set, not just the first occurrence. A regex like "^version\\d+" would become "ersion\\d+" because both '^' and 'v' are stripped greedily. If such patterns are possible, consider using explicit prefix removal instead.

Safer prefix removal
-                var adjustedRegex = app.GitHubVersionRegex.TrimStart('^', 'v').TrimEnd('$');
+                var adjustedRegex = app.GitHubVersionRegex;
+                if (adjustedRegex.StartsWith('^'))
+                    adjustedRegex = adjustedRegex[1..];
+                if (adjustedRegex.StartsWith('v'))
+                    adjustedRegex = adjustedRegex[1..];
+                if (adjustedRegex.EndsWith('$'))
+                    adjustedRegex = adjustedRegex[..^1];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Resolve the correct tag for the current version to ensure comparison works
// We extract the semantic version portion from the app version and use it to find the source tag
var adjustedRegex = app.GitHubVersionRegex.TrimStart('^', 'v').TrimEnd('$');
var versionMatch = Regex.Match(app.Version, adjustedRegex);
// Resolve the correct tag for the current version to ensure comparison works
// We extract the semantic version portion from the app version and use it to find the source tag
var adjustedRegex = app.GitHubVersionRegex;
if (adjustedRegex.StartsWith('^'))
adjustedRegex = adjustedRegex[1..];
if (adjustedRegex.StartsWith('v'))
adjustedRegex = adjustedRegex[1..];
if (adjustedRegex.EndsWith('$'))
adjustedRegex = adjustedRegex[..^1];
var versionMatch = Regex.Match(app.Version, adjustedRegex);
🤖 Prompt for AI Agents
In `@PatchPanda.Web/Services/VersionService.cs` around lines 177 - 180, The
current logic uses app.GitHubVersionRegex.TrimStart('^','v').TrimEnd('$') which
can remove multiple leading characters (e.g., an initial "v" inside a word) and
over-trim; update the transformation that builds adjustedRegex (used by
Regex.Match) to remove only a single leading '^' if present, then only a single
leading 'v' if present, and only a single trailing '$' if present (use explicit
StartsWith/EndsWith checks or equivalent string slicing) so the semantic pattern
inside app.GitHubVersionRegex is preserved.

Comment on lines +184 to +187
var currentRelease = allReleases.FirstOrDefault(r =>
r.TagName is not null
&& Regex.IsMatch(r.TagName, Regex.Escape(versionMatch.Value))
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bug: Substring match can select the wrong release.

Regex.IsMatch(r.TagName, Regex.Escape(versionMatch.Value)) performs a contains match. For example, if versionMatch.Value is "1.0.0", a tag like "v21.0.0" would also match because "21.0.0" contains the substring "1.0.0". This could silently pick the wrong base tag for the security diff.

Anchor the pattern or use simple string matching:

Proposed fix: anchor the escaped pattern
                     var currentRelease = allReleases.FirstOrDefault(r =>
                         r.TagName is not null
-                        && Regex.IsMatch(r.TagName, Regex.Escape(versionMatch.Value))
+                        && Regex.IsMatch(r.TagName, $@"(^|[^0-9]){Regex.Escape(versionMatch.Value)}($|[^0-9])")
                     );

Alternatively, a simpler non-regex approach:

                     var currentRelease = allReleases.FirstOrDefault(r =>
-                        r.TagName is not null
-                        && Regex.IsMatch(r.TagName, Regex.Escape(versionMatch.Value))
+                        r.TagName is not null
+                        && r.TagName.TrimStart('v') == versionMatch.Value
                     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var currentRelease = allReleases.FirstOrDefault(r =>
r.TagName is not null
&& Regex.IsMatch(r.TagName, Regex.Escape(versionMatch.Value))
);
var currentRelease = allReleases.FirstOrDefault(r =>
r.TagName is not null
&& Regex.IsMatch(r.TagName, $@"(^|[^0-9]){Regex.Escape(versionMatch.Value)}($|[^0-9])")
);
Suggested change
var currentRelease = allReleases.FirstOrDefault(r =>
r.TagName is not null
&& Regex.IsMatch(r.TagName, Regex.Escape(versionMatch.Value))
);
var currentRelease = allReleases.FirstOrDefault(r =>
r.TagName is not null
&& r.TagName.TrimStart('v') == versionMatch.Value
);
🤖 Prompt for AI Agents
In `@PatchPanda.Web/Services/VersionService.cs` around lines 184 - 187, The
substring regex allows false positives (e.g., "v21.0.0" matching "1.0.0");
update the selection so it matches the whole tag instead of containing the
version. Replace the predicate used to compute currentRelease (the lambda over
allReleases and r.TagName) to either compare strings exactly (r.TagName ==
versionMatch.Value or string.Equals) or anchor the escaped regex like
Regex.IsMatch(r.TagName, "^" + Regex.Escape(versionMatch.Value) + "$") so only
exact tag matches are selected.

Comment on lines +193 to 254
for (int i = 1; i <= Constants.Limits.MAX_OLLAMA_ATTEMPTS; i++)
{
var baseTag = currentRelease.TagName;

// Get the difference between the current version and the new version
var diff = await client.Repository.Commit.Compare(
repo.Item1,
repo.Item2,
baseTag,
notSeenNewVersion.VersionNumber
);

var textToAnalyze = string.Concat(
diff.Files.Select(f => f.Patch ?? "")
);

var analysis = await _aiService.AnalyzeDiff(textToAnalyze);

if (analysis is not null)
try
{
var client = GetClient();

// Get the difference between the current version and the new version
var diff = await client.Repository.Commit.Compare(
repo.Item1,
repo.Item2,
baseTag,
notSeenNewVersion.VersionNumber
);

var textToAnalyze = string.Concat(
diff.Files.Select(f => f.Patch ?? "")
);

var analysis = await _aiService.AnalyzeDiff(textToAnalyze);

if (analysis is not null)
{
notSeenNewVersion.SecurityAnalysis = analysis.Analysis;
notSeenNewVersion.IsSuspectedMalicious =
analysis.IsSuspectedMalicious;
break;
}
else
{
_logger.LogWarning(
"Attempting to perform security scan for {Repo} version {Version}, attempt {Count} out of {Max} resulted in null analysis.",
$"{repo.Item1}/{repo.Item2}",
notSeenNewVersion.VersionNumber,
i,
Constants.Limits.MAX_OLLAMA_ATTEMPTS
);
}
}
catch (Exception ex)
{
notSeenNewVersion.SecurityAnalysis = analysis.Analysis;
notSeenNewVersion.IsSuspectedMalicious =
analysis.IsSuspectedMalicious;
if (i == Constants.Limits.MAX_OLLAMA_ATTEMPTS)
{
_logger.LogError(
ex,
"Failed to perform security scan for {Repo} version {Version}",
$"{repo.Item1}/{repo.Item2}",
notSeenNewVersion.VersionNumber
);
}
else
{
_logger.LogWarning(
ex,
"Attempting to perform security scan for {Repo} version {Version}, attempt {Count} out of {Max} failed.",
$"{repo.Item1}/{repo.Item2}",
notSeenNewVersion.VersionNumber,
i,
Constants.Limits.MAX_OLLAMA_ATTEMPTS
);
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

No delay between retry attempts.

Immediate retries against the AI service are likely to hit the same transient failure. Adding a small delay (e.g., exponential backoff or a fixed pause) would improve reliability.

Example: add a short delay on failure
                         catch (Exception ex)
                         {
                             if (i == Constants.Limits.MAX_OLLAMA_ATTEMPTS)
                             {
                                 _logger.LogError(
                                     ex,
                                     "Failed to perform security scan for {Repo} version {Version}",
                                     $"{repo.Item1}/{repo.Item2}",
                                     notSeenNewVersion.VersionNumber
                                 );
                             }
                             else
                             {
                                 _logger.LogWarning(
                                     ex,
                                     "Attempting to perform security scan for {Repo} version {Version}, attempt {Count} out of {Max} failed.",
                                     $"{repo.Item1}/{repo.Item2}",
                                     notSeenNewVersion.VersionNumber,
                                     i,
                                     Constants.Limits.MAX_OLLAMA_ATTEMPTS
                                 );
+                                await Task.Delay(TimeSpan.FromSeconds(i * 2));
                             }
                         }
🤖 Prompt for AI Agents
In `@PatchPanda.Web/Services/VersionService.cs` around lines 193 - 254, The retry
loop in VersionService (the for loop that calls GetClient() and
_aiService.AnalyzeDiff for notSeenNewVersion) has no pause between attempts; add
a small delay between retries (e.g., Task.Delay with exponential backoff or a
fixed delay based on the attempt index i) inside the catch and/or after a null
analysis before the next iteration so transient failures are less likely to
repeat immediately—use the existing loop variable i to compute backoff (for
example delay = TimeSpan.FromSeconds(Math.Pow(2, i)) or a fixed ms) and await
the delay before continuing to the next attempt.

Comment on lines +193 to +209
for (int i = 1; i <= Constants.Limits.MAX_OLLAMA_ATTEMPTS; i++)
{
var baseTag = currentRelease.TagName;

// Get the difference between the current version and the new version
var diff = await client.Repository.Commit.Compare(
repo.Item1,
repo.Item2,
baseTag,
notSeenNewVersion.VersionNumber
);

var textToAnalyze = string.Concat(
diff.Files.Select(f => f.Patch ?? "")
);

var analysis = await _aiService.AnalyzeDiff(textToAnalyze);

if (analysis is not null)
try
{
var client = GetClient();

// Get the difference between the current version and the new version
var diff = await client.Repository.Commit.Compare(
repo.Item1,
repo.Item2,
baseTag,
notSeenNewVersion.VersionNumber
);

var textToAnalyze = string.Concat(
diff.Files.Select(f => f.Patch ?? "")
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

GitHub API call is needlessly inside the retry loop, wasting rate limit.

The Compare call to GitHub only needs to succeed once. If the AI analysis (line 211) is the flaky part, the diff retrieval should be hoisted above the loop so it is only called once. Each redundant Compare call consumes a GitHub API request, bringing you closer to rate limits.

Proposed restructuring
                     var baseTag = currentRelease.TagName;
+                    var client = GetClient();
+                    var diff = await client.Repository.Commit.Compare(
+                        repo.Item1,
+                        repo.Item2,
+                        baseTag,
+                        notSeenNewVersion.VersionNumber
+                    );
+                    var textToAnalyze = string.Concat(
+                        diff.Files.Select(f => f.Patch ?? "")
+                    );

                     for (int i = 1; i <= Constants.Limits.MAX_OLLAMA_ATTEMPTS; i++)
                     {
                         try
                         {
-                            var client = GetClient();
-
-                            var diff = await client.Repository.Commit.Compare(
-                                repo.Item1,
-                                repo.Item2,
-                                baseTag,
-                                notSeenNewVersion.VersionNumber
-                            );
-
-                            var textToAnalyze = string.Concat(
-                                diff.Files.Select(f => f.Patch ?? "")
-                            );
-
                             var analysis = await _aiService.AnalyzeDiff(textToAnalyze);
🤖 Prompt for AI Agents
In `@PatchPanda.Web/Services/VersionService.cs` around lines 193 - 209, The GitHub
Compare call (client.Repository.Commit.Compare) is being invoked on every retry
inside the loop (for i = 1..Constants.Limits.MAX_OLLAMA_ATTEMPTS) which wastes
API rate limits; move the call that computes diff (the call to
client.Repository.Commit.Compare and the construction of textToAnalyze from
diff.Files.Select(...)) out of the retry loop so it runs once before the retry
attempts, then inside the loop only retry the flaky AI analysis using
textToAnalyze and not make additional Compare calls; locate GetClient(),
client.Repository.Commit.Compare, textToAnalyze, MAX_OLLAMA_ATTEMPTS and
notSeenNewVersion to implement this change.

@dkorecko dkorecko closed this Feb 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant