github-monitor 2.1__py3-none-any.whl → 2.2__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
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=OpG9CftmM85d7pJrYhZ2W2Ik3WF3dlDR9TSraJwOmGo,137642
2
+ github_monitor-2.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
+ github_monitor-2.2.dist-info/METADATA,sha256=7K-qKbUhVU5YIgSnlZYlTPwkfhuvS0JHaL1JY6Zalz4,17459
4
+ github_monitor-2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
+ github_monitor-2.2.dist-info/entry_points.txt,sha256=hV03y00u1L16S5BwBSLQvFsZcL2WGRtjzlrmu9U9SN0,55
6
+ github_monitor-2.2.dist-info/top_level.txt,sha256=HDN2988ydvH9JZT32PushzqrcD05Q5qg960vgHGIaI8,15
7
+ github_monitor-2.2.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
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"
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)
@@ -1158,19 +1218,31 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1158
1218
  st += print_v(f"Event type:\t\t\t{event.type}")
1159
1219
 
1160
1220
  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
1221
  try:
1167
1222
  desc_len = 80
1168
1223
  repo = g.get_repo(event.repo.name)
1169
- desc = repo.description or ''
1224
+
1225
+ # For ForkEvent, prefer the source repo if available
1226
+ if event.type == "ForkEvent" and repo is not None:
1227
+ try:
1228
+ parent = gh_call(lambda: getattr(repo, "parent", None))()
1229
+ if parent:
1230
+ repo = parent
1231
+ except Exception:
1232
+ pass
1233
+
1234
+ repo_name = getattr(repo, "full_name", event.repo.name)
1235
+ repo_url = getattr(repo, "html_url", event.repo.url.replace("https://api.github.com/repos/", "https://github.com/"))
1236
+
1237
+ st += print_v(f"\nRepo name:\t\t\t{repo_name}")
1238
+ st += print_v(f"Repo URL:\t\t\t{repo_url}")
1239
+
1240
+ desc = (repo.description or "") if repo else ""
1170
1241
  cleaned = desc.replace('\n', ' ')
1171
1242
  short_desc = cleaned[:desc_len] + '...' if len(cleaned) > desc_len else cleaned
1172
1243
  if short_desc:
1173
1244
  st += print_v(f"Repo description:\t\t{short_desc}")
1245
+
1174
1246
  except UnknownObjectException:
1175
1247
  repo = None
1176
1248
  st += print_v("\nRepository not found or has been removed")
@@ -1198,6 +1270,7 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1198
1270
  if event.payload.get("action"):
1199
1271
  st += print_v(f"\nAction:\t\t\t\t{event.payload.get('action')}")
1200
1272
 
1273
+ # Prefer commits from payload when present (older API behavior)
1201
1274
  if event.payload.get("commits"):
1202
1275
  commits = event.payload["commits"]
1203
1276
  commits_total = len(commits)
@@ -1208,7 +1281,7 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1208
1281
 
1209
1282
  commit_details = None
1210
1283
  if repo:
1211
- commit_details = repo.get_commit(commit["sha"])
1284
+ commit_details = gh_call(lambda: repo.get_commit(commit["sha"]))()
1212
1285
 
1213
1286
  if commit_details:
1214
1287
  commit_date = commit_details.commit.author.date
@@ -1244,6 +1317,81 @@ def github_print_event(event, g, time_passed=False, ts: datetime | None = None):
1244
1317
  st += print_v(f"\n - Commit message:\t\t'{commit['message']}'")
1245
1318
  st += print_v("." * HORIZONTAL_LINE1)
1246
1319
 
