|
| 1 | +import subprocess |
| 2 | +import requests |
| 3 | +from collections import defaultdict, Counter |
| 4 | +import os |
| 5 | +import re |
| 6 | +from datetime import datetime, timedelta |
| 7 | + |
| 8 | +# Config |
| 9 | +import sys |
| 10 | + |
| 11 | +scope = sys.argv[1] if len(sys.argv) > 1 else "weekly" |
| 12 | + |
| 13 | +if scope == "weekly": |
| 14 | + SINCE = '--since="1 week ago"' |
| 15 | +elif scope == "monthly": |
| 16 | + SINCE = '--since="1 month ago"' |
| 17 | +elif scope == "yearly": |
| 18 | + SINCE = '--since="1 year ago"' |
| 19 | +elif scope == "all": |
| 20 | + SINCE = "" |
| 21 | +else: |
| 22 | + print(f"❌ Unknown scope: {scope}. Use 'weekly', 'monthly', 'yearly', or 'all'.") |
| 23 | + sys.exit(1) |
| 24 | + |
| 25 | +scope=scope.capitalize() |
| 26 | +DISCORD_WEBHOOK = "https://discord.com/api/webhooks/1403424863783227532/Ltc-F2WQJvHnvmGID61BstnICwsTOEbNU_HJYUHG6gpBEtd1rY-bJDpiIdXzytpz3cTP" |
| 27 | + |
| 28 | + |
| 29 | +mailmap_cache = {} |
| 30 | + |
| 31 | +def resolve_mailmap(name, email): |
| 32 | + contact = f"{name} <{email}>" |
| 33 | + print(f"Resolving mailmap for: {contact}") |
| 34 | + if contact in mailmap_cache: |
| 35 | + return mailmap_cache[contact] |
| 36 | + try: |
| 37 | + result = subprocess.run( |
| 38 | + ['git', 'check-mailmap', contact], |
| 39 | + stdout=subprocess.PIPE, |
| 40 | + stderr=subprocess.DEVNULL, |
| 41 | + text=True |
| 42 | + ) |
| 43 | + resolved = result.stdout.strip() |
| 44 | + # Extract name only (before the first '<') |
| 45 | + resolved_name = resolved.split('<')[0].strip() if resolved else name |
| 46 | + mailmap_cache[contact] = resolved_name |
| 47 | + |
| 48 | + print(f"Resolved to: {resolved_name}") |
| 49 | + return resolved_name |
| 50 | + except Exception: |
| 51 | + mailmap_cache[contact] = name |
| 52 | + return name |
| 53 | + |
| 54 | + |
| 55 | +# Git command with commit marker |
| 56 | +cmd = f'git log {SINCE} --pretty=format:"--COMMIT--%n%h|%an|%ae|%s%n%b" --numstat' |
| 57 | +result = subprocess.run(cmd, stdout=subprocess.PIPE, text=True, shell=True) |
| 58 | +blocks = result.stdout.split('--COMMIT--\n') |
| 59 | + |
| 60 | +author_commits = defaultdict(int) |
| 61 | +total_additions = 0 |
| 62 | +total_deletions = 0 |
| 63 | +changed_files = set() |
| 64 | +merged_prs = [] |
| 65 | + |
| 66 | +for block in blocks: |
| 67 | + lines = block.strip().splitlines() |
| 68 | + if not lines: |
| 69 | + continue |
| 70 | + |
| 71 | + try: |
| 72 | + commit_hash, name, email, subject = lines[0].split('|', 3) |
| 73 | + author = resolve_mailmap(name.strip(), email.strip()) |
| 74 | + author_commits[author] += 1 |
| 75 | + except ValueError: |
| 76 | + continue |
| 77 | + |
| 78 | + body_lines = [] |
| 79 | + stat_lines = [] |
| 80 | + |
| 81 | + for line in lines[1:]: |
| 82 | + if re.match(r'^\d+\s+\d+\s+.+$', line): |
| 83 | + stat_lines.append(line) |
| 84 | + else: |
| 85 | + body_lines.append(line) |
| 86 | + |
| 87 | + body = "\n".join(body_lines) |
| 88 | + |
| 89 | + # Co-authors |
| 90 | + coauthors = re.findall(r'Co-authored-by:\s*(.+?)\s*<(.+?)>', body) |
| 91 | + resolved_coauthors = [] |
| 92 | + for cname, cemail in coauthors: |
| 93 | + coauthor = resolve_mailmap(cname.strip(), cemail.strip()) |
| 94 | + author_commits[coauthor] += 1 |
| 95 | + resolved_coauthors.append(coauthor) |
| 96 | + |
| 97 | + # PR detection |
| 98 | + pr_number = None |
| 99 | + pr_branch = None |
| 100 | + match_subject = re.search(r'(?:Merge pull request #|#)(\d+)(?: from ([\w\-/]+))?', subject) |
| 101 | + if match_subject: |
| 102 | + pr_number = match_subject.group(1) |
| 103 | + pr_branch = match_subject.group(2) if match_subject.group(2) else None |
| 104 | + else: |
| 105 | + match_body = re.search(r'(?:Closes|Fixes|Resolves)\s+#(\d+)', body) |
| 106 | + if match_body: |
| 107 | + pr_number = match_body.group(1) |
| 108 | + |
| 109 | + if pr_number: |
| 110 | + merged_prs.append((commit_hash, pr_number, pr_branch, author, resolved_coauthors, subject)) |
| 111 | + |
| 112 | + # File stats |
| 113 | + for stat in stat_lines: |
| 114 | + match = re.match(r'^(\d+)\s+(\d+)\s+(.+)$', stat) |
| 115 | + if match: |
| 116 | + additions = int(match.group(1)) |
| 117 | + deletions = int(match.group(2)) |
| 118 | + file = match.group(3).strip() |
| 119 | + total_additions += additions |
| 120 | + total_deletions += deletions |
| 121 | + changed_files.add(file) |
| 122 | + |
| 123 | +# Extension summary |
| 124 | +ext_counts = Counter() |
| 125 | +for f in changed_files: |
| 126 | + ext = os.path.splitext(f)[1].lower().strip() |
| 127 | + ext = re.sub(r'[^\w.]+$', '', ext) or "(no_ext)" |
| 128 | + ext_counts[ext] += 1 |
| 129 | + |
| 130 | +# Build summary |
| 131 | +summary = f"🧾 **PackOS {scope} Git Summary**\n----------------------" |
| 132 | +for author, count in sorted(author_commits.items()): |
| 133 | + summary += f"\n👤 **{author}**: {count} commits" |
| 134 | + |
| 135 | +summary += f"\n\n➕ **Total additions**: {total_additions}" |
| 136 | +summary += f"\n➖ **Total deletions**: {total_deletions}" |
| 137 | + |
| 138 | +if ext_counts: |
| 139 | + summary += "\n📄 **Files changed by type:**\n" + "\n".join( |
| 140 | + f"• `{ext}`: {count}" for ext, count in sorted(ext_counts.items()) |
| 141 | + ) |
| 142 | + |
| 143 | +if merged_prs: |
| 144 | + summary += "\n📦 **Merged or squash PRs:**" |
| 145 | + for commit_hash, pr_number, pr_branch, author, coauthors, subject in merged_prs: |
| 146 | + co_list = ", ".join(coauthors) |
| 147 | + summary += f"\n• PR #{pr_number} from `{pr_branch or '-'}` by **{author}**" |
| 148 | + if co_list: |
| 149 | + summary += f" (with co-authors: {co_list})" |
| 150 | + |
| 151 | +print(summary) |
| 152 | + |
| 153 | +# Post to Discord |
| 154 | +payload = {"content": summary[:1999]} # Discord message limit |
| 155 | +response = requests.post(DISCORD_WEBHOOK, json=payload) |
| 156 | + |
| 157 | +if response.status_code == 204: |
| 158 | + print("✅ Summary posted to Discord.") |
| 159 | +else: |
| 160 | + print(f"❌ Failed to post: {response.status_code} - {response.text}") |
0 commit comments