Skip to content
Merged
Show file tree
Hide file tree
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
138 changes: 138 additions & 0 deletions .github/workflows/codex-api-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
name: Codex Code Review (API)

on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
types: [created]

# Cancel in-progress reviews if a new one is triggered
concurrency:
group: codex-review-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: true

jobs:
review:
name: Code Review
runs-on: ubuntu-latest
# Run on PR events OR when someone comments "@codex review" on a PR
if: >
github.event_name == 'pull_request' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@codex review'))
permissions:
contents: read
pull-requests: write
issues: write
outputs:
review_output: ${{ steps.run_codex.outputs.final-message }}

steps:
- name: Get PR number
id: pr_number
run: |
if [ "${{ github.event_name }}" == "pull_request" ]; then
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
else
echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT
fi

- name: Get PR details
id: pr_details
uses: actions/github-script@v7
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: ${{ steps.pr_number.outputs.number }}
});
core.setOutput('base_sha', pr.data.base.sha);
core.setOutput('head_sha', pr.data.head.sha);
core.setOutput('base_ref', pr.data.base.ref);

- name: Checkout PR
uses: actions/checkout@v4
with:
ref: refs/pull/${{ steps.pr_number.outputs.number }}/merge
fetch-depth: 0

- name: Fetch base and head refs
run: |
git fetch --no-tags origin \
${{ steps.pr_details.outputs.base_ref }} \
+refs/pull/${{ steps.pr_number.outputs.number }}/head

- name: Run Codex Review
id: run_codex
uses: openai/codex-action@v1
with:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
sandbox: read-only
model: codex-mini-latest
prompt: |
You are reviewing PR #${{ steps.pr_number.outputs.number }} in the ${{ github.repository }} repository.

Review ONLY the changes introduced by this PR between commits:
- Base: ${{ steps.pr_details.outputs.base_sha }}
- Head: ${{ steps.pr_details.outputs.head_sha }}

Use `git diff ${{ steps.pr_details.outputs.base_sha }}...${{ steps.pr_details.outputs.head_sha }}` to see the changes.

Focus your review on:
1. **Bugs & Logic Errors**: Identify potential bugs, edge cases, or incorrect logic
2. **Security Issues**: Flag any security vulnerabilities (XSS, injection, auth issues, etc.)
3. **Performance**: Note any obvious performance concerns
4. **Best Practices**: Suggest improvements for code quality and maintainability

Guidelines:
- Only report issues with HIGH confidence (P0/P1 severity)
- Be specific: reference file names and line numbers
- Be concise: no fluff, just actionable feedback
- If the code looks good, say so briefly
- Format your response in markdown

Do NOT:
- Nitpick style or formatting (we have linters for that)
- Suggest changes unrelated to the PR's purpose
- Report low-confidence or speculative issues

post_review:
name: Post Review Comment
runs-on: ubuntu-latest
needs: review
if: needs.review.outputs.review_output != ''
permissions:
pull-requests: write
issues: write

steps:
- name: Get PR number
id: pr_number
run: |
if [ "${{ github.event_name }}" == "pull_request" ]; then
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
else
echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT
fi

- name: Post review comment
uses: actions/github-script@v7
env:
REVIEW_OUTPUT: ${{ needs.review.outputs.review_output }}
with:
script: |
const reviewBody = `## 🤖 Codex Code Review

${process.env.REVIEW_OUTPUT}

---
<sub>Powered by [OpenAI Codex](https://openai.com/codex/) via API</sub>`;

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ steps.pr_number.outputs.number }},
body: reviewBody
});
66 changes: 55 additions & 11 deletions claude_code_wrapped/exporters/html_exporter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""HTML export for Claude Code Wrapped."""

from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from pathlib import Path

from ..stats import WrappedStats, format_tokens
Expand Down Expand Up @@ -265,15 +265,25 @@ def _get_css() -> str:
}}

.cost-table th {{
padding: 12px;
text-align: left;
padding: 12px 20px;
text-align: right;
border-bottom: 2px solid #30363D;
font-weight: bold;
}}

.cost-table th:first-child {{
text-align: left;
width: 30%;
}}

.cost-table td {{
padding: 12px;
padding: 12px 20px;
border-bottom: 1px solid #30363D;
text-align: right;
}}

.cost-table td:first-child {{
text-align: left;
}}

.cost-table tr:last-child td {{
Expand Down Expand Up @@ -425,14 +435,17 @@ def _build_dramatic_reveals(stats: WrappedStats, start_date: datetime, end_date:
<div class="reveal-subtitle">
{}<br>
<span style="color: var(--gray);">
Input: {} · Output: {}
Input: {} · Output: {}<br>
Cache write: {} · Cache read: {}
</span>
</div>
</div>""".format(
stats.total_tokens,
format_tokens(stats.total_tokens),
format_tokens(stats.total_input_tokens),
format_tokens(stats.total_output_tokens)
format_tokens(stats.total_output_tokens),
format_tokens(stats.total_cache_creation_tokens),
format_tokens(stats.total_cache_read_tokens)
)

