github-monitor 1.9rc1__py3-none-any.whl → 2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
github_monitor.py CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
3
  Author: Michal Szymanski <misiektoja-github@rm-rf.ninja>
4
- v1.9
4
+ v2.3
5
5
 
6
- OSINT tool implementing real-time tracking of Github users activities including profile and repositories changes:
6
+ OSINT tool implementing real-time tracking of GitHub users activities including profile and repositories changes:
7
7
  https://github.com/misiektoja/github_monitor/
8
8
 
9
9
  Python pip3 requirements:
@@ -16,14 +16,14 @@ tzlocal (optional)
16
16
  python-dotenv (optional)
17
17
  """
18
18
 
19
- VERSION = "1.9"
19
+ VERSION = "2.3"
20
20
 
21
21
  # ---------------------------
22
22
  # CONFIGURATION SECTION START
23
23
  # ---------------------------
24
24
 
25
25
  CONFIG_BLOCK = """
26
- # Get your Github personal access token (classic) by visiting:
26
+ # Get your GitHub personal access token (classic) by visiting:
27
27
  # https://github.com/settings/apps
28
28
  #
29
29
  # Then go to: Personal access tokens -> Tokens (classic) -> Generate new token (classic)
@@ -32,18 +32,24 @@ CONFIG_BLOCK = """
32
32
  # - Pass it at runtime with -t / --github-token
33
33
  # - Set it as an environment variable (e.g. export GITHUB_TOKEN=...)
34
34
  # - Add it to ".env" file (GITHUB_TOKEN=...) for persistent use
35
- # Fallback:
36
- # - Hard-code it in the code or config file
35
+ # - Fallback: hard-code it in the code or config file
37
36
  GITHUB_TOKEN = "your_github_classic_personal_access_token"
38
37
 
39
- # The URL of the Github API
38
+ # The URL of the GitHub API
40
39
  #
41
- # For Public Web Github use the default: https://api.github.com
42
- # For Github Enterprise change to: https://{your_hostname}/api/v3
40
+ # For Public Web GitHub use the default: https://api.github.com
41
+ # For GitHub Enterprise change to: https://{your_hostname}/api/v3
43
42
  #
44
43
  # Can also be set using the -x flag
45
44
  GITHUB_API_URL = "https://api.github.com"
46
45
 
46
+ # The base URL of the GitHub web interface
47
+ # Required to check if the profile is public or private
48
+ #
49
+ # For public GitHub use the default: https://github.com
50
+ # For GitHub Enterprise change to: https://{your_hostname}
51
+ GITHUB_HTML_URL = "https://github.com"
52
+
47
53
  # SMTP settings for sending email notifications
48
54
  # If left as-is, no notifications will be sent
49
55
  #
@@ -70,6 +76,7 @@ EVENT_NOTIFICATION = False
70
76
 
71
77
  # Whether to send an email when user's repositories change (stargazers, watchers, forks, issues,
72
78
  # PRs, description etc., except for update date)
79
+ # Requires TRACK_REPOS_CHANGES to be enabled
73
80
  # Can also be enabled via the -q flag
74
81
  REPO_NOTIFICATION = False
75
82
 
@@ -77,6 +84,11 @@ REPO_NOTIFICATION = False
77
84
  # Can also be enabled via the -u flag
78
85
  REPO_UPDATE_DATE_NOTIFICATION = False
79
86
 
87
+ # Whether to send an email when user's daily contributions count changes
88
+ # Requires TRACK_CONTRIB_CHANGES to be enabled
89
+ # Can also be enabled via the -y flag
90
+ CONTRIB_NOTIFICATION = False
91
+
80
92
  # Whether to send an email on errors
81
93
  # Can also be disabled via the -e flag
82
94
  ERROR_NOTIFICATION = True
@@ -85,7 +97,7 @@ ERROR_NOTIFICATION = True
85
97
  # Can also be set using the -c flag
86
98
  GITHUB_CHECK_INTERVAL = 1800 # 30 mins
87
99
 
88
- # Set your local time zone so that Github API timestamps are converted accordingly (e.g. 'Europe/Warsaw')
100
+ # Set your local time zone so that GitHub API timestamps are converted accordingly (e.g. 'Europe/Warsaw')
89
101
  # Use this command to list all time zones supported by pytz:
90
102
  # python3 -c "import pytz; print('\\n'.join(pytz.all_timezones))"
91
103
  # If set to 'Auto', the tool will try to detect your local time zone automatically (requires tzlocal)
@@ -120,6 +132,36 @@ EVENTS_TO_MONITOR = [
120
132
  # any events older than the most recent EVENTS_NUMBER will be missed
121
133
  EVENTS_NUMBER = 30 # 1 page
122
134
 
135
+ # If True, track user's repository changes (changed stargazers, watchers, forks, description, update date etc.)
136
+ # Can also be enabled using the -j flag
137
+ TRACK_REPOS_CHANGES = False
138
+
139
+ # Repositories to monitor when TRACK_REPOS_CHANGES is enabled
140
+ # Use 'ALL' to monitor all repositories (default behavior)
141
+ # Use 'user/repo_name' format to monitor specific repositories for specific users
142
+ # If the current user matches the user in the list, that repository will be monitored
143
+ # Example: ['user1/repo1', 'user2/repo2', 'user1/repo3']
144
+ # Can also be set using the --repos flag (comma-separated repo names only, without user prefix)
145
+ # Example: --repos "repo1,repo2,repo3"
146
+ # Note: When using a specific list (not 'ALL'), newly created repositories will NOT be
147
+ # automatically monitored - only repositories explicitly listed here will be monitored.
148
+ REPOS_TO_MONITOR = ['ALL']
149
+
150
+ # If True, disable event monitoring
151
+ # Can also be disabled using the -k flag
152
+ DO_NOT_MONITOR_GITHUB_EVENTS = False
153
+
154
+ # If True, fetch all user repos (owned, forks, collaborations); otherwise, fetch only owned repos
155
+ GET_ALL_REPOS = False
156
+
157
+ # Alert about blocked (403 - TOS violation and 451 - DMCA block) repos in the console output (in monitoring mode)
158
+ # In listing mode (-r), blocked repos are always shown
159
+ BLOCKED_REPOS = False
160
+
161
+ # If True, track and log user's daily contributions count changes
162
+ # Can also be enabled using the -m flag
163
+ TRACK_CONTRIB_CHANGES = False
164
+
123
165
  # How often to print a "liveness check" message to the output; in seconds
124
166
  # Set to 0 to disable
125
167
  LIVENESS_CHECK_INTERVAL = 43200 # 12 hours
@@ -148,10 +190,10 @@ GITHUB_LOGFILE = "github_monitor"
148
190
  # Can also be disabled via the -d flag
149
191
  DISABLE_LOGGING = False
150
192
 
151
- # Width of main horizontal line (─)
193
+ # Width of main horizontal line
152
194
  HORIZONTAL_LINE1 = 105
153
195
 
154
- # Width of horizontal line for repositories list output (─)
196
+ # Width of horizontal line for repositories list output
155
197
  HORIZONTAL_LINE2 = 80
156
198
 
157
199
  # Whether to clear the terminal screen after starting the tool
@@ -175,6 +217,7 @@ GITHUB_CHECK_SIGNAL_VALUE = 60 # 1 minute
175
217
  # Do not change values below - modify them in the configuration section or config file instead
176
218
  GITHUB_TOKEN = ""
177
219
  GITHUB_API_URL = ""
220
+ GITHUB_HTML_URL = ""
178
221
  SMTP_HOST = ""
179
222
  SMTP_PORT = 0
180
223
  SMTP_USER = ""
@@ -186,11 +229,18 @@ PROFILE_NOTIFICATION = False
186
229
  EVENT_NOTIFICATION = False
187
230
  REPO_NOTIFICATION = False
188
231
  REPO_UPDATE_DATE_NOTIFICATION = False
232
+ CONTRIB_NOTIFICATION = False
189
233
  ERROR_NOTIFICATION = False
190
234
  GITHUB_CHECK_INTERVAL = 0
191
235
  LOCAL_TIMEZONE = ""
192
236
  EVENTS_TO_MONITOR = []
193
237
  EVENTS_NUMBER = 0
238
+ TRACK_REPOS_CHANGES = False
239
+ REPOS_TO_MONITOR = []
240
+ DO_NOT_MONITOR_GITHUB_EVENTS = False
241
+ GET_ALL_REPOS = False
242
+ BLOCKED_REPOS = False
243
+ TRACK_CONTRIB_CHANGES = False
194
244
  LIVENESS_CHECK_INTERVAL = 0
195
245
  CHECK_INTERNET_URL = ""
196
246
  CHECK_INTERNET_TIMEOUT = 0
@@ -218,9 +268,6 @@ LIVENESS_CHECK_COUNTER = LIVENESS_CHECK_INTERVAL / GITHUB_CHECK_INTERVAL
218
268
  stdout_bck = None
219
269
  csvfieldnames = ['Date', 'Type', 'Name', 'Old', 'New']
220
270
 
221
- TRACK_REPOS_CHANGES = False
222
- DO_NOT_MONITOR_GITHUB_EVENTS = False
223
-
224
271
  CLI_CONFIG_PATH = None
225
272
 
226
273
  # to solve the issue: 'SyntaxError: f-string expression part cannot include a backslash'
@@ -236,7 +283,7 @@ if sys.version_info < (3, 10):
236
283
  import time
237
284
  import string
238
285
  import os
239
- from datetime import datetime, timezone
286
+ from datetime import datetime, timezone, date
240
287
  from dateutil import relativedelta
241
288
  from dateutil.parser import isoparse
242
289
  import calendar
@@ -262,6 +309,7 @@ import re
262
309
  import ipaddress
263
310
  try:
264
311
  from github import Github, Auth, GithubException, UnknownObjectException
312
+ from github.GithubException import RateLimitExceededException
265
313
  from github.GithubException import BadCredentialsException
266
314
  except ModuleNotFoundError:
267
315
  raise SystemExit("Error: Couldn't find the PyGitHub library !\n\nTo install it, run:\n pip3 install PyGithub\n\nOnce installed, re-run this tool. For more help, visit:\nhttps://github.com/PyGithub/PyGithub")
@@ -272,7 +320,9 @@ import socket
272
320
  from typing import Any, Callable
273
321
  import shutil
274
322
  from pathlib import Path
275
-
323
+ from typing import Optional
324
+ import datetime as dt
325
+ import requests
276
326
 
277
327
  NET_ERRORS = (
278
328
  req.exceptions.RequestException,
@@ -550,6 +600,11 @@ def now_local_naive():
550
600
  return datetime.now(pytz.timezone(LOCAL_TIMEZONE)).replace(microsecond=0, tzinfo=None)
551
601
 
552
602
 
603
+ # Returns today's date in LOCAL_TIMEZONE (naive date)
604
+ def today_local() -> dt.date:
605
+ return now_local_naive().date()
606
+
607
+
553
608
  # Returns the current date/time in human readable format; eg. Sun 21 Apr 2024, 15:08:45
554
609
  def get_cur_ts(ts_str=""):
555
610
  return (f'{ts_str}{calendar.day_abbr[(now_local_naive()).weekday()]}, {now_local_naive().strftime("%d %b %Y, %H:%M:%S")}')
@@ -619,6 +674,11 @@ def get_short_date_from_ts(ts, show_year=False, show_hour=True, show_weekday=Tru
619
674
  ts_rounded = int(round(ts))
620
675
  ts_new = datetime.fromtimestamp(ts_rounded, tz)
621
676
 
677
+ elif isinstance(ts, date):
678
+ ts = datetime.combine(ts, datetime.min.time())
679
+ ts = pytz.utc.localize(ts)
680
+ ts_new = ts.astimezone(tz)
681
+
622
682
  else:
623
683
  return ""
624
684
 
@@ -721,7 +781,7 @@ def toggle_profile_changes_notifications_signal_handler(sig, frame):
721
781
  PROFILE_NOTIFICATION = not PROFILE_NOTIFICATION
722
782
  sig_name = signal.Signals(sig).name
723
783
  print(f"* Signal {sig_name} received")
724
- print(f"* Email notifications: [profile changes = {PROFILE_NOTIFICATION}]")
784
+ print(f"* Email notifications:\t\t[profile changes = {PROFILE_NOTIFICATION}]")
725
785
  print_cur_ts("Timestamp:\t\t\t")
726
786
 
727
787
 
@@ -731,7 +791,7 @@ def toggle_new_events_notifications_signal_handler(sig, frame):
731
791
  EVENT_NOTIFICATION = not EVENT_NOTIFICATION
732
792
  sig_name = signal.Signals(sig).name
733
793
  print(f"* Signal {sig_name} received")
734
- print(f"* Email notifications: [new events = {EVENT_NOTIFICATION}]")
794
+ print(f"* Email notifications:\t\t[new events = {EVENT_NOTIFICATION}]")
735
795
  print_cur_ts("Timestamp:\t\t\t")
736
796
 
737
797
 
@@ -741,7 +801,7 @@ def toggle_repo_changes_notifications_signal_handler(sig, frame):
741
801
  REPO_NOTIFICATION = not REPO_NOTIFICATION
742
802
  sig_name = signal.Signals(sig).name
743
803
  print(f"* Signal {sig_name} received")
744
- print(f"* Email notifications: [repos changes = {REPO_NOTIFICATION}]")
804
+ print(f"* Email notifications:\t\t[repos changes = {REPO_NOTIFICATION}]")
745
805
  print_cur_ts("Timestamp:\t\t\t")
746
806
 
747
807
 
@@ -751,7 +811,17 @@ def toggle_repo_update_date_changes_notifications_signal_handler(sig, frame):
751
811
  REPO_UPDATE_DATE_NOTIFICATION = not REPO_UPDATE_DATE_NOTIFICATION
752
812
  sig_name = signal.Signals(sig).name
753
813
  print(f"* Signal {sig_name} received")
754
- print(f"* Email notifications: [repos update date = {REPO_UPDATE_DATE_NOTIFICATION}]")
814
+ print(f"* Email notifications:\t\t[repos update date = {REPO_UPDATE_DATE_NOTIFICATION}]")
815
+ print_cur_ts("Timestamp:\t\t\t")
816
+
817
+
818
+ # Signal handler for SIGURG allowing to switch email notifications for user's daily contributions changes
819
+ def toggle_contrib_changes_notifications_signal_handler(sig, frame):
820
+ global CONTRIB_NOTIFICATION
821
+ CONTRIB_NOTIFICATION = not CONTRIB_NOTIFICATION
822
+ sig_name = signal.Signals(sig).name
823
+ print(f"* Signal {sig_name} received")
824
+ print(f"* Email notifications:\t\t[contrib changes = {CONTRIB_NOTIFICATION}]")
755
825
  print_cur_ts("Timestamp:\t\t\t")
756
826
 
757
827
 
@@ -761,7 +831,7 @@ def increase_check_signal_handler(sig, frame):
761
831
  GITHUB_CHECK_INTERVAL = GITHUB_CHECK_INTERVAL + GITHUB_CHECK_SIGNAL_VALUE
762
832
  sig_name = signal.Signals(sig).name
763
833
  print(f"* Signal {sig_name} received")
764
- print(f"* Github timers: [check interval: {display_time(GITHUB_CHECK_INTERVAL)}]")
834
+ print(f"* GitHub polling interval:\t[ {display_time(GITHUB_CHECK_INTERVAL)} ]")
765
835
  print_cur_ts("Timestamp:\t\t\t")
766
836
 
767
837
 
@@ -772,7 +842,7 @@ def decrease_check_signal_handler(sig, frame):
772
842
  GITHUB_CHECK_INTERVAL = GITHUB_CHECK_INTERVAL - GITHUB_CHECK_SIGNAL_VALUE
773
843
  sig_name = signal.Signals(sig).name
774
844
  print(f"* Signal {sig_name} received")
775
- print(f"* Github timers: [check interval: {display_time(GITHUB_CHECK_INTERVAL)}]")
845
+ print(f"* GitHub polling interval:\t[ {display_time(GITHUB_CHECK_INTERVAL)} ]")
776
846
  print_cur_ts("Timestamp:\t\t\t")
777
847
 
778
848
 
@@ -824,6 +894,34 @@ def gh_call(fn: Callable[..., Any], retries=NET_MAX_RETRIES, backoff=NET_BASE_BA
824
894
  for i in range(1, retries + 1):
825
895
  try:
826
896
  return fn(*args, **kwargs)
897
+ except RateLimitExceededException as e:
898
+ headers = getattr(e, "headers", None)
899
+
900
+ reset_str = None
901
+ if headers:
902
+ val = headers.get("X-RateLimit-Reset")
903
+ if isinstance(val, str):
904
+ reset_str = val
905
+
906
+ sleep_for: int
907
+ if reset_str is not None and reset_str.isdigit():
908
+ reset_epoch = int(reset_str)
909
+ sleep_for = max(0, reset_epoch - int(time.time()) + 1)
910
+ else:
911
+ retry_after_str = None
912
+ if headers:
913
+ ra = headers.get("Retry-After")
914
+ if isinstance(ra, str):
915
+ retry_after_str = ra
916
+ if retry_after_str is not None and retry_after_str.isdigit():
917
+ sleep_for = int(retry_after_str)
918
+ else:
919
+ sleep_for = int(backoff * i)
920
+
921
+ print(f"* {fn.__name__} rate limited, sleeping {sleep_for}s (retry {i}/{retries})")
922
+ time.sleep(sleep_for)
923
+ continue
924
+
827
925
  except NET_ERRORS as e:
828
926
  print(f"* {fn.__name__} error: {e} (retry {i}/{retries})")
829
927
  time.sleep(backoff * i)
@@ -865,7 +963,7 @@ def github_print_followers_and_followings(user):
865
963
 
866
964
  print(f"\nUsername:\t\t{user_name_str}")
867
965
  print(f"User URL:\t\t{user_url}/")
868
- print(f"Github API URL:\t\t{GITHUB_API_URL}")
966
+ print(f"GitHub API URL:\t\t{GITHUB_API_URL}")
869
967
  print(f"Local timezone:\t\t{LOCAL_TIMEZONE}")
870
968
 
871
969
  print(f"\nFollowers:\t\t{followers_count}")
@@ -903,6 +1001,7 @@ def github_print_followers_and_followings(user):
903
1001
 
904
1002
  # Processes items from all passed repositories and returns a list of dictionaries
905
1003
  def github_process_repos(repos_list):
1004
+ import logging
906
1005
  list_of_repos = []
907
1006
  stargazers_list = []
908
1007
  subscribers_list = []
@@ -913,9 +1012,24 @@ def github_process_repos(repos_list):
913
1012
  try:
914
1013
  repo_created_date = repo.created_at
915
1014
  repo_updated_date = repo.updated_at
916
- stargazers_list = [star.login for star in repo.get_stargazers()]
917
- subscribers_list = [subscriber.login for subscriber in repo.get_subscribers()]
918
- forked_repos = [fork.full_name for fork in repo.get_forks()]
1015
+
1016
+ github_logger = logging.getLogger('github')
1017
+ original_level = github_logger.level
1018
+ github_logger.setLevel(logging.ERROR)
1019
+
1020
+ try:
1021
+ stargazers_list = [star.login for star in repo.get_stargazers()]
1022
+ subscribers_list = [subscriber.login for subscriber in repo.get_subscribers()]
1023
+ forked_repos = [fork.full_name for fork in repo.get_forks()]
1024
+ except GithubException as e:
1025
+ if e.status in [403, 451]:
1026
+ if BLOCKED_REPOS:
1027
+ print(f"* Repo '{repo.name}' is blocked, skipping for now: {e}")
1028
+ print_cur_ts("Timestamp:\t\t\t")
1029
+ continue
1030
+ raise
1031
+ finally:
1032
+ github_logger.setLevel(original_level)
919
1033
 
920
1034
  issues = list(repo.get_issues(state='open'))
921
1035
  pulls = list(repo.get_pulls(state='open'))
@@ -928,8 +1042,20 @@ def github_process_repos(repos_list):
928
1042
  pr_list = [f"#{pr.number} {pr.title} ({pr.user.login}) [ {pr.html_url} ]" for pr in pulls]
929
1043
 
930
1044
  list_of_repos.append({"name": repo.name, "descr": repo.description, "is_fork": repo.fork, "forks": repo.forks_count, "stars": repo.stargazers_count, "subscribers": repo.subscribers_count, "url": repo.html_url, "language": repo.language, "date": repo_created_date, "update_date": repo_updated_date, "stargazers_list": stargazers_list, "forked_repos": forked_repos, "subscribers_list": subscribers_list, "issues": issue_count, "pulls": pr_count, "issues_list": issues_list, "pulls_list": pr_list})
1045
+
1046
+ except GithubException as e:
1047
+ # Skip TOS-blocked (403) and legally blocked (451) repositories
1048
+ if e.status in [403, 451]:
1049
+ if BLOCKED_REPOS:
1050
+ print(f"* Repo '{repo.name}' is blocked, skipping for now: {e}")
1051
+ print_cur_ts("Timestamp:\t\t\t")
1052
+ continue
1053
+ else:
1054
+ print(f"* Cannot process repo '{repo.name}', skipping for now: {e}")
1055
+ print_cur_ts("Timestamp:\t\t\t")
1056
+ continue
931
1057
  except Exception as e:
932
- print(f"* Error while processing info for repo '{repo.name}', skipping for now: {e}")
1058
+ print(f"* Cannot process repo '{repo.name}', skipping for now: {e}")
933
1059
  print_cur_ts("Timestamp:\t\t\t")
934
1060
  continue
935
1061
 
@@ -938,6 +1064,7 @@ def github_process_repos(repos_list):
938
1064
 
939
1065
  # Prints a list of public repositories for a GitHub user (-r)
940
1066
  def github_print_repos(user):
1067
+ import logging
941
1068
  user_name_str = user
942
1069
  user_url = "-"
943
1070
  repos_count = 0
@@ -954,8 +1081,12 @@ def github_print_repos(user):
954
1081
  user_name = g_user.name
955
1082
  user_url = g_user.html_url
956
1083
 
957
- repos_count = g_user.public_repos
958
- repos_list = g_user.get_repos()
1084
+ if GET_ALL_REPOS:
1085
+ repos_list = g_user.get_repos()
1086
+ repos_count = g_user.public_repos
1087
+ else:
1088
+ repos_list = [repo for repo in g_user.get_repos(type='owner') if not repo.fork and repo.owner.login == user_login]
1089
+ repos_count = len(repos_list)
959
1090
 
960
1091
  user_name_str = user_login
961
1092
  if user_name:
@@ -965,7 +1096,8 @@ def github_print_repos(user):
965
1096
 
966
1097
  print(f"\nUsername:\t\t{user_name_str}")
967
1098
  print(f"User URL:\t\t{user_url}/")
968
- print(f"Github API URL:\t\t{GITHUB_API_URL}")
1099
+ print(f"GitHub API URL:\t\t{GITHUB_API_URL}")
1100
+ print(f"Owned repos only:\t{not GET_ALL_REPOS}")
969
1101
  print(f"Local timezone:\t\t{LOCAL_TIMEZONE}")
970
1102
 
971
1103
  print(f"\nRepositories:\t\t{repos_count}\n")
@@ -976,6 +1108,10 @@ def github_print_repos(user):
976
1108
  for repo in repos_list:
977
1109
  print(f"🔸 {repo.name} {'(fork)' if repo.fork else ''} \n")
978
1110
 
1111
+ github_logger = logging.getLogger('github')
1112
+ original_level = github_logger.level
1113
+ github_logger.setLevel(logging.ERROR)
1114
+
979
1115
  try:
980
1116
  pr_count = repo.get_pulls(state='open').totalCount
981
1117
  issue_count = repo.open_issues_count - pr_count
@@ -983,26 +1119,36 @@ def github_print_repos(user):
983
1119
  pr_count = "?"
984
1120
  issue_count = "?"
985
1121
 
986
- print(f" - 🌐 URL:\t\t{repo.html_url}")
987
- print(f" - 💻 Language:\t\t{repo.language}")
988
-
989
- print(f"\n - ⭐ Stars:\t\t{repo.stargazers_count}")
990
- print(f" - 🍴 Forks:\t\t{repo.forks_count}")
991
- print(f" - 👓 Watchers:\t\t{repo.subscribers_count}")
992
-
993
- # print(f" - 🐞 Issues+PRs:\t{repo.open_issues_count}")
994
- print(f" - 🐞 Issues:\t\t{issue_count}")
995
- print(f" - 📬 PRs:\t\t{pr_count}")
996
-
997
- print(f"\n - 📝 License:\t\t{repo.license.name if repo.license else 'None'}")
998
- print(f" - 🌿 Branch (default):\t{repo.default_branch}")
999
-
1000
- print(f"\n - 📅 Created:\t\t{get_date_from_ts(repo.created_at)} ({calculate_timespan(int(time.time()), repo.created_at, granularity=2)} ago)")
1001
- print(f" - 🔄 Updated:\t\t{get_date_from_ts(repo.updated_at)} ({calculate_timespan(int(time.time()), repo.updated_at, granularity=2)} ago)")
1002
- print(f" - 🔃 Last push:\t{get_date_from_ts(repo.pushed_at)} ({calculate_timespan(int(time.time()), repo.pushed_at, granularity=2)} ago)")
1122
+ try:
1123
+ print(f" - 🌐 URL:\t\t{repo.html_url}")
1124
+ print(f" - 💻 Language:\t\t{repo.language}")
1125
+
1126
+ print(f"\n - Stars:\t\t{repo.stargazers_count}")
1127
+ print(f" - 🍴 Forks:\t\t{repo.forks_count}")
1128
+ print(f" - 👓 Watchers:\t\t{repo.subscribers_count}")
1129
+
1130
+ # print(f" - 🐞 Issues+PRs:\t{repo.open_issues_count}")
1131
+ print(f" - 🐞 Issues:\t\t{issue_count}")
1132
+ print(f" - 📬 PRs:\t\t{pr_count}")
1133
+
1134
+ print(f"\n - 📝 License:\t\t{repo.license.name if repo.license else 'None'}")
1135
+ print(f" - 🌿 Branch (default):\t{repo.default_branch}")
1136
+
1137
+ print(f"\n - 📅 Created:\t\t{get_date_from_ts(repo.created_at)} ({calculate_timespan(int(time.time()), repo.created_at, granularity=2)} ago)")
1138
+ print(f" - 🔄 Updated:\t\t{get_date_from_ts(repo.updated_at)} ({calculate_timespan(int(time.time()), repo.updated_at, granularity=2)} ago)")
1139
+ print(f" - 🔃 Last push:\t{get_date_from_ts(repo.pushed_at)} ({calculate_timespan(int(time.time()), repo.pushed_at, granularity=2)} ago)")
1140
+
1141
+ if repo.description:
1142
+ print(f"\n - 📝 Desc:\t\t{repo.description}")
1143
+ except GithubException as e:
1144
+ # Inform about TOS-blocked (403) and legally blocked (451) repositories
1145
+ if e.status in [403, 451]:
1146
+ print(f"\n* Repo '{repo.name}' is blocked: {e}")
1147
+ print("─" * HORIZONTAL_LINE2)
1148
+ continue
1149
+ finally:
1150
+ github_logger.setLevel(original_level)
1003
1151
 
1004
- if repo.description:
1005
- print(f"\n - 📝 Desc:\t\t{repo.description}")
1006
1152
  print("─" * HORIZONTAL_LINE2)
1007
1153
  except Exception as e:
1008
1154
  raise RuntimeError(f"Cannot fetch user's repositories list: {e}")
@@ -1039,7 +1185,7 @@ def github_print_starred_repos(user):
1039
1185
 
1040
1186
  print(f"\nUsername:\t\t{user_name_str}")
1041
1187
  print(f"User URL:\t\t{user_url}/")
1042
- print(f"Github API URL:\t\t{GITHUB_API_URL}")
1188
+ print(f"GitHub API URL:\t\t{GITHUB_API_URL}")
1043
1189
  print(f"Local timezone:\t\t{LOCAL_TIMEZONE}")
1044
1190
 
1045
1191
  print(f"\nRepos starred by user:\t{starred_count}")
@@ -1074,6 +1220,13 @@ def format_body_block(content, indent=" "):
1074
1220
  return f"\n{indented}"
1075
1221
 
1076
1222
 
1223
+ # Returns the base web URL for GitHub or GHE (e.g. https://github.com or https://ghe.example.com)
1224
+ def github_web_base() -> str:
1225
+ if "api.github.com" in GITHUB_API_URL:
1226
+ return "https://github.com"
1227
+ return GITHUB_API_URL.replace("/api/v3", "").rstrip("/")
1228
+
1229
+
1077
1230
  # Prints details about passed GitHub event
1078
1231
  def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1079
1232
 
@@ -1094,19 +1247,33 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1094
1247
  st += print_v(f"Event type:\t\t\t{event.type}")
1095
1248
 
1096
1249
  if event.repo.id:
1097
- repo_name = event.repo.name
1098
- repo_url = event.repo.url.replace("https://api.github.com/repos/", "https://github.com/")
1099
- st += print_v(f"\nRepo name:\t\t\t{repo_name}")
1100
- st += print_v(f"Repo URL:\t\t\t{repo_url}")
1101
-
1102
1250
  try:
1103
1251
  desc_len = 80
1104
1252
  repo = g.get_repo(event.repo.name)
1105
- desc = repo.description or ''
1253
+
1254
+ # For ForkEvent, prefer the source repo if available
1255
+ if event.type == "ForkEvent" and repo is not None:
1256
+ try:
1257
+ parent = gh_call(lambda: getattr(repo, "parent", None))()
1258
+ if parent:
1259
+ repo = parent
1260
+ except Exception:
1261
+ pass
1262
+
1263
+ repo_name = getattr(repo, "full_name", event.repo.name)
1264
+
1265
+ api_prefix = GITHUB_API_URL.rstrip("/") + "/repos/"
1266
+ repo_url = getattr(repo, "html_url", event.repo.url.replace(api_prefix, github_web_base() + "/"))
1267
+
1268
+ st += print_v(f"\nRepo name:\t\t\t{repo_name}")
1269
+ st += print_v(f"Repo URL:\t\t\t{repo_url}")
1270
+
1271
+ desc = (repo.description or "") if repo else ""
1106
1272
  cleaned = desc.replace('\n', ' ')
1107
1273
  short_desc = cleaned[:desc_len] + '...' if len(cleaned) > desc_len else cleaned
1108
1274
  if short_desc:
1109
1275
  st += print_v(f"Repo description:\t\t{short_desc}")
1276
+
1110
1277
  except UnknownObjectException:
1111
1278
  repo = None
1112
1279
  st += print_v("\nRepository not found or has been removed")
@@ -1134,6 +1301,7 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1134
1301
  if event.payload.get("action"):
1135
1302
  st += print_v(f"\nAction:\t\t\t\t{event.payload.get('action')}")
1136
1303
 
1304
+ # Prefer commits from payload when present (older API behavior)
1137
1305
  if event.payload.get("commits"):
1138
1306
  commits = event.payload["commits"]
1139
1307
  commits_total = len(commits)
@@ -1144,7 +1312,7 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1144
1312
 
1145
1313
  commit_details = None
1146
1314
  if repo:
1147
- commit_details = repo.get_commit(commit["sha"])
1315
+ commit_details = gh_call(lambda: repo.get_commit(commit["sha"]))()
1148
1316
 
1149
1317
  if commit_details:
1150
1318
  commit_date = commit_details.commit.author.date
@@ -1180,6 +1348,81 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1180
1348
  st += print_v(f"\n - Commit message:\t\t'{commit['message']}'")
1181
1349
  st += print_v("." * HORIZONTAL_LINE1)
1182
1350
 
1351
+ # Fallback for new Events API where PushEvent no longer includes commit summaries
1352
+ elif event.type == "PushEvent" and repo:
1353
+ before_sha = event.payload.get("before")
1354
+ head_sha = event.payload.get("head") or event.payload.get("after")
1355
+ size_hint = event.payload.get("size")
1356
+
1357
+ # Debug when payload has no commits
1358
+ # st += print_v("\n[debug] PushEvent payload has no 'commits' array; using compare API")
1359
+ # st += print_v(f"[debug] before:\t\t\t{before_sha}")
1360
+ # st += print_v(f"[debug] head/after:\t\t{head_sha}")
1361
+ # if size_hint is not None:
1362
+ # st += print_v(f"[debug] size (hint):\t\t{size_hint}")
1363
+
1364
+ if before_sha and head_sha and before_sha != head_sha:
1365
+ try:
1366
+ compare = gh_call(lambda: repo.compare(before_sha, head_sha))()
1367
+ except Exception as e:
1368
+ compare = None
1369
+ st += print_v(f"* Error using compare({before_sha[:12]}...{head_sha[:12]}): {e}")
1370
+
1371
+ if compare:
1372
+ commits = list(compare.commits)
1373
+ commits_total = len(commits)
1374
+ short_repo = getattr(repo, "full_name", repo_name)
1375
+ compare_url = f"{github_web_base()}/{short_repo}/compare/{before_sha[:12]}...{head_sha[:12]}"
1376
+ st += print_v(f"\nNumber of commits:\t\t{commits_total}")
1377
+ st += print_v(f"Compare URL:\t\t\t{compare_url}")
1378
+
1379
+ for commit_count, c in enumerate(commits, start=1):
1380
+ st += print_v(f"\n=== Commit {commit_count}/{commits_total} ===")
1381
+ st += print_v("." * HORIZONTAL_LINE1)
1382
+
1383
+ commit_sha = getattr(c, "sha", None) or getattr(c, "id", None)
1384
+ commit_details = gh_call(lambda: repo.get_commit(commit_sha))() if (repo and commit_sha) else None
1385
+
1386
+ if commit_details:
1387
+ commit_date = commit_details.commit.author.date
1388
+ st += print_v(f" - Commit date:\t\t\t{get_date_from_ts(commit_date)}")
1389
+
1390
+ if commit_sha:
1391
+ st += print_v(f" - Commit SHA:\t\t\t{commit_sha}")
1392
+
1393
+ author_name = None
1394
+ if commit_details and commit_details.commit and commit_details.commit.author:
1395
+ author_name = commit_details.commit.author.name
1396
+ st += print_v(f" - Commit author:\t\t{author_name or 'N/A'}")
1397
+
1398
+ if commit_details and commit_details.author:
1399
+ st += print_v(f" - Commit author URL:\t\t{commit_details.author.html_url}")
1400
+
1401
+ if commit_details:
1402
+ st += print_v(f" - Commit URL:\t\t\t{commit_details.html_url}")
1403
+ st += print_v(f" - Commit raw patch URL:\t{commit_details.html_url}.patch")
1404
+
1405
+ stats = getattr(commit_details, "stats", None)
1406
+ additions = stats.additions if stats else 0
1407
+ deletions = stats.deletions if stats else 0
1408
+ stats_total = stats.total if stats else 0
1409
+ st += print_v(f"\n - Additions/Deletions:\t\t+{additions} / -{deletions} ({stats_total})")
1410
+
1411
+ try:
1412
+ file_count = sum(1 for _ in commit_details.files)
1413
+ except Exception:
1414
+ file_count = "N/A"
1415
+ st += print_v(f" - Files changed:\t\t{file_count}")
1416
+ if file_count and file_count != "N/A":
1417
+ st += print_v(" - Changed files list:")
1418
+ for f in commit_details.files:
1419
+ st += print_v(f" • '{f.filename}' - {f.status} (+{f.additions} / -{f.deletions})")
1420
+
1421
+ st += print_v(f"\n - Commit message:\t\t'{commit_details.commit.message}'")
1422
+ st += print_v("." * HORIZONTAL_LINE1)
1423
+ else:
1424
+ st += print_v("\nNo compare range available (forced push, tag push, or identical before/after)")
1425
+
1183
1426
  if event.payload.get("commits") == []:
1184
1427
  st += print_v("\nNo new commits (forced push, tag push, branch reset or other ref update)")
1185
1428
 
@@ -1557,7 +1800,7 @@ def github_list_events(user, number, csv_file_name):
1557
1800
 
1558
1801
  print(f"Username:\t\t\t{user_name_str}")
1559
1802
  print(f"User URL:\t\t\t{user_url}/")
1560
- print(f"Github API URL:\t\t\t{GITHUB_API_URL}")
1803
+ print(f"GitHub API URL:\t\t\t{GITHUB_API_URL}")
1561
1804
  if csv_file_name:
1562
1805
  print(f"CSV export enabled:\t\t{bool(csv_file_name)}" + (f" ({csv_file_name})" if csv_file_name else ""))
1563
1806
  print(f"Local timezone:\t\t\t{LOCAL_TIMEZONE}")
@@ -1636,9 +1879,11 @@ def handle_profile_change(label, count_old, count_new, list_old, raw_list, user,
1636
1879
  if removed_items:
1637
1880
  print(f"Removed {label.lower()}:\n")
1638
1881
  removed_mbody = f"\nRemoved {label.lower()}:\n\n"
1882
+ web_base = github_web_base()
1639
1883
  for item in removed_items:
1640
- item_url = (f"https://github.com/{item}/" if label.lower() in ["followers", "followings", "starred repos"]
1641
- else f"https://github.com/{user}/{item}/")
1884
+ item_url = (f"{web_base}/{item}/" if label.lower() in ["followers", "followings", "starred repos"]
1885
+ else f"{web_base}/{user}/{item}/")
1886
+
1642
1887
  print(f"- {item} [ {item_url} ]")
1643
1888
  removed_list_str += f"- {item} [ {item_url} ]\n"
1644
1889
  try:
@@ -1651,9 +1896,10 @@ def handle_profile_change(label, count_old, count_new, list_old, raw_list, user,
1651
1896
  if added_items:
1652
1897
  print(f"Added {label.lower()}:\n")
1653
1898
  added_mbody = f"\nAdded {label.lower()}:\n\n"
1899
+ web_base = github_web_base()
1654
1900
  for item in added_items:
1655
- item_url = (f"https://github.com/{item}/" if label.lower() in ["followers", "followings", "starred repos"]
1656
- else f"https://github.com/{user}/{item}/")
1901
+ item_url = (f"{web_base}/{item}/" if label.lower() in ["followers", "followings", "starred repos"]
1902
+ else f"{web_base}/{user}/{item}/")
1657
1903
  print(f"- {item} [ {item_url} ]")
1658
1904
  added_list_str += f"- {item} [ {item_url} ]\n"
1659
1905
  try:
@@ -1664,12 +1910,12 @@ def handle_profile_change(label, count_old, count_new, list_old, raw_list, user,
1664
1910
  print()
1665
1911
 
1666
1912
  if diff == 0:
1667
- m_subject = f"Github user {user} {label.lower()} list changed"
1913
+ m_subject = f"GitHub user {user} {label.lower()} list changed"
1668
1914
  m_body = (f"{label} list changed {label_context} user {user}\n"
1669
1915
  f"{removed_mbody}{removed_list_str}{added_mbody}{added_list_str}\n"
1670
1916
  f"Check interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}")
1671
1917
  else:
1672
- m_subject = f"Github user {user} {label.lower()} number has changed! ({diff_str}, {old_count} -> {new_count})"
1918
+ m_subject = f"GitHub user {user} {label.lower()} number has changed! ({diff_str}, {old_count} -> {new_count})"
1673
1919
  m_body = (f"{label} number changed {label_context} user {user} from {old_count} to {new_count} ({diff_str})\n"
1674
1920
  f"{removed_mbody}{removed_list_str}{added_mbody}{added_list_str}\n"
1675
1921
  f"Check interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}")
@@ -1725,7 +1971,7 @@ def check_repo_list_changes(count_old, count_new, list_old, list_new, label, rep
1725
1971
  print(f"{removal_text} {label.lower()}:\n")
1726
1972
  removed_mbody = f"\n{removal_text} {label.lower()}:\n\n"
1727
1973
  for item in removed_items:
1728
- item_line = f"- {item} [ https://github.com/{item}/ ]" if label.lower() in ["stargazers", "watchers", "forks"] else f"- {item}"
1974
+ item_line = f"- {item} [ {github_web_base()}/{item}/ ]" if label.lower() in ["stargazers", "watchers", "forks"] else f"- {item}"
1729
1975
  print(item_line)
1730
1976
  removed_list_str += item_line + "\n"
1731
1977
  try:
@@ -1740,7 +1986,7 @@ def check_repo_list_changes(count_old, count_new, list_old, list_new, label, rep
1740
1986
  print(f"Added {label.lower()}:\n")
1741
1987
  added_mbody = f"\nAdded {label.lower()}:\n\n"
1742
1988
  for item in added_items:
1743
- item_line = f"- {item} [ https://github.com/{item}/ ]" if label.lower() in ["stargazers", "watchers", "forks"] else f"- {item}"
1989
+ item_line = f"- {item} [ {github_web_base()}/{item}/ ]" if label.lower() in ["stargazers", "watchers", "forks"] else f"- {item}"
1744
1990
  print(item_line)
1745
1991
  added_list_str += item_line + "\n"
1746
1992
  try:
@@ -1752,12 +1998,12 @@ def check_repo_list_changes(count_old, count_new, list_old, list_new, label, rep
1752
1998
  print()
1753
1999
 
1754
2000
  if diff == 0:
1755
- m_subject = f"Github user {user} {label.lower()} list changed for repo '{repo_name}'!"
2001
+ m_subject = f"GitHub user {user} {label.lower()} list changed for repo '{repo_name}'!"
1756
2002
  m_body = (f"* Repo '{repo_name}': {label.lower()} list changed\n"
1757
2003
  f"* Repo URL: {repo_url}\n{removed_mbody}{removed_list_str}{added_mbody}{added_list_str}\n"
1758
2004
  f"Check interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}")
1759
2005
  else:
1760
- m_subject = f"Github user {user} number of {label.lower()} for repo '{repo_name}' has changed! ({diff_str}, {old_count} -> {new_count})"
2006
+ m_subject = f"GitHub user {user} number of {label.lower()} for repo '{repo_name}' has changed! ({diff_str}, {old_count} -> {new_count})"
1761
2007
  m_body = (f"* Repo '{repo_name}': number of {label.lower()} changed from {old_count} to {new_count} ({diff_str})\n"
1762
2008
  f"* Repo URL: {repo_url}\n{removed_mbody}{removed_list_str}{added_mbody}{added_list_str}\n"
1763
2009
  f"Check interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}")
@@ -1807,7 +2053,215 @@ def resolve_executable(path):
1807
2053
  raise FileNotFoundError(f"Could not find executable '{path}'")
1808
2054
 
1809
2055
 
1810
- # Main function that monitors activity of the specified GitHub user
2056
+ # Checks if the authenticated user (token's owner) is blocked by user
2057
+ def is_blocked_by(user):
2058
+ try:
2059
+
2060
+ headers = {
2061
+ "Authorization": f"Bearer {GITHUB_TOKEN}",
2062
+ "Accept": "application/vnd.github+json",
2063
+ }
2064
+
2065
+ response = req.get(f"{GITHUB_API_URL}/user", headers=headers, timeout=15)
2066
+ if response.status_code != 200:
2067
+ return False
2068
+ me_login = response.json().get("login", "").lower()
2069
+ if user.lower() == me_login:
2070
+ return False
2071
+
2072
+ graphql_endpoint = GITHUB_API_URL.rstrip("/") + "/graphql"
2073
+ query = """
2074
+ query($login: String!) {
2075
+ user(login: $login) {
2076
+ viewerCanFollow
2077
+ }
2078
+ }
2079
+ """
2080
+ payload = {"query": query, "variables": {"login": user}}
2081
+ response_graphql = req.post(graphql_endpoint, json=payload, headers=headers, timeout=15)
2082
+
2083
+ if response_graphql.status_code == 404:
2084
+ return False
2085
+
2086
+ if not response_graphql.ok:
2087
+ return False
2088
+
2089
+ data = response_graphql.json()
2090
+ can_follow = (data.get("data", {}).get("user", {}).get("viewerCanFollow", True))
2091
+ return not bool(can_follow)
2092
+
2093
+ except Exception:
2094
+ return False
2095
+
2096
+
2097
+ # Return the total number of repositories the user has starred (faster than via PyGithub)
2098
+ def get_starred_count(user):
2099
+ try:
2100
+
2101
+ headers = {
2102
+ "Authorization": f"Bearer {GITHUB_TOKEN}",
2103
+ "Accept": "application/vnd.github+json",
2104
+ }
2105
+
2106
+ graphql_endpoint = f"{GITHUB_API_URL.rstrip('/')}/graphql"
2107
+ query = """
2108
+ query($login:String!){
2109
+ user(login:$login){
2110
+ starredRepositories{
2111
+ totalCount
2112
+ }
2113
+ }
2114
+ }
2115
+ """
2116
+ payload = {"query": query, "variables": {"login": user}}
2117
+ response = req.post(graphql_endpoint, json=payload, headers=headers, timeout=15)
2118
+
2119
+ if not response.ok:
2120
+ return 0
2121
+
2122
+ data = response.json()
2123
+
2124
+ return (data.get("data", {}).get("user", {}).get("starredRepositories", {}).get("totalCount", 0))
2125
+
2126
+ except Exception:
2127
+ return 0
2128
+
2129
+
2130
+ # Returns True if the user's GitHub page shows "activity is private"
2131
+ def has_private_banner(user):
2132
+ try:
2133
+ url = f"{GITHUB_HTML_URL.rstrip('/')}/{user}"
2134
+ r = req.get(url, timeout=15)
2135
+ return r.ok and "activity is private" in r.text.lower()
2136
+ except Exception:
2137
+ return False
2138
+
2139
+
2140
+ # Returns True if the user's GitHub profile is public
2141
+ def is_profile_public(g: Github, user, new_account_days=30):
2142
+
2143
+ if has_private_banner(user):
2144
+ return False
2145
+
2146
+ try:
2147
+ u = g.get_user(user)
2148
+
2149
+ if any([
2150
+ u.followers > 0,
2151
+ u.following > 0,
2152
+ get_starred_count(user) > 0,
2153
+ ]):
2154
+ return True
2155
+
2156
+ try:
2157
+ events_iter = iter(u.get_events())
2158
+ next(events_iter)
2159
+ return True
2160
+ except (StopIteration, GithubException):
2161
+ pass
2162
+
2163
+ except GithubException:
2164
+ pass
2165
+
2166
+ return False
2167
+
2168
+
2169
+ # Returns a dict mapping 'YYYY-MM-DD' -> int contribution count for the range
2170
+ def get_daily_contributions(username: str, start: Optional[dt.date] = None, end: Optional[dt.date] = None, token: Optional[str] = None) -> dict:
2171
+ if token is None:
2172
+ raise ValueError("GitHub token is required")
2173
+
2174
+ today = dt.date.today()
2175
+ if start is None:
2176
+ start = today
2177
+ if end is None:
2178
+ end = today
2179
+
2180
+ url = GITHUB_API_URL.rstrip("/") + "/graphql"
2181
+ headers = {
2182
+ "Authorization": f"Bearer {token}",
2183
+ "Time-Zone": LOCAL_TIMEZONE,
2184
+ }
2185
+
2186
+ tz = pytz.timezone(LOCAL_TIMEZONE)
2187
+ start_w = start - dt.timedelta(days=1)
2188
+ end_w_exclusive = end + dt.timedelta(days=2)
2189
+ start_iso = tz.localize(dt.datetime.combine(start_w, dt.time.min)).isoformat()
2190
+ end_iso = tz.localize(dt.datetime.combine(end_w_exclusive, dt.time.min)).isoformat()
2191
+
2192
+ query = """
2193
+ query($login: String!, $from: DateTime!, $to: DateTime!) {
2194
+ user(login: $login) {
2195
+ contributionsCollection(from: $from, to: $to) {
2196
+ contributionCalendar {
2197
+ weeks {
2198
+ contributionDays {
2199
+ date
2200
+ contributionCount
2201
+ }
2202
+ }
2203
+ }
2204
+ }
2205
+ }
2206
+ }"""
2207
+
2208
+ variables = {"login": username, "from": start_iso, "to": end_iso}
2209
+ r = requests.post(url, json={"query": query, "variables": variables}, headers=headers, timeout=30)
2210
+ r.raise_for_status()
2211
+ data = r.json()
2212
+
2213
+ days = data["data"]["user"]["contributionsCollection"]["contributionCalendar"]["weeks"]
2214
+ out = {}
2215
+ for w in days:
2216
+ for d in w["contributionDays"]:
2217
+ date = d["date"]
2218
+ if start <= dt.date.fromisoformat(date) <= end:
2219
+ out[date] = d["contributionCount"]
2220
+ return out
2221
+
2222
+
2223
+ # Return contribution count for a single day
2224
+ def get_daily_contributions_count(username: str, day: dt.date, token: str) -> int:
2225
+ data = get_daily_contributions(username, day, day, token)
2226
+ return next(iter(data.values()), 0)
2227
+
2228
+
2229
+ # Checks count for today and decides whether to notify based on stored state.
2230
+ def check_daily_contribs(username: str, token: str, state: dict, min_delta: int = 1, fail_threshold: int = 3) -> tuple[bool, int, bool]:
2231
+ day = today_local()
2232
+
2233
+ try:
2234
+ curr = get_daily_contributions_count(username, day, token=token)
2235
+ state["consecutive_failures"] = 0
2236
+ state["last_error"] = None
2237
+ except Exception as e:
2238
+ state["consecutive_failures"] = state.get("consecutive_failures", 0) + 1
2239
+ state["last_error"] = f"{type(e).__name__}: {e}"
2240
+ error_notify = state["consecutive_failures"] >= fail_threshold
2241
+ return False, state.get("count", 0), error_notify
2242
+
2243
+ prev_day = state.get("day")
2244
+ prev_cnt = state.get("count")
2245
+
2246
+ # New day -> reset baseline silently
2247
+ if prev_day != day:
2248
+ state["day"] = day
2249
+ state["count"] = curr
2250
+ state["prev_count"] = curr
2251
+ return False, curr, False # no notify on rollover
2252
+
2253
+ # Same day -> notify if change >= threshold
2254
+ if prev_cnt is not None and abs(curr - prev_cnt) >= min_delta:
2255
+ state["prev_count"] = prev_cnt
2256
+ state["count"] = curr
2257
+ return True, curr, False
2258
+
2259
+ # No change
2260
+ state["count"] = curr
2261
+ return False, curr, False
2262
+
2263
+
2264
+ # Monitors activity of the specified GitHub user
1811
2265
  def github_monitor_user(user, csv_file_name):
1812
2266
 
1813
2267
  try:
@@ -1824,6 +2278,12 @@ def github_monitor_user(user, csv_file_name):
1824
2278
  events = []
1825
2279
  repos_list = []
1826
2280
  event_date: datetime | None = None
2281
+ blocked = None
2282
+ public = False
2283
+ contrib_state = {}
2284
+ contrib_curr = 0
2285
+
2286
+ print("Sneaking into GitHub like a ninja ...")
1827
2287
 
1828
2288
  try:
1829
2289
  auth = Auth.Token(GITHUB_TOKEN)
@@ -1847,21 +2307,37 @@ def github_monitor_user(user, csv_file_name):
1847
2307
 
1848
2308
  followers_count = g_user.followers
1849
2309
  followings_count = g_user.following
1850
- repos_count = g_user.public_repos
1851
2310
 
1852
2311
  followers_list = g_user.get_followers()
1853
2312
  followings_list = g_user.get_following()
1854
- repos_list = g_user.get_repos()
2313
+
2314
+ if GET_ALL_REPOS:
2315
+ repos_list = g_user.get_repos()
2316
+ repos_count = g_user.public_repos
2317
+ else:
2318
+ repos_list = [repo for repo in g_user.get_repos(type='owner') if not repo.fork and repo.owner.login == user_login]
2319
+ repos_count = len(repos_list)
1855
2320
 
1856
2321
  starred_list = g_user.get_starred()
1857
2322
  starred_count = starred_list.totalCount
1858
2323
 
2324
+ public = is_profile_public(g, user)
2325
+ blocked = is_blocked_by(user) if public else None
2326
+
2327
+ if TRACK_CONTRIB_CHANGES:
2328
+ contrib_curr = get_daily_contributions_count(user, today_local(), token=GITHUB_TOKEN)
2329
+ contrib_state = {
2330
+ "day": today_local(),
2331
+ "count": contrib_curr,
2332
+ "prev_count": contrib_curr
2333
+ }
2334
+
1859
2335
  if not DO_NOT_MONITOR_GITHUB_EVENTS:
1860
2336
  events = list(islice(g_user.get_events(), EVENTS_NUMBER))
1861
2337
  available_events = len(events)
1862
2338
 
1863
2339
  except Exception as e:
1864
- print(f"* Error: {e}")
2340
+ print(f"\n* Error: {e}")
1865
2341
  sys.exit(1)
1866
2342
 
1867
2343
  last_event_id = 0
@@ -1879,7 +2355,7 @@ def github_monitor_user(user, csv_file_name):
1879
2355
  if last_event_id:
1880
2356
  last_event_ts = newest.created_at
1881
2357
  except Exception as e:
1882
- print(f"* Cannot get event IDs / timestamps: {e}\n")
2358
+ print(f"\n* Cannot get event IDs / timestamps: {e}\n")
1883
2359
  pass
1884
2360
 
1885
2361
  followers_old_count = followers_count
@@ -1893,6 +2369,8 @@ def github_monitor_user(user, csv_file_name):
1893
2369
  company_old = company
1894
2370
  email_old = email
1895
2371
  blog_old = blog
2372
+ blocked_old = blocked
2373
+ public_old = public
1896
2374
 
1897
2375
  last_event_id_old = last_event_id
1898
2376
  last_event_ts_old = last_event_ts
@@ -1902,7 +2380,7 @@ def github_monitor_user(user, csv_file_name):
1902
2380
  if user_myself_name:
1903
2381
  user_myself_name_str += f" ({user_myself_name})"
1904
2382
 
1905
- print(f"Token belongs to:\t\t{user_myself_name_str}" + f"\n\t\t\t\t[ {user_myself_url} ]" if user_myself_url else "")
2383
+ print(f"\nToken belongs to:\t\t{user_myself_name_str}" + f"\n\t\t\t\t[ {user_myself_url} ]" if user_myself_url else "")
1906
2384
 
1907
2385
  user_name_str = user_login
1908
2386
  if user_name:
@@ -1923,6 +2401,9 @@ def github_monitor_user(user, csv_file_name):
1923
2401
  if blog:
1924
2402
  print(f"Blog URL:\t\t\t{blog}")
1925
2403
 
2404
+ print(f"\nPublic profile:\t\t\t{'Yes' if public else 'No'}")
2405
+ print(f"Blocked by the user:\t\t{'Unknown' if blocked is None else ('Yes' if blocked else 'No')}")
2406
+
1926
2407
  print(f"\nAccount creation date:\t\t{get_date_from_ts(account_created_date)} ({calculate_timespan(int(time.time()), account_created_date, show_seconds=False)} ago)")
1927
2408
  print(f"Account updated date:\t\t{get_date_from_ts(account_updated_date)} ({calculate_timespan(int(time.time()), account_updated_date, show_seconds=False)} ago)")
1928
2409
  account_updated_date_old = account_updated_date
@@ -1931,6 +2412,9 @@ def github_monitor_user(user, csv_file_name):
1931
2412
  print(f"Followings:\t\t\t{followings_count}")
1932
2413
  print(f"Repositories:\t\t\t{repos_count}")
1933
2414
  print(f"Starred repos:\t\t\t{starred_count}")
2415
+ if TRACK_CONTRIB_CHANGES:
2416
+ print(f"Today's contributions:\t\t{contrib_curr}")
2417
+
1934
2418
  if not DO_NOT_MONITOR_GITHUB_EVENTS:
1935
2419
  print(f"Available events:\t\t{available_events}{'+' if available_events == EVENTS_NUMBER else ''}")
1936
2420
 
@@ -1941,9 +2425,31 @@ def github_monitor_user(user, csv_file_name):
1941
2425
 
1942
2426
  list_of_repos = []
1943
2427
  if repos_list and TRACK_REPOS_CHANGES:
2428
+ # Filter repos for detailed monitoring only (keep full repos_list for profile change detection)
2429
+ repos_list_filtered = repos_list
2430
+ if 'ALL' not in REPOS_TO_MONITOR:
2431
+ repos_list_filtered = []
2432
+ for repo in repos_list:
2433
+ # Check if repo matches any entry in REPOS_TO_MONITOR
2434
+ should_monitor = False
2435
+ for monitor_entry in REPOS_TO_MONITOR:
2436
+ if '/' in monitor_entry:
2437
+ # Format: 'user/repo_name' - check if user matches and repo matches
2438
+ monitor_user, monitor_repo = monitor_entry.split('/', 1)
2439
+ if monitor_user == user_login and monitor_repo == repo.name:
2440
+ should_monitor = True
2441
+ break
2442
+ else:
2443
+ # Format: just 'repo_name' (from CLI) - check if repo name matches for current user
2444
+ if monitor_entry == repo.name and repo.owner.login == user_login:
2445
+ should_monitor = True
2446
+ break
2447
+ if should_monitor:
2448
+ repos_list_filtered.append(repo)
2449
+
1944
2450
  print("Processing list of public repositories (be patient, it might take a while) ...")
1945
2451
  try:
1946
- list_of_repos = github_process_repos(repos_list)
2452
+ list_of_repos = github_process_repos(repos_list_filtered)
1947
2453
  except Exception as e:
1948
2454
  print(f"* Cannot process list of public repositories: {e}")
1949
2455
  print_cur_ts("\nTimestamp:\t\t\t")
@@ -1983,7 +2489,7 @@ def github_monitor_user(user, csv_file_name):
1983
2489
  alive_counter = 0
1984
2490
  email_sent = False
1985
2491
 
1986
- # main loop
2492
+ # Primary loop
1987
2493
  while True:
1988
2494
 
1989
2495
  try:
@@ -2031,8 +2537,13 @@ def github_monitor_user(user, csv_file_name):
2031
2537
  followers_old, followers_old_count = handle_profile_change("Followers", followers_old_count, followers_count, followers_old, followers_raw, user, csv_file_name, field="login")
2032
2538
 
2033
2539
  # Changed public repositories
2034
- repos_raw = list(gh_call(g_user.get_repos)())
2035
- repos_count = gh_call(lambda: g_user.public_repos)()
2540
+ if GET_ALL_REPOS:
2541
+ repos_raw = list(gh_call(g_user.get_repos)())
2542
+ repos_count = gh_call(lambda: g_user.public_repos)()
2543
+ else:
2544
+ repos_raw = list(gh_call(lambda: [repo for repo in g_user.get_repos(type='owner') if not repo.fork and repo.owner.login == user_login])())
2545
+ repos_count = len(repos_raw)
2546
+
2036
2547
  if repos_raw is not None and repos_count is not None:
2037
2548
  repos_old, repos_old_count = handle_profile_change("Repos", repos_old_count, repos_count, repos_old, repos_raw, user, csv_file_name, field="name")
2038
2549
 
@@ -2043,6 +2554,36 @@ def github_monitor_user(user, csv_file_name):
2043
2554
  starred_count = starred_raw.totalCount
2044
2555
  starred_old, starred_old_count = handle_profile_change("Starred Repos", starred_old_count, starred_count, starred_old, starred_list, user, csv_file_name, field="full_name")
2045
2556
 
2557
+ # Changed contributions in a day
2558
+ if TRACK_CONTRIB_CHANGES:
2559
+ contrib_notify, contrib_curr, contrib_error_notify = check_daily_contribs(user, GITHUB_TOKEN, contrib_state, min_delta=1, fail_threshold=3)
2560
+ if contrib_error_notify and ERROR_NOTIFICATION:
2561
+ failures = contrib_state.get("consecutive_failures", 0)
2562
+ last_err = contrib_state.get("last_error", "Unknown error")
2563
+ err_msg = f"Error: GitHub daily contributions check failed {failures} times. Last error: {last_err}\n"
2564
+ print(err_msg)
2565
+ send_email(f"GitHub monitor errors for {user}", err_msg + get_cur_ts(nl_ch + "Timestamp: "), "", SMTP_SSL)
2566
+
2567
+ if contrib_notify:
2568
+ contrib_old = contrib_state.get("prev_count")
2569
+ print(f"* Daily contributions changed for user {user} on {get_short_date_from_ts(contrib_state['day'], show_hour=False)} from {contrib_old} to {contrib_curr}!\n")
2570
+
2571
+ try:
2572
+ if csv_file_name:
2573
+ write_csv_entry(csv_file_name, now_local_naive(), "Daily Contribs", user, contrib_old, contrib_curr)
2574
+ except Exception as e:
2575
+ print(f"* Error: {e}")
2576
+
2577
+ m_subject = f"GitHub user {user} daily contributions changed from {contrib_old} to {contrib_curr}!"
2578
+ m_body = (f"GitHub user {user} daily contributions changed on {get_short_date_from_ts(contrib_state['day'], show_hour=False)} from {contrib_old} to {contrib_curr}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}")
2579
+
2580
+ if CONTRIB_NOTIFICATION:
2581
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
2582
+ send_email(m_subject, m_body, "", SMTP_SSL)
2583
+
2584
+ print(f"Check interval:\t\t\t{display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)})")
2585
+ print_cur_ts("Timestamp:\t\t\t")
2586
+
2046
2587
  # Changed bio
2047
2588
  bio = gh_call(lambda: g_user.bio)()
2048
2589
  if bio is not None and bio != bio_old:
@@ -2056,8 +2597,8 @@ def github_monitor_user(user, csv_file_name):
2056
2597
  except Exception as e:
2057
2598
  print(f"* Error: {e}")
2058
2599
 
2059
- m_subject = f"Github user {user} bio has changed!"
2060
- m_body = f"Github user {user} bio has changed\n\nOld bio:\n\n{bio_old}\n\nNew bio:\n\n{bio}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2600
+ m_subject = f"GitHub user {user} bio has changed!"
2601
+ m_body = f"GitHub user {user} bio has changed\n\nOld bio:\n\n{bio_old}\n\nNew bio:\n\n{bio}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2061
2602
 
2062
2603
  if PROFILE_NOTIFICATION:
2063
2604
  print(f"Sending email notification to {RECEIVER_EMAIL}")
@@ -2080,8 +2621,8 @@ def github_monitor_user(user, csv_file_name):
2080
2621
  except Exception as e:
2081
2622
  print(f"* Error: {e}")
2082
2623
 
2083
- m_subject = f"Github user {user} location has changed!"
2084
- m_body = f"Github user {user} location has changed\n\nOld location: {location_old}\n\nNew location: {location}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2624
+ m_subject = f"GitHub user {user} location has changed!"
2625
+ m_body = f"GitHub user {user} location has changed\n\nOld location: {location_old}\n\nNew location: {location}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2085
2626
 
2086
2627
  if PROFILE_NOTIFICATION:
2087
2628
  print(f"Sending email notification to {RECEIVER_EMAIL}")
@@ -2104,8 +2645,8 @@ def github_monitor_user(user, csv_file_name):
2104
2645
  except Exception as e:
2105
2646
  print(f"* Error: {e}")
2106
2647
 
2107
- m_subject = f"Github user {user} name has changed!"
2108
- m_body = f"Github user {user} name has changed\n\nOld user name: {user_name_old}\n\nNew user name: {user_name}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2648
+ m_subject = f"GitHub user {user} name has changed!"
2649
+ m_body = f"GitHub user {user} name has changed\n\nOld user name: {user_name_old}\n\nNew user name: {user_name}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2109
2650
 
2110
2651
  if PROFILE_NOTIFICATION:
2111
2652
  print(f"Sending email notification to {RECEIVER_EMAIL}")
@@ -2128,8 +2669,8 @@ def github_monitor_user(user, csv_file_name):
2128
2669
  except Exception as e:
2129
2670
  print(f"* Error: {e}")
2130
2671
 
2131
- m_subject = f"Github user {user} company has changed!"
2132
- m_body = f"Github user {user} company has changed\n\nOld company: {company_old}\n\nNew company: {company}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2672
+ m_subject = f"GitHub user {user} company has changed!"
2673
+ m_body = f"GitHub user {user} company has changed\n\nOld company: {company_old}\n\nNew company: {company}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2133
2674
 
2134
2675
  if PROFILE_NOTIFICATION:
2135
2676
  print(f"Sending email notification to {RECEIVER_EMAIL}")
@@ -2152,8 +2693,8 @@ def github_monitor_user(user, csv_file_name):
2152
2693
  except Exception as e:
2153
2694
  print(f"* Error: {e}")
2154
2695
 
2155
- m_subject = f"Github user {user} email has changed!"
2156
- m_body = f"Github user {user} email has changed\n\nOld email: {email_old}\n\nNew email: {email}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2696
+ m_subject = f"GitHub user {user} email has changed!"
2697
+ m_body = f"GitHub user {user} email has changed\n\nOld email: {email_old}\n\nNew email: {email}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2157
2698
 
2158
2699
  if PROFILE_NOTIFICATION:
2159
2700
  print(f"Sending email notification to {RECEIVER_EMAIL}")
@@ -2176,8 +2717,8 @@ def github_monitor_user(user, csv_file_name):
2176
2717
  except Exception as e:
2177
2718
  print(f"* Error: {e}")
2178
2719
 
2179
- m_subject = f"Github user {user} blog URL has changed!"
2180
- m_body = f"Github user {user} blog URL has changed\n\nOld blog URL: {blog_old}\n\nNew blog URL: {blog}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2720
+ m_subject = f"GitHub user {user} blog URL has changed!"
2721
+ m_body = f"GitHub user {user} blog URL has changed\n\nOld blog URL: {blog_old}\n\nNew blog URL: {blog}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2181
2722
 
2182
2723
  if PROFILE_NOTIFICATION:
2183
2724
  print(f"Sending email notification to {RECEIVER_EMAIL}")
@@ -2200,8 +2741,8 @@ def github_monitor_user(user, csv_file_name):
2200
2741
  except Exception as e:
2201
2742
  print(f"* Error: {e}")
2202
2743
 
2203
- m_subject = f"Github user {user} account has been updated! (after {calculate_timespan(account_updated_date, account_updated_date_old, show_seconds=False, granularity=2)})"
2204
- m_body = f"Github user {user} account has been updated (after {calculate_timespan(account_updated_date, account_updated_date_old, show_seconds=False, granularity=2)})\n\nOld account update date: {get_date_from_ts(account_updated_date_old)}\n\nNew account update date: {get_date_from_ts(account_updated_date)}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2744
+ m_subject = f"GitHub user {user} account has been updated! (after {calculate_timespan(account_updated_date, account_updated_date_old, show_seconds=False, granularity=2)})"
2745
+ m_body = f"GitHub user {user} account has been updated (after {calculate_timespan(account_updated_date, account_updated_date_old, show_seconds=False, granularity=2)})\n\nOld account update date: {get_date_from_ts(account_updated_date_old)}\n\nNew account update date: {get_date_from_ts(account_updated_date)}\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2205
2746
 
2206
2747
  if PROFILE_NOTIFICATION:
2207
2748
  print(f"Sending email notification to {RECEIVER_EMAIL}")
@@ -2211,14 +2752,97 @@ def github_monitor_user(user, csv_file_name):
2211
2752
  print(f"Check interval:\t\t\t{display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)})")
2212
2753
  print_cur_ts("Timestamp:\t\t\t")
2213
2754
 
2755
+ # Profile visibility changed
2756
+ public = is_profile_public(g, user)
2757
+ if public != public_old:
2758
+
2759
+ def _get_profile_status(public):
2760
+ return "public" if public else "private"
2761
+
2762
+ print(f"* User {user} has changed profile visibility to '{_get_profile_status(public)}' !\n")
2763
+
2764
+ try:
2765
+ if csv_file_name:
2766
+ write_csv_entry(csv_file_name, now_local_naive(), "Profile Visibility", user, _get_profile_status(public_old), _get_profile_status(public))
2767
+ except Exception as e:
2768
+ print(f"* Error: {e}")
2769
+
2770
+ m_subject = f"GitHub user {user} has changed profile visibility to '{_get_profile_status(public)}' !"
2771
+ m_body = f"GitHub user {user} has changed profile visibility to '{_get_profile_status(public)}' !\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2772
+
2773
+ if PROFILE_NOTIFICATION:
2774
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
2775
+ send_email(m_subject, m_body, "", SMTP_SSL)
2776
+
2777
+ public_old = public
2778
+ print(f"Check interval:\t\t\t{display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)})")
2779
+ print_cur_ts("Timestamp:\t\t\t")
2780
+
2781
+ # Blocked status changed
2782
+ blocked = is_blocked_by(user) if public else None
2783
+
2784
+ if blocked is not None and blocked_old is None:
2785
+ blocked_old = blocked
2786
+
2787
+ elif None not in (blocked_old, blocked) and blocked != blocked_old:
2788
+
2789
+ def _get_blocked_status(blocked, public):
2790
+ return 'Unknown' if blocked is None else ('Yes' if blocked else 'No')
2791
+
2792
+ print(f"* User {user} has {'blocked' if blocked else 'unblocked'} you!\n")
2793
+
2794
+ try:
2795
+ if csv_file_name:
2796
+ write_csv_entry(csv_file_name, now_local_naive(), "Block Status", user, _get_blocked_status(blocked_old, public), _get_blocked_status(blocked, public))
2797
+ except Exception as e:
2798
+ print(f"* Error: {e}")
2799
+
2800
+ m_subject = f"GitHub user {user} has {'blocked' if blocked else 'unblocked'} you!"
2801
+ m_body = f"GitHub user {user} has {'blocked' if blocked else 'unblocked'} you!\n\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2802
+
2803
+ if PROFILE_NOTIFICATION:
2804
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
2805
+ send_email(m_subject, m_body, "", SMTP_SSL)
2806
+
2807
+ blocked_old = blocked
2808
+ print(f"Check interval:\t\t\t{display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)})")
2809
+ print_cur_ts("Timestamp:\t\t\t")
2810
+
2214
2811
  list_of_repos = []
2215
2812
 
2216
2813
  # Changed repos details
2217
2814
  if TRACK_REPOS_CHANGES:
2218
- repos_list = gh_call(g_user.get_repos)()
2219
- if repos_list is not None:
2815
+
2816
+ if GET_ALL_REPOS:
2817
+ repos_list = gh_call(g_user.get_repos)()
2818
+ else:
2819
+ repos_list = gh_call(lambda: [repo for repo in g_user.get_repos(type='owner') if not repo.fork and repo.owner.login == user_login])()
2820
+
2821
+ # Filter repos for detailed monitoring only (keep full repos_list for profile change detection)
2822
+ repos_list_filtered = repos_list
2823
+ if repos_list is not None and 'ALL' not in REPOS_TO_MONITOR:
2824
+ repos_list_filtered = []
2825
+ for repo in repos_list:
2826
+ # Check if repo matches any entry in REPOS_TO_MONITOR
2827
+ should_monitor = False
2828
+ for monitor_entry in REPOS_TO_MONITOR:
2829
+ if '/' in monitor_entry:
2830
+ # Format: 'user/repo_name' - check if user matches and repo matches
2831
+ monitor_user, monitor_repo = monitor_entry.split('/', 1)
2832
+ if monitor_user == user_login and monitor_repo == repo.name:
2833
+ should_monitor = True
2834
+ break
2835
+ else:
2836
+ # Format: just 'repo_name' (from CLI) - check if repo name matches for current user
2837
+ if monitor_entry == repo.name and repo.owner.login == user_login:
2838
+ should_monitor = True
2839
+ break
2840
+ if should_monitor:
2841
+ repos_list_filtered.append(repo)
2842
+
2843
+ if repos_list_filtered is not None:
2220
2844
  try:
2221
- list_of_repos = github_process_repos(repos_list)
2845
+ list_of_repos = github_process_repos(repos_list_filtered)
2222
2846
  list_of_repos_ok = True
2223
2847
  except Exception as e:
2224
2848
  list_of_repos = list_of_repos_old
@@ -2269,7 +2893,7 @@ def github_monitor_user(user, csv_file_name):
2269
2893
  write_csv_entry(csv_file_name, now_local_naive(), "Repo Update Date", r_name, convert_to_local_naive(r_update_old), convert_to_local_naive(r_update))
2270
2894
  except Exception as e:
2271
2895
  print(f"* Error: {e}")
2272
- m_subject = f"Github user {user} repo '{r_name}' update date has changed ! (after {calculate_timespan(r_update, r_update_old, show_seconds=False, granularity=2)})"
2896
+ m_subject = f"GitHub user {user} repo '{r_name}' update date has changed ! (after {calculate_timespan(r_update, r_update_old, show_seconds=False, granularity=2)})"
2273
2897
  m_body = f"{r_message}\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2274
2898
  if REPO_UPDATE_DATE_NOTIFICATION:
2275
2899
  print(f"Sending email notification to {RECEIVER_EMAIL}")
@@ -2301,7 +2925,7 @@ def github_monitor_user(user, csv_file_name):
2301
2925
  write_csv_entry(csv_file_name, now_local_naive(), "Repo Description", r_name, r_descr_old, r_descr)
2302
2926
  except Exception as e:
2303
2927
  print(f"* Error: {e}")
2304
- m_subject = f"Github user {user} repo '{r_name}' description has changed !"
2928
+ m_subject = f"GitHub user {user} repo '{r_name}' description has changed !"
2305
2929
  m_body = f"{r_message}\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2306
2930
  if REPO_NOTIFICATION:
2307
2931
  print(f"Sending email notification to {RECEIVER_EMAIL}")
@@ -2311,7 +2935,7 @@ def github_monitor_user(user, csv_file_name):
2311
2935
 
2312
2936
  list_of_repos_old = list_of_repos
2313
2937
 
2314
- # New Github events
2938
+ # New GitHub events
2315
2939
  if not DO_NOT_MONITOR_GITHUB_EVENTS:
2316
2940
  events = list(gh_call(lambda: list(islice(g_user.get_events(), EVENTS_NUMBER)))())
2317
2941
  if events is not None:
@@ -2366,8 +2990,8 @@ def github_monitor_user(user, csv_file_name):
2366
2990
  except Exception as e:
2367
2991
  print(f"* Error: {e}")
2368
2992
 
2369
- m_subject = f"Github user {user} has new {event.type} (repo: {repo_name})"
2370
- m_body = f"Github user {user} has new {event.type} event\n\n{event_text}\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2993
+ m_subject = f"GitHub user {user} has new {event.type} (repo: {repo_name})"
2994
+ m_body = f"GitHub user {user} has new {event.type} event\n\n{event_text}\nCheck interval: {display_time(GITHUB_CHECK_INTERVAL)} ({get_range_of_dates_from_tss(int(time.time()) - GITHUB_CHECK_INTERVAL, int(time.time()), short=True)}){get_cur_ts(nl_ch + 'Timestamp: ')}"
2371
2995
 
2372
2996
  if EVENT_NOTIFICATION:
2373
2997
  print(f"\nSending email notification to {RECEIVER_EMAIL}")
@@ -2392,7 +3016,7 @@ def github_monitor_user(user, csv_file_name):
2392
3016
 
2393
3017
 
2394
3018
  def main():
2395
- global CLI_CONFIG_PATH, DOTENV_FILE, LOCAL_TIMEZONE, LIVENESS_CHECK_COUNTER, GITHUB_TOKEN, GITHUB_API_URL, CSV_FILE, DISABLE_LOGGING, GITHUB_LOGFILE, PROFILE_NOTIFICATION, EVENT_NOTIFICATION, REPO_NOTIFICATION, REPO_UPDATE_DATE_NOTIFICATION, ERROR_NOTIFICATION, GITHUB_CHECK_INTERVAL, SMTP_PASSWORD, stdout_bck, DO_NOT_MONITOR_GITHUB_EVENTS, TRACK_REPOS_CHANGES
3019
+ global CLI_CONFIG_PATH, DOTENV_FILE, LOCAL_TIMEZONE, LIVENESS_CHECK_COUNTER, GITHUB_TOKEN, GITHUB_API_URL, CSV_FILE, DISABLE_LOGGING, GITHUB_LOGFILE, PROFILE_NOTIFICATION, EVENT_NOTIFICATION, REPO_NOTIFICATION, REPO_UPDATE_DATE_NOTIFICATION, ERROR_NOTIFICATION, GITHUB_CHECK_INTERVAL, SMTP_PASSWORD, stdout_bck, DO_NOT_MONITOR_GITHUB_EVENTS, TRACK_REPOS_CHANGES, REPOS_TO_MONITOR, GET_ALL_REPOS, CONTRIB_NOTIFICATION, TRACK_CONTRIB_CHANGES
2396
3020
 
2397
3021
  if "--generate-config" in sys.argv:
2398
3022
  print(CONFIG_BLOCK.strip("\n"))
@@ -2409,7 +3033,7 @@ def main():
2409
3033
 
2410
3034
  clear_screen(CLEAR_SCREEN)
2411
3035
 
2412
- print(f"Github Monitoring Tool v{VERSION}\n")
3036
+ print(f"GitHub Monitoring Tool v{VERSION}\n")
2413
3037
 
2414
3038
  parser = argparse.ArgumentParser(
2415
3039
  prog="github_monitor",
@@ -2499,6 +3123,13 @@ def main():
2499
3123
  default=None,
2500
3124
  help="Email when user's repositories update date changes"
2501
3125
  )
3126
+ notify.add_argument(
3127
+ "-y", "--notify-daily-contribs",
3128
+ dest="notify_daily_contribs",
3129
+ action="store_true",
3130
+ default=None,
3131
+ help="Email when user's daily contributions count changes"
3132
+ )
2502
3133
  notify.add_argument(
2503
3134
  "-e", "--no-error-notify",
2504
3135
  dest="notify_errors",
@@ -2577,6 +3208,13 @@ def main():
2577
3208
  default=None,
2578
3209
  help="Disable event monitoring"
2579
3210
  )
3211
+ opts.add_argument(
3212
+ "-a", "--get-all-repos",
3213
+ dest="get_all_repos",
3214
+ action="store_true",
3215
+ default=None,
3216
+ help="Fetch all user repos (owned, forks, collaborations)"
3217
+ )
2580
3218
  opts.add_argument(
2581
3219
  "-b", "--csv-file",
2582
3220
  dest="csv_file",
@@ -2591,6 +3229,20 @@ def main():
2591
3229
  default=None,
2592
3230
  help="Disable logging to github_monitor_<username>.log"
2593
3231
  )
3232
+ opts.add_argument(
3233
+ "-m", "--track-contribs-changes",
3234
+ dest="track_contribs_changes",
3235
+ action="store_true",
3236
+ default=None,
3237
+ help="Track user's daily contributions count and log changes"
3238
+ )
3239
+ opts.add_argument(
3240
+ "--repos",
3241
+ dest="repos",
3242
+ metavar="REPO_LIST",
3243
+ type=str,
3244
+ help="Comma-separated list of repository names to monitor (only when -j/--track-repos-changes is enabled). Overrides REPOS_TO_MONITOR config. Example: --repos \"repo1,repo2,repo3\""
3245
+ )
2594
3246
 
2595
3247
  args = parser.parse_args()
2596
3248
 
@@ -2694,6 +3346,9 @@ def main():
2694
3346
  print("* Error: GITHUB_API_URL (-x / --github_url) value is empty")
2695
3347
  sys.exit(1)
2696
3348
 
3349
+ if args.get_all_repos is True:
3350
+ GET_ALL_REPOS = True
3351
+
2697
3352
  if args.list_followers_and_followings:
2698
3353
  try:
2699
3354
  github_print_followers_and_followings(args.username)
@@ -2777,12 +3432,25 @@ def main():
2777
3432
  if args.notify_repo_update_date is True:
2778
3433
  REPO_UPDATE_DATE_NOTIFICATION = True
2779
3434
 
3435
+ if args.notify_daily_contribs is True:
3436
+ CONTRIB_NOTIFICATION = True
3437
+
2780
3438
  if args.notify_errors is False:
2781
3439
  ERROR_NOTIFICATION = False
2782
3440
 
2783
3441
  if args.track_repos_changes is True:
2784
3442
  TRACK_REPOS_CHANGES = True
2785
3443
 
3444
+ if args.repos is not None:
3445
+ if not TRACK_REPOS_CHANGES:
3446
+ print("* Error: --repos requires -j/--track-repos-changes to be enabled")
3447
+ sys.exit(1)
3448
+ # Split comma-separated repo names and strip whitespace
3449
+ REPOS_TO_MONITOR = [repo.strip() for repo in args.repos.split(',') if repo.strip()]
3450
+
3451
+ if args.track_contribs_changes is True:
3452
+ TRACK_CONTRIB_CHANGES = True
3453
+
2786
3454
  if args.no_monitor_events is True:
2787
3455
  DO_NOT_MONITOR_GITHUB_EVENTS = True
2788
3456
 
@@ -2790,6 +3458,9 @@ def main():
2790
3458
  REPO_NOTIFICATION = False
2791
3459
  REPO_UPDATE_DATE_NOTIFICATION = False
2792
3460
 
3461
+ if not TRACK_CONTRIB_CHANGES:
3462
+ CONTRIB_NOTIFICATION = False
3463
+
2793
3464
  if DO_NOT_MONITOR_GITHUB_EVENTS:
2794
3465
  EVENT_NOTIFICATION = False
2795
3466
 
@@ -2798,13 +3469,16 @@ def main():
2798
3469
  PROFILE_NOTIFICATION = False
2799
3470
  REPO_NOTIFICATION = False
2800
3471
  REPO_UPDATE_DATE_NOTIFICATION = False
3472
+ CONTRIB_NOTIFICATION = False
2801
3473
  ERROR_NOTIFICATION = False
2802
3474
 
2803
- print(f"* Github polling interval:\t[ {display_time(GITHUB_CHECK_INTERVAL)} ]")
2804
- print(f"* Email notifications:\t\t[profile changes = {PROFILE_NOTIFICATION}] [new events = {EVENT_NOTIFICATION}]\n*\t\t\t\t[repos changes = {REPO_NOTIFICATION}] [repos update date = {REPO_UPDATE_DATE_NOTIFICATION}]\n*\t\t\t\t[errors = {ERROR_NOTIFICATION}]")
2805
- print(f"* Github API URL:\t\t{GITHUB_API_URL}")
3475
+ print(f"* GitHub polling interval:\t[ {display_time(GITHUB_CHECK_INTERVAL)} ]")
3476
+ print(f"* Email notifications:\t\t[profile changes = {PROFILE_NOTIFICATION}] [new events = {EVENT_NOTIFICATION}]\n*\t\t\t\t[repos changes = {REPO_NOTIFICATION}] [repos update date = {REPO_UPDATE_DATE_NOTIFICATION}]\n*\t\t\t\t[contrib changes = {CONTRIB_NOTIFICATION}] [errors = {ERROR_NOTIFICATION}]")
3477
+ print(f"* GitHub API URL:\t\t{GITHUB_API_URL}")
2806
3478
  print(f"* Track repos changes:\t\t{TRACK_REPOS_CHANGES}")
2807
- print(f"* Monitor Github events:\t{not DO_NOT_MONITOR_GITHUB_EVENTS}")
3479
+ print(f"* Track contrib changes:\t{TRACK_CONTRIB_CHANGES}")
3480
+ print(f"* Monitor GitHub events:\t{not DO_NOT_MONITOR_GITHUB_EVENTS}")
3481
+ print(f"* Get owned repos only:\t\t{not GET_ALL_REPOS}")
2808
3482
  print(f"* Liveness check:\t\t{bool(LIVENESS_CHECK_INTERVAL)}" + (f" ({display_time(LIVENESS_CHECK_INTERVAL)})" if LIVENESS_CHECK_INTERVAL else ""))
2809
3483
  print(f"* CSV logging enabled:\t\t{bool(CSV_FILE)}" + (f" ({CSV_FILE})" if CSV_FILE else ""))
2810
3484
  print(f"* Output logging enabled:\t{not DISABLE_LOGGING}" + (f" ({FINAL_LOG_PATH})" if not DISABLE_LOGGING else ""))
@@ -2812,9 +3486,10 @@ def main():
2812
3486
  print(f"* Dotenv file:\t\t\t{env_path or 'None'}")
2813
3487
  print(f"* Local timezone:\t\t{LOCAL_TIMEZONE}")
2814
3488
 
2815
- out = f"\nMonitoring Github user {args.username}"
3489
+ out = f"\nMonitoring GitHub user {args.username}"
2816
3490
  print(out)
2817
- print("-" * len(out))
3491
+ # print("-" * len(out))
3492
+ print("─" * HORIZONTAL_LINE1)
2818
3493
 
2819
3494
  # We define signal handlers only for Linux, Unix & MacOS since Windows has limited number of signals supported
2820
3495
  if platform.system() != 'Windows':
@@ -2822,6 +3497,7 @@ def main():
2822
3497
  signal.signal(signal.SIGUSR2, toggle_new_events_notifications_signal_handler)
2823
3498
  signal.signal(signal.SIGCONT, toggle_repo_changes_notifications_signal_handler)
2824
3499
  signal.signal(signal.SIGPIPE, toggle_repo_update_date_changes_notifications_signal_handler)
3500
+ signal.signal(signal.SIGURG, toggle_contrib_changes_notifications_signal_handler)
2825
3501
  signal.signal(signal.SIGTRAP, increase_check_signal_handler)
2826
3502
  signal.signal(signal.SIGABRT, decrease_check_signal_handler)
2827
3503
  signal.signal(signal.SIGHUP, reload_secrets_signal_handler)