main.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. #!/usr/bin/python3
  2. """
  3. Forgejo / Gitea Stats Generator
  4. Copyright (C) 2022 Jacob Strieb
  5. Copyright (C) 2024 Tuxilio
  6. This program is free software: you can redistribute it and/or modify
  7. it under the terms of the GNU General Public License as published by
  8. the Free Software Foundation, either version 3 of the License, or
  9. (at your option) any later version.
  10. This program is distributed in the hope that it will be useful,
  11. but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. GNU General Public License for more details.
  14. You should have received a copy of the GNU General Public License
  15. along with this program. If not, see <https://www.gnu.org/licenses/>.
  16. """
  17. import os
  18. import re
  19. from typing import Dict, List, Optional, Set, Tuple, Any, cast
  20. import json
  21. import sys
  22. import requests
  23. ###############################################################################
  24. # Main Classes
  25. ###############################################################################
  26. class Queries(object):
  27. """
  28. Class with functions to query the Forgejo / Gitea API.
  29. """
  30. def __init__(self, username: str, access_token: str, git_url: str):
  31. self.username = username
  32. self.access_token = access_token
  33. self.git_url = git_url
  34. def query(self, query: str) -> Dict:
  35. """
  36. Make a request to the Forgejo / Gitea API using the authentication token from
  37. the environment
  38. :param query: string query to be sent to the API
  39. :return: JSON output
  40. """
  41. headers = {
  42. "accept": "application/json"
  43. }
  44. generated_query = self.git_url + "/api/v1" + query + "?token=" + self.access_token
  45. response = requests.get(generated_query, headers=headers)
  46. if response.status_code != 200:
  47. if respone.status_code == 409:
  48. print("Warning: Request failed with status code 409, /nQuery:", query)
  49. print("This may be becuase code is disabled for a repo, but could also be a bug.")
  50. return {}
  51. else:
  52. print("Error while making the request. Status code:", response.status_code, "/nQuery:", query)
  53. sys.exit(1)
  54. return response.json()
  55. class Stats(object):
  56. """
  57. Retrieve and store statistics about Forgejo / Gitea usage.
  58. """
  59. def __init__(self, username: str, access_token: str, git_url: str, exclude_repos: Optional[Set] = None, exclude_langs: Optional[Set] = None, ignore_forked_repos: bool = False):
  60. self.username = username
  61. self.git_url = git_url
  62. self._ignore_forked_repos = ignore_forked_repos
  63. self._exclude_repos = set() if exclude_repos is None else exclude_repos
  64. self._exclude_langs = set() if exclude_langs is None else exclude_langs
  65. self.queries = Queries(username, access_token, git_url)
  66. self._name: Optional[str] = None
  67. self._stargazers: Optional[int] = None
  68. self._forks: Optional[int] = None
  69. self._total_contributions: Optional[int] = None
  70. self._languages: Optional[Dict[str, Any]] = None
  71. self._repos: Optional[Set[str]] = None
  72. self._repo_list: Optional[Set[str]] = None
  73. self._lines_changed: Optional[Tuple[int, int]] = None
  74. self._views: Optional[int] = None
  75. self._streak: Optional[int] = None
  76. def get_stats(self) -> None:
  77. """
  78. Get lots of summary statistics. This is the main program for getting statistics.
  79. """
  80. print("[INFO] Querying:")
  81. print(" Name")
  82. self.name()
  83. print(" Repo list")
  84. self.repo_list()
  85. print(" Stargazers")
  86. self.stargazers()
  87. print(" Forks")
  88. self.forks()
  89. print(" Contributions")
  90. self.contributions()
  91. print(" Languages")
  92. self.languages()
  93. print(" Streak")
  94. self.streak()
  95. print_data = [
  96. ["User:", self._name],
  97. ["Repos:", self._repo_list],
  98. ["Stars:", self._stargazers],
  99. ["Forks:", self._forks],
  100. ["Lines changed:", self._lines_changed],
  101. ["Contributions:", self._total_contributions],
  102. ["Languages:", self._languages],
  103. ["Streak:", self._streak]
  104. ]
  105. max_len = max(len(row[0]) for row in print_data)
  106. for row in print_data:
  107. print(row[0].ljust(max_len) + " " + str(row[1]))
  108. def name(self) -> str:
  109. """
  110. :return: Forgejo / Gitea user's name (e.g., Tuxilio)
  111. """
  112. if self._name is not None:
  113. return self._name
  114. response = self.queries.query("/users/" + self.username)
  115. self._name = response['full_name']
  116. if self._name is None or self._name == "":
  117. self._name = self._name = response['username']
  118. return self._name
  119. def repo_list(self) -> str:
  120. """
  121. :return: Forgejo / Gitea repo list (e.g., [repo1, repo2])
  122. """
  123. if self._repo_list is not None:
  124. return self._repo_list
  125. self._repo_list = []
  126. response = self.queries.query("/users/" + self.username + "/repos")
  127. for repo in response:
  128. if not repo['private']:
  129. self._repo_list.append(repo['name'])
  130. return self.repo_list
  131. def stargazers(self) -> str:
  132. """
  133. :return: Forgejo / Gitea total stargazers (e.g., 24)
  134. """
  135. if self._stargazers is not None:
  136. return self._stargazers
  137. self._stargazers = 0
  138. for i in self._repo_list:
  139. response = self.queries.query("/repos/" + self.username + "/" + i + "/stargazers")
  140. for a in response:
  141. self._stargazers += 1
  142. return self._stargazers
  143. def forks(self) -> str:
  144. """
  145. :return: Forgejo / Gitea total forks (e.g., 12)
  146. """
  147. if self._forks is not None:
  148. return self._forks
  149. self._forks = 0
  150. for i in self._repo_list:
  151. response = self.queries.query("/repos/" + self.username + "/" + i + "/forks")
  152. for a in response:
  153. self._forks += 1
  154. return self._forks
  155. def contributions(self) -> str:
  156. """
  157. :return: Forgejo / Gitea total lines changed (e.g., 35011)
  158. :return: Forgejo / Gitea total contributions (e.g., 514)
  159. """
  160. if self._lines_changed is not None and self._total_contributions is not None:
  161. return self._lines_changed, self._total_contributions
  162. self._lines_changed = 0
  163. self._total_contributions = 0
  164. for i in self._repo_list:
  165. response = self.queries.query("/repos/" + self.username + "/" + i + "/commits")
  166. for a in response:
  167. self._total_contributions += 1
  168. self._lines_changed += a['stats']['total']
  169. return self._lines_changed, self._total_contributions
  170. def languages(self) -> str:
  171. """
  172. :return: Most used languages (e.g., ["Py"thon":20, "Java":80])
  173. """
  174. if self._languages is not None:
  175. return self._languages
  176. language_data = {}
  177. for i in self._repo_list:
  178. response = self.queries.query("/repos/" + self.username + "/" + i + "/languages")
  179. for language, bytes in response.items():
  180. if language in language_data:
  181. language_data[language] += bytes
  182. else:
  183. language_data[language] = bytes
  184. total_bytes = sum(language_data.values())
  185. self._languages = {language: round(bytes / total_bytes * 100, 2) for language, bytes in language_data.items()}
  186. return self._languages
  187. def streak(self):
  188. """
  189. :return: Streak length
  190. """
  191. if self._streak is not None:
  192. return self._streak
  193. self._streak = 0
  194. last_timestamp = None
  195. response = self.queries.query("/users/" + self.username + "/heatmap")
  196. for day in response:
  197. if last_timestamp is not None:
  198. if day['timestamp'] - last_timestamp > 900:
  199. self._streak = 0
  200. if day['contributions'] > 0:
  201. self._streak += 1
  202. else:
  203. break
  204. last_timestamp = day['timestamp']
  205. return self._streak
  206. class Generate(object):
  207. """
  208. Class for generating images from data generated in stats
  209. """
  210. def __init__(self, stats, exclude_repos: Optional[Set] = None, exclude_langs: Optional[Set] = None, ignore_forked_repos: bool = False):
  211. self.stats = stats
  212. def generate_output_folder(self) -> None:
  213. """
  214. Create the output folder if it does not already exist
  215. """
  216. if not os.path.isdir("generated"):
  217. os.mkdir("generated")
  218. def generate_overview(self) -> None:
  219. """
  220. Generate an SVG badge with summary statistics
  221. """
  222. with open("templates/overview.svg", "r") as f:
  223. output = f.read()
  224. output = re.sub("{{ name }}", self.stats._name, output)
  225. output = re.sub("{{ stars }}", f"{self.stats._stargazers:,}", output)
  226. output = re.sub("{{ forks }}", f"{self.stats._forks:,}", output)
  227. output = re.sub("{{ contributions }}", f"{self.stats._total_contributions:,}", output)
  228. changed = sum(self.stats._lines_changed) if isinstance(self.stats._lines_changed, tuple) else self.stats._lines_changed
  229. output = re.sub("{{ lines_changed }}", f"{changed:,}", output)
  230. """views_str = f"{self.stats._views:,}" if self.stats._views is not None else "N/A"
  231. output = re.sub("{{ views }}", views_str, output)
  232. repos_len = len(self.stats._repos) if self.stats._repos is not None else 0
  233. output = re.sub("{{ repos }}", f"{repos_len:,}", output)"""
  234. self.generate_output_folder()
  235. with open("generated/overview.svg", "w") as f:
  236. f.write(output)
  237. def generate_languages(self) -> None:
  238. """
  239. Generate an SVG badge with summary languages used
  240. """
  241. with open("templates/languages.svg", "r") as f:
  242. output = f.read()
  243. with open('colors.json') as d:
  244. color_data = json.load(d)
  245. progress = ""
  246. lang_list = ""
  247. # Sort languages based on their percentage
  248. sorted_languages = sorted(
  249. self.stats._languages.items(),
  250. reverse=True,
  251. key=lambda t: t[1] # Sort based on the language percentage directly
  252. )
  253. delay_between = 150
  254. for i, (lang, data) in enumerate(sorted_languages):
  255. if lang in color_data:
  256. color = color_data[lang]['color']
  257. else:
  258. color = "#000000"
  259. progress += (
  260. f'<span style="background-color: {color};'
  261. f'width: {data:0.3f}%;" '
  262. f'class="progress-item"></span>'
  263. )
  264. lang_list += f"""
  265. <li style="animation-delay: {i * delay_between}ms;">
  266. <svg xmlns="http://www.w3.org/2000/svg" class="octicon" style="fill:{color};"
  267. viewBox="0 0 16 16" version="1.1" width="16" height="16"><path
  268. fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8z"></path></svg>
  269. <span class="lang">{lang}</span>
  270. <span class="percent">{data:0.2f}%</span>
  271. </li>
  272. """
  273. output = re.sub(r"{{ progress }}", progress, output)
  274. output = re.sub(r"{{ lang_list }}", lang_list, output)
  275. self.generate_output_folder()
  276. with open("generated/languages.svg", "w") as f:
  277. f.write(output)
  278. ###############################################################################
  279. # Main Function
  280. ###############################################################################
  281. def main() -> None:
  282. """
  283. Main program
  284. """
  285. access_token = os.getenv("ACCESS_TOKEN")
  286. user = os.getenv("USER")
  287. git_url = os.getenv("GIT_URL")
  288. print("Generating stats for user " + user)
  289. if access_token is None or user is None:
  290. raise RuntimeError(
  291. "ACCESS_TOKEN and USERNAME environment variables cannot be None!"
  292. )
  293. stats = Stats(user, access_token, git_url)
  294. stats.get_stats()
  295. generate = Generate(stats)
  296. generate.generate_overview()
  297. generate.generate_languages()
  298. if __name__ == "__main__":
  299. main()