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

1"""ReviewRunner: Code review pipeline stage.

2

3 Extracted from MalaOrchestrator to separate review orchestration from main

4 orchestration 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

9 The ReviewRunner receives explicit inputs and returns explicit outputs,

10 making it testable without SDK or subprocess dependencies.

11

12 Design principles:

13- Protocol-based dependencies for testability

14- Explicit input/output types for clarity

15- Pure functions where possible

16"""

17

18 from __future__ import annotations

19

20 import logging

21 from dataclasses import dataclass, field

22 from pathlib import Path

23 from typing import TYPE_CHECKING, cast

24

25 logger = logging.getLogger(__name__)

26

27 if 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

39 class 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

57 class 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

82 class 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

95 class 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

114 class 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 )