Skip to content

Commit e2ec6ce

Browse files
Merge pull request #74 from Contrast-Security-OSS/AIML-233_qa_build_summary_bug
AIML-233 qa build summary bug
2 parents 42a5bef + 9017ec9 commit e2ec6ce

File tree

7 files changed

+577
-66
lines changed

7 files changed

+577
-66
lines changed

Makefile

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Makefile for contrast-ai-smartfix-action
2+
#
3+
# Available targets:
4+
# make test - Run all tests using the test runner
5+
# make lint - Run linting via pre-push hook
6+
# make help - Show this help message
7+
8+
.PHONY: test lint help
9+
10+
# Run tests using the test runner script
11+
test:
12+
@echo "Running tests..."
13+
./test/run_tests.sh
14+
15+
# Run linting via the pre-push hook
16+
lint:
17+
@echo "Running linter..."
18+
./.git/hooks/pre-push
19+
20+
# Show help message
21+
help:
22+
@echo "Available make targets:"
23+
@echo " test - Run all tests using ./test/run_tests.sh"
24+
@echo " lint - Run linting via ./.git/hooks/pre-push"
25+
@echo " help - Show this help message"
26+
27+
# Default target
28+
.DEFAULT_GOAL := help

src/main.py

Lines changed: 46 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from src.utils import debug_log, log, error_exit
3333
from src import telemetry_handler
3434
from src.version_check import do_version_check
35+
from src.smartfix.domains.workflow.session_handler import create_session_handler, QASectionConfig
3536
from src.smartfix.shared.failure_categories import FailureCategory
3637

3738
# Import domain-specific handlers
@@ -352,15 +353,19 @@ def main(): # noqa: C901
352353
vuln_title = vulnerability_data['vulnerabilityTitle']
353354
remediation_id = vulnerability_data['remediationId']
354355
session_id = vulnerability_data.get('sessionId')
355-
previous_vuln_uuid = vuln_uuid # Update tracking variable
356-
357-
# Create prompt configuration for SmartFix agent
358-
prompts = PromptConfiguration.for_smartfix_agent(
359-
fix_system_prompt=vulnerability_data['fixSystemPrompt'],
360-
fix_user_prompt=vulnerability_data['fixUserPrompt'],
361-
qa_system_prompt=vulnerability_data['qaSystemPrompt'],
362-
qa_user_prompt=vulnerability_data['qaUserPrompt']
363-
)
356+
357+
# Validate and create prompt configuration for SmartFix agent
358+
try:
359+
PromptConfiguration.validate_raw_prompts_data(vulnerability_data)
360+
prompts = PromptConfiguration.for_smartfix_agent(
361+
fix_system_prompt=vulnerability_data['fixSystemPrompt'],
362+
fix_user_prompt=vulnerability_data['fixUserPrompt'],
363+
qa_system_prompt=vulnerability_data['qaSystemPrompt'],
364+
qa_user_prompt=vulnerability_data['qaUserPrompt']
365+
)
366+
except ValueError as e:
367+
log(f"Error: Invalid prompts from backend: {e}", is_error=True)
368+
error_exit(remediation_id, FailureCategory.GENERAL_FAILURE.value)
364369
else:
365370
# For external coding agents (GITHUB_COPILOT/CLAUDE_CODE), get vulnerability details
366371
log("\n::group::--- Fetching next vulnerability details from Contrast API ---")
@@ -381,12 +386,11 @@ def main(): # noqa: C901
381386
# Check if this is the same vulnerability UUID as the previous iteration
382387
if vuln_uuid == previous_vuln_uuid:
383388
log(f"Error: Backend provided the same vulnerability UUID ({vuln_uuid}) as the previous iteration. This indicates a backend error.", is_warning=True)
384-
error_exit(remediation_id, "DUPLICATE_VULNERABILITY_UUID")
389+
error_exit(remediation_id, FailureCategory.GENERAL_FAILURE.value)
385390

386391
vuln_title = vulnerability_data['vulnerabilityTitle']
387392
remediation_id = vulnerability_data['remediationId']
388393
session_id = None # External agents don't use Contrast LLM sessions
389-
previous_vuln_uuid = vuln_uuid # Update tracking variable
390394

