Merge pull request #371 from anmol098/feat/code_cleanup

Code Cleanup
This commit is contained in:
Alexander Sergeev
2023-02-22 18:47:28 +01:00
committed by GitHub
19 changed files with 724 additions and 635 deletions

View File

@@ -1,4 +1,5 @@
INPUT_WAKATIME_API_KEY=YOUR_WAKATIME_API_KEY INPUT_WAKATIME_API_KEY=YOUR_WAKATIME_API_KEY
INPUT_GH_TOKEN=YOUR_GITHUB_TOKEN_KEY
INPUT_PUSH_BRANCH_NAME=main INPUT_PUSH_BRANCH_NAME=main
INPUT_SECTION_NAME=waka INPUT_SECTION_NAME=waka
INPUT_SHOW_TIMEZONE=True INPUT_SHOW_TIMEZONE=True
@@ -6,7 +7,6 @@ INPUT_SHOW_PROJECTS=True
INPUT_SHOW_EDITORS=True INPUT_SHOW_EDITORS=True
INPUT_SHOW_OS=True INPUT_SHOW_OS=True
INPUT_SHOW_LANGUAGE=True INPUT_SHOW_LANGUAGE=True
INPUT_GH_TOKEN=YOUR_GITHUB_TOKEN_KEY
INPUT_SYMBOL_VERSION=1 INPUT_SYMBOL_VERSION=1
INPUT_SHOW_LINES_OF_CODE=True INPUT_SHOW_LINES_OF_CODE=True
INPUT_SHOW_LOC_CHART=True INPUT_SHOW_LOC_CHART=True

View File

@@ -30,7 +30,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
- name: Build and push Docker image 🏗️ - name: Build and push Docker image 🏗️
uses: docker/build-push-action@v3 uses: docker/build-push-action@v4
with: with:
push: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/releases') }} push: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/releases') }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}

24
.github/workflows/codestyle.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: CODESTYLE
on:
push:
jobs:
lint:
name: Run codestyle check
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v3
- name: Setup Python 3.8 🐍
uses: actions/setup-python@v4
with:
python-version: 3.8
- name: Install Dependencies 📥
run: pip install -r requirements.txt
- name: Run Codestyle ✔️
run: flake8 --max-line-length=160 --exclude venv,assets . && black --line-length=160 --check --exclude='/venv/|/assets/' .

7
.gitignore vendored
View File

@@ -2,19 +2,14 @@
*.env *.env
# Generated graph images: # Generated graph images:
*.png assets/
# Library roots: # Library roots:
node_modules/
venv/ venv/
# Python caches: # Python caches:
__pycache__/ __pycache__/
# Package manager configuration files:
package.json
package-lock.json
# IDE configuration files: # IDE configuration files:
.vscode .vscode
.idea .idea

View File

@@ -21,7 +21,7 @@ Once you've worked on your feature/bugfix etc, you can open a pull request using
### Setting up development environment ### Setting up development environment
This project is written in Python, requires **Python 3.6 or higher**, and uses `pip` . This project is written in Python, requires **Python 3.8 or higher**, and uses `pip` .
To set it up, just fork + clone it, install all the dependencies: To set it up, just fork + clone it, install all the dependencies:

View File

