main.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  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. print("Error while making the request. Status code:", response.status_code, "/nQuery:", query)
  48. sys.exit(1)
  49. return response.json()
  50. class Stats(object):
  51. """
  52. Retrieve and store statistics about Forgejo / Gitea usage.
  53. """
  54. 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):
  55. self.username = username
  56. self.git_url = git_url
  57. self._ignore_forked_repos = ignore_forked_repos
  58. self._exclude_repos = set() if exclude_repos is None else exclude_repos
  59. self._exclude_langs = set() if exclude_langs is None else exclude_langs
  60. self.queries = Queries(username, access_token, git_url)
  61. self._name: Optional[str] = None
  62. self._stargazers: Optional[int] = None
  63. self._forks: Optional[int] = None
  64. self._total_contributions: Optional[int] = None
  65. self._languages: Optional[Dict[str, Any]] = None
  66. self._repos: Optional[Set[str]] = None
  67. self._repo_list: Optional[Set[str]] = None
  68. self._lines_changed: Optional[Tuple[int, int]] = None
  69. self._views: Optional[int] = None
  70. self._streak: Optional[int] = None
  71. def get_stats(self) -> None:
  72. """
  73. Get lots of summary statistics. This is the main program for getting statistics.
  74. """
  75. print("[INFO] Querying:")
  76. print(" Name")
  77. self.name()
  78. print(" Repo list")
  79. self.repo_list()
  80. print(" Stargazers")
  81. self.stargazers()
  82. print(" Forks")
  83. self.forks()
  84. print(" Contributions")
  85. self.contributions()
  86. print(" Languages")
  87. self.languages()
  88. print(" Streak")
  89. self.streak()
  90. print_data = [
  91. ["User:", self._name],
  92. ["Repos:", self._repo_list],
  93. ["Stars:", self._stargazers],
  94. ["Forks:", self._forks],
  95. ["Lines changed:", self._lines_changed],
  96. ["Contributions:", self._total_contributions],
  97. ["Languages:", self._languages],
  98. ["Streak:", self._streak]
  99. ]
  100. max_len = max(len(row[0]) for row in print_data)
  101. for row in print_data:
  102. print(row[0].ljust(max_len) + " " + str(row[1]))
  103. def name(self) -> str:
  104. """
  105. :return: Forgejo / Gitea user's name (e.g., Tuxilio)
  106. """
  107. if self._name is not None:
  108. return self._name
  109. response = self.queries.query("/users/" + self.username)
  110. self._name = response['full_name']
  111. if self._name is None or self._name == "":
  112. self._name = self._name = response['username']
  113. return self._name
  114. def repo_list(self) -> str:
  115. """
  116. :return: Forgejo / Gitea repo list (e.g., [repo1, repo2])
  117. """
  118. if self._repo_list is not None:
  119. return self._repo_list
  120. self._repo_list = []
  121. response = self.queries.query("/users/" + self.username + "/repos")
  122. for repo in response:
  123. if not repo['private']:
  124. self._repo_list.append(repo['name'])
  125. return self.repo_list
  126. def stargazers(self) -> str:
  127. """
  128. :return: Forgejo / Gitea total stargazers (e.g., 24)
  129. """
  130. if self._stargazers is not None:
  131. return self._stargazers
  132. self._stargazers = 0
  133. for i in self._repo_list:
  134. response = self.queries.query("/repos/" + self.username + "/" + i + "/stargazers")
  135. for a in response:
  136. self._stargazers += 1
  137. return self._stargazers
  138. def forks(self) -> str:
  139. """
  140. :return: Forgejo / Gitea total forks (e.g., 12)
  141. """
  142. if self._forks is not None:
  143. return self._forks
  144. self._forks = 0
  145. for i in self._repo_list:
  146. response = self.queries.query("/repos/" + self.username + "/" + i + "/forks")
  147. for a in response:
  148. self._forks += 1
  149. return self._forks
  150. def contributions(self) -> str:
  151. """
  152. :return: Forgejo / Gitea total lines changed (e.g., 35011)
  153. :return: Forgejo / Gitea total contributions (e.g., 514)
  154. """
  155. if self._lines_changed is not None and self._total_contributions is not None:
  156. return self._lines_changed, self._total_contributions
  157. self._lines_changed = 0
  158. self._total_contributions = 0
  159. for i in self._repo_list:
  160. response = self.queries.query("/repos/" + self.username + "/" + i + "/commits")
  161. for a in response:
  162. self._total_contributions += 1
  163. self._lines_changed += a['stats']['total']
  164. return self._lines_changed, self._total_contributions
  165. def languages(self) -> str:
  166. """
  167. :return: Most used languages (e.g., ["Py"thon":20, "Java":80])
  168. """
  169. if self._languages is not None:
  170. return self._languages
  171. language_data = {}
  172. for i in self._repo_list:
  173. response = self.queries.query("/repos/" + self.username + "/" + i + "/languages")
  174. for language, bytes in response.items():
  175. if language in language_data:
  176. language_data[language] += bytes
  177. else:
  178. language_data[language] = bytes
  179. total_bytes = sum(language_data.values())
  180. self._languages = {language: round(bytes / total_bytes * 100, 2) for language, bytes in language_data.items()}
  181. return self._languages
  182. def streak(self):
  183. """
  184. :return: Streak length
  185. """
  186. if self._streak is not None:
  187. return self._streak
  188. self._streak = 0
  189. last_timestamp = None
  190. response = self.queries.query("/users/" + self.username + "/heatmap")
  191. for day in response:
  192. if last_timestamp is not None:
  193. if day['timestamp'] - last_timestamp > 900:
  194. self._streak = 0
  195. if day['contributions'] > 0:
  196. self._streak += 1
  197. else:
  198. break
  199. last_timestamp = day['timestamp']
  200. return self._streak
  201. class Generate(object):
  202. """
  203. Class for generating images from data generated in stats
  204. """
  205. def __init__(self, stats, exclude_repos: Optional[Set] = None, exclude_langs: Optional[Set] = None, ignore_forked_repos: bool = False):
  206. self.stats = stats
  207. def generate_output_folder(self) -> None:
  208. """
  209. Create the output folder if it does not already exist
  210. """
  211. if not os.path.isdir("generated"):
  212. os.mkdir("generated")
  213. def generate_overview(self) -> None:
  214. """
  215. Generate an SVG badge with summary statistics
  216. """
  217. with open("templates/overview.svg", "r") as f:
  218. output = f.read()
  219. output = re.sub("{{ name }}", self.stats._name, output)
  220. output = re.sub("{{ stars }}", f"{self.stats._stargazers:,}", output)
  221. output = re.sub("{{ forks }}", f"{self.stats._forks:,}", output)
  222. output = re.sub("{{ contributions }}", f"{self.stats._total_contributions:,}", output)
  223. changed = sum(self.stats._lines_changed) if isinstance(self.stats._lines_changed, tuple) else self.stats._lines_changed
  224. output = re.sub("{{ lines_changed }}", f"{changed:,}", output)
  225. """views_str = f"{self.stats._views:,}" if self.stats._views is not None else "N/A"
  226. output = re.sub("{{ views }}", views_str, output)
  227. repos_len = len(self.stats._repos) if self.stats._repos is not None else 0
  228. output = re.sub("{{ repos }}", f"{repos_len:,}", output)"""
  229. self.generate_output_folder()
  230. with open("generated/overview.svg", "w") as f:
  231. f.write(output)
  232. def generate_languages(self) -> None:
  233. """
  234. Generate an SVG badge with summary languages used
  235. """
  236. with open("templates/languages.svg", "r") as f:
  237. output = f.read()
  238. with open('colors.json') as d:
  239. color_data = json.load(d)
  240. progress = ""
  241. lang_list = ""
  242. # Sort languages based on their percentage
  243. sorted_languages = sorted(
  244. self.stats._languages.items(),
  245. reverse=True,
  246. key=lambda t: t[1] # Sort based on the language percentage directly
  247. )
  248. delay_between = 150
  249. for i, (lang, data) in enumerate(sorted_languages):
  250. if lang in color_data:
  251. color = color_data[lang]['color']
  252. else:
  253. color = "#000000"
  254. progress += (
  255. f'<span style="background-color: {color};'
  256. f'width: {data:0.3f}%;" '
  257. f'class="progress-item"></span>'
  258. )
  259. lang_list += f"""
  260. <li style="animation-delay: {i * delay_between}ms;">
  261. <svg xmlns="http://www.w3.org/2000/svg" class="octicon" style="fill:{color};"
  262. viewBox="0 0 16 16" version="1.1" width="16" height="16"><path
  263. fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8z"></path></svg>
  264. <span class="lang">{lang}</span>
  265. <span class="percent">{data:0.2f}%</span>
  266. </li>
  267. """
  268. output = re.sub(r"{{ progress }}", progress, output)
  269. output = re.sub(r"{{ lang_list }}", lang_list, output)
  270. self.generate_output_folder()
  271. with open("generated/languages.svg", "w") as f:
  272. f.write(output)
  273. ###############################################################################
  274. # Main Function
  275. ###############################################################################
  276. def main() -> None:
  277. """
  278. Main program
  279. """
  280. access_token = os.getenv("ACCESS_TOKEN")
  281. user = os.getenv("USER")
  282. git_url = os.getenv("GIT_URL")
  283. print("Generating stats for user " + user)
  284. if access_token is None or user is None:
  285. raise RuntimeError(
  286. "ACCESS_TOKEN and USERNAME environment variables cannot be None!"
  287. )
  288. stats = Stats(user, access_token, git_url)
  289. stats.get_stats()
  290. generate = Generate(stats)
  291. generate.generate_overview()
  292. generate.generate_languages()
  293. if __name__ == "__main__":
  294. main()