391395
# Create prompt configuration for external agent (no prompts required)
392396
prompts = PromptConfiguration.for_external_agent()
@@ -414,6 +418,10 @@ def main(): # noqa: C901
414418
else:
415419
log(f"No existing OPEN or MERGED PR found for vulnerability {vuln_uuid}. Proceeding with fix attempt.")
416420
log("\n::endgroup::")
421+
422+
# Update tracking variable now that we know we're actually processing this vuln
423+
previous_vuln_uuid = vuln_uuid
424+
417425
log(f"\n\033[0;33m Selected vuln to fix: {vuln_title} \033[0m")
418426

419427
# --- Create Common Remediation Context ---
@@ -460,13 +468,33 @@ def main(): # noqa: C901
460468
session = smartfix_agent.remediate(context)
461469

462470
# Extract results from the session
463-
if session.success:
464-
ai_fix_summary_full = session.pr_body if session.pr_body else "Fix completed successfully"
465-
else:
466-
# Agent failed - handle the error
467-
failure_category = session.failure_category.value if session.failure_category else FailureCategory.AGENT_FAILURE.value
468-
log(f"Agent failed with reason: {failure_category}")
469-
error_exit(remediation_id, failure_category)
471+
session_handler = create_session_handler()
472+
session_result = session_handler.handle_session_result(session)
473+
474+
if not session_result.should_continue:
475+
# QA Agent failed to fix the build
476+
log(f"Agent failed with reason: {session_result.failure_category}")
477+
git_handler.cleanup_branch(new_branch_name)
478+
contrast_api.notify_remediation_failed(
479+
remediation_id=remediation_id,
480+
failure_category=session_result.failure_category,
481+
contrast_host=config.CONTRAST_HOST,
482+
contrast_org_id=config.CONTRAST_ORG_ID,
483+
contrast_app_id=config.CONTRAST_APP_ID,
484+
contrast_auth_key=config.CONTRAST_AUTHORIZATION_KEY,
485+
contrast_api_key=config.CONTRAST_API_KEY
486+
)
487+
continue # Move to next vulnerability
488+
489+
ai_fix_summary_full = session_result.ai_fix_summary
490+
# Generate QA section based on session results
491+
# The SmartFix agent already handled the QA loop internally
492+
qa_config = QASectionConfig(
493+
skip_qa_review=config.SKIP_QA_REVIEW,
494+
has_build_command=build_config.has_build_command(),
495+
build_command=build_config.build_command
496+
)
497+
qa_section = session_handler.generate_qa_section(session, qa_config)
470498

471499
# --- Git and GitHub Operations ---
472500
# All file changes from the agent (fix + QA + formatting) are uncommitted at this point
@@ -486,54 +514,6 @@ def main(): # noqa: C901
486514
git_handler.commit_changes(commit_message)
487515
log("Committed all agent changes.")
488516

