github-monitor 2.1__py3-none-any.whl → 2.2.1__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.

Potentially problematic release.


This version of github-monitor might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github_monitor
3
- Version: 2.1
3
+ Version: 2.2.1
4
4
  Summary: Tool implementing real-time tracking of Github users activities including profile and repositories changes
5
5
  Author-email: Michal Szymanski <misiektoja-pypi@rm-rf.ninja>
6
6
  License-Expression: GPL-3.0-or-later
@@ -39,6 +39,7 @@ OSINT tool for real-time monitoring of GitHub users' activities, including profi
39
39
  - added/removed public repositories
40
40
  - changes in user name, email, location, company, bio and blog URL
41
41
  - changes in profile visibility (public to private and vice versa)
42
+ - changes in user's daily contributions
42
43
  - detection when a user blocks or unblocks you
43
44
  - detection of account metadata changes (such as account update date)
44
45
  - Email notifications for different events (new GitHub events, changed followings, followers, repositories, user name, email, location, company, bio, blog URL etc.)
@@ -86,8 +87,8 @@ OSINT tool for real-time monitoring of GitHub users' activities, including profi
86
87
 
87
88
  Tested on:
88
89
 
89
- * **macOS**: Ventura, Sonoma, Sequoia
90
- * **Linux**: Raspberry Pi OS (Bullseye, Bookworm), Ubuntu 24, Rocky Linux 8.x/9.x, Kali Linux 2024/2025
90
+ * **macOS**: Ventura, Sonoma, Sequoia, Tahoe
91
+ * **Linux**: Raspberry Pi OS (Bullseye, Bookworm, Trixie), Ubuntu 24/25, Rocky Linux 8.x/9.x, Kali Linux 2024/2025
91
92
  * **Windows**: 10, 11
92
93
 
93
94
  It should work on other versions of macOS, Linux, Unix and Windows as well.
@@ -298,6 +299,12 @@ By default, only user-owned repos are tracked. To include forks and collaboratio
298
299
  github_monitor github_username -j -a
299
300
  ```
300
301
 
302
+ If you want to track user's daily contributions then use the `-m` flag:
303
+
304
+ ```sh
305
+ github_monitor github_username -m
306
+ ```
307
+
301
308
  If for any reason you do not want to monitor GitHub events for the user (e.g. new pushes, PRs, issues, forks, releases etc.), then use the `-k` flag:
302
309
 
303
310
  ```sh
