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.
- {github_monitor-2.1.dist-info → github_monitor-2.2.dist-info}/METADATA +22 -5
- github_monitor-2.2.dist-info/RECORD +7 -0
- github_monitor.py +322 -16
- github_monitor-2.1.dist-info/RECORD +0 -7
- {github_monitor-2.1.dist-info → github_monitor-2.2.dist-info}/WHEEL +0 -0
- {github_monitor-2.1.dist-info → github_monitor-2.2.dist-info}/entry_points.txt +0 -0
- {github_monitor-2.1.dist-info → github_monitor-2.2.dist-info}/licenses/LICENSE +0 -0
- {github_monitor-2.1.dist-info → github_monitor-2.2.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: github_monitor
|
|
3
|
-
Version: 2.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|