489-
# Generate QA section based on session results
490-
# The SmartFix agent already handled the QA loop internally
491-
qa_section = "\n\n---\n\n## Review \n\n"
492-
493-
if not config.SKIP_QA_REVIEW and build_config.has_build_command():
494-
# QA was expected to run - check session results
495-
if session.qa_attempts > 0: # QA was attempted
496-
qa_section += f"* **Build Run:** Yes (`{build_config.build_command}`)\n"
497-
if session.success:
498-
qa_section += "* **Final Build Status:** Success \n"
499-
else:
500-
qa_section += "* **Final Build Status:** Failure \n"
501-
502-
# Check if we should skip PR creation due to QA failure
503-
log("\n--- Skipping PR creation as QA Agent failed to fix build issues ---")
504-
505-
# Get failure category directly from session
506-
failure_category = session.failure_category.value if session.failure_category else FailureCategory.QA_AGENT_FAILURE.value
507-
508-
# Notify the Remediation service about the failed remediation
509-
remediation_notified = contrast_api.notify_remediation_failed(
510-
remediation_id=remediation_id,
511-
failure_category=failure_category,
512-
contrast_host=config.CONTRAST_HOST,
513-
contrast_org_id=config.CONTRAST_ORG_ID,
514-
contrast_app_id=config.CONTRAST_APP_ID,
515-
contrast_auth_key=config.CONTRAST_AUTHORIZATION_KEY,
516-
contrast_api_key=config.CONTRAST_API_KEY
517-
)
518-
519-
if remediation_notified:
520-
log(f"Successfully notified Remediation service about {failure_category} for remediation {remediation_id}.")
521-
else:
522-
log(f"Failed to notify Remediation service about {failure_category} for remediation {remediation_id}.", is_warning=True)
523-
524-
git_handler.cleanup_branch(new_branch_name)
525-
contrast_api.send_telemetry_data()
526-
continue # Move to the next vulnerability
527-
else:
528-
qa_section += "* **Build Run:** No (BUILD_COMMAND not provided)\n"
529-
qa_section += "* **Final Build Status:** Skipped\n"
530-
else: # QA was skipped
531-
qa_section = "" # Ensure qa_section is empty if QA is skipped
532-
if config.SKIP_QA_REVIEW:
533-
log("QA Review was skipped based on SKIP_QA_REVIEW setting.")
534-
elif not build_config.has_build_command():
535-
log("QA Review was skipped as no BUILD_COMMAND was provided.")
536-
537517
# --- Create Pull Request ---
538518
pr_title = git_handler.generate_pr_title(vuln_title)
539519
# Use the result from SmartFix agent remediation as the base PR body.

src/smartfix/domains/vulnerability/context.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,32 @@ def _replace_security_test_section(self, prompt: str) -> str:
137137

138138
return prompt
139139

