issue_definitions.py 27 KB

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