issue_definitions.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
  1. import json
  2. import os
  3. import re
  4. from abc import ABC, abstractmethod
  5. from typing import Any, ClassVar
  6. import jinja2
  7. import litellm
  8. import requests
  9. from openhands.core.config import LLMConfig
  10. from openhands.core.logger import openhands_logger as logger
  11. from openhands.events.event import Event
  12. from openhands.resolver.github_issue import GithubIssue, ReviewThread
  13. class IssueHandlerInterface(ABC):
  14. issue_type: ClassVar[str]
  15. @abstractmethod
  16. def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]:
  17. """Download issues from GitHub."""
  18. pass
  19. @abstractmethod
  20. def get_instruction(
  21. self,
  22. issue: GithubIssue,
  23. prompt_template: str,
  24. repo_instruction: str | None = None,
  25. ) -> tuple[str, list[str]]:
  26. """Generate instruction and image urls for the agent."""
  27. pass
  28. @abstractmethod
  29. def guess_success(
  30. self, issue: GithubIssue, history: list[Event], llm_config: LLMConfig
  31. ) -> tuple[bool, list[bool] | None, str]:
  32. """Guess if the issue has been resolved based on the agent's output."""
  33. pass
  34. class IssueHandler(IssueHandlerInterface):
  35. issue_type: ClassVar[str] = 'issue'
  36. def __init__(self, owner: str, repo: str, token: str):
  37. self.download_url = 'https://api.github.com/repos/{}/{}/issues'
  38. self.owner = owner
  39. self.repo = repo
  40. self.token = token
  41. def _download_issues_from_github(self) -> list[Any]:
  42. url = self.download_url.format(self.owner, self.repo)
  43. headers = {
  44. 'Authorization': f'token {self.token}',
  45. 'Accept': 'application/vnd.github.v3+json',
  46. }
  47. params: dict[str, int | str] = {'state': 'open', 'per_page': 100, 'page': 1}
  48. all_issues = []
  49. while True:
  50. response = requests.get(url, headers=headers, params=params)
  51. response.raise_for_status()
  52. issues = response.json()
  53. if not issues:
  54. break
  55. if not isinstance(issues, list) or any(
  56. [not isinstance(issue, dict) for issue in issues]
  57. ):
  58. raise ValueError('Expected list of dictionaries from Github API.')
  59. all_issues.extend(issues)
  60. assert isinstance(params['page'], int)
  61. params['page'] += 1
  62. return all_issues
  63. def _extract_image_urls(self, issue_body: str) -> list[str]:
  64. # Regular expression to match Markdown image syntax ![alt text](image_url)
  65. image_pattern = r'!\[.*?\]\((https?://[^\s)]+)\)'
  66. return re.findall(image_pattern, issue_body)
  67. def _extract_issue_references(self, body: str) -> list[int]:
  68. # First, remove code blocks as they may contain false positives
  69. body = re.sub(r'```.*?```', '', body, flags=re.DOTALL)
  70. # Remove inline code
  71. body = re.sub(r'`[^`]*`', '', body)
  72. # Remove URLs that contain hash symbols
  73. body = re.sub(r'https?://[^\s)]*#\d+[^\s)]*', '', body)
  74. # Now extract issue numbers, making sure they're not part of other text
  75. # The pattern matches #number that:
  76. # 1. Is at the start of text or after whitespace/punctuation
  77. # 2. Is followed by whitespace, punctuation, or end of text
  78. # 3. Is not part of a URL
  79. pattern = r'(?:^|[\s\[({]|[^\w#])#(\d+)(?=[\s,.\])}]|$)'
  80. return [int(match) for match in re.findall(pattern, body)]
  81. def _get_issue_comments(
  82. self, issue_number: int, comment_id: int | None = None
  83. ) -> list[str] | None:
  84. """Download comments for a specific issue from Github."""
  85. url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments'
  86. headers = {
  87. 'Authorization': f'token {self.token}',
  88. 'Accept': 'application/vnd.github.v3+json',
  89. }
  90. params = {'per_page': 100, 'page': 1}
  91. all_comments = []
  92. while True:
  93. response = requests.get(url, headers=headers, params=params)
  94. response.raise_for_status()
  95. comments = response.json()
  96. if not comments:
  97. break
  98. if comment_id:
  99. matching_comment = next(
  100. (
  101. comment['body']
  102. for comment in comments
  103. if comment['id'] == comment_id
  104. ),
  105. None,
  106. )
  107. if matching_comment:
  108. return [matching_comment]
  109. else:
  110. all_comments.extend([comment['body'] for comment in comments])
  111. params['page'] += 1
  112. return all_comments if all_comments else None
  113. def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]:
  114. """Download issues from Github.
  115. Returns:
  116. List of Github issues.
  117. """
  118. all_issues = self._download_issues_from_github()
  119. converted_issues = []
  120. for issue in all_issues:
  121. if any([issue.get(key) is None for key in ['number', 'title', 'body']]):
  122. logger.warning(
  123. f'Skipping issue {issue} as it is missing number, title, or body.'
  124. )
  125. continue
  126. if 'pull_request' in issue:
  127. continue
  128. # Get issue thread comments
  129. thread_comments = self._get_issue_comments(
  130. issue['number'], comment_id=comment_id
  131. )
  132. # Convert empty lists to None for optional fields
  133. issue_details = GithubIssue(
  134. owner=self.owner,
  135. repo=self.repo,
  136. number=issue['number'],
  137. title=issue['title'],
  138. body=issue['body'],
  139. thread_comments=thread_comments,
  140. review_comments=None, # Initialize review comments as None for regular issues
  141. )
  142. converted_issues.append(issue_details)
  143. return converted_issues
  144. def get_instruction(
  145. self,
  146. issue: GithubIssue,
  147. prompt_template: str,
  148. repo_instruction: str | None = None,
  149. ) -> tuple[str, list[str]]:
  150. """Generate instruction for the agent."""
  151. # Format thread comments if they exist
  152. thread_context = ''
  153. if issue.thread_comments:
  154. thread_context = '\n\nIssue Thread Comments:\n' + '\n---\n'.join(
  155. issue.thread_comments
  156. )
  157. images = []
  158. images.extend(self._extract_image_urls(issue.body))
  159. images.extend(self._extract_image_urls(thread_context))
  160. template = jinja2.Template(prompt_template)
  161. return (
  162. template.render(
  163. body=issue.title + '\n\n' + issue.body + thread_context,
  164. repo_instruction=repo_instruction,
  165. ),
  166. images,
  167. )
  168. def guess_success(
  169. self, issue: GithubIssue, history: list[Event], llm_config: LLMConfig
  170. ) -> tuple[bool, None | list[bool], str]:
  171. """Guess if the issue is fixed based on the history and the issue description."""
  172. last_message = history[-1].message
  173. # Include thread comments in the prompt if they exist
  174. issue_context = issue.body
  175. if issue.thread_comments:
  176. issue_context += '\n\nIssue Thread Comments:\n' + '\n---\n'.join(
  177. issue.thread_comments
  178. )
  179. with open(
  180. os.path.join(
  181. os.path.dirname(__file__),
  182. 'prompts/guess_success/issue-success-check.jinja',
  183. ),
  184. 'r',
  185. ) as f:
  186. template = jinja2.Template(f.read())
  187. prompt = template.render(issue_context=issue_context, last_message=last_message)
  188. response = litellm.completion(
  189. model=llm_config.model,
  190. messages=[{'role': 'user', 'content': prompt}],
  191. api_key=llm_config.api_key,
  192. base_url=llm_config.base_url,
  193. )
  194. answer = response.choices[0].message.content.strip()
  195. pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)'
  196. match = re.search(pattern, answer)
  197. if match:
  198. return match.group(1).lower() == 'true', None, match.group(2)
  199. return False, None, f'Failed to decode answer from LLM response: {answer}'
  200. class PRHandler(IssueHandler):
  201. issue_type: ClassVar[str] = 'pr'
  202. def __init__(self, owner: str, repo: str, token: str):
  203. super().__init__(owner, repo, token)
  204. self.download_url = 'https://api.github.com/repos/{}/{}/pulls'
  205. def __download_pr_metadata(
  206. self, pull_number: int, comment_id: int | None = None
  207. ) -> tuple[list[str], list[int], list[str], list[ReviewThread], list[str]]:
  208. """Run a GraphQL query against the GitHub API for information.
  209. Retrieves information about:
  210. 1. unresolved review comments
  211. 2. referenced issues the pull request would close
  212. Args:
  213. pull_number: The number of the pull request to query.
  214. comment_id: Optional ID of a specific comment to focus on.
  215. query: The GraphQL query as a string.
  216. variables: A dictionary of variables for the query.
  217. token: Your GitHub personal access token.
  218. Returns:
  219. The JSON response from the GitHub API.
  220. """
  221. # Using graphql as REST API doesn't indicate resolved status for review comments
  222. # TODO: grabbing the first 10 issues, 100 review threads, and 100 coments; add pagination to retrieve all
  223. query = """
  224. query($owner: String!, $repo: String!, $pr: Int!) {
  225. repository(owner: $owner, name: $repo) {
  226. pullRequest(number: $pr) {
  227. closingIssuesReferences(first: 10) {
  228. edges {
  229. node {
  230. body
  231. number
  232. }
  233. }
  234. }
  235. url
  236. reviews(first: 100) {
  237. nodes {
  238. body
  239. state
  240. fullDatabaseId
  241. }
  242. }
  243. reviewThreads(first: 100) {
  244. edges{
  245. node{
  246. id
  247. isResolved
  248. comments(first: 100) {
  249. totalCount
  250. nodes {
  251. body
  252. path
  253. fullDatabaseId
  254. }
  255. }
  256. }
  257. }
  258. }
  259. }
  260. }
  261. }
  262. """
  263. variables = {'owner': self.owner, 'repo': self.repo, 'pr': pull_number}
  264. url = 'https://api.github.com/graphql'
  265. headers = {
  266. 'Authorization': f'Bearer {self.token}',
  267. 'Content-Type': 'application/json',
  268. }
  269. response = requests.post(
  270. url, json={'query': query, 'variables': variables}, headers=headers
  271. )
  272. response.raise_for_status()
  273. response_json = response.json()
  274. # Parse the response to get closing issue references and unresolved review comments
  275. pr_data = (
  276. response_json.get('data', {}).get('repository', {}).get('pullRequest', {})
  277. )
  278. # Get closing issues
  279. closing_issues = pr_data.get('closingIssuesReferences', {}).get('edges', [])
  280. closing_issues_bodies = [issue['node']['body'] for issue in closing_issues]
  281. closing_issue_numbers = [
  282. issue['node']['number'] for issue in closing_issues
  283. ] # Extract issue numbers
  284. # Get review comments
  285. reviews = pr_data.get('reviews', {}).get('nodes', [])
  286. if comment_id is not None:
  287. reviews = [
  288. review
  289. for review in reviews
  290. if int(review['fullDatabaseId']) == comment_id
  291. ]
  292. review_bodies = [review['body'] for review in reviews]
  293. # Get unresolved review threads
  294. review_threads = []
  295. thread_ids = [] # Store thread IDs; agent replies to the thread
  296. raw_review_threads = pr_data.get('reviewThreads', {}).get('edges', [])
  297. for thread in raw_review_threads:
  298. node = thread.get('node', {})
  299. if not node.get(
  300. 'isResolved', True
  301. ): # Check if the review thread is unresolved
  302. id = node.get('id')
  303. thread_contains_comment_id = False
  304. my_review_threads = node.get('comments', {}).get('nodes', [])
  305. message = ''
  306. files = []
  307. for i, review_thread in enumerate(my_review_threads):
  308. if (
  309. comment_id is not None
  310. and int(review_thread['fullDatabaseId']) == comment_id
  311. ):
  312. thread_contains_comment_id = True
  313. if (
  314. i == len(my_review_threads) - 1
  315. ): # Check if it's the last thread in the thread
  316. if len(my_review_threads) > 1:
  317. message += '---\n' # Add "---" before the last message if there's more than one thread
  318. message += 'latest feedback:\n' + review_thread['body'] + '\n'
  319. else:
  320. message += (
  321. review_thread['body'] + '\n'
  322. ) # Add each thread in a new line
  323. file = review_thread.get('path')
  324. if file and file not in files:
  325. files.append(file)
  326. if comment_id is None or thread_contains_comment_id:
  327. unresolved_thread = ReviewThread(comment=message, files=files)
  328. review_threads.append(unresolved_thread)
  329. thread_ids.append(id)
  330. return (
  331. closing_issues_bodies,
  332. closing_issue_numbers,
  333. review_bodies,
  334. review_threads,
  335. thread_ids,
  336. )
  337. # Override processing of downloaded issues
  338. def _get_pr_comments(
  339. self, pr_number: int, comment_id: int | None = None
  340. ) -> list[str] | None:
  341. """Download comments for a specific pull request from Github."""
  342. url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{pr_number}/comments'
  343. headers = {
  344. 'Authorization': f'token {self.token}',
  345. 'Accept': 'application/vnd.github.v3+json',
  346. }
  347. params = {'per_page': 100, 'page': 1}
  348. all_comments = []
  349. while True:
  350. response = requests.get(url, headers=headers, params=params)
  351. response.raise_for_status()
  352. comments = response.json()
  353. if not comments:
  354. break
  355. if comment_id is not None:
  356. matching_comment = next(
  357. (
  358. comment['body']
  359. for comment in comments
  360. if comment['id'] == comment_id
  361. ),
  362. None,
  363. )
  364. if matching_comment:
  365. return [matching_comment]
  366. else:
  367. all_comments.extend([comment['body'] for comment in comments])
  368. params['page'] += 1
  369. return all_comments if all_comments else None
  370. def __get_context_from_external_issues_references(
  371. self,
  372. closing_issues: list[str],
  373. closing_issue_numbers: list[int],
  374. issue_body: str,
  375. review_comments: list[str],
  376. review_threads: list[ReviewThread],
  377. thread_comments: list[str] | None,
  378. ):
  379. new_issue_references = []
  380. if issue_body:
  381. new_issue_references.extend(self._extract_issue_references(issue_body))
  382. if review_comments:
  383. for comment in review_comments:
  384. new_issue_references.extend(self._extract_issue_references(comment))
  385. if review_threads:
  386. for review_thread in review_threads:
  387. new_issue_references.extend(
  388. self._extract_issue_references(review_thread.comment)
  389. )
  390. if thread_comments:
  391. for thread_comment in thread_comments:
  392. new_issue_references.extend(
  393. self._extract_issue_references(thread_comment)
  394. )
  395. non_duplicate_references = set(new_issue_references)
  396. unique_issue_references = non_duplicate_references.difference(
  397. closing_issue_numbers
  398. )
  399. for issue_number in unique_issue_references:
  400. try:
  401. url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}'
  402. headers = {
  403. 'Authorization': f'Bearer {self.token}',
  404. 'Accept': 'application/vnd.github.v3+json',
  405. }
  406. response = requests.get(url, headers=headers)
  407. response.raise_for_status()
  408. issue_data = response.json()
  409. issue_body = issue_data.get('body', '')
  410. if issue_body:
  411. closing_issues.append(issue_body)
  412. except requests.exceptions.RequestException as e:
  413. logger.warning(f'Failed to fetch issue {issue_number}: {str(e)}')
  414. return closing_issues
  415. def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]:
  416. all_issues = self._download_issues_from_github()
  417. converted_issues = []
  418. for issue in all_issues:
  419. # For PRs, body can be None
  420. if any([issue.get(key) is None for key in ['number', 'title']]):
  421. logger.warning(f'Skipping #{issue} as it is missing number or title.')
  422. continue
  423. # Handle None body for PRs
  424. body = issue.get('body') if issue.get('body') is not None else ''
  425. (
  426. closing_issues,
  427. closing_issues_numbers,
  428. review_comments,
  429. review_threads,
  430. thread_ids,
  431. ) = self.__download_pr_metadata(issue['number'], comment_id=comment_id)
  432. head_branch = issue['head']['ref']
  433. # Get PR thread comments
  434. thread_comments = self._get_pr_comments(
  435. issue['number'], comment_id=comment_id
  436. )
  437. closing_issues = self.__get_context_from_external_issues_references(
  438. closing_issues,
  439. closing_issues_numbers,
  440. body,
  441. review_comments,
  442. review_threads,
  443. thread_comments,
  444. )
  445. issue_details = GithubIssue(
  446. owner=self.owner,
  447. repo=self.repo,
  448. number=issue['number'],
  449. title=issue['title'],
  450. body=body,
  451. closing_issues=closing_issues,
  452. review_comments=review_comments,
  453. review_threads=review_threads,
  454. thread_ids=thread_ids,
  455. head_branch=head_branch,
  456. thread_comments=thread_comments,
  457. )
  458. converted_issues.append(issue_details)
  459. return converted_issues
  460. def get_instruction(
  461. self,
  462. issue: GithubIssue,
  463. prompt_template: str,
  464. repo_instruction: str | None = None,
  465. ) -> tuple[str, list[str]]:
  466. """Generate instruction for the agent."""
  467. template = jinja2.Template(prompt_template)
  468. images = []
  469. issues_str = None
  470. if issue.closing_issues:
  471. issues_str = json.dumps(issue.closing_issues, indent=4)
  472. images.extend(self._extract_image_urls(issues_str))
  473. # Handle PRs with review comments
  474. review_comments_str = None
  475. if issue.review_comments:
  476. review_comments_str = json.dumps(issue.review_comments, indent=4)
  477. images.extend(self._extract_image_urls(review_comments_str))
  478. # Handle PRs with file-specific review comments
  479. review_thread_str = None
  480. review_thread_file_str = None
  481. if issue.review_threads:
  482. review_threads = [
  483. review_thread.comment for review_thread in issue.review_threads
  484. ]
  485. review_thread_files = []
  486. for review_thread in issue.review_threads:
  487. review_thread_files.extend(review_thread.files)
  488. review_thread_str = json.dumps(review_threads, indent=4)
  489. review_thread_file_str = json.dumps(review_thread_files, indent=4)
  490. images.extend(self._extract_image_urls(review_thread_str))
  491. # Format thread comments if they exist
  492. thread_context = ''
  493. if issue.thread_comments:
  494. thread_context = '\n\nPR Thread Comments:\n' + '\n---\n'.join(
  495. issue.thread_comments
  496. )
  497. images.extend(self._extract_image_urls(thread_context))
  498. instruction = template.render(
  499. issues=issues_str,
  500. review_comments=review_comments_str,
  501. review_threads=review_thread_str,
  502. files=review_thread_file_str,
  503. thread_context=thread_context,
  504. repo_instruction=repo_instruction,
  505. )
  506. return instruction, images
  507. def _check_feedback_with_llm(
  508. self, prompt: str, llm_config: LLMConfig
  509. ) -> tuple[bool, str]:
  510. """Helper function to check feedback with LLM and parse response."""
  511. response = litellm.completion(
  512. model=llm_config.model,
  513. messages=[{'role': 'user', 'content': prompt}],
  514. api_key=llm_config.api_key,
  515. base_url=llm_config.base_url,
  516. )
  517. answer = response.choices[0].message.content.strip()
  518. pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)'
  519. match = re.search(pattern, answer)
  520. if match:
  521. return match.group(1).lower() == 'true', match.group(2).strip()
  522. return False, f'Failed to decode answer from LLM response: {answer}'
  523. def _check_review_thread(
  524. self,
  525. review_thread: ReviewThread,
  526. issues_context: str,
  527. last_message: str,
  528. llm_config: LLMConfig,
  529. ) -> tuple[bool, str]:
  530. """Check if a review thread's feedback has been addressed."""
  531. files_context = json.dumps(review_thread.files, indent=4)
  532. with open(
  533. os.path.join(
  534. os.path.dirname(__file__),
  535. 'prompts/guess_success/pr-feedback-check.jinja',
  536. ),
  537. 'r',
  538. ) as f:
  539. template = jinja2.Template(f.read())
  540. prompt = template.render(
  541. issue_context=issues_context,
  542. feedback=review_thread.comment,
  543. files_context=files_context,
  544. last_message=last_message,
  545. )
  546. return self._check_feedback_with_llm(prompt, llm_config)
  547. def _check_thread_comments(
  548. self,
  549. thread_comments: list[str],
  550. issues_context: str,
  551. last_message: str,
  552. llm_config: LLMConfig,
  553. ) -> tuple[bool, str]:
  554. """Check if thread comments feedback has been addressed."""
  555. thread_context = '\n---\n'.join(thread_comments)
  556. with open(
  557. os.path.join(
  558. os.path.dirname(__file__), 'prompts/guess_success/pr-thread-check.jinja'
  559. ),
  560. 'r',
  561. ) as f:
  562. template = jinja2.Template(f.read())
  563. prompt = template.render(
  564. issue_context=issues_context,
  565. thread_context=thread_context,
  566. last_message=last_message,
  567. )
  568. return self._check_feedback_with_llm(prompt, llm_config)
  569. def _check_review_comments(
  570. self,
  571. review_comments: list[str],
  572. issues_context: str,
  573. last_message: str,
  574. llm_config: LLMConfig,
  575. ) -> tuple[bool, str]:
  576. """Check if review comments feedback has been addressed."""
  577. review_context = '\n---\n'.join(review_comments)
  578. with open(
  579. os.path.join(
  580. os.path.dirname(__file__), 'prompts/guess_success/pr-review-check.jinja'
  581. ),
  582. 'r',
  583. ) as f:
  584. template = jinja2.Template(f.read())
  585. prompt = template.render(
  586. issue_context=issues_context,
  587. review_context=review_context,
  588. last_message=last_message,
  589. )
  590. return self._check_feedback_with_llm(prompt, llm_config)
  591. def guess_success(
  592. self, issue: GithubIssue, history: list[Event], llm_config: LLMConfig
  593. ) -> tuple[bool, None | list[bool], str]:
  594. """Guess if the issue is fixed based on the history and the issue description."""
  595. last_message = history[-1].message
  596. issues_context = json.dumps(issue.closing_issues, indent=4)
  597. success_list = []
  598. explanation_list = []
  599. # Handle PRs with file-specific review comments
  600. if issue.review_threads:
  601. for review_thread in issue.review_threads:
  602. if issues_context and last_message:
  603. success, explanation = self._check_review_thread(
  604. review_thread, issues_context, last_message, llm_config
  605. )
  606. else:
  607. success, explanation = False, 'Missing context or message'
  608. success_list.append(success)
  609. explanation_list.append(explanation)
  610. # Handle PRs with only thread comments (no file-specific review comments)
  611. elif issue.thread_comments:
  612. if issue.thread_comments and issues_context and last_message:
  613. success, explanation = self._check_thread_comments(
  614. issue.thread_comments, issues_context, last_message, llm_config
  615. )
  616. else:
  617. success, explanation = (
  618. False,
  619. 'Missing thread comments, context or message',
  620. )
  621. success_list.append(success)
  622. explanation_list.append(explanation)
  623. elif issue.review_comments:
  624. # Handle PRs with only review comments (no file-specific review comments or thread comments)
  625. if issue.review_comments and issues_context and last_message:
  626. success, explanation = self._check_review_comments(
  627. issue.review_comments, issues_context, last_message, llm_config
  628. )
  629. else:
  630. success, explanation = (
  631. False,
  632. 'Missing review comments, context or message',
  633. )
  634. success_list.append(success)
  635. explanation_list.append(explanation)
  636. else:
  637. # No review comments, thread comments, or file-level review comments found
  638. return False, None, 'No feedback was found to process'
  639. # Return overall success (all must be true) and explanations
  640. if not success_list:
  641. return False, None, 'No feedback was processed'
  642. return all(success_list), success_list, '\n'.join(explanation_list)