1320
+ # Fallback for new Events API where PushEvent no longer includes commit summaries
1321
+ elif event.type == "PushEvent" and repo:
1322
+ before_sha = event.payload.get("before")
1323
+ head_sha = event.payload.get("head") or event.payload.get("after")
1324
+ size_hint = event.payload.get("size")
1325
+
1326
+ # Debug when payload has no commits
1327
+ # st += print_v("\n[debug] PushEvent payload has no 'commits' array; using compare API")
1328
+ # st += print_v(f"[debug] before:\t\t\t{before_sha}")
1329
+ # st += print_v(f"[debug] head/after:\t\t{head_sha}")
1330
+ if size_hint is not None:
1331
+ st += print_v(f"[debug] size (hint):\t\t{size_hint}")
1332
+
1333
+ if before_sha and head_sha and before_sha != head_sha:
1334
+ try:
1335
+ compare = gh_call(lambda: repo.compare(before_sha, head_sha))()
1336
+ except Exception as e:
1337
+ compare = None
1338
+ st += print_v(f"* Error using compare({before_sha[:12]}...{head_sha[:12]}): {e}")
1339
+
1340
+ if compare:
1341
+ commits = list(compare.commits)
1342
+ commits_total = len(commits)
1343
+ short_repo = getattr(repo, "full_name", repo_name)
1344
+ compare_url = f"https://github.com/{short_repo}/compare/{before_sha[:12]}...{head_sha[:12]}"
1345
+ st += print_v(f"\nNumber of commits:\t\t{commits_total}")
1346
+ st += print_v(f"Compare URL:\t\t\t{compare_url}")
1347
+
1348
+ for commit_count, c in enumerate(commits, start=1):
1349
+ st += print_v(f"\n=== Commit {commit_count}/{commits_total} ===")
1350
+ st += print_v("." * HORIZONTAL_LINE1)
1351
+
1352
+ commit_sha = getattr(c, "sha", None) or getattr(c, "id", None)
1353
+ commit_details = gh_call(lambda: repo.get_commit(commit_sha))() if (repo and commit_sha) else None
1354
+
1355
+ if commit_details:
1356
+ commit_date = commit_details.commit.author.date
1357
+ st += print_v(f" - Commit date:\t\t\t{get_date_from_ts(commit_date)}")
1358
+
1359
+ if commit_sha:
1360
+ st += print_v(f" - Commit SHA:\t\t\t{commit_sha}")
1361
+
1362
+ author_name = None
1363
+ if commit_details and commit_details.commit and commit_details.commit.author:
1364
+ author_name = commit_details.commit.author.name
1365
+ st += print_v(f" - Commit author:\t\t{author_name or 'N/A'}")
1366
+
1367
+ if commit_details and commit_details.author:
1368
+ st += print_v(f" - Commit author URL:\t\t{commit_details.author.html_url}")
1369
+
1370
+ if commit_details:
1371
+ st += print_v(f" - Commit URL:\t\t\t{commit_details.html_url}")
1372
+ st += print_v(f" - Commit raw patch URL:\t{commit_details.html_url}.patch")
1373
+
1374
+ stats = getattr(commit_details, "stats", None)
1375
+ additions = stats.additions if stats else 0
1376
+ deletions = stats.deletions if stats else 0
1377
+ stats_total = stats.total if stats else 0
1378
+ st += print_v(f"\n - Additions/Deletions:\t\t+{additions} / -{deletions} ({stats_total})")
1379
+
1380
+ try:
1381
+ file_count = sum(1 for _ in commit_details.files)
1382
+ except Exception:
1383
+ file_count = "N/A"
1384
+ st += print_v(f" - Files changed:\t\t{file_count}")
1385
+ if file_count and file_count != "N/A":
1386
+ st += print_v(" - Changed files list:")
1387
+ for f in commit_details.files:
1388
+ st += print_v(f" • '{f.filename}' - {f.status} (+{f.additions} / -{f.deletions})")
1389
+
1390
+ st += print_v(f"\n - Commit message:\t\t'{commit_details.commit.message}'")
1391
+ st += print_v("." * HORIZONTAL_LINE1)
1392
+ else:
1393
+ st += print_v("\nNo compare range available (forced push, tag push, or identical before/after)")
1394
+
1247
1395
  if event.payload.get("commits") == []:
1248
1396
  st += print_v("\nNo new commits (forced push, tag push, branch reset or other ref update)")
1249
1397
 