return reveals
Expand Down Expand Up @@ -844,20 +857,42 @@ def _build_credits(stats: WrappedStats, year: int | None) -> str:
</div>
<div class="credits-subitem">Input: {format_tokens(stats.total_input_tokens)}</div>
<div class="credits-subitem">Output: {format_tokens(stats.total_output_tokens)}</div>
<div class="credits-subitem">Cache write: {format_tokens(stats.total_cache_creation_tokens)}</div>
<div class="credits-subitem">Cache read: {format_tokens(stats.total_cache_read_tokens)}</div>
</div>'''

# Frame 2: Timeline
today = datetime.now()
if year is None:
# All-time: calculate days from first to last message
if stats.first_message_date and stats.last_message_date:
total_days = (stats.last_message_date - stats.first_message_date).days + 1
total_days_year = (stats.last_message_date - stats.first_message_date).days + 1
else:
total_days = stats.active_days
total_days_year = stats.active_days
elif year == today.year:
total_days = (today - datetime(year, 1, 1)).days + 1
total_days_year = (today - datetime(year, 1, 1)).days + 1
else:
total_days = 366 if year % 4 == 0 else 365
total_days_year = 366 if year % 4 == 0 else 365

# Calculate days since journey start
if stats.first_message_date:
if year is None:
# All-time: days from first to last message
if stats.last_message_date:
days_since_journey = (stats.last_message_date - stats.first_message_date).days + 1
else:
days_since_journey = stats.active_days
elif year == today.year:
# Current year: days from first message to today
first_msg_date = stats.first_message_date.date() if hasattr(stats.first_message_date, 'date') else stats.first_message_date
days_since_journey = (today.date() - first_msg_date).days + 1
else:
# Past year: days from first message to end of year
year_end = datetime(year, 12, 31).date()
first_msg_date = stats.first_message_date.date() if hasattr(stats.first_message_date, 'date') else stats.first_message_date
days_since_journey = (year_end - first_msg_date).days + 1
else:
days_since_journey = stats.active_days

year_display = format_year_display(year)
# Use sentence case for "All time" in Period field
Expand All @@ -877,11 +912,20 @@ def _build_credits(stats: WrappedStats, year: int | None) -> str:
<span class="credits-value" style="color: var(--gray);">{stats.first_message_date.strftime('%B %d')}</span>
</div>'''

year_pct = (stats.active_days / total_days_year * 100) if total_days_year > 0 else 0
journey_pct = (stats.active_days / days_since_journey * 100) if days_since_journey > 0 else 0
html += f'''
<div class="credits-item">
<span class="credits-label">Active days</span>
<span class="credits-value" style="color: var(--orange);">{stats.active_days}</span>
<span style="color: var(--gray);"> of {total_days}</span>
</div>
<div class="credits-item">
<span class="credits-label">Active days of year</span>
<span class="credits-value" style="color: var(--gray);">{year_pct:.1f}%</span>
</div>
<div class="credits-item">
<span class="credits-label">Active days on journey</span>
<span class="credits-value" style="color: var(--purple);">{journey_pct:.1f}%</span>
</div>'''

if stats.most_active_hour is not None:
Expand Down
52 changes: 40 additions & 12 deletions claude_code_wrapped/exporters/markdown_exporter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Markdown export for Claude Code Wrapped."""

from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from pathlib import Path

from ..stats import WrappedStats, format_tokens
Expand Down Expand Up @@ -103,7 +103,9 @@ def _build_dramatic_reveals(stats: WrappedStats, start_date: datetime, end_date:
**{stats.total_tokens:,}** tokens ({format_tokens(stats.total_tokens)})

- Input: {format_tokens(stats.total_input_tokens)}
- Output: {format_tokens(stats.total_output_tokens)}""")
- Output: {format_tokens(stats.total_output_tokens)}
- Cache write: {format_tokens(stats.total_cache_creation_tokens)}
- Cache read: {format_tokens(stats.total_cache_read_tokens)}""")

return "\n\n".join(sections)

Expand Down Expand Up @@ -290,9 +292,9 @@ def _build_monthly_costs(stats: WrappedStats) -> str:
if not stats.monthly_costs:
return ""

# Build table
table = "| Month | Input | Output | Cache | Cost |\n"
table += "|-------|-------|--------|-------|------|\n"
# Build table with wider columns for better spacing
table = "| Month | Input | Output | Cache | Cost |\n"
table += "|:--------------|--------:|--------:|--------:|---------:|\n"

total_cost = 0
total_input = 0
Expand All @@ -318,9 +320,9 @@ def _build_monthly_costs(stats: WrappedStats) -> str:
month_date = datetime.strptime(month_str, "%Y-%m")
month_label = month_date.strftime("%b %Y")

table += f"| {month_label} | {format_tokens(input_tokens)} | {format_tokens(output_tokens)} | {format_tokens(cache_tokens)} | {format_cost(cost)} |\n"
table += f"| {month_label:<13} | {format_tokens(input_tokens):>7} | {format_tokens(output_tokens):>7} | {format_tokens(cache_tokens):>7} | {format_cost(cost):>8} |\n"

table += f"| **Total** | **{format_tokens(total_input)}** | **{format_tokens(total_output)}** | **{format_tokens(total_cache)}** | **{format_cost(total_cost)}** |\n"
table += f"| **Total** | **{format_tokens(total_input)}** | **{format_tokens(total_output)}** | **{format_tokens(total_cache)}** | **{format_cost(total_cost)}** |\n"

return f"""---

