1717
1818import click
1919from datetime import datetime , timedelta , timezone
20+ import json
2021import os
2122from pathlib import Path
2223from typing import Optional
2324import sys
2425import webbrowser
2526from github import Github
27+ from platformdirs import user_config_dir
28+
29+ # Define app name and author for config directory
30+ APP_NAME = "whatdidyougetdone"
31+ APP_AUTHOR = "brayo"
32+
33+ def get_config_dir () -> Path :
34+ """Get the platform-specific config directory."""
35+ config_dir = Path (user_config_dir (APP_NAME , APP_AUTHOR ))
36+ config_dir .mkdir (parents = True , exist_ok = True )
37+ return config_dir
38+
39+ def get_github_token () -> str :
40+ """
41+ Get GitHub token from config file or environment.
42+ Updates config file if token is in environment.
43+ Exits if no token is found.
44+ """
45+ config_file = get_config_dir () / ".env"
46+
47+ # Try reading from config file first
48+ if config_file .exists ():
49+ with open (config_file ) as f :
50+ for line in f :
51+ if line .startswith ('GITHUB_TOKEN=' ):
52+ return line .split ('=' )[1 ].strip ()
53+
54+ # Check environment variable
55+ token = os .getenv ("GITHUB_TOKEN" )
56+ if token :
57+ # Save to config file for future use
58+ with open (config_file , 'w' ) as f :
59+ f .write (f"GITHUB_TOKEN={ token } \n " )
60+ return token
61+
62+ # No token found
63+ print ("GitHub token not found!" )
64+ print ("Either:" )
65+ print (f"1. Create { config_file } with GITHUB_TOKEN=your_token" )
66+ print ("2. Set GITHUB_TOKEN environment variable" )
67+ print ("\n You can create a token at: https://github.com/settings/tokens" )
68+ print ("Required scopes: repo, read:user" )
69+ exit (1 )
2670
2771# Get script directory
2872SCRIPT_DIR = Path (__file__ ).parent .absolute ()
@@ -33,7 +77,6 @@ def preview_in_browser(filename: Path):
3377 if click .confirm ("Open in browser?" ):
3478 webbrowser .open (f"file://{ os .path .abspath (filename )} " )
3579
36- from config import get_github_token
3780
3881def setup_github ():
3982 """Ensure GitHub token is available."""
@@ -58,25 +101,81 @@ def get_user_activity(username: str, days: int = 7):
58101
59102 # Format based on event type
60103 if event .type == "PushEvent" :
61- for commit in event .payload ["commits" ]:
62- activities .append (
63- {
64- "type" : "commit" ,
65- "repo" : event .repo .name ,
66- "message" : commit ["message" ],
67- "date" : event .created_at ,
68- }
69- )
104+ # Only process commits if:
105+ # 1. The push was by the user we're interested in
106+ # 2. If it's a fork, only include commits not from parent repo
107+ if event .actor .login == username :
108+ # Get repo info to check if it's a fork
109+ repo_info = json .loads (os .popen (f'gh repo view { event .repo .name } --json isFork,parent' ).read ())
110+
111+ # For forks, skip showing commits if they're in the parent
112+ if repo_info .get ('isFork' , False ):
113+ parent_repo = repo_info ['parent' ]
114+ parent_name = f"{ parent_repo ['owner' ]['login' ]} /{ parent_repo ['name' ]} "
115+
116+ # If user has access to parent repo, show commits there instead
117+ try :
118+ os .popen (f'gh repo view { parent_name } ' ).read ()
119+ # User has access, skip showing in fork
120+ continue
121+ except :
122+ # User doesn't have access to parent, show in fork
123+ for commit in event .payload ["commits" ]:
124+ activities .append ({
125+ "type" : "commit" ,
126+ "repo" : event .repo .name ,
127+ "message" : commit ["message" ],
128+ "date" : event .created_at ,
129+ "author" : commit ["author" ],
130+ "sha" : commit ["sha" ]
131+ })
132+ else :
133+ # For non-forks, include all commits
134+ for commit in event .payload ["commits" ]:
135+ # Get commit stats from GitHub API
136+ try :
137+ stats_output = os .popen (f'gh api repos/{ event .repo .name } /commits/{ commit ["sha" ]} ' ).read ()
138+ commit_data = json .loads (stats_output )
139+ activities .append ({
140+ "type" : "commit" ,
141+ "repo" : event .repo .name ,
142+ "message" : commit ["message" ],
143+ "date" : event .created_at ,
144+ "author" : commit ["author" ],
145+ "sha" : commit ["sha" ],
146+ "stats" : commit_data .get ("stats" , {})
147+ })
148+ except :
149+ # If we can't get stats, add commit without them
150+ activities .append ({
151+ "type" : "commit" ,
152+ "repo" : event .repo .name ,
153+ "message" : commit ["message" ],
154+ "date" : event .created_at ,
155+ "author" : commit ["author" ],
156+ "sha" : commit ["sha" ]
157+ })
70158 elif event .type == "PullRequestEvent" :
71- activities .append (
72- {
159+ pr = event .payload ["pull_request" ]
160+ base_repo = pr ["base" ]["repo" ]["full_name" ]
161+ head_repo = pr ["head" ]["repo" ]["full_name" ]
162+
163+ # Include PR if:
164+ # 1. User created the PR (regardless of target)
165+ # 2. PR is targeting user's repo
166+ pr_author = pr ["user" ]["login" ]
167+ base_owner = pr ["base" ]["repo" ]["owner" ]["login" ]
168+
169+ if pr_author == username or base_owner == username :
170+ activities .append ({
73171 "type" : "pr" ,
74- "repo" : event . repo . name ,
75- "title" : event . payload [ "pull_request" ] ["title" ],
76- "state" : event . payload [ "pull_request" ] ["state" ],
172+ "repo" : base_repo ,
173+ "title" : pr ["title" ],
174+ "state" : pr ["state" ],
77175 "date" : event .created_at ,
78- }
79- )
176+ "head_repo" : head_repo ,
177+ "author" : pr_author
178+ })
80179 # Add more event types as needed
81180
82181 return activities
@@ -93,28 +192,91 @@ def generate_report(username: str, days: int = 7):
93192 if repo not in repos :
94193 repos [repo ] = []
95194 repos [repo ].append (activity )
96-
195+
196+ # Calculate date range
197+ end_date = datetime .now (timezone .utc )
198+ start_date = end_date - timedelta (days = days )
199+
97200 # Generate markdown
98201 report = f"# What did { username } get done?\n \n "
99- report += f"Activity report for the last { days } days:\n \n "
100-
101- for repo , acts in repos .items ():
102- report += f"## { repo } \n \n "
202+ report += f"Activity from { start_date .strftime ('%Y-%m-%d' )} to { end_date .strftime ('%Y-%m-%d' )} :\n \n "
203+
204+ # Add summary if there's activity
205+ if activities :
206+ total_commits = len ({a ["sha" ] for a in activities if a ["type" ] == "commit" })
207+ total_prs = len ({(a ["repo" ], a ["title" ]) for a in activities if a ["type" ] == "pr" })
208+ report += f"Summary:\n "
209+ report += f"- 💻 { total_commits } unique commits\n "
210+ report += f"- 🔀 { total_prs } pull requests\n \n "
211+ else :
212+ report += "No activity found in this time period.\n \n "
213+
214+ # Sort repos by activity date
215+ sorted_repos = sorted (
216+ repos .items (),
217+ key = lambda x : max ((act ["date" ] for act in x [1 ])),
218+ reverse = True
219+ )
220+
221+ for repo , acts in sorted_repos :
222+ # Get repo info
223+ repo_info = json .loads (os .popen (f'gh repo view { repo } --json isFork,parent' ).read ())
224+
225+ # Format repo header
226+ if repo_info .get ('isFork' , False ):
227+ parent = repo_info ['parent' ]
228+ report += f"## { repo } (fork of { parent ['owner' ]['login' ]} /{ parent ['name' ]} )\n \n "
229+ else :
230+ report += f"## { repo } \n \n "
231+
103232 # Group by type
104233 commits = [act for act in acts if act ["type" ] == "commit" ]
105234 prs = [act for act in acts if act ["type" ] == "pr" ]
106-
235+
236+ # Skip PRs in fork if they target parent
237+ if repo_info .get ('isFork' , False ):
238+ prs = [pr for pr in prs if pr ["head_repo" ] == repo ]
239+
240+ # Group PRs by title
241+ pr_groups = {}
242+ for pr in prs :
243+ title = pr ['title' ]
244+ if title not in pr_groups :
245+ pr_groups [title ] = {'date' : pr ['date' ], 'states' : set ()}
246+ pr_groups [title ]['states' ].add (pr ['state' ])
247+ # Keep the most recent date
248+ if pr ['date' ] > pr_groups [title ]['date' ]:
249+ pr_groups [title ]['date' ] = pr ['date' ]
250+
107251 # Show PRs first (higher level changes)
108- for act in sorted (prs , key = lambda x : x ["date" ], reverse = True ):
109- report += f"- 🔀 { act ['title' ]} ({ act ['state' ]} )\n "
110-
111- # Then show commits
252+ for title , info in sorted (pr_groups .items (), key = lambda x : x [1 ]['date' ], reverse = True ):
253+ states = info ['states' ]
254+ status = "✅" if "closed" in states else "🔄"
255+ report += f"- { status } { title } \n "
256+
257+ # Then show commits (deduplicated by SHA)
258+ seen_shas = set ()
112259 for act in sorted (commits , key = lambda x : x ["date" ], reverse = True ):
113260 # Skip merge commits and commits that are part of PRs
114261 if act ["message" ].startswith ("Merge" ) or "Co-authored-by" in act ["message" ]:
115262 continue
116- report += f"- 💻 { act ['message' ]} \n "
117-
263+
264+ if act ["sha" ] not in seen_shas :
265+ seen_shas .add (act ["sha" ])
266+ # Get first line of commit message for display
267+ message = act ["message" ].split ('\n ' )[0 ].strip ()
268+ short_sha = act ["sha" ][:7 ] # First 7 chars of SHA
269+
270+ # Format additions/deletions stats
271+ stats = ""
272+ if "stats" in act :
273+ adds = act ["stats" ].get ("additions" , 0 )
274+ dels = act ["stats" ].get ("deletions" , 0 )
275+ if adds or dels :
276+ stats = f"<span style=\" color: #28a745\" >+{ adds } </span><span style=\" color: #dc3545\" >-{ dels } </span>"
277+
278+ report += f"- 💻 { message } ({ short_sha } ){ stats } \n "
279+
118280 return report
119281
120282def save_report (username : str , report : str ) -> Path :
@@ -181,7 +343,8 @@ def team(usernames: tuple[str], days: int):
181343 if activity ["type" ] == "commit" :
182344 report += f"- [{ activity ['repo' ]} ] { activity ['message' ]} \n "
183345 elif activity ["type" ] == "pr" :
184- report += f"- [{ activity ['repo' ]} ] { activity ['title' ]} ({ activity ['state' ]} )\n "
346+ status = "✅" if activity ["state" ] == "closed" else "🔄"
347+ report += f"- [{ activity ['repo' ]} ] { status } { activity ['title' ]} \n "
185348 report += "\n "
186349
187350 # Save report
0 commit comments