@@ -1983,6 +2131,97 @@ def is_profile_public(g: Github, user, new_account_days=30):
1983
2131
 
1984
2132
  return False
1985
2133
 
2134
+
2135
+ # Returns a dict mapping 'YYYY-MM-DD' -> int contribution count for the range
2136
+ def get_daily_contributions(username: str, start: Optional[dt.date] = None, end: Optional[dt.date] = None, token: Optional[str] = None) -> dict:
2137
+ if token is None:
2138
+ raise ValueError("GitHub token is required")
2139
+
2140
+ today = dt.date.today()
2141
+ if start is None:
2142
+ start = today
2143
+ if end is None:
2144
+ end = today
2145
+
2146
+ url = GITHUB_API_URL.rstrip("/") + "/graphql"
2147
+ headers = {"Authorization": f"Bearer {token}"}
2148
+
2149
+ start_iso = dt.datetime.combine(start, dt.time.min).isoformat()
2150
+ to_exclusive = end + dt.timedelta(days=1)
2151
+ end_iso = dt.datetime.combine(to_exclusive, dt.time.min).isoformat()
2152
+
2153
+ query = """
2154
+ query($login: String!, $from: DateTime!, $to: DateTime!) {
2155
+ user(login: $login) {
2156
+ contributionsCollection(from: $from, to: $to) {
2157
+ contributionCalendar {
2158
+ weeks {
2159
+ contributionDays {
2160
+ date
2161
+ contributionCount
2162
+ }
2163
+ }
2164
+ }
2165
+ }
2166
+ }
2167
+ }"""
2168
+
2169
+ variables = {"login": username, "from": start_iso, "to": end_iso}
2170
+ r = requests.post(url, json={"query": query, "variables": variables}, headers=headers, timeout=30)
2171
+ r.raise_for_status()
2172
+ data = r.json()
2173
+
2174
+ days = data["data"]["user"]["contributionsCollection"]["contributionCalendar"]["weeks"]
2175
+ out = {}
2176
+ for w in days:
2177
+ for d in w["contributionDays"]:
2178
+ date = d["date"]
2179
+ if start <= dt.date.fromisoformat(date) <= end:
2180
+ out[date] = d["contributionCount"]
2181
+ return out
2182
+
2183
+
2184
+ # Return contribution count for a single day
2185
+ def get_daily_contributions_count(username: str, day: dt.date, token: str) -> int:
2186
+ data = get_daily_contributions(username, day, day, token)
2187
+ return next(iter(data.values()), 0)
2188
+
2189
+
2190
+ # Checks count for today and decides whether to notify based on stored state.
2191
+ def check_daily_contribs(username: str, token: str, state: dict, min_delta: int = 1, fail_threshold: int = 3) -> tuple[bool, int, bool]:
2192
+ day = today_local()
2193
+
2194
+ try:
2195
+ curr = get_daily_contributions_count(username, day, token=token)
2196
+ state["consecutive_failures"] = 0
2197
+ state["last_error"] = None
2198
+ except Exception as e:
2199
+ state["consecutive_failures"] = state.get("consecutive_failures", 0) + 1
2200
+ state["last_error"] = f"{type(e).__name__}: {e}"
2201
+ error_notify = state["consecutive_failures"] >= fail_threshold
2202
+ return False, state.get("count", 0), error_notify
2203
+
2204
+ prev_day = state.get("day")
2205
+ prev_cnt = state.get("count")
2206
+
2207
+ # New day -> reset baseline silently
2208
+ if prev_day != day:
2209
+ state["day"] = day
2210
+ state["count"] = curr
2211
+ state["prev_count"] = curr
2212
+ return False, curr, False # no notify on rollover
2213
+
2214
+ # Same day -> notify if change >= threshold
2215
+ if prev_cnt is not None and abs(curr - prev_cnt) >= min_delta:
2216
+ state["prev_count"] = prev_cnt
2217
+ state["count"] = curr
2218
+ return True, curr, False
2219
+
2220
+ # No change
2221
+ state["count"] = curr
2222
+ return False, curr, False
2223
+
2224
+
1986
2225
  # Monitors activity of the specified GitHub user
