@@ -56,8 +56,18 @@ def get_github_token() -> str:
5656 exit (1 )
5757
5858
59- def get_aw_activity (days : int = 7 ):
60- """Get ActivityWatch activity data for the last N days."""
59+ def get_aw_activity (
60+ days : int = 7 ,
61+ start_date : Optional [datetime ] = None ,
62+ end_date : Optional [datetime ] = None ,
63+ ):
64+ """Get ActivityWatch activity data for a date range.
65+
66+ Args:
67+ days: Number of days to look back (default 7, used if start_date/end_date not provided)
68+ start_date: Optional start date (UTC)
69+ end_date: Optional end date (UTC)
70+ """
6171 try :
6272 from aw_client import ActivityWatchClient
6373 except ImportError :
@@ -69,8 +79,13 @@ def get_aw_activity(days: int = 7):
6979 aw = ActivityWatchClient ("whatdidyougetdone" , testing = False )
7080
7181 # Calculate date range
72- end_date = datetime .now (timezone .utc )
73- start_date = start_of_day (end_date - timedelta (days = days ))
82+ if start_date and end_date :
83+ # Use provided date range
84+ pass
85+ else :
86+ # Use days parameter
87+ end_date = datetime .now (timezone .utc )
88+ start_date = start_of_day (end_date - timedelta (days = days ))
7489
7590 # Get buckets for window activity
7691 buckets = aw .get_buckets ()
@@ -113,14 +128,31 @@ def setup_github():
113128 os .environ ["GITHUB_TOKEN" ] = token
114129
115130
116- def get_user_activity (username : str , days : int = 7 ):
117- """Get GitHub activity for a user over the last N days."""
131+ def get_user_activity (
132+ username : str ,
133+ days : int = 7 ,
134+ start_date : Optional [datetime ] = None ,
135+ end_date : Optional [datetime ] = None ,
136+ ):
137+ """Get GitHub activity for a user over a date range.
138+
139+ Args:
140+ username: GitHub username
141+ days: Number of days to look back (default 7, used if start_date/end_date not provided)
142+ start_date: Optional start date (UTC)
143+ end_date: Optional end date (UTC)
144+ """
118145 g = Github (os .getenv ("GITHUB_TOKEN" ))
119146 user = g .get_user (username )
120147
121148 # Calculate date range (in UTC)
122- end_date = datetime .now (timezone .utc )
123- start_date = start_of_day (end_date - timedelta (days = days ))
149+ if start_date and end_date :
150+ # Use provided date range
151+ pass
152+ else :
153+ # Use days parameter
154+ end_date = datetime .now (timezone .utc )
155+ start_date = start_of_day (end_date - timedelta (days = days ))
124156
125157 # Warn about GitHub API limitations
126158 if days > 90 :
@@ -208,38 +240,105 @@ def get_user_activity(username: str, days: int = 7):
208240def generate_report (
209241 username : str ,
210242 days : int = 7 ,
243+ start_date : Optional [datetime ] = None ,
244+ end_date : Optional [datetime ] = None ,
211245 include_timeline : bool = False ,
212246 include_aw : bool = False ,
213247):
214- """Generate a markdown report of user activity."""
215- activities = get_user_activity (username , days )
248+ """Generate a markdown report of user activity.
249+
250+ Args:
251+ username: GitHub username
252+ days: Number of days to look back (default 7, ignored if start_date/end_date provided)
253+ start_date: Optional start date (UTC)
254+ end_date: Optional end date (UTC)
255+ include_timeline: Include detailed timeline
256+ include_aw: Include ActivityWatch data
257+ """
258+ activities = get_user_activity (username , days , start_date , end_date )
259+
260+ # Deduplicate PRs by URL (same PR can have multiple events)
261+ seen_prs = {}
262+ for activity in activities :
263+ if activity ["type" ] == "pr" :
264+ # Use title + repo as key since URL might not always be present
265+ pr_key = f"{ activity ['repo' ]} :{ activity ['title' ]} "
266+ if pr_key not in seen_prs :
267+ seen_prs [pr_key ] = activity
216268
217269 # Calculate summary stats
218270 total_commits = sum (1 for a in activities if a ["type" ] == "commit" )
219- total_prs = sum ( 1 for a in activities if a [ "type" ] == "pr" )
271+ total_prs = len ( seen_prs ) # Use deduplicated count
220272 active_repos = len ({a ["repo" ] for a in activities })
221273
274+ # Categorize PRs by type
275+ pr_categories : dict [str , list [dict ]] = {
276+ "feat" : [],
277+ "fix" : [],
278+ "docs" : [],
279+ "test" : [],
280+ "chore" : [],
281+ "other" : [],
282+ }
283+ for pr_url , pr in seen_prs .items ():
284+ title = pr ["title" ].lower ()
285+ if title .startswith ("feat" ):
286+ pr_categories ["feat" ].append (pr )
287+ elif title .startswith ("fix" ):
288+ pr_categories ["fix" ].append (pr )
289+ elif title .startswith ("docs" ):
290+ pr_categories ["docs" ].append (pr )
291+ elif title .startswith ("test" ):
292+ pr_categories ["test" ].append (pr )
293+ elif title .startswith ("chore" ) or title .startswith ("refactor" ):
294+ pr_categories ["chore" ].append (pr )
295+ else :
296+ pr_categories ["other" ].append (pr )
297+
222298 # Generate markdown
223299 report = f"# What did { username } get done?\n \n "
224- report += f"Activity report for the last { days } days:\n \n "
300+
301+ # Use custom date range in header if provided
302+ if start_date and end_date :
303+ start_str = start_date .strftime ("%Y-%m-%d" )
304+ end_str = end_date .strftime ("%Y-%m-%d" )
305+ report += f"Activity from { start_str } to { end_str } :\n \n "
306+ else :
307+ report += f"Activity for the last { days } days:\n \n "
225308
226309 # Summary stats
227310 report += "## Summary\n \n "
228311 report += f"- 💻 { total_commits } commits\n "
229312 report += f"- 🔀 { total_prs } pull requests\n "
230313 report += f"- 📦 { active_repos } active repositories\n "
231314
315+ # PR breakdown by category
316+ if total_prs > 0 :
317+ report += "\n ### PR Breakdown by Type\n \n "
318+ for category , prs in pr_categories .items ():
319+ if prs :
320+ category_emoji = {
321+ "feat" : "✨" ,
322+ "fix" : "🐛" ,
323+ "docs" : "📝" ,
324+ "test" : "🧪" ,
325+ "chore" : "🔧" ,
326+ "other" : "📦" ,
327+ }
328+ emoji = category_emoji .get (category , "📦" )
329+ report += f"- { emoji } { category .capitalize ()} : { len (prs )} \n "
330+
232331 # Add ActivityWatch data if available
233332 if include_aw :
234- aw_data = get_aw_activity (days )
333+ aw_data = get_aw_activity (days , start_date , end_date )
235334 if aw_data :
236335 report += f"- ⏱️ { aw_data ['total_hours' ]:.1f} hours of local activity\n "
237336
238337 report += "\n "
239338
240339 # ActivityWatch section
241340 if include_aw :
242- aw_data = get_aw_activity (days )
341+ aw_data = get_aw_activity (days , start_date , end_date )
243342 if aw_data and aw_data ["apps" ]:
244343 report += "## Local Activity (via ActivityWatch)\n \n "
245344 report += "Top applications by time:\n \n "
@@ -249,23 +348,44 @@ def generate_report(
249348 report += f"- 💻 { app_name } : { hours :.1f} h\n "
250349 report += "\n "
251350
252- # Group by repo
253- repos : dict [str , list [dict ]] = {}
351+ # Group by repo using deduplicated PRs
352+ repos : dict [str , dict [str , list ]] = {}
353+
354+ # Add all commits
254355 for activity in activities :
255- repo = activity ["repo" ]
356+ if activity ["type" ] == "commit" :
357+ repo = activity ["repo" ]
358+ if repo not in repos :
359+ repos [repo ] = {"commits" : [], "prs" : []}
360+ repos [repo ]["commits" ].append (activity )
361+
362+ # Add deduplicated PRs
363+ for pr_key , pr in seen_prs .items ():
364+ repo = pr ["repo" ]
256365 if repo not in repos :
257- repos [repo ] = []
258- repos [repo ].append (activity )
366+ repos [repo ] = { "commits" : [], "prs" : []}
367+ repos [repo ][ "prs" ] .append (pr )
259368
260369 # Activity by repo
261370 report += "## Activity by Repository\n \n "
262371 for repo , acts in repos .items ():
263372 report += f"### { repo } \n \n "
264- commits = [act for act in acts if act ["type" ] == "commit" ]
265- prs = [act for act in acts if act ["type" ] == "pr" ]
266373
267- for act in sorted (prs , key = lambda x : x ["date" ], reverse = True ):
268- report += f"- 🔀 { act ['title' ]} ({ act ['state' ]} )\n "
374+ # PRs with better formatting
375+ if acts ["prs" ]:
376+ for pr in sorted (acts ["prs" ], key = lambda x : x ["date" ], reverse = True ):
377+ # State emoji
378+ state_emoji = {"merged" : "✅" , "open" : "🔄" , "closed" : "❌" }.get (
379+ pr ["state" ], "🔀"
380+ )
381+
382+ # Format: emoji title (state)
383+ title = pr ["title" ]
384+ report += f"- { state_emoji } { title } \n "
385+ report += "\n "
386+
387+ # Commits
388+ commits = acts ["commits" ]
269389
270390 for act in sorted (commits , key = lambda x : x ["date" ], reverse = True ):
271391 if act ["message" ].startswith ("Merge" ) or "Co-authored-by" in act ["message" ]:
@@ -298,16 +418,67 @@ def cli():
298418
299419@cli .command ()
300420@click .argument ("username" )
301- @click .option ("--days" , default = 7 , help = "Number of days to look back" )
421+ @click .option (
422+ "--days" ,
423+ default = 7 ,
424+ help = "Number of days to look back (ignored if --start-date and --end-date provided)" ,
425+ )
426+ @click .option ("--start-date" , help = "Start date (YYYY-MM-DD, requires --end-date)" )
427+ @click .option ("--end-date" , help = "End date (YYYY-MM-DD, requires --start-date)" )
302428@click .option ("--file" , help = "Save output to file instead of stdout" )
303429@click .option ("--timeline" , is_flag = True , help = "Include detailed timeline" )
304430@click .option ("--activitywatch" , is_flag = True , help = "Include local ActivityWatch data" )
305431def report (
306- username : str , days : int , file : Optional [str ], timeline : bool , activitywatch : bool
432+ username : str ,
433+ days : int ,
434+ start_date : Optional [str ],
435+ end_date : Optional [str ],
436+ file : Optional [str ],
437+ timeline : bool ,
438+ activitywatch : bool ,
307439):
308- """Generate activity report for a single user"""
440+ """Generate activity report for a single user.
441+
442+ Use --days for recent activity, or --start-date and --end-date for a specific range.
443+ """
444+ # Parse and validate date range
445+ parsed_start_date : Optional [datetime ] = None
446+ parsed_end_date : Optional [datetime ] = None
447+
448+ if start_date or end_date :
449+ # Both must be provided if either is provided
450+ if not (start_date and end_date ):
451+ click .echo (
452+ "Error: Both --start-date and --end-date must be provided together"
453+ )
454+ return
455+
456+ try :
457+ # Parse dates and make them timezone-aware (UTC)
458+ parsed_start_date = datetime .strptime (start_date , "%Y-%m-%d" ).replace (
459+ tzinfo = timezone .utc
460+ )
461+ parsed_end_date = datetime .strptime (end_date , "%Y-%m-%d" ).replace (
462+ hour = 23 , minute = 59 , second = 59 , tzinfo = timezone .utc
463+ )
464+
465+ # Validate date range
466+ if parsed_start_date >= parsed_end_date :
467+ click .echo ("Error: --start-date must be before --end-date" )
468+ return
469+
470+ except ValueError as e :
471+ click .echo (f"Error parsing dates: { e } " )
472+ click .echo ("Date format should be YYYY-MM-DD (e.g., 2024-01-15)" )
473+ return
474+
309475 report_text = generate_report (
310- username , days , include_timeline = timeline , include_aw = activitywatch
476+ username ,
477+ days ,
478+ start_date = parsed_start_date ,
479+ end_date = parsed_end_date ,
480+ include_timeline = timeline ,
481+ include_aw = activitywatch ,
311482 )
312483
313484 if file :
@@ -320,22 +491,75 @@ def report(
320491
321492@cli .command ()
322493@click .argument ("usernames" , nargs = - 1 )
323- @click .option ("--days" , default = 7 , help = "Number of days to look back" )
494+ @click .option (
495+ "--days" ,
496+ default = 7 ,
497+ help = "Number of days to look back (ignored if --start-date and --end-date provided)" ,
498+ )
499+ @click .option ("--start-date" , help = "Start date (YYYY-MM-DD, requires --end-date)" )
500+ @click .option ("--end-date" , help = "End date (YYYY-MM-DD, requires --start-date)" )
324501@click .option ("--file" , help = "Save output to file instead of stdout" )
325502@click .option ("--timeline" , is_flag = True , help = "Include detailed timeline" )
326- def team (usernames : tuple [str ], days : int , file : Optional [str ], timeline : bool ):
503+ def team (
504+ usernames : tuple [str ],
505+ days : int ,
506+ start_date : Optional [str ],
507+ end_date : Optional [str ],
508+ file : Optional [str ],
509+ timeline : bool ,
510+ ):
327511 """Generate combined activity report for multiple users"""
328512 if not usernames :
329513 print ("Error: Please provide at least one username" )
330514 return
331515
516+ # Parse and validate date range
517+ parsed_start_date : Optional [datetime ] = None
518+ parsed_end_date : Optional [datetime ] = None
519+
520+ if start_date or end_date :
521+ # Both must be provided if either is provided
522+ if not (start_date and end_date ):
523+ click .echo (
524+ "Error: Both --start-date and --end-date must be provided together"
525+ )
526+ return
527+
528+ try :
529+ # Parse dates and make them timezone-aware (UTC)
530+ parsed_start_date = datetime .strptime (start_date , "%Y-%m-%d" ).replace (
531+ tzinfo = timezone .utc
532+ )
533+ parsed_end_date = datetime .strptime (end_date , "%Y-%m-%d" ).replace (
534+ hour = 23 , minute = 59 , second = 59 , tzinfo = timezone .utc
535+ )
536+
537+ # Validate date range
538+ if parsed_start_date >= parsed_end_date :
539+ click .echo ("Error: --start-date must be before --end-date" )
540+ return
541+
542+ except ValueError as e :
543+ click .echo (f"Error parsing dates: { e } " )
544+ click .echo ("Date format should be YYYY-MM-DD (e.g., 2024-01-15)" )
545+ return
546+
547+ # Generate header with appropriate date range text
332548 report_text = "# Team Activity Report\n \n "
333- report_text += f"Activity for the last { days } days:\n \n "
549+ if parsed_start_date and parsed_end_date :
550+ report_text += f"Activity from { start_date } to { end_date } :\n \n "
551+ else :
552+ report_text += f"Activity for the last { days } days:\n \n "
334553
335554 for username in usernames :
336555 print (f"Fetching activity for { username } ..." )
337556 user_report = generate_report (
338- username , days , include_timeline = timeline , include_aw = False
557+ username ,
558+ days ,
559+ start_date = parsed_start_date ,
560+ end_date = parsed_end_date ,
561+ include_timeline = timeline ,
562+ include_aw = False ,
339563 )
340564 # Remove the title and first line from individual reports
341565 lines = user_report .split ("\n " )
0 commit comments