github.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import concurrent.futures
  2. import hashlib
  3. import logging
  4. import re
  5. import shlex
  6. from typing import Any, Optional
  7. from tqdm import tqdm
  8. from embedchain.loaders.base_loader import BaseLoader
  9. from embedchain.utils.misc import clean_string
  10. GITHUB_URL = "https://github.com"
  11. GITHUB_API_URL = "https://api.github.com"
  12. VALID_SEARCH_TYPES = set(["code", "repo", "pr", "issue", "discussion", "branch", "file"])
  13. class GithubLoader(BaseLoader):
  14. """Load data from GitHub search query."""
  15. def __init__(self, config: Optional[dict[str, Any]] = None):
  16. super().__init__()
  17. if not config:
  18. raise ValueError(
  19. "GithubLoader requires a personal access token to use github api. Check - `https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic`" # noqa: E501
  20. )
  21. try:
  22. from github import Github
  23. except ImportError as e:
  24. raise ValueError(
  25. "GithubLoader requires extra dependencies. Install with `pip install --upgrade 'embedchain[github]'`"
  26. ) from e
  27. self.config = config
  28. token = config.get("token")
  29. if not token:
  30. raise ValueError(
  31. "GithubLoader requires a personal access token to use github api. Check - `https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic`" # noqa: E501
  32. )
  33. try:
  34. self.client = Github(token)
  35. except Exception as e:
  36. logging.error(f"GithubLoader failed to initialize client: {e}")
  37. self.client = None
  38. def _github_search_code(self, query: str):
  39. """Search GitHub code."""
  40. data = []
  41. results = self.client.search_code(query)
  42. for result in tqdm(results, total=results.totalCount, desc="Loading code files from github"):
  43. url = result.html_url
  44. logging.info(f"Added data from url: {url}")
  45. content = result.decoded_content.decode("utf-8")
  46. metadata = {
  47. "url": url,
  48. }
  49. data.append(
  50. {
  51. "content": clean_string(content),
  52. "meta_data": metadata,
  53. }
  54. )
  55. return data
  56. def _get_github_repo_data(self, repo_name: str, branch_name: str = None, file_path: str = None) -> list[dict]:
  57. """Get file contents from Repo"""
  58. data = []
  59. repo = self.client.get_repo(repo_name)
  60. repo_contents = repo.get_contents("")
  61. if branch_name:
  62. repo_contents = repo.get_contents("", ref=branch_name)
  63. if file_path:
  64. repo_contents = [repo.get_contents(file_path)]
  65. with tqdm(desc="Loading files:", unit="item") as progress_bar:
  66. while repo_contents:
  67. file_content = repo_contents.pop(0)
  68. if file_content.type == "dir":
  69. try:
  70. repo_contents.extend(repo.get_contents(file_content.path))
  71. except Exception:
  72. logging.warning(f"Failed to read directory: {file_content.path}")
  73. progress_bar.update(1)
  74. continue
  75. else:
  76. try:
  77. file_text = file_content.decoded_content.decode()
  78. except Exception:
  79. logging.warning(f"Failed to read file: {file_content.path}")
  80. progress_bar.update(1)
  81. continue
  82. file_path = file_content.path
  83. data.append(
  84. {
  85. "content": clean_string(file_text),
  86. "meta_data": {
  87. "path": file_path,
  88. },
  89. }
  90. )
  91. progress_bar.update(1)
  92. return data
  93. def _github_search_repo(self, query: str) -> list[dict]:
  94. """Search GitHub repo."""
  95. logging.info(f"Searching github repos with query: {query}")
  96. updated_query = query.split(":")[-1]
  97. data = self._get_github_repo_data(updated_query)
  98. return data
  99. def _github_search_issues_and_pr(self, query: str, type: str) -> list[dict]:
  100. """Search GitHub issues and PRs."""
  101. data = []
  102. query = f"{query} is:{type}"
  103. logging.info(f"Searching github for query: {query}")
  104. results = self.client.search_issues(query)
  105. logging.info(f"Total results: {results.totalCount}")
  106. for result in tqdm(results, total=results.totalCount, desc=f"Loading {type} from github"):
  107. url = result.html_url
  108. title = result.title
  109. body = result.body
  110. if not body:
  111. logging.warning(f"Skipping issue because empty content for: {url}")
  112. continue
  113. labels = " ".join([label.name for label in result.labels])
  114. issue_comments = result.get_comments()
  115. comments = []
  116. comments_created_at = []
  117. for comment in issue_comments:
  118. comments_created_at.append(str(comment.created_at))
  119. comments.append(f"{comment.user.name}:{comment.body}")
  120. content = "\n".join([title, labels, body, *comments])
  121. metadata = {
  122. "url": url,
  123. "created_at": str(result.created_at),
  124. "comments_created_at": " ".join(comments_created_at),
  125. }
  126. data.append(
  127. {
  128. "content": clean_string(content),
  129. "meta_data": metadata,
  130. }
  131. )
  132. return data
  133. # need to test more for discussion
  134. def _github_search_discussions(self, query: str):
  135. """Search GitHub discussions."""
  136. data = []
  137. query = f"{query} is:discussion"
  138. logging.info(f"Searching github repo for query: {query}")
  139. repos_results = self.client.search_repositories(query)
  140. logging.info(f"Total repos found: {repos_results.totalCount}")
  141. for repo_result in tqdm(repos_results, total=repos_results.totalCount, desc="Loading discussions from github"):
  142. teams = repo_result.get_teams()
  143. for team in teams:
  144. team_discussions = team.get_discussions()
  145. for discussion in team_discussions:
  146. url = discussion.html_url
  147. title = discussion.title
  148. body = discussion.body
  149. if not body:
  150. logging.warning(f"Skipping discussion because empty content for: {url}")
  151. continue
  152. comments = []
  153. comments_created_at = []
  154. print("Discussion comments: ", discussion.comments_url)
  155. content = "\n".join([title, body, *comments])
  156. metadata = {
  157. "url": url,
  158. "created_at": str(discussion.created_at),
  159. "comments_created_at": " ".join(comments_created_at),
  160. }
  161. data.append(
  162. {
  163. "content": clean_string(content),
  164. "meta_data": metadata,
  165. }
  166. )
  167. return data
  168. def _get_github_repo_branch(self, query: str, type: str) -> list[dict]:
  169. """Get file contents for specific branch"""
  170. logging.info(f"Searching github repo for query: {query} is:{type}")
  171. pattern = r"repo:(\S+) name:(\S+)"
  172. match = re.search(pattern, query)
  173. if match:
  174. repo_name = match.group(1)
  175. branch_name = match.group(2)
  176. else:
  177. raise ValueError(
  178. f"Repository name and Branch name not found, instead found this \
  179. Repo: {repo_name}, Branch: {branch_name}"
  180. )
  181. data = self._get_github_repo_data(repo_name=repo_name, branch_name=branch_name)
  182. return data
  183. def _get_github_repo_file(self, query: str, type: str) -> list[dict]:
  184. """Get specific file content"""
  185. logging.info(f"Searching github repo for query: {query} is:{type}")
  186. pattern = r"repo:(\S+) path:(\S+)"
  187. match = re.search(pattern, query)
  188. if match:
  189. repo_name = match.group(1)
  190. file_path = match.group(2)
  191. else:
  192. raise ValueError(
  193. f"Repository name and File name not found, instead found this Repo: {repo_name}, File: {file_path}"
  194. )
  195. data = self._get_github_repo_data(repo_name=repo_name, file_path=file_path)
  196. return data
  197. def _search_github_data(self, search_type: str, query: str):
  198. """Search github data."""
  199. if search_type == "code":
  200. data = self._github_search_code(query)
  201. elif search_type == "repo":
  202. data = self._github_search_repo(query)
  203. elif search_type == "issue":
  204. data = self._github_search_issues_and_pr(query, search_type)
  205. elif search_type == "pr":
  206. data = self._github_search_issues_and_pr(query, search_type)
  207. elif search_type == "branch":
  208. data = self._get_github_repo_branch(query, search_type)
  209. elif search_type == "file":
  210. data = self._get_github_repo_file(query, search_type)
  211. elif search_type == "discussion":
  212. raise ValueError("GithubLoader does not support searching discussions yet.")
  213. else:
  214. raise NotImplementedError(f"{search_type} not supported")
  215. return data
  216. @staticmethod
  217. def _get_valid_github_query(query: str):
  218. """Check if query is valid and return search types and valid GitHub query."""
  219. query_terms = shlex.split(query)
  220. # query must provide repo to load data from
  221. if len(query_terms) < 1 or "repo:" not in query:
  222. raise ValueError(
  223. "GithubLoader requires a search query with `repo:` term. Refer docs - `https://docs.embedchain.ai/data-sources/github`" # noqa: E501
  224. )
  225. github_query = []
  226. types = set()
  227. type_pattern = r"type:([a-zA-Z,]+)"
  228. for term in query_terms:
  229. term_match = re.search(type_pattern, term)
  230. if term_match:
  231. search_types = term_match.group(1).split(",")
  232. types.update(search_types)
  233. else:
  234. github_query.append(term)
  235. # query must provide search type
  236. if len(types) == 0:
  237. raise ValueError(
  238. "GithubLoader requires a search query with `type:` term. Refer docs - `https://docs.embedchain.ai/data-sources/github`" # noqa: E501
  239. )
  240. for search_type in search_types:
  241. if search_type not in VALID_SEARCH_TYPES:
  242. raise ValueError(
  243. f"Invalid search type: {search_type}. Valid types are: {', '.join(VALID_SEARCH_TYPES)}"
  244. )
  245. query = " ".join(github_query)
  246. return types, query
  247. def load_data(self, search_query: str, max_results: int = 1000):
  248. """Load data from GitHub search query."""
  249. if not self.client:
  250. raise ValueError(
  251. "GithubLoader client is not initialized, data will not be loaded. Refer docs - `https://docs.embedchain.ai/data-sources/github`" # noqa: E501
  252. )
  253. search_types, query = self._get_valid_github_query(search_query)
  254. logging.info(f"Searching github for query: {query}, with types: {', '.join(search_types)}")
  255. data = []
  256. with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
  257. futures_map = executor.map(self._search_github_data, search_types, [query] * len(search_types))
  258. for search_data in tqdm(futures_map, total=len(search_types), desc="Searching data from github"):
  259. data.extend(search_data)
  260. return {
  261. "doc_id": hashlib.sha256(query.encode()).hexdigest(),
  262. "data": data,
  263. }