1987
2226
  def github_monitor_user(user, csv_file_name):
1988
2227
 
@@ -2002,6 +2241,8 @@ def github_monitor_user(user, csv_file_name):
2002
2241
  event_date: datetime | None = None
2003
2242
  blocked = None
2004
2243
  public = False
2244
+ contrib_state = {}
2245
+ contrib_curr = 0
2005
2246
 
2006
2247
  print("Sneaking into GitHub like a ninja ...")
2007
2248
 
@@ -2044,6 +2285,14 @@ def github_monitor_user(user, csv_file_name):
2044
2285
  public = is_profile_public(g, user)
2045
2286
  blocked = is_blocked_by(user) if public else None
2046
2287
 
2288
+ if TRACK_CONTRIB_CHANGES:
2289
+ contrib_curr = get_daily_contributions_count(user, today_local(), token=GITHUB_TOKEN)
2290
+ contrib_state = {
2291
+ "day": today_local(),
2292
+ "count": contrib_curr,
2293
+ "prev_count": contrib_curr
2294
+ }
2295
+
2047
2296
  if not DO_NOT_MONITOR_GITHUB_EVENTS:
2048
2297
  events = list(islice(g_user.get_events(), EVENTS_NUMBER))
2049
2298
  available_events = len(events)
@@ -2124,6 +2373,8 @@ def github_monitor_user(user, csv_file_name):
2124
2373
  print(f"Followings:\t\t\t{followings_count}")
2125
2374
  print(f"Repositories:\t\t\t{repos_count}")
2126
2375
  print(f"Starred repos:\t\t\t{starred_count}")
2376
+ if TRACK_CONTRIB_CHANGES:
2377
+ print(f"Today's contributions:\t\t{contrib_curr}")
2127
2378
 
2128
2379
  if not DO_NOT_MONITOR_GITHUB_EVENTS:
2129
2380
  print(f"Available events:\t\t{available_events}{'+' if available_events == EVENTS_NUMBER else ''}")
@@ -2242,6 +2493,36 @@ def github_monitor_user(user, csv_file_name):
2242
2493
  starred_count = starred_raw.totalCount
2243
2494
  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
2495
 
2496
+ # Changed contributions in a day
2497
+ if TRACK_CONTRIB_CHANGES:
2498
+ contrib_notify, contrib_curr, contrib_error_notify = check_daily_contribs(user, GITHUB_TOKEN, contrib_state, min_delta=1, fail_threshold=3)
2499
+ if contrib_error_notify and ERROR_NOTIFICATION:
2500
+ failures = contrib_state.get("consecutive_failures", 0)
2501
+ last_err = contrib_state.get("last_error", "Unknown error")
2502
+ err_msg = f"Error: GitHub daily contributions check failed {failures} times. Last error: {last_err}\n"
2503
+ print(err_msg)
2504
+ send_email(f"GitHub monitor errors for {user}", err_msg + get_cur_ts(nl_ch + "Timestamp: "), "", SMTP_SSL)
2505
+
2506
+ if contrib_notify:
2507
+ contrib_old = contrib_state.get("prev_count")
2508
+ 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")
2509
+
2510
+ try:
2511
+ if csv_file_name:
2512
+ write_csv_entry(csv_file_name, now_local_naive(), "Daily Contribs", user, contrib_old, contrib_curr)
2513
+ except Exception as e:
2514
+ print(f"* Error: {e}")
2515
+
2516
+ m_subject = f"GitHub user {user} daily contributions changed from {contrib_old} to {contrib_curr}!"
2517
+ 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: ')}")
2518
+
2519
+ if CONTRIB_NOTIFICATION:
2520
+ print(f"Sending email notification to {RECEIVER_EMAIL}")
2521
+ send_email(m_subject, m_body, "", SMTP_SSL)
2522
+
2523
+ 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)})")
2524
+ print_cur_ts("Timestamp:\t\t\t")
2525
+
2245
2526
  # Changed bio
