diff --git a/whatdidyougetdone.py b/whatdidyougetdone.py
index d124782..55965b2 100755
--- a/whatdidyougetdone.py
+++ b/whatdidyougetdone.py
@@ -1,9 +1,10 @@
#!/usr/bin/env -S uv run
# /// script
-# requires-python = ">=3.10"
+# requires-python = ">=3.10,<3.13"
# dependencies = [
# "PyGithub>=2.1.1",
# "click>=8.1.7",
+# "platformdirs>=4.1.0",
# ]
# [tool.uv]
# exclude-newer = "2024-01-01T00:00:00Z"
@@ -16,10 +17,54 @@
import click
from datetime import datetime, timedelta, timezone
+import json
import os
from pathlib import Path
from typing import Optional
from github import Github
+from platformdirs import user_config_dir
+
+# Define app name and author for config directory
+APP_NAME = "whatdidyougetdone"
+APP_AUTHOR = "brayo"
+
+def get_config_dir() -> Path:
+ """Get the platform-specific config directory."""
+ config_dir = Path(user_config_dir(APP_NAME, APP_AUTHOR))
+ config_dir.mkdir(parents=True, exist_ok=True)
+ return config_dir
+
+def get_github_token() -> str:
+ """
+ Get GitHub token from config file or environment.
+ Updates config file if token is in environment.
+ Exits if no token is found.
+ """
+ config_file = get_config_dir() / ".env"
+
+ # Try reading from config file first
+ if config_file.exists():
+ with open(config_file) as f:
+ for line in f:
+ if line.startswith('GITHUB_TOKEN='):
+ return line.split('=')[1].strip()
+
+ # Check environment variable
+ token = os.getenv("GITHUB_TOKEN")
+ if token:
+ # Save to config file for future use
+ with open(config_file, 'w') as f:
+ f.write(f"GITHUB_TOKEN={token}\n")
+ return token
+
+ # No token found
+ print("GitHub token not found!")
+ print("Either:")
+ print(f"1. Create {config_file} with GITHUB_TOKEN=your_token")
+ print("2. Set GITHUB_TOKEN environment variable")
+ print("\nYou can create a token at: https://github.com/settings/tokens")
+ print("Required scopes: repo, read:user")
+ exit(1)
# Get script directory
SCRIPT_DIR = Path(__file__).parent.absolute()
@@ -49,13 +94,18 @@ def get_github_token() -> str:
print("Required scopes: repo, read:user")
exit(1)
+def preview_in_browser(filename: Path):
+ """Open file in browser if in interactive mode."""
+ if sys.stdout.isatty():
+ if click.confirm("Open in browser?"):
+ webbrowser.open(f"file://{os.path.abspath(filename)}")
+
def setup_github():
"""Ensure GitHub token is available."""
token = get_github_token()
os.environ["GITHUB_TOKEN"] = token # Set for Github instance
-
def get_user_activity(username: str, days: int = 7):
"""Get GitHub activity for a user over the last N days."""
g = Github(os.getenv("GITHUB_TOKEN"))
@@ -73,25 +123,81 @@ def get_user_activity(username: str, days: int = 7):
# Format based on event type
if event.type == "PushEvent":
- for commit in event.payload["commits"]:
- activities.append(
- {
- "type": "commit",
- "repo": event.repo.name,
- "message": commit["message"],
- "date": event.created_at,
- }
- )
+ # Only process commits if:
+ # 1. The push was by the user we're interested in
+ # 2. If it's a fork, only include commits not from parent repo
+ if event.actor.login == username:
+ # Get repo info to check if it's a fork
+ repo_info = json.loads(os.popen(f'gh repo view {event.repo.name} --json isFork,parent').read())
+
+ # For forks, skip showing commits if they're in the parent
+ if repo_info.get('isFork', False):
+ parent_repo = repo_info['parent']
+ parent_name = f"{parent_repo['owner']['login']}/{parent_repo['name']}"
+
+ # If user has access to parent repo, show commits there instead
+ try:
+ os.popen(f'gh repo view {parent_name}').read()
+ # User has access, skip showing in fork
+ continue
+ except:
+ # User doesn't have access to parent, show in fork
+ for commit in event.payload["commits"]:
+ activities.append({
+ "type": "commit",
+ "repo": event.repo.name,
+ "message": commit["message"],
+ "date": event.created_at,
+ "author": commit["author"],
+ "sha": commit["sha"]
+ })
+ else:
+ # For non-forks, include all commits
+ for commit in event.payload["commits"]:
+ # Get commit stats from GitHub API
+ try:
+ stats_output = os.popen(f'gh api repos/{event.repo.name}/commits/{commit["sha"]}').read()
+ commit_data = json.loads(stats_output)
+ activities.append({
+ "type": "commit",
+ "repo": event.repo.name,
+ "message": commit["message"],
+ "date": event.created_at,
+ "author": commit["author"],
+ "sha": commit["sha"],
+ "stats": commit_data.get("stats", {})
+ })
+ except:
+ # If we can't get stats, add commit without them
+ activities.append({
+ "type": "commit",
+ "repo": event.repo.name,
+ "message": commit["message"],
+ "date": event.created_at,
+ "author": commit["author"],
+ "sha": commit["sha"]
+ })
elif event.type == "PullRequestEvent":
- activities.append(
- {
+ pr = event.payload["pull_request"]
+ base_repo = pr["base"]["repo"]["full_name"]
+ head_repo = pr["head"]["repo"]["full_name"]
+
+ # Include PR if:
+ # 1. User created the PR (regardless of target)
+ # 2. PR is targeting user's repo
+ pr_author = pr["user"]["login"]
+ base_owner = pr["base"]["repo"]["owner"]["login"]
+
+ if pr_author == username or base_owner == username:
+ activities.append({
"type": "pr",
- "repo": event.repo.name,
- "title": event.payload["pull_request"]["title"],
- "state": event.payload["pull_request"]["state"],
+ "repo": base_repo,
+ "title": pr["title"],
+ "state": pr["state"],
"date": event.created_at,
- }
- )
+ "head_repo": head_repo,
+ "author": pr_author
+ })
# Add more event types as needed
return activities
@@ -123,44 +229,101 @@ def generate_report(username: str, days: int = 7, include_timeline: bool = False
if repo not in repos:
repos[repo] = []
repos[repo].append(activity)
-
- # Activity by repo
- report += "## Activity by Repository\n\n"
- for repo, acts in repos.items():
- report += f"### {repo}\n\n"
+
+ # Calculate date range
+ end_date = datetime.now(timezone.utc)
+ start_date = end_date - timedelta(days=days)
+
+ # Generate markdown
+ report = f"# What did {username} get done?\n\n"
+ report += f"Activity from {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}:\n\n"
+
+ # Add summary if there's activity
+ if activities:
+ total_commits = len({a["sha"] for a in activities if a["type"] == "commit"})
+ total_prs = len({(a["repo"], a["title"]) for a in activities if a["type"] == "pr"})
+ report += f"Summary:\n"
+ report += f"- 💻 {total_commits} unique commits\n"
+ report += f"- 🔀 {total_prs} pull requests\n\n"
+ else:
+ report += "No activity found in this time period.\n\n"
+
+ # Sort repos by activity date
+ sorted_repos = sorted(
+ repos.items(),
+ key=lambda x: max((act["date"] for act in x[1])),
+ reverse=True
+ )
+
+ for repo, acts in sorted_repos:
+ # Get repo info
+ repo_info = json.loads(os.popen(f'gh repo view {repo} --json isFork,parent').read())
+
+ # Format repo header
+ if repo_info.get('isFork', False):
+ parent = repo_info['parent']
+ report += f"## {repo} (fork of {parent['owner']['login']}/{parent['name']})\n\n"
+ else:
+ report += f"## {repo}\n\n"
+
# Group by type
commits = [act for act in acts if act["type"] == "commit"]
prs = [act for act in acts if act["type"] == "pr"]
-
+
+ # Skip PRs in fork if they target parent
+ if repo_info.get('isFork', False):
+ prs = [pr for pr in prs if pr["head_repo"] == repo]
+
+ # Group PRs by title
+ pr_groups = {}
+ for pr in prs:
+ title = pr['title']
+ if title not in pr_groups:
+ pr_groups[title] = {'date': pr['date'], 'states': set()}
+ pr_groups[title]['states'].add(pr['state'])
+ # Keep the most recent date
+ if pr['date'] > pr_groups[title]['date']:
+ pr_groups[title]['date'] = pr['date']
+
# Show PRs first (higher level changes)
- for act in sorted(prs, key=lambda x: x["date"], reverse=True):
- report += f"- 🔀 {act['title']} ({act['state']})\n"
-
- # Then show commits
+ for title, info in sorted(pr_groups.items(), key=lambda x: x[1]['date'], reverse=True):
+ states = info['states']
+ status = "✅" if "closed" in states else "🔄"
+ report += f"- {status} {title}\n"
+
+ # Then show commits (deduplicated by SHA)
+ seen_shas = set()
for act in sorted(commits, key=lambda x: x["date"], reverse=True):
# Skip merge commits and commits that are part of PRs
if act["message"].startswith("Merge") or "Co-authored-by" in act["message"]:
continue
- # Take only first line of commit message
- message = act["message"].split("\n")[0]
- report += f"- 💻 {message}\n"
-
- # Optional timeline
- if include_timeline:
- report += "\nDetailed Timeline
\n\n"
- for act in sorted(activities, key=lambda x: x["date"], reverse=True):
- date_str = act["date"].strftime("%Y-%m-%d %H:%M")
- if act["type"] == "commit":
- message = act["message"].split("\n")[0]
- report += f"- {date_str} 💻 [{act['repo']}] {message}\n"
- elif act["type"] == "pr":
- report += (
- f"- {date_str} 🔀 [{act['repo']}] {act['title']} ({act['state']})\n"
- )
- report += "\n \n"
-
+
+ if act["sha"] not in seen_shas:
+ seen_shas.add(act["sha"])
+ # Get first line of commit message for display
+ message = act["message"].split('\n')[0].strip()
+ short_sha = act["sha"][:7] # First 7 chars of SHA
+
+ # Format additions/deletions stats
+ stats = ""
+ if "stats" in act:
+ adds = act["stats"].get("additions", 0)
+ dels = act["stats"].get("deletions", 0)
+ if adds or dels:
+ stats = f"+{adds}-{dels}"
+
+ report += f"- 💻 {message} ({short_sha}){stats}\n"
+
return report
+def save_report(username: str, report: str) -> Path:
+ """Save report to file."""
+ reports_dir = SCRIPT_DIR / "reports"
+ reports_dir.mkdir(exist_ok=True)
+
+ filename = reports_dir / f"{username}-{datetime.now().strftime('%Y-%m-%d')}.md"
+ filename.write_text(report)
+ return Path(filename)
@click.group()
def cli():
@@ -184,8 +347,6 @@ def report(username: str, days: int, file: Optional[str], timeline: bool):
if file:
Path(file).write_text(report_text)
print(f"Report saved to: {file}")
- else:
- print(report_text)
@cli.command()
@@ -241,13 +402,15 @@ def team(usernames: tuple[str], days: int, file: Optional[str], timeline: bool):
report += f"- 🔀 {pr_count} pull requests\n"
report += f"- 📦 {user_repos} active repositories\n\n"
- # Group by repo
- repos: dict[str, list[dict]] = {}
- for activity in user_activities:
- repo = activity["repo"]
- if repo not in repos:
- repos[repo] = []
- repos[repo].append(activity)
+
+ # Add details
+ for activity in sorted(activities, key=lambda x: x["date"], reverse=True):
+ if activity["type"] == "commit":
+ report += f"- [{activity['repo']}] {activity['message']}\n"
+ elif activity["type"] == "pr":
+ status = "✅" if activity["state"] == "closed" else "🔄"
+ report += f"- [{activity['repo']}] {status} {activity['title']}\n"
+ report += "\n"
for repo, acts in repos.items():
report += f"### {repo}\n\n"