@@ -3,10 +3,10 @@ FROM python:3.9-alpine
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONDONTWRITEBYTECODE 1
WORKDIR /waka-readme-stats RUN mkdir -p /waka-readme-stats/assets
ADD requirements.txt ./requirements.txt ADD requirements.txt /waka-readme-stats/requirements.txt
RUN apk add --no-cache g++ jpeg-dev zlib-dev libjpeg make && pip3 install -r requirements.txt RUN apk add --no-cache g++ jpeg-dev zlib-dev libjpeg make && pip3 install -r /waka-readme-stats/requirements.txt
ADD sources/* ./ ADD sources/* /waka-readme-stats/
ENTRYPOINT python3 /waka-readme-stats/main.py ENTRYPOINT cd /waka-readme-stats/ && python3 main.py

View File

@@ -1,15 +1,18 @@
.ONESHELL: .ONESHELL:
.DEFAULT_GOAL = help .DEFAULT_GOAL = help
SHELL = /bin/bash .EXPORT_ALL_VARIABLES:
PATH := venv/bin:node_modules/.bin:$(PATH) PATH := venv/bin:$(PATH)
ENV = .env.example
include $(ENV)
help: help:
@ # Print help commands @ # Print help commands
echo "Welcome to 'waka-readme-stats' GitHub Actions!" echo "Welcome to 'waka-readme-stats' GitHub Actions!"
echo "The action can be tested locally with: 'make run'." echo "The action can be tested locally with: 'make run'."
echo "NB! For local testing Python version 3.6+ and NodeJS version between 14 and 16 are required." echo "NB! For local testing Python version 3.8+ is required."
echo "The action image can be built locally with: 'make container'." echo "The action image can be built locally with: 'make container'."
echo "NB! For local container building Docker version 20+ is required." echo "NB! For local container building Docker version 20+ is required."
echo "The action directory and image can be cleaned with: 'make clean'." echo "The action directory and image can be cleaned with: 'make clean'."
@@ -21,28 +24,30 @@ venv:
pip install --upgrade pip pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
node_modules:
@ # Install NodeJS dependencies
npm i npm@next-8
npm i vega vega-lite vega-cli canvas
run-locally: venv
run-locally: venv node_modules
@ # Run action locally @ # Run action locally
source <(cat .env.example | sed 's/=/=/' | sed 's/^/export /') && python3 ./sources/main.py mkdir ./assets/ 2>/dev/null || true
python3 ./sources/main.py
.PHONY: run-locally .PHONY: run-locally
run-container: run-container:
@ # Run action in container @ # Run action in container
docker build -t waka-readme-stats -f Dockerfile . docker build -t waka-readme-stats -f Dockerfile .
docker run --env-file .env.example waka-readme-stats docker run --env-file $(ENV) -v ./assets/:/waka-readme-stats/assets/ waka-readme-stats
.PHONY: run-container .PHONY: run-container
lint: venv
@ # Run flake8 and black linters
flake8 --max-line-length=160 --exclude venv,assets .
black --line-length=160 --exclude='/venv/|/assets/' .
.PHONY: lint
clean: clean:
@ # Clean all build files, including: libraries, package manager configs, docker images and containers @ # Clean all build files, including: libraries, package manager configs, docker images and containers
rm -rf venv rm -rf venv
rm -rf node_modules rm -rf assets
rm -f package*.json rm -f package*.json
docker rm -f waka-readme-stats 2>/dev/null || true docker rm -f waka-readme-stats 2>/dev/null || true
docker rmi $(docker images | grep "waka-readme-stats") 2> /dev/null || true docker rmi $(docker images | grep "waka-readme-stats") 2> /dev/null || true

View File

@@ -82,21 +82,21 @@ inputs:
description: "Shows the short facts" description: "Shows the short facts"
default: "True" default: "True"
LOCALE: SHOW_UPDATED_DATE:
required: false required: false
description: "Show stats in your own language" description: "Show updated date"
default: "en" default: "True"
SHOW_TOTAL_CODE_TIME:
required: false
description: "Show Total Time you have coded"
default: "True"
COMMIT_BY_ME: COMMIT_BY_ME:
required: false required: false
description: "Git commit with your own name and email" description: "Git commit with your own name and email"
default: "False" default: "False"
IGNORED_REPOS:
required: false
description: "Repos you don't want to be counted"
default: ""
COMMIT_MESSAGE: COMMIT_MESSAGE:
required: false required: false
description: "Git commit message" description: "Git commit message"
@@ -112,20 +112,20 @@ inputs:
description: "Git commit custom email" description: "Git commit custom email"
default: "" default: ""
SHOW_UPDATED_DATE: LOCALE:
required: false required: false
description: "Show updated date" description: "Show stats in your own language"
default: "True" default: "en"
UPDATED_DATE_FORMAT: UPDATED_DATE_FORMAT:
required: false required: false
description: "Updated date format" description: "Updated date format"
default: "%d/%m/%Y %H:%M:%S" default: "%d/%m/%Y %H:%M:%S"
SHOW_TOTAL_CODE_TIME: IGNORED_REPOS:
required: false required: false
description: "Show Total Time you have coded" description: "Repos you don't want to be counted"
default: "True" default: ""
SYMBOL_VERSION: SYMBOL_VERSION:
required: false required: false

View File

@@ -1,8 +1,18 @@
PyGithub==1.54.1 # GitHub integration modules:
matplotlib==3.6.3 PyGithub~=1.57
numpy==1.24.2
python-dotenv==0.17.0 # Markdown visualization modules:
pytz==2021.1 pytz~=2022.7
humanize==3.3.0 humanize~=4.6
httpx==0.23.3
PyYAML==6.0 # Graphs drawing modules:
matplotlib~=3.7
numpy~=1.24
# Request making and response parsing modules:
httpx~=0.23
PyYAML~=6.0
# Codestyle checking modules:
flake8~=6.0
black~=23.1

View File

View File

@@ -1,45 +1,44 @@
from typing import Dict from typing import Dict
from os.path import join, dirname
from json import load
import numpy as np from numpy import arange, array, add, amax
import matplotlib.patches as mpatches import matplotlib.patches as mpatches
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from download_manager import DownloadManager from manager_download import DownloadManager as DM
MAX_LANGUAGES = 5 MAX_LANGUAGES = 5 # Number of top languages to add to chart, for each year quarter
GRAPH_PATH = "assets/bar_graph.png" # Chart saving path.
async def build_graph(yearly_data: Dict) -> str: async def create_loc_graph(yearly_data: Dict, save_path: str):
""" """
Draws graph of lines of code written by user by quarters of years. Draws graph of lines of code written by user by quarters of years.
Picks top `MAX_LANGUAGES` languages from each quarter only. Picks top `MAX_LANGUAGES` languages from each quarter only.
:param yearly_data: GitHub user yearly data. :param yearly_data: GitHub user yearly data.
:return: String, path to graph file. :param save_path: Path to save the graph file.
""" """
colors = await DownloadManager.get_remote_yaml("linguist") colors = await DM.get_remote_yaml("linguist")
years = len(yearly_data.keys())
year_indexes = arange(years)
languages_all_loc = dict() languages_all_loc = dict()
years = len(yearly_data.keys())
year_indexes = np.arange(years)
for i, y in enumerate(sorted(yearly_data.keys())): for i, y in enumerate(sorted(yearly_data.keys())):
for q in yearly_data[y].keys(): for q in yearly_data[y].keys():
langs = sorted(yearly_data[y][q].keys(), key=lambda l: yearly_data[y][q][l], reverse=True)[0:MAX_LANGUAGES] langs = sorted(yearly_data[y][q].keys(), key=lambda n: yearly_data[y][q][n], reverse=True)[0:MAX_LANGUAGES]
for lang in langs: for lang in langs:
if lang not in languages_all_loc: if lang not in languages_all_loc:
languages_all_loc[lang] = np.array([[0] * years] * 4) languages_all_loc[lang] = array([[0] * years] * 4)
languages_all_loc[lang][q - 1][i] = yearly_data[y][q][lang] languages_all_loc[lang][q - 1][i] = yearly_data[y][q][lang]
fig = plt.figure() fig = plt.figure()
ax = fig.add_axes([0, 0, 1.5, 1]) ax = fig.add_axes([0, 0, 1.5, 1])
language_handles = [] language_handles = []
cumulative = np.array([[0] * years] * 4) cumulative = array([[0] * years] * 4)
for key, value in languages_all_loc.items(): for key, value in languages_all_loc.items():
color = colors[key]["color"] if colors[key]["color"] is not None else "w" color = colors[key]["color"] if colors[key]["color"] is not None else "w"
@@ -47,10 +46,10 @@ async def build_graph(yearly_data: Dict) -> str:
for quarter in range(4): for quarter in range(4):
ax.bar(year_indexes + quarter * 0.21, value[quarter], 0.2, bottom=cumulative[quarter], color=color) ax.bar(year_indexes + quarter * 0.21, value[quarter], 0.2, bottom=cumulative[quarter], color=color)
cumulative[quarter] = np.add(cumulative[quarter], value[quarter]) cumulative[quarter] = add(cumulative[quarter], value[quarter])
ax.set_ylabel("LOC added", fontdict=dict(weight="bold")) ax.set_ylabel("LOC added", fontdict=dict(weight="bold"))
ax.set_xticks(np.array([np.arange(i, i + 0.84, step=0.21) for i in year_indexes]).flatten(), labels=["Q1", "Q2", "Q3", "Q4"] * years) ax.set_xticks(array([arange(i, i + 0.84, step=0.21) for i in year_indexes]).flatten(), labels=["Q1", "Q2", "Q3", "Q4"] * years)
sax = ax.secondary_xaxis("top") sax = ax.secondary_xaxis("top")
sax.set_xticks(year_indexes + 0.42, labels=sorted(yearly_data.keys())) sax.set_xticks(year_indexes + 0.42, labels=sorted(yearly_data.keys()))
@@ -63,7 +62,6 @@ async def build_graph(yearly_data: Dict) -> str:
ax.spines["top"].set_visible(False) ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False) ax.spines["right"].set_visible(False)
plt.ylim(0, 1.05 * np.amax(cumulative)) plt.ylim(0, 1.05 * amax(cumulative))
plt.savefig("bar_graph.png", bbox_inches="tight") plt.savefig(save_path, bbox_inches="tight")
plt.close(fig) plt.close(fig)
return "bar_graph.png"

View File

@@ -0,0 +1,148 @@
from enum import Enum
from typing import Dict, Tuple, List
from datetime import datetime
from pytz import timezone, utc
from manager_download import DownloadManager as DM
from manager_environment import EnvironmentManager as EM
from manager_github import GitHubManager as GHM
from manager_localization import LocalizationManager as LM
DAY_TIME_EMOJI = ["🌞", "🌆", "🌃", "🌙"] # Emojis, representing different times of day.
DAY_TIME_NAMES = ["Morning", "Daytime", "Evening", "Night"] # Localization identifiers for different times of day.
WEEK_DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] # Localization identifiers for different days of week.
class Symbol(Enum):
"""
Symbol version enum.
Allows to retrieve symbols pairs by calling `Symbol.get_symbols(version)`.
"""
VERSION_1 = "", ""
VERSION_2 = "", ""
VERSION_3 = "", ""
@staticmethod
def get_symbols(version: int) -> Tuple[str, str]:
"""
Retrieves symbols pair for specified version.
:param version: Required symbols version.
:returns: Two strings for filled and empty symbol value in a tuple.
"""
return Symbol[f"VERSION_{version}"].value
def make_graph(percent: float):
"""
Make text progress bar.
Length of the progress bar is 25 characters.
:param percent: Completion percent of the progress bar.
:return: The string progress bar representation.
"""
done_block, empty_block = Symbol.get_symbols(EM.SYMBOL_VERSION)
percent_quart = round(percent / 4)
return f"{done_block * percent_quart}{empty_block * (25 - percent_quart)}"
def make_list(data: List = None, names: List[str] = None, texts: List[str] = None, percents: List[float] = None, top_num: int = 5, sort: bool = True) -> str:
"""
Make list of text progress bars with supportive info.
Each row has the following structure: [name of the measure] [quantity description (with words)] [progress bar] [total percentage].
Name of the measure: up to 25 characters.
Quantity description: how many _things_ were found, up to 20 characters.
Progress bar: measure percentage, 25 characters.
Total percentage: floating point percentage.
:param data: list of dictionaries, each of them containing a measure (name, text and percent).
:param names: list of names (names of measure), overloads data if defined.
:param texts: list of texts (quantity descriptions), overloads data if defined.
:param percents: list of percents (total percentages), overloads data if defined.
:param top_num: how many measures to display, default: 5.
:param sort: if measures should be sorted by total percentage, default: True.
:returns: The string representation of the list.
"""
if data is not None:
names = [value for item in data for key, value in item.items() if key == "name"] if names is None else names
texts = [value for item in data for key, value in item.items() if key == "text"] if texts is None else texts
percents = [value for item in data for key, value in item.items() if key == "percent"] if percents is None else percents
data = list(zip(names, texts, percents))
top_data = sorted(data[:top_num], key=lambda record: record[2], reverse=True) if sort else data[:top_num]
data_list = [f"{n[:25]}{' ' * (25 - len(n))}{t}{' ' * (20 - len(t))}{make_graph(p)} {p:05.2f} % " for n, t, p in top_data]
return "\n".join(data_list)
async def make_commit_day_time_list(time_zone: str) -> str:
"""
Calculate commit-related info, how many commits were made, and at what time of day and day of week.
:param time_zone: User time zone.
:returns: string representation of statistics.
"""
stats = str()
result = await DM.get_remote_graphql("repos_contributed_to", username=GHM.USER.login)
repos = [d for d in result["data"]["user"]["repositoriesContributedTo"]["nodes"] if d["isFork"] is False]
day_times = [0] * 4 # 0 - 6, 6 - 12, 12 - 18, 18 - 24
week_days = [0] * 7 # Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
for repository in repos:
result = await DM.get_remote_graphql("repo_committed_dates", owner=repository["owner"]["login"], name=repository["name"], id=GHM.USER.node_id)
if result["data"]["repository"] is None or result["data"]["repository"]["defaultBranchRef"] is None:
continue
committed_dates = result["data"]["repository"]["defaultBranchRef"]["target"]["history"]["nodes"]
for committed_date in committed_dates:
local_date = datetime.strptime(committed_date["committedDate"], "%Y-%m-%dT%H:%M:%SZ")
date = local_date.replace(tzinfo=utc).astimezone(timezone(time_zone))
day_times[date.hour // 6] += 1
week_days[date.isoweekday() - 1] += 1
sum_day = sum(day_times)
sum_week = sum(week_days)
day_times = day_times[1:] + day_times[:1]
dt_names = [f"{DAY_TIME_EMOJI[i]} {LM.t(DAY_TIME_NAMES[i])}" for i in range(len(day_times))]
dt_texts = [f"{day_time} commits" for day_time in day_times]
dt_percents = [round((day_time / sum_day) * 100, 2) for day_time in day_times]
title = LM.t("I am an Early") if sum(day_times[0:2]) >= sum(day_times[2:4]) else LM.t("I am a Night")
stats += f"**{title}** \n\n```text\n{make_list(names=dt_names, texts=dt_texts, percents=dt_percents, top_num=7, sort=False)}\n```\n"
if EM.SHOW_DAYS_OF_WEEK:
wd_names = [LM.t(week_day) for week_day in WEEK_DAY_NAMES]
wd_texts = [f"{week_day} commits" for week_day in week_days]
wd_percents = [round((week_day / sum_week) * 100, 2) for week_day in week_days]
title = LM.t("I am Most Productive on") % wd_names[wd_percents.index(max(wd_percents))]
stats += f"📅 **{title}** \n\n```text\n{make_list(names=wd_names, texts=wd_texts, percents=wd_percents, top_num=7, sort=False)}\n```\n"
return stats
def make_language_per_repo_list(repositories: Dict) -> str:
"""
Calculate language-related info, how many repositories in what language user has.
:param repositories: User repositories.
:returns: string representation of statistics.
"""
language_count = dict()
repos_with_language = [repo for repo in repositories["data"]["user"]["repositories"]["nodes"] if repo["primaryLanguage"] is not None]
for repo in repos_with_language:
language = repo["primaryLanguage"]["name"]
language_count[language] = language_count.get(language, {"count": 0})
language_count[language]["count"] += 1
names = list(language_count.keys())
texts = [f"{language_count[lang]['count']} {'repo' if language_count[lang]['count'] == 1 else 'repos'}" for lang in names]
percents = [round(language_count[lang]["count"] / len(repos_with_language) * 100, 2) for lang in names]
top_language = max(list(language_count.keys()), key=lambda x: language_count[x]["count"])
title = f"**{LM.t('I Mostly Code in') % top_language}** \n\n" if len(repos_with_language) > 0 else ""
return f"{title}```text\n{make_list(names=names, texts=texts, percents=percents)}\n```\n\n"

View File

@@ -1,77 +0,0 @@
import re
from asyncio import sleep
from github import Github, InputGitAuthor, AuthenticatedUser
import datetime
from download_manager import DownloadManager
from make_bar_graph import build_graph
class LinesOfCode:
def __init__(self, user: AuthenticatedUser, ghtoken, repositoryData, ignored_repos):
self.g = Github(ghtoken)
self.user = user
self.repositoryData = repositoryData
self.ignored_repos = ignored_repos
async def calculateLoc(self):
result = self.repositoryData
yearly_data = {}
total = len(result['data']['user']['repositories']['nodes'])
for ind, repo in enumerate(result['data']['user']['repositories']['nodes']):
if repo['name'] not in self.ignored_repos:
print(f"{ind}/{total}", "Retrieving repo:", repo["owner"]["login"], repo['name'])
await self.getCommitStat(repo, yearly_data)
await sleep(0.7)
return yearly_data
async def plotLoc(self, yearly_data):
await build_graph(yearly_data)
self.pushChart()
def getQuarter(self, timeStamp):
month = datetime.datetime.fromisoformat(timeStamp).month
if month >= 1 and month <= 3:
return 1
elif month >= 4 and month <= 6:
return 2
elif month >= 7 and month <= 9:
return 3
elif month >= 10 and month <= 12:
return 4
async def getCommitStat(self, repoDetails, yearly_data):
branch_data = await DownloadManager.get_remote_graphql("repository_branches_list", owner=repoDetails["owner"]["login"], name=repoDetails['name'])
if branch_data["data"]["repository"] is None:
print("\tSkipping:", repoDetails['name'])
return
for branch in branch_data["data"]["repository"]["refs"]["nodes"]:
commit_data = await DownloadManager.get_remote_graphql("repository_branch_commit_list", owner=repoDetails["owner"]["login"], name=repoDetails['name'], branch=branch["name"], id=self.user.node_id)
for commit in commit_data["data"]["repository"]["ref"]["target"]["history"]["nodes"]:
date = re.search(r'\d+-\d+-\d+', commit["committedDate"]).group(0)
curr_year = datetime.datetime.fromisoformat(date).year
quarter = self.getQuarter(date)
if repoDetails['primaryLanguage'] is not None:
if curr_year not in yearly_data:
yearly_data[curr_year] = {}
if quarter not in yearly_data[curr_year]:
yearly_data[curr_year][quarter] = {}
if repoDetails['primaryLanguage']['name'] not in yearly_data[curr_year][quarter]:
yearly_data[curr_year][quarter][repoDetails['primaryLanguage']['name']] = 0
yearly_data[curr_year][quarter][repoDetails['primaryLanguage']['name']] += (commit["additions"] - commit["deletions"])
def pushChart(self):
repo = self.g.get_repo(f"{self.user.login}/{self.user.login}")
committer = InputGitAuthor('readme-bot', '41898282+github-actions[bot]@users.noreply.github.com')
with open('bar_graph.png', 'rb') as input_file:
data = input_file.read()
try:
contents = repo.get_contents("charts/bar_graph.png")
repo.update_file(contents.path, "Charts Updated", data, contents.sha, committer=committer)
except Exception as e:
repo.create_file("charts/bar_graph.png", "Charts Added", data, committer=committer)

View File

@@ -1,459 +1,169 @@
''' """
Readme Development Metrics With waka time progress Readme Development Metrics With waka time progress
''' """
import re
import os
import base64
from asyncio import run from asyncio import run
from typing import Dict from datetime import datetime
from pytz import timezone
import pytz
from github import Github, InputGitAuthor, AuthenticatedUser
import datetime
from download_manager import init_download_manager, DownloadManager
from loc import LinesOfCode
import humanize
from urllib.parse import quote from urllib.parse import quote
import json
import math
from dotenv import load_dotenv from humanize import intword, naturalsize, intcomma, precisedelta
load_dotenv() from manager_download import init_download_manager, DownloadManager as DM
from manager_environment import EnvironmentManager as EM
START_COMMENT = f'<!--START_SECTION:{os.getenv("INPUT_SECTION_NAME")}-->' from manager_github import init_github_manager, GitHubManager as GHM
END_COMMENT = f'<!--END_SECTION:{os.getenv("INPUT_SECTION_NAME")}-->' from manager_localization import init_localization_manager, LocalizationManager as LM
listReg = f"{START_COMMENT}[\\s\\S]+{END_COMMENT}" from graphics_chart_drawer import create_loc_graph, GRAPH_PATH
from yearly_commit_calculator import calculate_yearly_commit_data
waka_key = os.getenv('INPUT_WAKATIME_API_KEY') from graphics_list_formatter import make_list, make_commit_day_time_list, make_language_per_repo_list
ghtoken = os.getenv('INPUT_GH_TOKEN')
branchName = os.getenv('INPUT_PUSH_BRANCH_NAME')
showTimeZone = os.getenv('INPUT_SHOW_TIMEZONE')
showProjects = os.getenv('INPUT_SHOW_PROJECTS')
showEditors = os.getenv('INPUT_SHOW_EDITORS')
showOs = os.getenv('INPUT_SHOW_OS')
showCommit = os.getenv('INPUT_SHOW_COMMIT')
showLanguage = os.getenv('INPUT_SHOW_LANGUAGE')
show_loc = os.getenv('INPUT_SHOW_LINES_OF_CODE')
show_days_of_week = os.getenv('INPUT_SHOW_DAYS_OF_WEEK')
showLanguagePerRepo = os.getenv('INPUT_SHOW_LANGUAGE_PER_REPO')
showLocChart = os.getenv('INPUT_SHOW_LOC_CHART')
show_profile_view = os.getenv('INPUT_SHOW_PROFILE_VIEWS')
show_short_info = os.getenv('INPUT_SHOW_SHORT_INFO')
locale = os.getenv('INPUT_LOCALE')
commit_by_me = os.getenv('INPUT_COMMIT_BY_ME')
ignored_repos_name = str(os.getenv('INPUT_IGNORED_REPOS') or '').replace(' ', '').split(',')
show_updated_date = os.getenv('INPUT_SHOW_UPDATED_DATE')
updated_date_format = os.getenv('INPUT_UPDATED_DATE_FORMAT')
commit_message = os.getenv('INPUT_COMMIT_MESSAGE')
commit_username = os.getenv('INPUT_COMMIT_USERNAME')
commit_email = os.getenv('INPUT_COMMIT_EMAIL')
show_total_code_time = os.getenv('INPUT_SHOW_TOTAL_CODE_TIME')
symbol_version = os.getenv('INPUT_SYMBOL_VERSION').strip()
show_waka_stats = 'y'
truthy = ['true', '1', 't', 'y', 'yes']
translate: Dict[str, str]
user: AuthenticatedUser
def millify(n): async def get_waka_time_stats() -> str:
millnames = ['', ' Thousand', ' Million', ' Billion', ' Trillion'] """
n = float(n) Collects user info from wakatime.
millidx = max(0, min(len(millnames) - 1, Info includes most common commit time, timezone, language, editors, projects and OSs.
int(math.floor(0
if n == 0
else math.log10(abs(n)) / 3))))
return '{:.0f}{}'.format(n / 10 ** (3 * millidx), millnames[millidx]) :returns: String representation of the info.
"""
stats = str()
data = await DM.get_remote_json("waka_latest")
if EM.SHOW_COMMIT:
stats += f"{await make_commit_day_time_list(data['data']['timezone'])}\n\n"
def make_graph(percent: float): if EM.SHOW_TIMEZONE or EM.SHOW_LANGUAGE or EM.SHOW_EDITORS or EM.SHOW_PROJECTS or EM.SHOW_OS:
'''Make progress graph from API graph''' no_activity = LM.t("No Activity Tracked This Week")
if (symbol_version == '1'): # version 1 stats += f"📊 **{LM.t('This Week I Spend My Time On')}** \n\n```text\n"
done_block = ''
empty_block = ''
elif (symbol_version == '2'): # version 2
done_block = ''
empty_block = ''
elif (symbol_version == '3'): # version 3
done_block = ''
empty_block = ''
else:
done_block = '' # default is version 1
empty_block = ''
pc_rnd = round(percent) if EM.SHOW_TIMEZONE:
return f"{done_block * int(pc_rnd / 4)}{empty_block * int(25 - int(pc_rnd / 4))}" time_zone = data["data"]["timezone"]
stats += f"🕑︎ {LM.t('Timezone')}: {time_zone}\n\n"
if EM.SHOW_LANGUAGE:
lang_list = no_activity if len(data["data"]["languages"]) == 0 else make_list(data["data"]["languages"])
stats += f"💬 {LM.t('Languages')}: \n{lang_list}\n\n"
def make_list(data: list): if EM.SHOW_EDITORS:
'''Make List''' edit_list = no_activity if len(data["data"]["editors"]) == 0 else make_list(data["data"]["editors"])
data_list = [] stats += f"🔥 {LM.t('Editors')}: \n{edit_list}\n\n"
for l in data[:5]:
ln = len(l['name'])
ln_text = len(l['text'])
percent = "{:05.2f}".format(float(l['percent']))
op = f"{l['name'][:25]}{' ' * (25 - ln)}{l['text']}{' ' * (20 - ln_text)}{make_graph(l['percent'])} {percent} % "
data_list.append(op)
return '\n'.join(data_list)
if EM.SHOW_PROJECTS:
project_list = no_activity if len(data["data"]["projects"]) == 0 else make_list(data["data"]["projects"])
stats += f"🐱‍💻 {LM.t('Projects')}: \n{project_list}\n\n"
def make_commit_list(data: list): if EM.SHOW_OS:
'''Make List''' os_list = no_activity if len(data["data"]["operating_systems"]) == 0 else make_list(data["data"]["operating_systems"])
data_list = [] stats += f"💻 {LM.t('operating system')}: \n{os_list}\n\n"
for l in data[:7]:
ln = len(l['name'])
ln_text = len(l['text'])
percent = "{:05.2f}".format(float(l['percent']))
op = f"{l['name']}{' ' * ((15 - ln) + (11 - ln_text))}{l['text']}{' ' * (7)}{make_graph(l['percent'])} {percent} % "
data_list.append(op)
return '\n'.join(data_list)
stats = f"{stats[:-1]}```\n\n"
async def generate_commit_list(tz):
string = ''
result = await DownloadManager.get_remote_graphql("repositories_contributed_to", username=user.login)
nodes = result["data"]["user"]["repositoriesContributedTo"]["nodes"]
repos = [d for d in nodes if d['isFork'] is False]
morning = 0 # 6 - 12
daytime = 0 # 12 - 18
evening = 0 # 18 - 24
night = 0 # 0 - 6
Monday = 0
Tuesday = 0
Wednesday = 0
Thursday = 0
Friday = 0
Saturday = 0
Sunday = 0
for repository in repos:
result = await DownloadManager.get_remote_graphql("repository_committed_dates", owner=repository["owner"]["login"], name=repository["name"], id=user.node_id)
if result["data"]["repository"] is None or result["data"]["repository"]["defaultBranchRef"] is None:
continue
committed_dates = result["data"]["repository"]["defaultBranchRef"]["target"]["history"]["nodes"]
for committedDate in committed_dates:
date = datetime.datetime.strptime(committedDate["committedDate"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.utc).astimezone(timezone(tz))
hour = date.hour
weekday = date.strftime('%A')
if 6 <= hour < 12:
morning += 1
if 12 <= hour < 18:
daytime += 1
if 18 <= hour < 24:
evening += 1
if 0 <= hour < 6:
night += 1
if weekday == "Monday":
Monday += 1
if weekday == "Tuesday":
Tuesday += 1
if weekday == "Wednesday":
Wednesday += 1
if weekday == "Thursday":
Thursday += 1
if weekday == "Friday":
Friday += 1
if weekday == "Saturday":
Saturday += 1
if weekday == "Sunday":
Sunday += 1
sumAll = morning + daytime + evening + night
sum_week = Sunday + Monday + Tuesday + Friday + Saturday + Wednesday + Thursday
title = translate['I am an Early'] if morning + daytime >= evening + night else translate['I am a Night']
one_day = [
{"name": "🌞 " + translate['Morning'], "text": str(morning) + " commits",
"percent": round((morning / sumAll) * 100, 2)},
{"name": "🌆 " + translate['Daytime'], "text": str(daytime) + " commits",
"percent": round((daytime / sumAll) * 100, 2)},
{"name": "🌃 " + translate['Evening'], "text": str(evening) + " commits",
"percent": round((evening / sumAll) * 100, 2)},
{"name": "🌙 " + translate['Night'], "text": str(night) + " commits",
"percent": round((night / sumAll) * 100, 2)},
]
dayOfWeek = [
{"name": translate['Monday'], "text": str(Monday) + " commits", "percent": round((Monday / sum_week) * 100, 2)},
{"name": translate['Tuesday'], "text": str(Tuesday) + " commits",
"percent": round((Tuesday / sum_week) * 100, 2)},
{"name": translate['Wednesday'], "text": str(Wednesday) + " commits",
"percent": round((Wednesday / sum_week) * 100, 2)},
{"name": translate['Thursday'], "text": str(Thursday) + " commits",
"percent": round((Thursday / sum_week) * 100, 2)},
{"name": translate['Friday'], "text": str(Friday) + " commits", "percent": round((Friday / sum_week) * 100, 2)},
{"name": translate['Saturday'], "text": str(Saturday) + " commits",
"percent": round((Saturday / sum_week) * 100, 2)},
{"name": translate['Sunday'], "text": str(Sunday) + " commits", "percent": round((Sunday / sum_week) * 100, 2)},
]
string = string + '**' + title + '** \n\n' + '```text\n' + make_commit_list(one_day) + '\n\n```\n'
if show_days_of_week.lower() in truthy:
max_element = {
'percent': 0
}
for day in dayOfWeek:
if day['percent'] > max_element['percent']:
max_element = day
days_title = translate['I am Most Productive on'] % max_element['name']
string = string + '📅 **' + days_title + '** \n\n' + '```text\n' + make_commit_list(dayOfWeek) + '\n\n```\n'
return string
async def get_waka_time_stats():
stats = ''
no_activity = translate["No Activity Tracked This Week"]
data = await DownloadManager.get_remote_json("waka_latest")
if showCommit.lower() in truthy:
stats = stats + await generate_commit_list(data['data']['timezone']) + '\n\n'
if showTimeZone.lower() in truthy or showLanguage.lower() in truthy or showEditors.lower() in truthy or showProjects.lower() in truthy or showOs.lower() in truthy:
stats += '📊 **' + translate['This Week I Spend My Time On'] + '** \n\n'
stats += '```text\n'
if showTimeZone.lower() in truthy:
tzone = data['data']['timezone']
stats = stats + '⌚︎ ' + translate['Timezone'] + ': ' + tzone + '\n\n'
if showLanguage.lower() in truthy:
if len(data['data']['languages']) == 0:
lang_list = no_activity
else:
lang_list = make_list(data['data']['languages'])
stats = stats + '💬 ' + translate['Languages'] + ': \n' + lang_list + '\n\n'
if showEditors.lower() in truthy:
if len(data['data']['editors']) == 0:
edit_list = no_activity
else:
edit_list = make_list(data['data']['editors'])
stats = stats + '🔥 ' + translate['Editors'] + ': \n' + edit_list + '\n\n'
if showProjects.lower() in truthy:
if len(data['data']['projects']) == 0:
project_list = no_activity
else:
# Re-order the project list by percentage
data['data']['projects'] = sorted(data['data']['projects'], key=lambda x: x["percent"],
reverse=True)
project_list = make_list(data['data']['projects'])
stats = stats + '🐱‍💻 ' + translate['Projects'] + ': \n' + project_list + '\n\n'
if showOs.lower() in truthy:
if len(data['data']['operating_systems']) == 0:
os_list = no_activity
else:
os_list = make_list(data['data']['operating_systems'])
stats = stats + '💻 ' + translate['operating system'] + ': \n' + os_list + '\n\n'
stats += '```\n\n'
return stats return stats
def generate_language_per_repo(result): async def get_short_github_info() -> str:
language_count = {} """
total = 0 Collects user info from GitHub public profile.
for repo in result['data']['user']['repositories']['nodes']: The stats include: disk usage, contributions number, whether the user has opted to hire, public and private repositories number.
if repo['primaryLanguage'] is None:
continue
language = repo['primaryLanguage']['name']
total += 1
if language not in language_count.keys():
language_count[language] = {}
language_count[language]['count'] = 1
else:
language_count[language]['count'] = language_count[language]['count'] + 1
data = []
sorted_labels = list(language_count.keys())
sorted_labels.sort(key=lambda x: language_count[x]['count'], reverse=True)
for label in sorted_labels:
percent = round(language_count[label]['count'] / total * 100, 2)
extension = " repos"
if language_count[label]['count'] == 1:
extension = " repo"
data.append({
"name": label,
"text": str(language_count[label]['count']) + extension,
"percent": percent
})
title = '**' + translate['I Mostly Code in'] % sorted_labels[0] + '** \n\n' if len(sorted_labels) > 0 else '' :returns: String representation of the info.
return title + '```text\n' + make_list(data) + '\n\n```\n' """
stats = f"**🐱 {LM.t('My GitHub Data')}** \n\n"
if GHM.USER.disk_usage is None:
async def get_yearly_data(): disk_usage = LM.t("Used in GitHub's Storage") % "?"
repository_list = await DownloadManager.get_remote_graphql("user_repository_list", username=user.login, id=user.node_id) print("Please add new github personal access token with user permission!")
loc = LinesOfCode(user, ghtoken, repository_list, ignored_repos_name)
yearly_data = await loc.calculateLoc()
if showLocChart.lower() in truthy:
await loc.plotLoc(yearly_data)
return yearly_data
async def get_line_of_code() -> str:
repositoryList = await DownloadManager.get_remote_graphql("user_repository_list", username=user.login, id=user.node_id)
loc = LinesOfCode(user, ghtoken, repositoryList, ignored_repos_name)
yearly_data = await loc.calculateLoc()
total_loc = sum(
[yearly_data[year][quarter][lang] for year in yearly_data for quarter in yearly_data[year] for lang in
yearly_data[year][quarter]])
return millify(int(total_loc))
async def get_short_info():
string = '**🐱 ' + translate['My GitHub Data'] + '** \n\n'
if user.disk_usage is None:
disk_usage = humanize.naturalsize(0)
print("Please add new github personal access token with user permission")
else: else:
disk_usage = humanize.naturalsize(user.disk_usage) disk_usage = LM.t("Used in GitHub's Storage") % naturalsize(GHM.USER.disk_usage)
data = await DownloadManager.get_remote_json("github_stats") stats += f"> 📦 {disk_usage} \n > \n"
if len(data['years']) > 0:
this_year_data = data['years'][0]
total = this_year_data['total']
year = this_year_data['year']
string += '> 🏆 ' + translate['Contributions in the year'] % (humanize.intcomma(total), year) + '\n > \n'
string += '> 📦 ' + translate["Used in GitHub's Storage"] % disk_usage + ' \n > \n' data = await DM.get_remote_json("github_stats")
is_hireable = user.hireable if len(data["years"]) > 0:
public_repo = user.public_repos contributions = LM.t("Contributions in the year") % (intcomma(data["years"][0]["total"]), data["years"][0]["year"])
private_repo = user.owned_private_repos stats += f"> 🏆 {contributions}\n > \n"
if private_repo is None:
private_repo = 0 opted_to_hire = GHM.USER.hireable
if is_hireable: if opted_to_hire:
string += "> 💼 " + translate["Opted to Hire"] + "\n > \n" stats += f"> 💼 {LM.t('Opted to Hire')}\n > \n"
else: else:
string += "> 🚫 " + translate["Not Opted to Hire"] + "\n > \n" stats += f"> 🚫 {LM.t('Not Opted to Hire')}\n > \n"
string += '> 📜 ' public_repo = GHM.USER.public_repos
string += translate['public repositories'] % public_repo + " " + '\n > \n' if public_repo != 1 else translate[ if public_repo != 1:
'public repository'] % public_repo + " " + '\n > \n' stats += f"> 📜 {LM.t('public repositories') % public_repo} \n > \n"
string += '> 🔑 ' else:
string += translate['private repositories'] % private_repo + " " + ' \n > \n' if private_repo != 1 else translate[ stats += f"> 📜 {LM.t('public repository') % public_repo} \n > \n"
'private repository'] % private_repo + " " + '\n > \n'
return string private_repo = GHM.USER.owned_private_repos if GHM.USER.owned_private_repos is not None else 0
if public_repo != 1:
stats += f"> 🔑 {LM.t('private repositories') % private_repo} \n > \n"
async def get_stats(github) -> str: else:
'''Gets API data and returns markdown progress''' stats += f"> 🔑 {LM.t('private repository') % private_repo} \n > \n"
stats = ''
repositoryList = await DownloadManager.get_remote_graphql("user_repository_list", username=user.login, id=user.node_id)
if show_loc.lower() in truthy or showLocChart.lower() in truthy:
# This condition is written to calculate the lines of code because it is heavy process soo needs to be calculate once this will reduce the execution time
await get_yearly_data()
if show_total_code_time.lower() in truthy:
data = await DownloadManager.get_remote_json("waka_all")
stats += '![Code Time](http://img.shields.io/badge/' + quote(
str("Code Time")) + '-' + quote(str(
data['data']['text'])) + '-blue)\n\n'
if show_profile_view.lower() in truthy:
data = github.get_repo(f"{user.login}/{user.login}").get_views_traffic(per="week")
stats += '![Profile Views](http://img.shields.io/badge/' + quote(str(translate['Profile Views'])) + '-' + str(
data['count']) + '-blue)\n\n'
if show_loc.lower() in truthy:
stats += '![Lines of code](https://img.shields.io/badge/' + quote(
str(translate['From Hello World I have written'])) + '-' + quote(
str(await get_line_of_code())) + '%20' + quote(str(translate['Lines of code'])) + '-blue)\n\n'
if show_short_info.lower() in truthy:
stats += await get_short_info()
if show_waka_stats.lower() in truthy:
stats += await get_waka_time_stats()
if showLanguagePerRepo.lower() in truthy:
stats = stats + generate_language_per_repo(repositoryList) + '\n\n'
if showLocChart.lower() in truthy:
stats += '**' + translate['Timeline'] + '**\n\n'
branch_name = github.get_repo(f'{user.login}/{user.login}').default_branch
stats = stats + '![Chart not found](https://raw.githubusercontent.com/' + user.login + '/' + user.login + '/' + branch_name + '/charts/bar_graph.png) \n\n'
if show_updated_date.lower() in truthy:
now = datetime.datetime.utcnow()
d1 = now.strftime(updated_date_format)
stats = stats + "\n Last Updated on " + d1 + " UTC"
return stats return stats
def decode_readme(data: str): async def get_stats() -> str:
'''Decode the contents of old readme''' """
decoded_bytes = base64.b64decode(data) Creates new README.md content from all the acquired statistics from all places.
return str(decoded_bytes, 'utf-8') The readme includes data from wakatime, contributed lines of code number, GitHub profile info and last updated date.
:returns: String representation of README.md contents.
"""
stats = str()
repositories = await DM.get_remote_graphql("user_repository_list", username=GHM.USER.login, id=GHM.USER.node_id)
def generate_new_readme(stats: str, readme: str): if EM.SHOW_LINES_OF_CODE or EM.SHOW_LOC_CHART:
'''Generate a new Readme.md''' yearly_data = await calculate_yearly_commit_data(repositories)
stats_in_readme = f"{START_COMMENT}\n{stats}\n{END_COMMENT}" else:
return re.sub(listReg, stats_in_readme, readme) yearly_data = (None, dict())
if EM.SHOW_TOTAL_CODE_TIME:
data = await DM.get_remote_json("waka_all")
stats += f"![Code Time](http://img.shields.io/badge/{quote('Code Time')}-{quote(str(data['data']['text']))}-blue)\n\n"
if EM.SHOW_PROFILE_VIEWS:
data = GHM.REPO.get_views_traffic(per="week")
stats += f"![Profile Views](http://img.shields.io/badge/{quote(LM.t('Profile Views'))}-{data['count']}-blue)\n\n"
if EM.SHOW_LINES_OF_CODE:
total_loc = sum([yearly_data[y][q][d] for y in yearly_data.keys() for q in yearly_data[y].keys() for d in yearly_data[y][q].keys()])
data = f"{intword(total_loc)} {LM.t('Lines of code')}"
stats += f"![Lines of code](https://img.shields.io/badge/{quote(LM.t('From Hello World I have written'))}-{quote(data)}-blue)\n\n"
if EM.SHOW_SHORT_INFO:
stats += await get_short_github_info()
stats += await get_waka_time_stats()
if EM.SHOW_LANGUAGE_PER_REPO:
stats += f"{make_language_per_repo_list(repositories)}\n\n"
if EM.SHOW_LOC_CHART:
await create_loc_graph(yearly_data, GRAPH_PATH)
GHM.update_chart(GRAPH_PATH)
chart_path = f"{GHM.USER.login}/{GHM.USER.login}/{GHM.branch()}/{GRAPH_PATH}"
stats += f"**{LM.t('Timeline')}**\n\n![Lines of Code chart](https://raw.githubusercontent.com/{chart_path})\n\n"
if EM.SHOW_UPDATED_DATE:
stats += f"\n Last Updated on {datetime.now().strftime(EM.UPDATED_DATE_FORMAT)} UTC"
return stats
async def main(): async def main():
global translate, user """
Application main function.
Initializes all managers, collects user info and updates README.md if necessary.
"""
init_github_manager()
await init_download_manager()
init_localization_manager()
if ghtoken is None: if GHM.update_readme(await get_stats()):
raise Exception('Token not available') print("Readme updated!")
user = Github(ghtoken).get_user() await DM.close_remote_resources()
print(f"Current user: {user.login}")
await init_download_manager(waka_key, ghtoken, user)
try:
with open(os.path.join(os.path.dirname(__file__), 'translation.json'), encoding='utf-8') as config_file:
data = json.load(config_file)
translate = data[locale]
except Exception as e:
print("Cannot find the Locale choosing default to english")
translate = data['en']
g = Github(ghtoken)
waka_stats = await get_stats(g)
repo = g.get_repo(f"{user.login}/{user.login}")
contents = repo.get_readme()
rdmd = decode_readme(contents.content)
new_readme = generate_new_readme(stats=waka_stats, readme=rdmd)
if commit_by_me.lower() in truthy:
committer = InputGitAuthor(user.login or commit_username, user.email or commit_email)
else:
committer = InputGitAuthor(
commit_username or 'readme-bot',
commit_email or '41898282+github-actions[bot]@users.noreply.github.com'
)
if new_readme != rdmd:
try:
repo.update_file(path=contents.path, message=commit_message,
content=new_readme, sha=contents.sha, branch=branchName,
committer=committer)
except:
repo.update_file(path=contents.path, message=commit_message,
content=new_readme, sha=contents.sha, branch='main',
committer=committer)
print("Readme updated")
if __name__ == '__main__': if __name__ == "__main__":
start_time = datetime.datetime.now().timestamp() * 1000 start_time = datetime.now()
run(main()) run(main())
end_time = datetime.datetime.now().timestamp() * 1000 run_delta = datetime.now() - start_time
print(f"Program processed in {round(end_time - start_time, 0)} miliseconds.") print(f"Program processed in {precisedelta(run_delta, minimum_unit='microseconds')}.")

View File

@@ -1,3 +1,4 @@
from asyncio import Task
from hashlib import md5 from hashlib import md5
from json import dumps from json import dumps
from string import Template from string import Template
@@ -5,11 +6,15 @@ from typing import Awaitable, Dict, Callable, Optional, List, Tuple
from httpx import AsyncClient from httpx import AsyncClient
from yaml import safe_load from yaml import safe_load
from github import AuthenticatedUser
from manager_environment import EnvironmentManager as EM
from manager_github import GitHubManager as GHM
GITHUB_API_QUERIES = { GITHUB_API_QUERIES = {
"repositories_contributed_to": """ # Query to collect info about all user repositories, including: is it a fork, name and owner login.
# NB! Query includes information about recent repositories only (apparently, contributed within a year).
"repos_contributed_to": """
{ {
user(login: "$username") { user(login: "$username") {
repositoriesContributedTo(orderBy: {field: CREATED_AT, direction: DESC}, $pagination, includeUserRepositories: true) { repositoriesContributedTo(orderBy: {field: CREATED_AT, direction: DESC}, $pagination, includeUserRepositories: true) {
@@ -27,7 +32,9 @@ GITHUB_API_QUERIES = {
} }
} }
}""", }""",
"repository_committed_dates": """ # Query to collect info about all commits in user repositories, including: commit date.
# NB! Query includes information about repositories owned by user only.
"repo_committed_dates": """
{ {
repository(owner: "$owner", name: "$name") { repository(owner: "$owner", name: "$name") {
defaultBranchRef { defaultBranchRef {
@@ -47,6 +54,8 @@ GITHUB_API_QUERIES = {
} }
} }
}""", }""",
# Query to collect info about all repositories user created or collaborated on, including: name, primary language and owner login.
# NB! Query doesn't include information about repositories user contributed to via pull requests.
"user_repository_list": """ "user_repository_list": """
{ {
user(login: "$username") { user(login: "$username") {
@@ -68,7 +77,8 @@ GITHUB_API_QUERIES = {
} }
} }
""", """,
"repository_branches_list": """ # Query to collect info about branches in the given repository, including: names.
"repo_branch_list": """
{ {
repository(owner: "$owner", name: "$name") { repository(owner: "$owner", name: "$name") {
refs(refPrefix: "refs/heads/", orderBy: {direction: DESC, field: TAG_COMMIT_DATE}, $pagination) { refs(refPrefix: "refs/heads/", orderBy: {direction: DESC, field: TAG_COMMIT_DATE}, $pagination) {
@@ -83,7 +93,8 @@ GITHUB_API_QUERIES = {
} }
} }
""", """,
"repository_branch_commit_list": """ # Query to collect info about user commits to given repository, including: commit date, additions and deletions numbers.
"repo_commit_list": """
{ {
repository(owner: "$owner", name: "$name") { repository(owner: "$owner", name: "$name") {
ref(qualifiedName: "refs/heads/$branch") { ref(qualifiedName: "refs/heads/$branch") {
@@ -107,27 +118,25 @@ GITHUB_API_QUERIES = {
} }
} }
} }
""" """,
} }
async def init_download_manager(waka_key: str, github_key: str, user: AuthenticatedUser): async def init_download_manager():
""" """
Initialize download manager: Initialize download manager:
- Setup headers for GitHub GraphQL requests. - Setup headers for GitHub GraphQL requests.
- Launch static queries in background. - Launch static queries in background.
:param waka_key: WakaTime API token.
:param github_key: GitHub API token.
:param user: GitHub current user info.
""" """
await DownloadManager.load_remote_resources({ await DownloadManager.load_remote_resources(
"linguist": "https://cdn.jsdelivr.net/gh/github/linguist@master/lib/linguist/languages.yml", {
"waka_latest": f"https://wakatime.com/api/v1/users/current/stats/last_7_days?api_key={waka_key}", "linguist": "https://cdn.jsdelivr.net/gh/github/linguist@master/lib/linguist/languages.yml",
"waka_all": f"https://wakatime.com/api/v1/users/current/all_time_since_today?api_key={waka_key}", "waka_latest": f"https://wakatime.com/api/v1/users/current/stats/last_7_days?api_key={EM.WAKATIME_API_KEY}",
"github_stats": f"https://github-contributions.vercel.app/api/v1/{user.login}" "waka_all": f"https://wakatime.com/api/v1/users/current/all_time_since_today?api_key={EM.WAKATIME_API_KEY}",
}, { "github_stats": f"https://github-contributions.vercel.app/api/v1/{GHM.USER.login}",
"Authorization": f"Bearer {github_key}" },
}) {"Authorization": f"Bearer {EM.GH_TOKEN}"},
)
class DownloadManager: class DownloadManager:
@@ -141,6 +150,7 @@ class DownloadManager:
DownloadManager launches all static queries asynchronously upon initialization and caches their results. DownloadManager launches all static queries asynchronously upon initialization and caches their results.
It also executes dynamic queries upon request and caches result. It also executes dynamic queries upon request and caches result.
""" """
_client = AsyncClient(timeout=60.0) _client = AsyncClient(timeout=60.0)
_REMOTE_RESOURCES_CACHE = dict() _REMOTE_RESOURCES_CACHE = dict()
@@ -155,6 +165,18 @@ class DownloadManager:
DownloadManager._REMOTE_RESOURCES_CACHE[resource] = DownloadManager._client.get(url) DownloadManager._REMOTE_RESOURCES_CACHE[resource] = DownloadManager._client.get(url)
DownloadManager._client.headers = github_headers DownloadManager._client.headers = github_headers
@staticmethod
async def close_remote_resources():
"""
Close DownloadManager and cancel all un-awaited static web queries.
Await all queries that could not be cancelled.
"""
for resource in DownloadManager._REMOTE_RESOURCES_CACHE.values():
if isinstance(resource, Task):
resource.cancel()
elif isinstance(resource, Awaitable):
await resource
@staticmethod @staticmethod
async def _get_remote_resource(resource: str, convertor: Optional[Callable[[bytes], Dict]]) -> Dict: async def _get_remote_resource(resource: str, convertor: Optional[Callable[[bytes], Dict]]) -> Dict:
""" """
@@ -203,9 +225,7 @@ class DownloadManager:
:param kwargs: Parameters for substitution of variables in dynamic query. :param kwargs: Parameters for substitution of variables in dynamic query.
:return: Response JSON dictionary. :return: Response JSON dictionary.
""" """
res = await DownloadManager._client.post("https://api.github.com/graphql", json={ res = await DownloadManager._client.post("https://api.github.com/graphql", json={"query": Template(GITHUB_API_QUERIES[query]).substitute(kwargs)})
"query": Template(GITHUB_API_QUERIES[query]).substitute(kwargs)
})
if res.status_code == 200: if res.status_code == 200:
return res.json() return res.json()
else: else:
@@ -244,7 +264,7 @@ class DownloadManager:
:param kwargs: Parameters for substitution of variables in dynamic query. :param kwargs: Parameters for substitution of variables in dynamic query.
:return: Response JSON dictionary. :return: Response JSON dictionary.
""" """
initial_query_response = await DownloadManager._fetch_graphql_query(query, **kwargs, pagination=f"first: 100") initial_query_response = await DownloadManager._fetch_graphql_query(query, **kwargs, pagination="first: 100")
page_list, page_info = DownloadManager._find_pagination_and_data_list(initial_query_response) page_list, page_info = DownloadManager._find_pagination_and_data_list(initial_query_response)
while page_info["hasNextPage"]: while page_info["hasNextPage"]:
query_response = await DownloadManager._fetch_graphql_query(query, **kwargs, pagination=f'first: 100, after: "{page_info["endCursor"]}"') query_response = await DownloadManager._fetch_graphql_query(query, **kwargs, pagination=f'first: 100, after: "{page_info["endCursor"]}"')

View File

@@ -0,0 +1,45 @@
from os import getenv, environ
class EnvironmentManager:
"""
Class for handling all environmental variables used by the action.
There are only two required variables: `INPUT_GH_TOKEN` and `INPUT_WAKATIME_API_KEY`.
The others have a provided default value.
For all boolean variables a 'truthy'-list is checked (not only true/false, but also 1, t, y and yes are accepted).
List variable `IGNORED_REPOS` is split and parsed.
Integer variable `SYMBOL_VERSION` is parsed.
"""
_TRUTHY = ["true", "1", "t", "y", "yes"]
GH_TOKEN = environ["INPUT_GH_TOKEN"]
WAKATIME_API_KEY = environ["INPUT_WAKATIME_API_KEY"]
SECTION_NAME = getenv("INPUT_SECTION_NAME", "waka")
BRANCH_NAME = getenv("INPUT_PUSH_BRANCH_NAME", "")
SHOW_OS = getenv("INPUT_SHOW_OS", "False").lower() in _TRUTHY
SHOW_PROJECTS = getenv("INPUT_SHOW_PROJECTS", "True").lower() in _TRUTHY
SHOW_EDITORS = getenv("INPUT_SHOW_EDITORS", "True").lower() in _TRUTHY
SHOW_TIMEZONE = getenv("INPUT_SHOW_TIMEZONE", "True").lower() in _TRUTHY
SHOW_COMMIT = getenv("INPUT_SHOW_COMMIT", "True").lower() in _TRUTHY
SHOW_LANGUAGE = getenv("INPUT_SHOW_LANGUAGE", "True").lower() in _TRUTHY
SHOW_LINES_OF_CODE = getenv("INPUT_SHOW_LINES_OF_CODE", "False").lower() in _TRUTHY
SHOW_LANGUAGE_PER_REPO = getenv("INPUT_SHOW_LANGUAGE_PER_REPO", "True").lower() in _TRUTHY
SHOW_LOC_CHART = getenv("INPUT_SHOW_LOC_CHART", "True").lower() in _TRUTHY
SHOW_DAYS_OF_WEEK = getenv("INPUT_SHOW_DAYS_OF_WEEK", "True").lower() in _TRUTHY
SHOW_PROFILE_VIEWS = getenv("INPUT_SHOW_PROFILE_VIEWS", "True").lower() in _TRUTHY
SHOW_SHORT_INFO = getenv("INPUT_SHOW_SHORT_INFO", "True").lower() in _TRUTHY
SHOW_UPDATED_DATE = getenv("INPUT_SHOW_UPDATED_DATE", "True").lower() in _TRUTHY
SHOW_TOTAL_CODE_TIME = getenv("INPUT_SHOW_TOTAL_CODE_TIME", "True").lower() in _TRUTHY
COMMIT_BY_ME = getenv("INPUT_COMMIT_BY_ME", "False").lower() in _TRUTHY
COMMIT_MESSAGE = getenv("INPUT_COMMIT_MESSAGE", "Updated with Dev Metrics")
COMMIT_USERNAME = getenv("INPUT_COMMIT_USERNAME", "")
COMMIT_EMAIL = getenv("INPUT_COMMIT_EMAIL", "")
LOCALE = getenv("INPUT_LOCALE", "en")
UPDATED_DATE_FORMAT = getenv("INPUT_UPDATED_DATE_FORMAT", "%d/%m/%Y %H:%M:%S")
IGNORED_REPOS = getenv("INPUT_IGNORED_REPOS", "").replace(" ", "").split(",")
SYMBOL_VERSION = int(getenv("INPUT_SYMBOL_VERSION"))

113
sources/manager_github.py Normal file
View File

@@ -0,0 +1,113 @@
from base64 import b64decode
from re import sub
from github import Github, AuthenticatedUser, Repository, ContentFile, InputGitAuthor, UnknownObjectException
from manager_environment import EnvironmentManager as EM
def init_github_manager():
"""
Initialize GitHub manager.
Current user, user readme repo and readme file are downloaded.
"""
GitHubManager.prepare_github_env()
print(f"Current user: {GitHubManager.USER.login}")
class GitHubManager:
USER: AuthenticatedUser
REPO: Repository
_README: ContentFile
_README_CONTENTS: str
_START_COMMENT = f"<!--START_SECTION:{EM.SECTION_NAME}-->"
_END_COMMENT = f"<!--END_SECTION:{EM.SECTION_NAME}-->"
_README_REGEX = f"{_START_COMMENT}[\\s\\S]+{_END_COMMENT}"
@staticmethod
def prepare_github_env():
"""
Download and store for future use:
- Current GitHub user.
- Named repo of the user [username]/[username].
- README.md file of this repo.
- Parsed contents of the file.
"""
github = Github(EM.GH_TOKEN)
GitHubManager.USER = github.get_user()
GitHubManager.REPO = github.get_repo(f"{GitHubManager.USER.login}/{GitHubManager.USER.login}")
GitHubManager._README = GitHubManager.REPO.get_readme()
GitHubManager._README_CONTENTS = str(b64decode(GitHubManager._README.content), "utf-8")
@staticmethod
def _generate_new_readme(stats: str) -> str:
"""
Generates new README.md file, inserts its contents between start and end tags.
:param stats: contents to insert.
:returns: new README.md string.
"""
readme_stats = f"{GitHubManager._START_COMMENT}\n{stats}\n{GitHubManager._END_COMMENT}"
return sub(GitHubManager._README_REGEX, readme_stats, GitHubManager._README_CONTENTS)
@staticmethod
def _get_author() -> InputGitAuthor:
"""
Gets GitHub commit author specified by environmental variables.
It is the user himself or a 'readme-bot'.
:returns: Commit author.
"""
if EM.COMMIT_BY_ME:
return InputGitAuthor(GitHubManager.USER.login or EM.COMMIT_USERNAME, GitHubManager.USER.email or EM.COMMIT_EMAIL)
else:
return InputGitAuthor(EM.COMMIT_USERNAME or "readme-bot", EM.COMMIT_EMAIL or "41898282+github-actions[bot]@users.noreply.github.com")
@staticmethod
def branch() -> str:
"""
Gets name of branch to commit to specified by environmental variables.
It is the default branch (regularly, 'main' or 'master') or a branch specified by user.
:returns: Commit author.
"""
return GitHubManager.REPO.default_branch if EM.BRANCH_NAME == "" else EM.BRANCH_NAME
@staticmethod
def update_readme(stats: str) -> bool:
"""
Updates readme with given data if necessary.
Uses commit author, commit message and branch name specified by environmental variables.
:returns: whether the README.md file was updated or not.
"""
new_readme = GitHubManager._generate_new_readme(stats)
if new_readme != GitHubManager._README_CONTENTS:
GitHubManager.REPO.update_file(
path=GitHubManager._README.path,
message=EM.COMMIT_MESSAGE,
content=new_readme,
sha=GitHubManager._README.sha,
branch=GitHubManager.branch(),
committer=GitHubManager._get_author(),
)
return True
else:
return False
@staticmethod
def update_chart(chart_path: str):
"""
Updates lines of code chart.
Uses commit author, commit message and branch name specified by environmental variables.
:param chart_path: path to saved lines of code chart.
"""
with open(chart_path, "rb") as input_file:
data = input_file.read()
try:
contents = GitHubManager.REPO.get_contents(chart_path)
GitHubManager.REPO.update_file(contents.path, "Charts Updated", data, contents.sha, committer=GitHubManager._get_author())
except UnknownObjectException:
GitHubManager.REPO.create_file(chart_path, "Charts Added", data, committer=GitHubManager._get_author())

View File

@@ -0,0 +1,43 @@
from json import load
from os.path import join, dirname
from typing import Dict
from manager_environment import EnvironmentManager as EM
def init_localization_manager():
"""
Initialize localization manager.
Load GUI translations JSON file.
"""
LocalizationManager.load_localization("translation.json")
class LocalizationManager:
"""
Class for handling localization (and maybe other file IO in future).
Stores localization in dictionary.
"""
_LOCALIZATION: Dict[str, str] = dict()
@staticmethod
def load_localization(file: str):
"""
Read localization file and store locale defined with environmental variable.
:param file: Localization file path, related to current file (in sources root).
"""
with open(join(dirname(__file__), file), encoding="utf-8") as config_file:
data = load(config_file)
LocalizationManager._LOCALIZATION = data[EM.LOCALE]
@staticmethod
def t(key: str) -> str:
"""
Translate string to current localization.
:param key: Localization key.
:returns: Translation string.
"""
return LocalizationManager._LOCALIZATION[key]

View File

@@ -0,0 +1,55 @@
from re import search
from datetime import datetime
from typing import Dict
from manager_download import DownloadManager as DM
from manager_environment import EnvironmentManager as EM
from manager_github import GitHubManager as GHM
async def calculate_yearly_commit_data(repositories: Dict) -> Dict:
"""
Calculate commit data by years.
Commit data includes difference between contribution additions and deletions in each quarter of each recorded year.
:param repositories: user repositories info dictionary.
:returns: Commit quarter yearly data dictionary.
"""
yearly_data = dict()
total = len(repositories["data"]["user"]["repositories"]["nodes"])
for ind, repo in enumerate(repositories["data"]["user"]["repositories"]["nodes"]):
if repo["name"] not in EM.IGNORED_REPOS:
print(f"{ind + 1}/{total}", "Retrieving repo:", repo["owner"]["login"], repo["name"])
await update_yearly_data_with_commit_stats(repo, yearly_data)
return yearly_data
async def update_yearly_data_with_commit_stats(repo_details: Dict, yearly_data: Dict):
"""
Updates yearly commit data with commits from given repository.
Skips update if the commit isn't related to any repository.
:param repo_details: Dictionary with information about the given repository.
:param yearly_data: Yearly data dictionary to update.
"""
owner = repo_details["owner"]["login"]
branch_data = await DM.get_remote_graphql("repo_branch_list", owner=owner, name=repo_details["name"])
if branch_data["data"]["repository"] is None:
print(f"\tSkipping repo: {repo_details['name']}")
return dict()
for branch in branch_data["data"]["repository"]["refs"]["nodes"]:
commit_data = await DM.get_remote_graphql("repo_commit_list", owner=owner, name=repo_details["name"], branch=branch["name"], id=GHM.USER.node_id)
for commit in commit_data["data"]["repository"]["ref"]["target"]["history"]["nodes"]:
date = search(r"\d+-\d+-\d+", commit["committedDate"]).group()
curr_year = datetime.fromisoformat(date).year
quarter = (datetime.fromisoformat(date).month - 1) // 3 + 1
if repo_details["primaryLanguage"] is not None:
if curr_year not in yearly_data:
yearly_data[curr_year] = dict()
if quarter not in yearly_data[curr_year]:
yearly_data[curr_year][quarter] = dict()
if repo_details["primaryLanguage"]["name"] not in yearly_data[curr_year][quarter]:
yearly_data[curr_year][quarter][repo_details["primaryLanguage"]["name"]] = 0
yearly_data[curr_year][quarter][repo_details["primaryLanguage"]["name"]] += commit["additions"] - commit["deletions"]