2246
2527
  bio = gh_call(lambda: g_user.bio)()
2247
2528
  if bio is not None and bio != bio_old:
@@ -2652,7 +2933,7 @@ def github_monitor_user(user, csv_file_name):
2652
2933
 
2653
2934
 
2654
2935
  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
2936
+ 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
2937
 
2657
2938
  if "--generate-config" in sys.argv:
2658
2939
  print(CONFIG_BLOCK.strip("\n"))
@@ -2759,6 +3040,13 @@ def main():
2759
3040
  default=None,
2760
3041
  help="Email when user's repositories update date changes"
2761
3042
  )
3043
+ notify.add_argument(
3044
+ "-y", "--notify-daily-contribs",
3045
+ dest="notify_daily_contribs",
3046
+ action="store_true",
3047
+ default=None,
3048
+ help="Email when user's daily contributions count changes"
3049
+ )
2762
3050
  notify.add_argument(
2763
3051
  "-e", "--no-error-notify",
2764
3052
  dest="notify_errors",
@@ -2858,6 +3146,13 @@ def main():
2858
3146
  default=None,
2859
3147
  help="Disable logging to github_monitor_<username>.log"
2860
3148
  )
3149
+ opts.add_argument(
3150
+ "-m", "--track-contribs-changes",
3151
+ dest="track_contribs_changes",
3152
+ action="store_true",
3153
+ default=None,
3154
+ help="Track user's daily contributions count and log changes"
3155
+ )
2861
3156
 
2862
3157
  args = parser.parse_args()
2863
3158
 
@@ -3047,12 +3342,18 @@ def main():
3047
3342
  if args.notify_repo_update_date is True:
3048
3343
  REPO_UPDATE_DATE_NOTIFICATION = True
3049
3344
 
3345
+ if args.notify_daily_contribs is True:
3346
+ CONTRIB_NOTIFICATION = True
3347
+
3050
3348
  if args.notify_errors is False:
3051
3349
  ERROR_NOTIFICATION = False
3052
3350
 
3053
3351
  if args.track_repos_changes is True:
3054
3352
  TRACK_REPOS_CHANGES = True
3055
3353
 
3354
+ if args.track_contribs_changes is True:
3355
+ TRACK_CONTRIB_CHANGES = True
3356
+
3056
3357
  if args.no_monitor_events is True:
3057
3358
  DO_NOT_MONITOR_GITHUB_EVENTS = True
3058
3359
 
@@ -3060,6 +3361,9 @@ def main():
3060
3361
  REPO_NOTIFICATION = False
3061
3362
  REPO_UPDATE_DATE_NOTIFICATION = False
3062
3363
 
3364
+ if not TRACK_CONTRIB_CHANGES:
3365
+ CONTRIB_NOTIFICATION = False
3366
+
3063
3367
  if DO_NOT_MONITOR_GITHUB_EVENTS:
3064
3368
  EVENT_NOTIFICATION = False
3065
3369
 
@@ -3068,12 +3372,14 @@ def main():
3068
3372
  PROFILE_NOTIFICATION = False
3069
3373
  REPO_NOTIFICATION = False
3070
3374
  REPO_UPDATE_DATE_NOTIFICATION = False
3375
+ CONTRIB_NOTIFICATION = False
3071
3376
  ERROR_NOTIFICATION = False
3072
3377
 
3073
3378
  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}]")
3379
+ 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
3380
  print(f"* GitHub API URL:\t\t{GITHUB_API_URL}")
3076
3381
  print(f"* Track repos changes:\t\t{TRACK_REPOS_CHANGES}")
3382
+ print(f"* Track contrib changes:\t{TRACK_CONTRIB_CHANGES}")
3077
3383
  print(f"* Monitor GitHub events:\t{not DO_NOT_MONITOR_GITHUB_EVENTS}")
3078
3384
  print(f"* Get owned repos only:\t\t{not GET_ALL_REPOS}")
3079
3385
  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,,