140+
@classmethod
141+
def validate_raw_prompts_data(cls, prompts_data: dict) -> bool:
142+
"""
143+
Validate raw prompts data dictionary for SmartFix agents.
144+
145+
Args:
146+
prompts_data: Dictionary containing prompt data from backend
147+
148+
Returns:
149+
bool: True if valid
150+
151+
Raises:
152+
ValueError: If prompts are invalid/missing
153+
"""
154+
required_prompts = ['fixSystemPrompt', 'fixUserPrompt', 'qaSystemPrompt', 'qaUserPrompt']
155+
156+
for prompt_key in required_prompts:
157+
if prompt_key not in prompts_data:
158+
raise ValueError(f"Missing required prompt key: {prompt_key}")
159+
160+
prompt_value = prompts_data[prompt_key]
161+
if not prompt_value or not prompt_value.strip():
162+
raise ValueError(f"Missing or empty required prompt: {prompt_key}")
163+
164+
return True
165+
140166
@classmethod
141167
def for_smartfix_agent(
142168
cls,
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# -
2+
# #%L
3+
# Contrast AI SmartFix
4+
# %%
5+
# Copyright (C) 2025 Contrast Security, Inc.
6+
# %%
7+
8+
# License: Commercial
9+
# NOTICE: This Software and the patented inventions embodied within may only be
10+
# used as part of Contrast Security's commercial offerings. Even though it is
11+
# made available through public repositories, use of this Software is subject to
12+
# the applicable End User Licensing Agreement found at
13+
# https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
14+
# between Contrast Security and the End User. The Software may not be reverse
15+
# engineered, modified, repackaged, sold, redistributed or otherwise used in a
16+
# way not consistent with the End User License Agreement.
17+
# #L%
18+
#
19+
20+
"""
21+
Session handling workflow for SmartFix agent results.
22+
23+
This module provides object-oriented handling of agent session results,
24+
including QA section generation, success/failure determination, and validation.
25+
"""
26+
27+
from typing import Optional
28+
from src.smartfix.shared.failure_categories import FailureCategory
29+
from src.utils import log
30+
31+
32+
class SessionResult:
33+
"""
34+
Encapsulates the result of processing an agent session.
35+
36+
Attributes:
37+
should_continue: Whether processing should continue to PR creation
38+
failure_category: Failure category if session failed
39+
ai_fix_summary: Summary for successful sessions
40+
"""
41+
42+
def __init__(self, should_continue: bool, failure_category: Optional[str] = None, ai_fix_summary: Optional[str] = None):
43+
self.should_continue = should_continue
44+
self.failure_category = failure_category
45+
self.ai_fix_summary = ai_fix_summary
46+
47+
48+
class QASectionConfig:
49+
"""
50+
Configuration for QA section generation.
51+
52+
Attributes:
53+
skip_qa_review: Whether QA review was skipped by configuration
54+
has_build_command: Whether a build command is available
55+
build_command: The build command used
56+
"""
57+
58+
def __init__(self, skip_qa_review: bool, has_build_command: bool, build_command: str):
59+
self.skip_qa_review = skip_qa_review
60+
self.has_build_command = has_build_command
61+
self.build_command = build_command
62+
63+
64+
class SessionHandler:
65+
"""
66+
Handles SmartFix agent session results and generates appropriate responses.
67+
68+
This class encapsulates the business logic for:
69+
- Determining session success/failure outcomes
70+
- Generating QA sections for PR bodies
71+
"""
72+
73+
def __init__(self):
74+
pass
75+
76+
def handle_session_result(self, session) -> SessionResult:
77+
"""
78+
Handle session result and determine next action.
79+
80+
Args:
81+
session: AgentSession with success, failure_category, pr_body properties
82+
83+
Returns:
84+
SessionResult: Result indicating whether to continue processing
85+
"""
86+
if session.success:
87+
ai_fix_summary = session.pr_body if session.pr_body else "Fix completed successfully"
88+
return SessionResult(should_continue=True, ai_fix_summary=ai_fix_summary)
89+
else:
90+
# Agent failed - determine failure category
91+
failure_category = (
92+
session.failure_category.value
93+
if session.failure_category
94+
else FailureCategory.AGENT_FAILURE.value
95+
)
96+
return SessionResult(should_continue=False, failure_category=failure_category)
97+
98+
def generate_qa_section(self, session, config: QASectionConfig) -> str:
99+
"""
100+
Generate the QA section for PR body based on session results.
101+
102+
Args:
103+
session: AgentSession with success, qa_attempts properties
104+
config: QA section configuration
105+
106+
Returns:
107+
str: QA section for PR body
108+
"""
109+
110+
# Note: At this point session.success must be True
111+
# (failures are handled by handle_session_result earlier)
112+
# Start with standard QA section header
113+
qa_section = "\n\n---\n\n## Review \n\n"
114+
if not config.skip_qa_review and config.has_build_command:
115+
# QA was expected to run - check session results
116+
if session.qa_attempts > 0:
117+
# QA loop ran and eventually succeeded
118+
qa_section += f"* **Build Run:** Yes (`{config.build_command}`)\n"
119+
qa_section += "* **Final Build Status:** Success \n"
120+
else:
121+
# Build passed on first attempt, no QA needed
122+
qa_section += f"* **Build Run:** Yes (`{config.build_command}`)\n"
123+
qa_section += "* **Final Build Status:** Success (passed on first attempt)\n"
124+
else:
125+
# QA was skipped - provide empty section and log reason
126+
qa_section = ""
127+
self._log_qa_skip_reason(config)
128+
129+
return qa_section
130+
131+
def _log_qa_skip_reason(self, config: QASectionConfig) -> None:
132+
"""
133+
Log the reason why QA review was skipped.
134+
135+
Args:
136+
config: QA section configuration
137+
"""
138+
if config.skip_qa_review:
139+
log("QA Review was skipped based on SKIP_QA_REVIEW setting.")
140+
elif not config.has_build_command:
141+
log("QA Review was skipped as no BUILD_COMMAND was provided.")
142+
143+
144+
# Factory function for backward compatibility and easy instantiation
145+
def create_session_handler() -> SessionHandler:
146+
"""
147+
Create a SessionHandler instance.
148+
149+
Returns:
150+
SessionHandler: Configured session handler
151+
"""
152+
return SessionHandler()

0 commit comments

Comments
 (0)