Coverage for src / pipeline / review_runner.py: 62%

63 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-01-04 04:43 +0000

1"""ReviewRunner: Code review pipeline stage. 

2 

3Extracted from MalaOrchestrator to separate review orchestration from main 

4orchestration logic. This module handles: 

5- Running code reviews via injected CodeReviewer protocol 

6- Retry decisions and no-progress detection 

7- Session log path tracking 

8 

9The ReviewRunner receives explicit inputs and returns explicit outputs, 

10making it testable without SDK or subprocess dependencies. 

11 

12Design principles: 

13- Protocol-based dependencies for testability 

14- Explicit input/output types for clarity 

15- Pure functions where possible 

16""" 

17 

18from __future__ import annotations 

19 

20import logging 

21from dataclasses import dataclass, field 

22from pathlib import Path 

23from typing import TYPE_CHECKING, cast 

24 

25logger = logging.getLogger(__name__) 

26 

27if TYPE_CHECKING: 

28 from src.infra.clients.review_output_parser import ReviewResult 

29 from src.core.protocols import ( 

30 CodeReviewer, 

31 GateChecker, 

32 ReviewResultProtocol, 

33 ValidationSpecProtocol, 

34 ) 

35 from src.domain.validation.spec import ValidationSpec 

36 

37 

38@dataclass 

39class ReviewRunnerConfig: 

40 """Configuration for ReviewRunner behavior. 

41 

42 Attributes: 

43 max_review_retries: Maximum number of review retry attempts. 

44 review_timeout: Timeout in seconds for review operations. 

45 thinking_mode: Deprecated, kept for backward compatibility. 

46 capture_session_log: Deprecated, kept for backward compatibility. 

47 """ 

48 

49 max_review_retries: int = 3 

50 review_timeout: int = 1200 

51 # Deprecated fields (kept for backward compatibility with orchestrator) 

52 thinking_mode: str | None = None 

53 capture_session_log: bool = False 

54 

55 

56@dataclass 

57class ReviewInput: 

58 """Input for running a code review. 

59 

60 Bundles all data needed to run a single review check. 

61 

62 Attributes: 

63 issue_id: The issue being reviewed. 

64 repo_path: Path to the repository. 

65 commit_sha: Current commit SHA to review. 

66 issue_description: Issue description for scope verification. 

67 baseline_commit: Optional baseline commit for cumulative diff. 

68 commit_shas: Optional list of commit SHAs to review directly. 

69 claude_session_id: Optional Claude session ID for external review context. 

70 """ 

71 

72 issue_id: str 

73 repo_path: Path 

74 commit_sha: str 

75 issue_description: str | None = None 

76 baseline_commit: str | None = None 

77 commit_shas: list[str] | None = None 

78 claude_session_id: str | None = None 

79 

80 

81@dataclass 

82class ReviewOutput: 

83 """Output from a code review check. 

84 

85 Attributes: 

86 result: The ReviewResult from the review. 

87 session_log_path: Path to the review session log (if captured). 

88 """ 

89 

90 result: ReviewResult | ReviewResultProtocol 

91 session_log_path: str | None = None 

92 

93 

94@dataclass 

95class NoProgressInput: 

96 """Input for no-progress check before review retry. 

97 

98 Attributes: 

99 log_path: Path to the session log file. 

100 log_offset: Byte offset marking the end of the previous attempt. 

101 previous_commit_hash: Commit hash from the previous attempt. 

102 current_commit_hash: Commit hash from this attempt. 

103 spec: Optional ValidationSpec for evidence detection. 

104 """ 

105 

106 log_path: Path 

107 log_offset: int 

108 previous_commit_hash: str | None 

109 current_commit_hash: str | None 

110 spec: ValidationSpec | None = None 

111 

112 

113@dataclass 

114class ReviewRunner: 

