issue_definitions.py 28 KB

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