Expand Down Expand Up @@ -365,20 +367,42 @@ def _build_credits(stats: WrappedStats, year: int | None) -> str:
numbers += f"**Tokens:** {format_tokens(stats.total_tokens)}\n\n"
numbers += f"- Input: {format_tokens(stats.total_input_tokens)}\n"
numbers += f"- Output: {format_tokens(stats.total_output_tokens)}\n"
numbers += f"- Cache write: {format_tokens(stats.total_cache_creation_tokens)}\n"
numbers += f"- Cache read: {format_tokens(stats.total_cache_read_tokens)}\n"
sections.append(numbers)

# Timeline
today = datetime.now()
if year is None:
# All-time: calculate days from first to last message
if stats.first_message_date and stats.last_message_date:
total_days = (stats.last_message_date - stats.first_message_date).days + 1
total_days_year = (stats.last_message_date - stats.first_message_date).days + 1
else:
total_days = stats.active_days
total_days_year = stats.active_days
elif year == today.year:
total_days = (today - datetime(year, 1, 1)).days + 1
total_days_year = (today - datetime(year, 1, 1)).days + 1
else:
total_days = 366 if year % 4 == 0 else 365
total_days_year = 366 if year % 4 == 0 else 365

# Calculate days since journey start
if stats.first_message_date:
if year is None:
# All-time: days from first to last message
if stats.last_message_date:
days_since_journey = (stats.last_message_date - stats.first_message_date).days + 1
else:
days_since_journey = stats.active_days
elif year == today.year:
# Current year: days from first message to today
first_msg_date = stats.first_message_date.date() if hasattr(stats.first_message_date, 'date') else stats.first_message_date
days_since_journey = (today.date() - first_msg_date).days + 1
else:
# Past year: days from first message to end of year
year_end = datetime(year, 12, 31).date()
first_msg_date = stats.first_message_date.date() if hasattr(stats.first_message_date, 'date') else stats.first_message_date
days_since_journey = (year_end - first_msg_date).days + 1
else:
days_since_journey = stats.active_days

year_display = format_year_display(year)
# Use sentence case for "All time" in Period field
Expand All @@ -388,7 +412,11 @@ def _build_credits(stats: WrappedStats, year: int | None) -> str:
if stats.first_message_date:
date_str = stats.first_message_date.strftime('%B %d, %Y') if year is None else stats.first_message_date.strftime('%B %d')
timeline += f"- **Journey started:** {date_str}\n"
timeline += f"- **Active days:** {stats.active_days} of {total_days}\n"
year_pct = (stats.active_days / total_days_year * 100) if total_days_year > 0 else 0
journey_pct = (stats.active_days / days_since_journey * 100) if days_since_journey > 0 else 0
timeline += f"- **Active days:** {stats.active_days}\n"
timeline += f"- **Active days of year:** {year_pct:.1f}%\n"
timeline += f"- **Active days on journey:** {journey_pct:.1f}%\n"
if stats.most_active_hour is not None:
hour_label = "AM" if stats.most_active_hour < 12 else "PM"
hour_12 = stats.most_active_hour % 12 or 12
Expand Down
10 changes: 10 additions & 0 deletions claude_code_wrapped/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@

def main():
"""Main entry point for Claude Code Wrapped."""
try:
_run()
except KeyboardInterrupt:
console = Console()
console.print("\n\n[#C96442]You pulled the plug. No hard feelings.[/]")
sys.exit(0)


def _run():
"""Internal run function."""
# Check if we should use interactive mode
if should_use_interactive_mode():
# Get user selections through interactive prompts
Expand Down
Loading