@@ -379,7 +386,7 @@ To get email notifications when changes in user repositories are detected (e.g.
379
386
  - or use the `-q` flag
380
387
 
381
388
  ```sh
382
- github_monitor github_username -q
389
+ github_monitor github_username -j -q
383
390
  ```
384
391
 
385
392
  To be informed whenever changes in the update date of user repositories are detected:
@@ -387,11 +394,21 @@ To be informed whenever changes in the update date of user repositories are dete
387
394
  - or use the `-u` flag
388
395
 
389
396
  ```sh
390
- github_monitor github_username -u
397
+ github_monitor github_username -j -u
391
398
  ```
392
399
 
393
400
  The last two options (`-q` and `-u`) only work if tracking of repositories changes is enabled (`-j`).
394
401
 
402
+ To be informed about user's daily contributions:
403
+ - set `CONTRIB_NOTIFICATION` to `True`
404
+ - or use the `-y` flag
405
+
406
+ ```sh
407
+ github_monitor github_username -m -y
408
+ ```
409
+
410
+ The `-y` only works if tracking of daily contributions is enabled (`-m`).
411
+
395
412
  To disable sending an email on errors (enabled by default):
396
413
  - set `ERROR_NOTIFICATION` to `False`
397
414
  - or use the `-e` flag
@@ -0,0 +1,7 @@
1
+ github_monitor.py,sha256=y-KCw1m1Z_ck3dy81Wu0WGtmJY5wJXRtOKJhaVFp-rI,138012
2
+ github_monitor-2.2.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
+ github_monitor-2.2.1.dist-info/METADATA,sha256=TBFgooDz_els2Fgm4wMNoP1dcNMirFtq61DnbGAi6ls,17461
4
+ github_monitor-2.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
+ github_monitor-2.2.1.dist-info/entry_points.txt,sha256=hV03y00u1L16S5BwBSLQvFsZcL2WGRtjzlrmu9U9SN0,55
6
+ github_monitor-2.2.1.dist-info/top_level.txt,sha256=HDN2988ydvH9JZT32PushzqrcD05Q5qg960vgHGIaI8,15
7
+ github_monitor-2.2.1.dist-info/RECORD,,
github_monitor.py CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
3
  Author: Michal Szymanski <misiektoja-github@rm-rf.ninja>
4
- v2.1
4
+ v2.2.1
5
5
 
6
6
  OSINT tool implementing real-time tracking of GitHub users activities including profile and repositories changes:
7
7
  https://github.com/misiektoja/github_monitor/
@@ -16,7 +16,7 @@ tzlocal (optional)
16
16
  python-dotenv (optional)
17
17
  """
18
18
 
19
- VERSION = "2.1"
19
+ VERSION = "2.2.1"
20
20
 
21
21
  # ---------------------------
22
22
  # CONFIGURATION SECTION START
@@ -76,6 +76,7 @@ EVENT_NOTIFICATION = False
76
76
 
77
77
  # Whether to send an email when user's repositories change (stargazers, watchers, forks, issues,
78
78
  # PRs, description etc., except for update date)
79
+ # Requires TRACK_REPOS_CHANGES to be enabled
79
80
  # Can also be enabled via the -q flag
80
81
  REPO_NOTIFICATION = False
81
82
 
@@ -83,6 +84,11 @@ REPO_NOTIFICATION = False
83
84
  # Can also be enabled via the -u flag
84
85
  REPO_UPDATE_DATE_NOTIFICATION = False
85
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
+
86
92
  # Whether to send an email on errors
87
93
  # Can also be disabled via the -e flag
88
94
  ERROR_NOTIFICATION = True
@@ -126,6 +132,14 @@ EVENTS_TO_MONITOR = [
126
132
  # any events older than the most recent EVENTS_NUMBER will be missed
127
133
  EVENTS_NUMBER = 30 # 1 page
128
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
+ # If True, disable event monitoring
140
+ # Can also be disabled using the -k flag
141
+ DO_NOT_MONITOR_GITHUB_EVENTS = False
142
+
129
143
  # If True, fetch all user repos (owned, forks, collaborations); otherwise, fetch only owned repos
130
144
  GET_ALL_REPOS = False
131
145
 
@@ -133,6 +147,10 @@ GET_ALL_REPOS = False
133
147
  # In listing mode (-r), blocked repos are always shown
134
148
  BLOCKED_REPOS = False
135
149
 
150
+ # If True, track and log user's daily contributions count changes
151
+ # Can also be enabled using the -m flag
152
+ TRACK_CONTRIB_CHANGES = False
153
+
136
154
  # How often to print a "liveness check" message to the output; in seconds
137
155
  # Set to 0 to disable
138
156
  LIVENESS_CHECK_INTERVAL = 43200 # 12 hours
@@ -200,13 +218,17 @@ PROFILE_NOTIFICATION = False
200
218
  EVENT_NOTIFICATION = False
201
219
  REPO_NOTIFICATION = False
202
220
  REPO_UPDATE_DATE_NOTIFICATION = False
221
+ CONTRIB_NOTIFICATION = False
203
222
  ERROR_NOTIFICATION = False
204
223
  GITHUB_CHECK_INTERVAL = 0
205
224
  LOCAL_TIMEZONE = ""
206
225
  EVENTS_TO_MONITOR = []
207
226
  EVENTS_NUMBER = 0
227
+ TRACK_REPOS_CHANGES = False
228
+ DO_NOT_MONITOR_GITHUB_EVENTS = False
208
229
  GET_ALL_REPOS = False
209
230
  BLOCKED_REPOS = False
231
+ TRACK_CONTRIB_CHANGES = False
210
232
  LIVENESS_CHECK_INTERVAL = 0
211
233
  CHECK_INTERNET_URL = ""
212
234
  CHECK_INTERNET_TIMEOUT = 0
@@ -234,9 +256,6 @@ LIVENESS_CHECK_COUNTER = LIVENESS_CHECK_INTERVAL / GITHUB_CHECK_INTERVAL
234
256
  stdout_bck = None
235
257
  csvfieldnames = ['Date', 'Type', 'Name', 'Old', 'New']
236
258
 
237
- TRACK_REPOS_CHANGES = False
238
- DO_NOT_MONITOR_GITHUB_EVENTS = False
239
-
240
259
  CLI_CONFIG_PATH = None
241
260
 
242
261
  # to solve the issue: 'SyntaxError: f-string expression part cannot include a backslash'
@@ -252,7 +271,7 @@ if sys.version_info < (3, 10):
252
271
  import time
253
272
  import string
254
273
  import os
255
- from datetime import datetime, timezone
274
+ from datetime import datetime, timezone, date
256
275
  from dateutil import relativedelta
257
276
  from dateutil.parser import isoparse
258
277
  import calendar
@@ -278,6 +297,7 @@ import re
278
297
  import ipaddress
279
298
  try:
280
299
  from github import Github, Auth, GithubException, UnknownObjectException
300
+ from github.GithubException import RateLimitExceededException
281
301
  from github.GithubException import BadCredentialsException
282
302
  except ModuleNotFoundError:
283
303
  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")
@@ -288,7 +308,9 @@ import socket
288
308
  from typing import Any, Callable
289
309
  import shutil
290
310
  from pathlib import Path
291
-
311
+ from typing import Optional
312
+ import datetime as dt
313
+ import requests
292
314
 
293
315
  NET_ERRORS = (
294
316
  req.exceptions.RequestException,
@@ -566,6 +588,11 @@ def now_local_naive():
566
588
  return datetime.now(pytz.timezone(LOCAL_TIMEZONE)).replace(microsecond=0, tzinfo=None)
567
589
 
568
590
 
591
+ # Returns today's date in LOCAL_TIMEZONE (naive date)
592
+ def today_local() -> dt.date:
593
+ return now_local_naive().date()
594
+
595
+
569
596
  # Returns the current date/time in human readable format; eg. Sun 21 Apr 2024, 15:08:45
570
597
  def get_cur_ts(ts_str=""):
571
598
  return (f'{ts_str}{calendar.day_abbr[(now_local_naive()).weekday()]}, {now_local_naive().strftime("%d %b %Y, %H:%M:%S")}')
@@ -635,6 +662,11 @@ def get_short_date_from_ts(ts, show_year=False, show_hour=True, show_weekday=Tru
635
662
  ts_rounded = int(round(ts))
636
663
  ts_new = datetime.fromtimestamp(ts_rounded, tz)
637
664
 
665
+ elif isinstance(ts, date):
666
+ ts = datetime.combine(ts, datetime.min.time())
667
+ ts = pytz.utc.localize(ts)
668
+ ts_new = ts.astimezone(tz)
669
+
638
670
  else:
639
671
  return ""
640
672
 
@@ -840,6 +872,34 @@ def gh_call(fn: Callable[..., Any], retries=NET_MAX_RETRIES, backoff=NET_BASE_BA
840
872
  for i in range(1, retries + 1):
841
873
  try:
842
874
  return fn(*args, **kwargs)
875
+ except RateLimitExceededException as e:
876
+ headers = getattr(e, "headers", None)
877
+
878
+ reset_str = None
879
+ if headers:
880
+ val = headers.get("X-RateLimit-Reset")
881
+ if isinstance(val, str):
882
+ reset_str = val
883
+
884
+ sleep_for: int
885
+ if reset_str is not None and reset_str.isdigit():
886
+ reset_epoch = int(reset_str)
887
+ sleep_for = max(0, reset_epoch - int(time.time()) + 1)
888
+ else:
889
+ retry_after_str = None
890
+ if headers:
891
+ ra = headers.get("Retry-After")
892
+ if isinstance(ra, str):
893
+ retry_after_str = ra
894
+ if retry_after_str is not None and retry_after_str.isdigit():
895
+ sleep_for = int(retry_after_str)
896
+ else:
897
+ sleep_for = int(backoff * i)
898
+
899
+ print(f"* {fn.__name__} rate limited, sleeping {sleep_for}s (retry {i}/{retries})")
900
+ time.sleep(sleep_for)
901
+ continue
902
+
843
903
  except NET_ERRORS as e:
844
904
  print(f"* {fn.__name__} error: {e} (retry {i}/{retries})")
845
905
  time.sleep(backoff * i)
@@ -1138,6 +1198,13 @@ def format_body_block(content, indent=" "):
1138
1198
  return f"\n{indented}"
1139
1199
 
1140
1200
 
1201
+ # Returns the base web URL for GitHub or GHE (e.g. https://github.com or https://ghe.example.com)
1202
+ def github_web_base() -> str:
1203
+ if "api.github.com" in GITHUB_API_URL:
1204
+ return "https://github.com"
1205
+ return GITHUB_API_URL.replace("/api/v3", "").rstrip("/")
1206
+
1207
+
1141
1208
  # Prints details about passed GitHub event
1142
1209
  def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1143
1210
 
@@ -1158,19 +1225,33 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1158
1225
  st += print_v(f"Event type:\t\t\t{event.type}")
1159
1226
 
1160
1227
  if event.repo.id:
1161
- repo_name = event.repo.name
1162
- repo_url = event.repo.url.replace("https://api.github.com/repos/", "https://github.com/")
1163
- st += print_v(f"\nRepo name:\t\t\t{repo_name}")
1164
- st += print_v(f"Repo URL:\t\t\t{repo_url}")
1165
-
1166
1228
  try:
1167
1229
  desc_len = 80
1168
1230
  repo = g.get_repo(event.repo.name)
1169
- desc = repo.description or ''
1231
+
1232
+ # For ForkEvent, prefer the source repo if available
1233
+ if event.type == "ForkEvent" and repo is not None:
1234
+ try:
1235
+ parent = gh_call(lambda: getattr(repo, "parent", None))()
1236
+ if parent:
1237
+ repo = parent
1238
+ except Exception:
1239
+ pass
1240
+
1241
+ repo_name = getattr(repo, "full_name", event.repo.name)
1242
+
1243
+ api_prefix = GITHUB_API_URL.rstrip("/") + "/repos/"
1244
+ repo_url = getattr(repo, "html_url", event.repo.url.replace(api_prefix, github_web_base() + "/"))
1245
+
1246
+ st += print_v(f"\nRepo name:\t\t\t{repo_name}")
1247
+ st += print_v(f"Repo URL:\t\t\t{repo_url}")
1248
+
1249
+ desc = (repo.description or "") if repo else ""
1170
1250
  cleaned = desc.replace('\n', ' ')
1171
1251
  short_desc = cleaned[:desc_len] + '...' if len(cleaned) > desc_len else cleaned
1172
1252
  if short_desc:
1173
1253
  st += print_v(f"Repo description:\t\t{short_desc}")
1254
+
1174
1255
  except UnknownObjectException:
1175
1256
  repo = None
1176
1257
  st += print_v("\nRepository not found or has been removed")
@@ -1198,6 +1279,7 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1198
1279
  if event.payload.get("action"):
1199
1280
  st += print_v(f"\nAction:\t\t\t\t{event.payload.get('action')}")
1200
1281
 
1282
+ # Prefer commits from payload when present (older API behavior)
1201
1283
  if event.payload.get("commits"):
1202
1284
  commits = event.payload["commits"]
1203
1285
  commits_total = len(commits)
@@ -1208,7 +1290,7 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1208
1290
 
1209
1291
  commit_details = None
1210
1292
  if repo:
1211
- commit_details = repo.get_commit(commit["sha"])
1293
+ commit_details = gh_call(lambda: repo.get_commit(commit["sha"]))()
1212
1294
 
1213
1295
  if commit_details:
1214
1296
  commit_date = commit_details.commit.author.date
@@ -1244,6 +1326,81 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1244
1326
  st += print_v(f"\n - Commit message:\t\t'{commit['message']}'")
1245
1327
  st += print_v("." * HORIZONTAL_LINE1)
1246
1328
 
1329
+ # Fallback for new Events API where PushEvent no longer includes commit summaries
1330
+ elif event.type == "PushEvent" and repo:
1331
+ before_sha = event.payload.get("before")
1332
+ head_sha = event.payload.get("head") or event.payload.get("after")
1333
+ size_hint = event.payload.get("size")
1334
+
1335
+ # Debug when payload has no commits
1336
+ # st += print_v("\n[debug] PushEvent payload has no 'commits' array; using compare API")
1337
+ # st += print_v(f"[debug] before:\t\t\t{before_sha}")
1338
+ # st += print_v(f"[debug] head/after:\t\t{head_sha}")
1339
+ # if size_hint is not None:
1340
+ # st += print_v(f"[debug] size (hint):\t\t{size_hint}")
1341
+
1342
+ if before_sha and head_sha and before_sha != head_sha:
1343
+ try:
1344
+ compare = gh_call(lambda: repo.compare(before_sha, head_sha))()
1345
+ except Exception as e:
1346
+ compare = None
1347
+ st += print_v(f"* Error using compare({before_sha[:12]}...{head_sha[:12]}): {e}")
1348
+
1349
+ if compare:
1350
+ commits = list(compare.commits)
1351
+ commits_total = len(commits)
1352
+ short_repo = getattr(repo, "full_name", repo_name)
1353
+ compare_url = f"{github_web_base()}/{short_repo}/compare/{before_sha[:12]}...{head_sha[:12]}"
1354
+ st += print_v(f"\nNumber of commits:\t\t{commits_total}")
1355
+ st += print_v(f"Compare URL:\t\t\t{compare_url}")
1356
+
1357
+ for commit_count, c in enumerate(commits, start=1):
1358
+ st += print_v(f"\n=== Commit {commit_count}/{commits_total} ===")
1359
+ st += print_v("." * HORIZONTAL_LINE1)
1360
+
1361
+ commit_sha = getattr(c, "sha", None) or getattr(c, "id", None)
1362
+ commit_details = gh_call(lambda: repo.get_commit(commit_sha))() if (repo and commit_sha) else None
1363
+
1364
+ if commit_details:
1365
+ commit_date = commit_details.commit.author.date
1366
+ st += print_v(f" - Commit date:\t\t\t{get_date_from_ts(commit_date)}")
1367
+
1368
+ if commit_sha:
1369
+ st += print_v(f" - Commit SHA:\t\t\t{commit_sha}")
1370
+
1371
+ author_name = None
1372
+ if commit_details and commit_details.commit and commit_details.commit.author:
1373
+ author_name = commit_details.commit.author.name
1374
+ st += print_v(f" - Commit author:\t\t{author_name or 'N/A'}")
1375
+
1376
+ if commit_details and commit_details.author:
1377
+ st += print_v(f" - Commit author URL:\t\t{commit_details.author.html_url}")
1378
+
1379
+ if commit_details:
1380
+ st += print_v(f" - Commit URL:\t\t\t{commit_details.html_url}")
1381
+ st += print_v(f" - Commit raw patch URL:\t{commit_details.html_url}.patch")
1382
+
1383
+ stats = getattr(commit_details, "stats", None)
1384
+ additions = stats.additions if stats else 0
1385
+ deletions = stats.deletions if stats else 0
1386
+ stats_total = stats.total if stats else 0
1387
+ st += print_v(f"\n - Additions/Deletions:\t\t+{additions} / -{deletions} ({stats_total})")
1388
+
1389
+ try:
1390
+ file_count = sum(1 for _ in commit_details.files)
1391
+ except Exception:
1392
+ file_count = "N/A"
1393
+ st += print_v(f" - Files changed:\t\t{file_count}")
1394
+ if file_count and file_count != "N/A":
1395
+ st += print_v(" - Changed files list:")
1396
+ for f in commit_details.files:
1397
+ st += print_v(f" • '{f.filename}' - {f.status} (+{f.additions} / -{f.deletions})")
1398
+
1399
+ st += print_v(f"\n - Commit message:\t\t'{commit_details.commit.message}'")
1400
+ st += print_v("." * HORIZONTAL_LINE1)
1401
+ else:
1402
+ st += print_v("\nNo compare range available (forced push, tag push, or identical before/after)")
1403
+
1247
1404
  if event.payload.get("commits") == []:
1248
1405
  st += print_v("\nNo new commits (forced push, tag push, branch reset or other ref update)")
1249
1406
 
@@ -1700,9 +1857,11 @@ def handle_profile_change(label, count_old, count_new, list_old, raw_list, user,
1700
1857
  if removed_items:
1701
1858
  print(f"Removed {label.lower()}:\n")
1702
1859
  removed_mbody = f"\nRemoved {label.lower()}:\n\n"
1860
+ web_base = github_web_base()
1703
1861
  for item in removed_items:
1704
- item_url = (f"https://github.com/{item}/" if label.lower() in ["followers", "followings", "starred repos"]
1705
- else f"https://github.com/{user}/{item}/")
1862
+ item_url = (f"{web_base}/{item}/" if label.lower() in ["followers", "followings", "starred repos"]
1863
+ else f"{web_base}/{user}/{item}/")
1864
+
1706
1865
  print(f"- {item} [ {item_url} ]")
1707
1866
  removed_list_str += f"- {item} [ {item_url} ]\n"
1708
1867
  try:
@@ -1715,9 +1874,10 @@ def handle_profile_change(label, count_old, count_new, list_old, raw_list, user,
1715
1874
  if added_items:
1716
1875
  print(f"Added {label.lower()}:\n")
1717
1876
  added_mbody = f"\nAdded {label.lower()}:\n\n"
1877
+ web_base = github_web_base()
1718
1878
  for item in added_items:
1719
- item_url = (f"https://github.com/{item}/" if label.lower() in ["followers", "followings", "starred repos"]
1720
- else f"https://github.com/{user}/{item}/")
1879
+ item_url = (f"{web_base}/{item}/" if label.lower() in ["followers", "followings", "starred repos"]
1880
+ else f"{web_base}/{user}/{item}/")
1721
1881
  print(f"- {item} [ {item_url} ]")
1722
1882
  added_list_str += f"- {item} [ {item_url} ]\n"
1723
1883
  try:
@@ -1789,7 +1949,7 @@ def check_repo_list_changes(count_old, count_new, list_old, list_new, label, rep
1789
1949
  print(f"{removal_text} {label.lower()}:\n")
1790
1950
  removed_mbody = f"\n{removal_text} {label.lower()}:\n\n"
1791
1951
  for item in removed_items:
1792
- item_line = f"- {item} [ https://github.com/{item}/ ]" if label.lower() in ["stargazers", "watchers", "forks"] else f"- {item}"
1952
+ item_line = f"- {item} [ {github_web_base()}/{item}/ ]" if label.lower() in ["stargazers", "watchers", "forks"] else f"- {item}"
1793
1953
  print(item_line)
1794
1954
  removed_list_str += item_line + "\n"
1795
1955
  try:
@@ -1804,7 +1964,7 @@ def check_repo_list_changes(count_old, count_new, list_old, list_new, label, rep
1804
1964
  print(f"Added {label.lower()}:\n")
1805
1965
  added_mbody = f"\nAdded {label.lower()}:\n\n"
1806
1966
  for item in added_items:
1807
- item_line = f"- {item} [ https://github.com/{item}/ ]" if label.lower() in ["stargazers", "watchers", "forks"] else f"- {item}"
1967
+ item_line = f"- {item} [ {github_web_base()}/{item}/ ]" if label.lower() in ["stargazers", "watchers", "forks"] else f"- {item}"
1808
1968
  print(item_line)
1809
1969
  added_list_str += item_line + "\n"
1810
1970
  try:
@@ -1983,6 +2143,97 @@ def is_profile_public(g: Github, user, new_account_days=30):
1983
2143
 
1984
2144
  return False
1985
2145
 
2146
+
2147
+ # Returns a dict mapping 'YYYY-MM-DD' -> int contribution count for the range
2148
+ def get_daily_contributions(username: str, start: Optional[dt.date] = None, end: Optional[dt.date] = None, token: Optional[str] = None) -> dict:
2149
+ if token is None:
2150
+ raise ValueError("GitHub token is required")
2151
+
2152
+ today = dt.date.today()
2153
+ if start is None:
2154
+ start = today
2155
+ if end is None:
2156
+ end = today
2157
+
2158
+ url = GITHUB_API_URL.rstrip("/") + "/graphql"
2159
+ headers = {"Authorization": f"Bearer {token}"}
2160
+
2161
+ start_iso = dt.datetime.combine(start, dt.time.min).isoformat()
2162
+ to_exclusive = end + dt.timedelta(days=1)
2163
+ end_iso = dt.datetime.combine(to_exclusive, dt.time.min).isoformat()
2164
+
2165
+ query = """
2166
+ query($login: String!, $from: DateTime!, $to: DateTime!) {
2167
+ user(login: $login) {
2168
+ contributionsCollection(from: $from, to: $to) {
2169
+ contributionCalendar {
2170
+ weeks {
2171
+ contributionDays {
2172
+ date
2173
+ contributionCount
2174
+ }
2175
+ }
2176
+ }
2177
+ }
2178
+ }
2179
+ }"""
2180
+
2181
+ variables = {"login": username, "from": start_iso, "to": end_iso}
2182
+ r = requests.post(url, json={"query": query, "variables": variables}, headers=headers, timeout=30)
2183
+ r.raise_for_status()
2184
+ data = r.json()
2185
+
2186
+ days = data["data"]["user"]["contributionsCollection"]["contributionCalendar"]["weeks"]
2187
+ out = {}
2188
+ for w in days:
2189
+ for d in w["contributionDays"]:
2190
+ date = d["date"]
2191
+ if start <= dt.date.fromisoformat(date) <= end:
2192
+ out[date] = d["contributionCount"]
2193
+ return out
2194
+
2195
+
2196
+ # Return contribution count for a single day
2197
+ def get_daily_contributions_count(username: str, day: dt.date, token: str) -> int:
2198
+ data = get_daily_contributions(username, day, day, token)
2199
+ return next(iter(data.values()), 0)
2200
+
2201
+
2202
+ # Checks count for today and decides whether to notify based on stored state.
2203
+ def check_daily_contribs(username: str, token: str, state: dict, min_delta: int = 1, fail_threshold: int = 3) -> tuple[bool, int, bool]:
2204
+ day = today_local()
2205
+
2206
+ try:
2207
+ curr = get_daily_contributions_count(username, day, token=token)
2208
+ state["consecutive_failures"] = 0
2209
+ state["last_error"] = None
2210
+ except Exception as e:
2211
+ state["consecutive_failures"] = state.get("consecutive_failures", 0) + 1
2212
+ state["last_error"] = f"{type(e).__name__}: {e}"
2213
+ error_notify = state["consecutive_failures"] >= fail_threshold
2214
+ return False, state.get("count", 0), error_notify
2215
+
2216
+ prev_day = state.get("day")
2217
+ prev_cnt = state.get("count")
2218
+
2219
+ # New day -> reset baseline silently
2220
+ if prev_day != day:
2221
+ state["day"] = day
2222
+ state["count"] = curr
2223
+ state["prev_count"] = curr
2224
+ return False, curr, False # no notify on rollover
2225
+
2226
+ # Same day -> notify if change >= threshold
2227
+ if prev_cnt is not None and abs(curr - prev_cnt) >= min_delta:
2228
+ state["prev_count"] = prev_cnt
2229
+ state["count"] = curr
2230
+ return True, curr, False
2231
+
2232
+ # No change
2233
+ state["count"] = curr
2234
+ return False, curr, False
2235
+
2236
+
1986
2237
  # Monitors activity of the specified GitHub user
1987
2238
  def github_monitor_user(user, csv_file_name):
1988
2239
 
@@ -2002,6 +2253,8 @@ def github_monitor_user(user, csv_file_name):
2002
2253
  event_date: datetime | None = None
2003
2254
  blocked = None
2004
2255
  public = False
2256
+ contrib_state = {}
2257
+ contrib_curr = 0
2005
2258
 
2006
2259
  print("Sneaking into GitHub like a ninja ...")
2007
2260
 
@@ -2044,6 +2297,14 @@ def github_monitor_user(user, csv_file_name):
2044
2297
  public = is_profile_public(g, user)
2045
2298
  blocked = is_blocked_by(user) if public else None
2046
2299
 
2300
+ if TRACK_CONTRIB_CHANGES:
2301
+ contrib_curr = get_daily_contributions_count(user, today_local(), token=GITHUB_TOKEN)
2302
+ contrib_state = {
2303
+ "day": today_local(),
2304
+ "count": contrib_curr,
2305
+ "prev_count": contrib_curr
2306
+ }
2307
+
2047
2308
  if not DO_NOT_MONITOR_GITHUB_EVENTS:
2048
2309
  events = list(islice(g_user.get_events(), EVENTS_NUMBER))
2049
2310
  available_events = len(events)
@@ -2124,6 +2385,8 @@ def github_monitor_user(user, csv_file_name):
2124
2385
  print(f"Followings:\t\t\t{followings_count}")
2125
2386
  print(f"Repositories:\t\t\t{repos_count}")
2126
2387
  print(f"Starred repos:\t\t\t{starred_count}")
2388
+ if TRACK_CONTRIB_CHANGES:
2389
+ print(f"Today's contributions:\t\t{contrib_curr}")
2127
2390
 
2128
2391
  if not DO_NOT_MONITOR_GITHUB_EVENTS:
2129
2392
  print(f"Available events:\t\t{available_events}{'+' if available_events == EVENTS_NUMBER else ''}")
@@ -2242,6 +2505,36 @@ def github_monitor_user(user, csv_file_name):
2242
2505
  starred_count = starred_raw.totalCount
2243
2506
  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")
2244
2507
 
2508
+ # Changed contributions in a day
2509
+ if TRACK_CONTRIB_CHANGES:
2510
+ contrib_notify, contrib_curr, contrib_error_notify = check_daily_contribs(user, GITHUB_TOKEN, contrib_state, min_delta=1, fail_threshold=3)
2511
+ if contrib_error_notify and ERROR_NOTIFICATION:
2512
+ failures = contrib_state.get("consecutive_failures", 0)
2513
+ last_err = contrib_state.get("last_error", "Unknown error")
2514
+ err_msg = f"Error: GitHub daily contributions check failed {failures} times. Last error: {last_err}\n"
2515
+ print(err_msg)
2516
+ send_email(f"GitHub monitor errors for {user}", err_msg + get_cur_ts(nl_ch + "Timestamp: "), "", SMTP_SSL)
2517
+
2518
+ if contrib_notify:
2519
+ contrib_old = contrib_state.get("prev_count")
2520
+ 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")
2521
+
2522
+ try:
2523
+ if csv_file_name:
2524
+ write_csv_entry(csv_file_name, now_local_naive(), "Daily Contribs", user, contrib_old, contrib_curr)
2525
+ except Exception as e:
2526
+ print(f"* Error: {e}")
2527
+
2528
+ m_subject = f"GitHub user {user} daily contributions changed from {contrib_old} to {contrib_curr}!"
2529
+ 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: ')}")
2530
+
2531
+ if CONTRIB_NOTIFICATION:
2532
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
2533
+ send_email(m_subject, m_body, "", SMTP_SSL)
2534
+
2535
+ 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)})")
2536
+ print_cur_ts("Timestamp:\t\t\t")
2537
+
2245
2538
  # Changed bio
2246
2539
  bio = gh_call(lambda: g_user.bio)()
2247
2540
  if bio is not None and bio != bio_old:
@@ -2652,7 +2945,7 @@ def github_monitor_user(user, csv_file_name):
2652
2945
 
2653
2946
 
2654
2947
  def main():
2655
- 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, GET_ALL_REPOS
2948
+ 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, GET_ALL_REPOS, CONTRIB_NOTIFICATION, TRACK_CONTRIB_CHANGES
2656
2949
 
2657
2950
  if "--generate-config" in sys.argv:
2658
2951
  print(CONFIG_BLOCK.strip("\n"))
@@ -2759,6 +3052,13 @@ def main():
2759
3052
  default=None,
2760
3053
  help="Email when user's repositories update date changes"
2761
3054
  )
3055
+ notify.add_argument(
3056
+ "-y", "--notify-daily-contribs",
3057
+ dest="notify_daily_contribs",
3058
+ action="store_true",
3059
+ default=None,
3060
+ help="Email when user's daily contributions count changes"
3061
+ )
2762
3062
  notify.add_argument(
2763
3063
  "-e", "--no-error-notify",
2764
3064
  dest="notify_errors",
@@ -2858,6 +3158,13 @@ def main():
2858
3158
  default=None,
2859
3159
  help="Disable logging to github_monitor_<username>.log"
2860
3160
  )
3161
+ opts.add_argument(
3162
+ "-m", "--track-contribs-changes",
3163
+ dest="track_contribs_changes",
3164
+ action="store_true",
3165
+ default=None,
3166
+ help="Track user's daily contributions count and log changes"
3167
+ )
2861
3168
 
2862
3169
  args = parser.parse_args()
2863
3170
 
@@ -3047,12 +3354,18 @@ def main():
3047
3354
  if args.notify_repo_update_date is True:
3048
3355
  REPO_UPDATE_DATE_NOTIFICATION = True
3049
3356
 
3357
+ if args.notify_daily_contribs is True:
3358
+ CONTRIB_NOTIFICATION = True
3359
+
3050
3360
  if args.notify_errors is False:
3051
3361
  ERROR_NOTIFICATION = False
3052
3362
 
3053
3363
  if args.track_repos_changes is True:
3054
3364
  TRACK_REPOS_CHANGES = True
3055
3365
 
3366
+ if args.track_contribs_changes is True:
3367
+ TRACK_CONTRIB_CHANGES = True
3368
+
3056
3369
  if args.no_monitor_events is True:
3057
3370
  DO_NOT_MONITOR_GITHUB_EVENTS = True
3058
3371
 
@@ -3060,6 +3373,9 @@ def main():
3060
3373
  REPO_NOTIFICATION = False
3061
3374
  REPO_UPDATE_DATE_NOTIFICATION = False
3062
3375
 
3376
+ if not TRACK_CONTRIB_CHANGES:
3377
+ CONTRIB_NOTIFICATION = False
3378
+
3063
3379
  if DO_NOT_MONITOR_GITHUB_EVENTS:
3064
3380
  EVENT_NOTIFICATION = False
3065
3381
 
@@ -3068,12 +3384,14 @@ def main():
3068
3384
  PROFILE_NOTIFICATION = False
3069
3385
  REPO_NOTIFICATION = False
3070
3386
  REPO_UPDATE_DATE_NOTIFICATION = False
3387
+ CONTRIB_NOTIFICATION = False
3071
3388
  ERROR_NOTIFICATION = False
3072
3389
 
3073
3390
  print(f"* GitHub polling interval:\t[ {display_time(GITHUB_CHECK_INTERVAL)} ]")
3074
- 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}]")
3391
+ 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}]")
3075
3392
  print(f"* GitHub API URL:\t\t{GITHUB_API_URL}")
3076
3393
  print(f"* Track repos changes:\t\t{TRACK_REPOS_CHANGES}")
3394
+ print(f"* Track contrib changes:\t{TRACK_CONTRIB_CHANGES}")
3077
3395
  print(f"* Monitor GitHub events:\t{not DO_NOT_MONITOR_GITHUB_EVENTS}")
3078
3396
  print(f"* Get owned repos only:\t\t{not GET_ALL_REPOS}")
3079
3397
  print(f"* Liveness check:\t\t{bool(LIVENESS_CHECK_INTERVAL)}" + (f" ({display_time(LIVENESS_CHECK_INTERVAL)})" if LIVENESS_CHECK_INTERVAL else ""))
@@ -1,7 +0,0 @@
1
- github_monitor.py,sha256=TwfAtvASFyS-SFWZryolvv81Q0yoPhz_FBNjw2xXkBA,124098
2
- github_monitor-2.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
- github_monitor-2.1.dist-info/METADATA,sha256=kTwOp_S9Gq9baD6Gzl9k8s-0BGwVbU9MWuD3Zi7lkdM,17039
4
- github_monitor-2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
- github_monitor-2.1.dist-info/entry_points.txt,sha256=hV03y00u1L16S5BwBSLQvFsZcL2WGRtjzlrmu9U9SN0,55
6
- github_monitor-2.1.dist-info/top_level.txt,sha256=HDN2988ydvH9JZT32PushzqrcD05Q5qg960vgHGIaI8,15
7
- github_monitor-2.1.dist-info/RECORD,,