Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 218 additions & 55 deletions whatdidyougetdone.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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()
Expand Down Expand Up @@ -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"))
Expand All @@ -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
Expand Down Expand Up @@ -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 += "\n<details><summary>Detailed Timeline</summary>\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</details>\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"<span style=\"color: #28a745\">+{adds}</span><span style=\"color: #dc3545\">-{dels}</span>"

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():
Expand All @@ -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()
Expand Down Expand Up @@ -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"
Expand Down