115 """Code review runner for post-gate validation. 

116 

117 This class encapsulates the review orchestration logic that was previously 

118 inline in MalaOrchestrator. It receives a CodeReviewer (protocol) for 

119 actual review execution. 

120 

121 The ReviewRunner is responsible for: 

122 - Running code reviews via the injected CodeReviewer 

123 - Checking no-progress conditions for retry termination 

124 - Tracking session log paths 

125 

126 Usage: 

127 runner = ReviewRunner( 

128 code_reviewer=reviewer, 

129 config=ReviewRunnerConfig(max_review_retries=3), 

130 ) 

131 output = await runner.run_review(input) 

132 

133 Attributes: 

134 code_reviewer: CodeReviewer implementation for running reviews. 

135 config: Configuration for review behavior. 

136 gate_checker: Optional GateChecker for no-progress detection. 

137 """ 

138 

139 code_reviewer: CodeReviewer 

140 config: ReviewRunnerConfig = field(default_factory=ReviewRunnerConfig) 

141 gate_checker: GateChecker | None = None 

142 

143 async def run_review(self, input: ReviewInput) -> ReviewOutput: 

144 """Run code review on the given commit. 

145 

146 This method invokes the injected CodeReviewer with the appropriate 

147 parameters derived from the input. 

148 

149 Args: 

150 input: ReviewInput with commit_sha, issue_description, etc. 

151 

152 Returns: 

153 ReviewOutput with result and optional session log path. 

154 """ 

155 import tempfile 

156 

157 # Build diff range 

158 # Use commit's own parent as default baseline, not HEAD~1 

159 # This ensures historical reviews compare against the correct base 

160 baseline = input.baseline_commit or f"{input.commit_sha}~1" 

161 diff_range = f"{baseline}..{input.commit_sha}" 

162 logger.info( 

163 "Review started: issue_id=%s diff_range=%s", input.issue_id, diff_range 

164 ) 

165 

166 # Create context file if issue_description provided 

167 # Use NamedTemporaryFile to avoid permission issues on shared systems 

168 context_file: Path | None = None 

169 temp_file = None 

170 if input.issue_description: 

171 temp_file = tempfile.NamedTemporaryFile( 

172 mode="w", 

173 prefix=f"review-context-{input.issue_id}-", 

174 suffix=".txt", 

175 delete=False, 

176 ) 

177 # Set context_file immediately so cleanup happens even if write/close fails 

178 context_file = Path(temp_file.name) 

179 

180 try: 

181 # Write and close inside try block to ensure cleanup on failure 

182 if temp_file is not None and input.issue_description is not None: 

183 temp_file.write(input.issue_description) 

184 temp_file.close() 

185 result = await self.code_reviewer( 

186 diff_range=diff_range, 

187 context_file=context_file, 

188 timeout=self.config.review_timeout, 

189 claude_session_id=input.claude_session_id, 

190 commit_shas=input.commit_shas, 

191 ) 

192 

193 session_log_path = None 

194 if result.review_log_path: 

195 session_log_path = str(result.review_log_path) 

196 

197 logger.info( 

198 "Review result: issue_id=%s passed=%s issues=%d", 

199 input.issue_id, 

200 result.passed, 

201 len(result.issues), 

202 ) 

203 

204 return ReviewOutput( 

205 result=result, 

206 session_log_path=session_log_path, 

207 ) 

208 finally: 

209 # Clean up context file after review completes (success or failure) 

210 if context_file is not None and context_file.exists(): 

211 context_file.unlink() 

212 

213 def check_no_progress(self, input: NoProgressInput) -> bool: 

214 """Check if no progress was made since the last review attempt. 

215 

216 No progress is detected when the commit hash hasn't changed and 

217 there are no uncommitted changes in the working tree. 

218 

219 This should be called before running a review retry to avoid 

220 wasting resources on a review that will likely fail again. 

221 

222 Args: 

223 input: NoProgressInput with log_path, offsets, and commit hashes. 

224 

225 Returns: 

226 True if no progress was made, False if progress was detected. 

227 

228 Raises: 

229 ValueError: If gate_checker is not set (required for no-progress). 

230 """ 

231 if self.gate_checker is None: 

232 raise ValueError("gate_checker must be set for no-progress detection") 

233 

234 return self.gate_checker.check_no_progress( 

235 log_path=input.log_path, 

236 log_offset=input.log_offset, 

237 previous_commit_hash=input.previous_commit_hash, 

238 current_commit_hash=input.current_commit_hash, 

239 spec=cast("ValidationSpecProtocol | None", input.spec), 

240 check_validation_evidence=False, # Only commit/working-tree for reviews 

241 )