From 85acbbd07c826f133dcc63613eb72700b301bf81 Mon Sep 17 00:00:00 2001 From: Ashish Patel Date: Thu, 23 Oct 2025 10:44:28 +0530 Subject: [PATCH 1/2] feat: type for the new daily report feature --- README.md | 274 +++++++++++++++++++++++++++++++++++++++-------- commit-chronicle | 240 +++++++++++++++++++++++++++++++---------- 2 files changed, 411 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 7e3d9ff..1470287 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,275 @@ # πŸ“Š Commit Chronicle -**Generate beautiful monthly development reports from your git commits - organized by branch!** +**Generate beautiful development reports from your git commits - organized by branch!** -Turn your git history into professional reports that show exactly what you worked on, organized by feature branches, bug fixes, and more. +Turn your git history into professional reports that show exactly what you worked on, with support for both monthly and daily reports, organized by feature branches, bug fixes, and more. -## πŸš€ Super Simple Setup +## ✨ Features -### Step 1: Copy this to your terminal +βœ… **Daily or Monthly Reports** - Choose between single-day or full month analysis +βœ… **Branch-Based Organization** - See commits grouped by feature branches +βœ… **Professional Markdown Reports** - Clean formatting with tables and statistics +βœ… **Multi-Repository Support** - Analyze multiple projects at once +βœ… **GitHub Integration** - Includes PR reviews and authored PRs +βœ… **Cross-Platform** - Works on macOS, Linux, and Windows (Git Bash/WSL) +βœ… **Auto-Save** - Reports saved to your Downloads folder + +## πŸš€ Quick Start for Teams + +### Prerequisites + +- **git** (required) - Usually already installed +- **gh** (optional) - GitHub CLI for PR data +- **zsh** or **bash** shell + +### Installation ```bash -# Add this line to your ~/.zshrc file -echo 'source /Users/ashish/projects/commit-chronicle/commit-chronicle' >> ~/.zshrc +# 1. Clone the repository +git clone https://github.com/yourusername/commit-chronicle.git +cd commit-chronicle + +# 2. Make the script executable +chmod +x commit-chronicle -# Reload your terminal +# 3. Optional: Add to PATH for global access +echo 'export PATH="$PATH:'"$(pwd)"'"' >> ~/.zshrc source ~/.zshrc ``` -### Step 2: Configure your repositories +### Configuration -Open the script file and find this section (around line 162): +Before running, configure your repository paths by editing the `commit-chronicle` script: ```bash +# Open the script +nano commit-chronicle # or use your preferred editor + +# Find this section (around line 264): REPO_PATHS=( "/Users/ashish/work/forked/cx-saas-dashboard" "/Users/ashish/work/forked/cx-saas-server" - # Add your repository paths here + "/Users/ashish/work/cx-partners" + "/Users/ashish/work/saas-super-admin" ) -``` -**Replace with your actual repository paths!** For example: -```bash +# Replace with YOUR repository paths: REPO_PATHS=( - "/Users/yourname/projects/my-awesome-app" - "/Users/yourname/work/company-project" + "/Users/yourname/projects/my-project" + "/Users/yourname/work/team-repo" + # Add as many repos as you need ) ``` -### Step 3: Done! πŸŽ‰ +**πŸ’‘ Tip:** Use `pwd` inside your project folders to get the full path. + +## πŸ“– Usage + +### Interactive Mode (Recommended) -Now you can run `commit_chronicle` from anywhere in your terminal: +Simply run the script and follow the prompts: ```bash -commit_chronicle +./commit-chronicle +``` + +You'll be asked to: +1. **Choose report type**: Monthly (1) or Daily (2) +2. **Enter date**: + - Month format: `2025-10` (for monthly) + - Date format: `2025-10-23` (for daily) +3. **Enter your name**: e.g., "John Doe" +4. **Enter GitHub username**: e.g., "johndoe" + +### Example Session + +``` +πŸ“‹ Development Report Generator +======================================== +πŸ–₯️ Platform: macos (Darwin) + +πŸ“Š Report Type: + 1) Month-based (entire month) + 2) Date-based (specific day) +Choose option [1]: 2 + +πŸ“… Date-based report selected + +πŸ“… Enter date (YYYY-MM-DD) [2025-10-23]: 2025-10-18 +πŸ‘€ Enter your full name [Ashish Patel]: John Doe +πŸ™ Enter GitHub username [ashishxcode]: johndoe + +βœ… Found repository: /Users/john/projects/my-app +---------------------------------------- +πŸ”„ Extracting commits from 1 repositories... +βœ… Report generated successfully! ``` ## πŸ“‹ What You'll Get -βœ… **Branch-Based Organization** - See commits grouped by feature branches -βœ… **Professional Reports** - Clean markdown with tables and statistics -βœ… **Multi-Repository Support** - Analyze multiple projects at once -βœ… **GitHub Integration** - Includes PR data when available -βœ… **Saved to Downloads** - Reports automatically saved to your Downloads folder +### Monthly Report Example + +```markdown +# πŸ“Š Monthly Development Report - October 2025 + +> **Developer:** John Doe +> **GitHub Username:** `johndoe` +> **Reporting Period:** `2025-10-01 to 2025-10-31` + +--- + +## πŸ”„ Pull Request Reviews + +### PRs Reviewed by Me +**Total PRs Reviewed:** 5 + +### PRs Authored by Me +**Total PRs Authored:** 3 + +--- + +## πŸ“ Commits Summary + +### 🌿 Commits Organized by Branch + +##### 🌱 `my-app/feat/user-authentication` (15 commits) +``` +β”Œβ”€ 2025-10-15 14:23:45 +└─ feat: add JWT token validation + +β”Œβ”€ 2025-10-15 16:45:12 +└─ feat: implement refresh token mechanism +``` +``` + +### Daily Report Example + +```markdown +# πŸ“Š Daily Development Report - October 18, 2025 + +> **Developer:** John Doe +> **GitHub Username:** `johndoe` +> **Reporting Period:** `2025-10-18` +--- + +## πŸ“ Commits Summary -### Sample Output: +##### 🌱 `my-app/fix/login-bug` (3 commits) +``` +β”Œβ”€ 2025-10-18 09:15:30 +└─ fix: resolve login timeout issue + +β”Œβ”€ 2025-10-18 11:42:18 +└─ test: add unit tests for login flow +``` ``` -πŸ“Š Monthly Development Report - September 2025 -🌿 Commits Organized by Branch: -β”œβ”€β”€ cx-saas-dashboard/feat/user-authentication (15 commits) -β”œβ”€β”€ cx-saas-dashboard/fix/login-bug (3 commits) -β”œβ”€β”€ my-app/feature/dark-mode (8 commits) -└── my-app/main (2 commits) +## πŸ”§ GitHub CLI Setup (Optional but Recommended) + +To include PR review data, install and authenticate with GitHub CLI: + +### Installation + +**macOS:** +```bash +brew install gh +``` + +**Linux:** +```bash +sudo apt install gh +# or +sudo snap install gh +``` + +**Windows:** +```bash +winget install GitHub.cli +# or +choco install gh +``` + +### Authentication + +```bash +gh auth login ``` -## πŸ”§ Optional: GitHub CLI Setup +Follow the prompts to authenticate with your GitHub account. -For pull request data, install GitHub CLI: +## πŸ› οΈ Advanced Configuration -**macOS:** `brew install gh` -**Linux:** `sudo apt install gh` -**Windows:** `winget install GitHub.cli` +### Multiple Authors -Then: `gh auth login` +If you use different git author names across repos, the script automatically detects and uses the most common one. -## ❓ Common Questions +### Custom Output Location -**Q: Where do I find my repository paths?** -A: Use `pwd` command when you're inside your project folder +Reports are saved to: +- **macOS/Linux:** `~/Downloads/` +- **Windows:** `%USERPROFILE%/Downloads/` -**Q: Can I change the output location?** -A: Reports are automatically saved to your Downloads folder +Format: `October_2025_username_report.md` or `October_18_2025_username_report.md` -**Q: What if I don't see my commits?** -A: Make sure your git name matches what you enter when running the script +### Cross-Platform Compatibility -**Q: Does this work on Windows?** -A: Yes! Works on Windows Git Bash, macOS, and Linux +The script automatically detects your OS and adjusts: +- Date calculations +- Path handling +- Temp file creation +- Downloads directory location + +## ❓ Troubleshooting + +### "No commits found" + +**Possible causes:** +1. Git author name mismatch - Check with: `git config user.name` +2. Wrong date range +3. No commits in configured repositories for that period + +**Fix:** Make sure the name you enter matches your git configuration. + +### "Not a git repository" errors + +**Fix:** Update the `REPO_PATHS` array in the script with valid repository paths. + +### Permissions error + +**Fix:** Make the script executable: +```bash +chmod +x commit-chronicle +``` + +### GitHub CLI not working + +**Fix:** +1. Install: `brew install gh` (macOS) or `sudo apt install gh` (Linux) +2. Authenticate: `gh auth login` +3. Verify: `gh auth status` + +## 🀝 Contributing + +Contributions are welcome! Feel free to: +- Report bugs via GitHub Issues +- Submit feature requests +- Create pull requests with improvements + +## πŸ“ License + +MIT License - Feel free to use this in your team! + +## 🎯 Pro Tips + +πŸ’‘ **Quarterly Reports:** Use month mode for each month, then combine reports +πŸ’‘ **Daily Standup:** Use date mode to quickly see yesterday's work +πŸ’‘ **Performance Reviews:** Generate reports for review periods +πŸ’‘ **Team Sync:** Share reports in Slack/Teams to show progress +πŸ’‘ **Automation:** Create a cron job for weekly auto-generated reports --- -**🎯 Generate your development report in under 30 seconds!** \ No newline at end of file +**Made with ❀️ for developers who want to track their work efficiently** + +Need help? Open an issue on GitHub! diff --git a/commit-chronicle b/commit-chronicle index e7059e4..bd59ef9 100755 --- a/commit-chronicle +++ b/commit-chronicle @@ -97,18 +97,27 @@ validate_prerequisites() { # Validate user input validate_input() { - local month="$1" - local author="$2" - local username="$3" - - if [[ ! $month =~ ^[0-9]{4}-[0-9]{2}$ ]]; then - handle_error "Invalid month format '$month'. Use YYYY-MM (e.g., 2024-08)" + local date_input="$1" + local mode="$2" + local author="$3" + local username="$4" + + if [ "$mode" = "month" ]; then + if [[ ! $date_input =~ ^[0-9]{4}-[0-9]{2}$ ]]; then + handle_error "Invalid month format '$date_input'. Use YYYY-MM (e.g., 2024-08)" + fi + elif [ "$mode" = "date" ]; then + if [[ ! $date_input =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + handle_error "Invalid date format '$date_input'. Use YYYY-MM-DD (e.g., 2024-08-15)" + fi + else + handle_error "Invalid mode '$mode'. Must be 'month' or 'date'" fi - + if [ -z "$author" ]; then handle_error "Author name cannot be empty" fi - + if [ -z "$username" ]; then handle_error "GitHub username cannot be empty" fi @@ -117,16 +126,48 @@ validate_input() { # Start timing START_TIME=$(date +%s) -echo "πŸ“‹ Monthly Development Report Generator" +echo "πŸ“‹ Development Report Generator" echo "========================================" echo "πŸ–₯️ Platform: $OS_TYPE ($(uname -s))" echo "⏱️ Started: $(date +"%Y-%m-%d %H:%M:%S")" echo "" -# Interactive input collection -echo -n "πŸ“… Enter month (YYYY-MM) [$(date +"%Y-%m")]: " -read MONTH_INPUT -MONTH=${MONTH_INPUT:-$(date +"%Y-%m")} +# Ask user for extraction mode +echo "πŸ“Š Report Type:" +echo " 1) Month-based (entire month)" +echo " 2) Date-based (specific day)" +echo -n "Choose option [1]: " +read MODE_CHOICE +MODE_CHOICE=${MODE_CHOICE:-1} + +case $MODE_CHOICE in + 1) + REPORT_MODE="month" + echo "" + echo "πŸ“… Month-based report selected" + ;; + 2) + REPORT_MODE="date" + echo "" + echo "πŸ“… Date-based report selected" + ;; + *) + echo "❌ Invalid option. Defaulting to month-based." + REPORT_MODE="month" + ;; +esac + +# Interactive input collection based on mode +echo "" +if [ "$REPORT_MODE" = "month" ]; then + echo -n "πŸ“… Enter month (YYYY-MM) [$(date +"%Y-%m")]: " + read DATE_INPUT + DATE_INPUT=${DATE_INPUT:-$(date +"%Y-%m")} +else + echo -n "πŸ“… Enter date (YYYY-MM-DD) [$(date +"%Y-%m-%d")]: " + read DATE_INPUT + DATE_INPUT=${DATE_INPUT:-$(date +"%Y-%m-%d")} +fi echo -n "πŸ‘€ Enter your full name [Ashish Patel]: " read AUTHOR_INPUT @@ -137,7 +178,7 @@ read GITHUB_USERNAME_INPUT GITHUB_USERNAME=${GITHUB_USERNAME_INPUT:-"ashishxcode"} # Validate input -validate_input "$MONTH" "$AUTHOR" "$GITHUB_USERNAME" +validate_input "$DATE_INPUT" "$REPORT_MODE" "$AUTHOR" "$GITHUB_USERNAME" # Create comprehensive author patterns for git log matching # This handles various name/email combinations and case variations @@ -264,6 +305,8 @@ get_original_branch() { REPO_PATHS=( "/Users/ashish/work/forked/cx-saas-dashboard" "/Users/ashish/work/forked/cx-saas-server" + "/Users/ashish/work/cx-partners" + "/Users/ashish/work/saas-super-admin" ) # If no repositories configured, use current working directory @@ -307,50 +350,118 @@ fi # Validate prerequisites after repository setup validate_prerequisites -# Validate month format -if [[ ! $MONTH =~ ^[0-9]{4}-[0-9]{2}$ ]]; then - echo "❌ Invalid month format. Use YYYY-MM (e.g., 2025-07)" - exit 1 -fi +# Parse date input and calculate date range based on mode +if [ "$REPORT_MODE" = "month" ]; then + MONTH="$DATE_INPUT" + + # Validate month format + if [[ ! $MONTH =~ ^[0-9]{4}-[0-9]{2}$ ]]; then + echo "❌ Invalid month format. Use YYYY-MM (e.g., 2025-07)" + exit 1 + fi + + # Parse month for date range + YEAR=$(echo $MONTH | cut -d'-' -f1) + MONTH_NUMBER=$(echo $MONTH | cut -d'-' -f2) + START_DATE="${YEAR}-${MONTH_NUMBER}-01" + + # Calculate last day of month correctly (accounts for Feb, 30-day months, etc.) + if [ "$OS_TYPE" = "macos" ]; then + # macOS date command + END_DATE=$(date -j -v1d -v+1m -v-1d -f "%Y-%m-%d" "${YEAR}-${MONTH_NUMBER}-01" "+%Y-%m-%d" 2>/dev/null) + else + # Linux date command + END_DATE=$(date -d "${YEAR}-${MONTH_NUMBER}-01 +1 month -1 day" "+%Y-%m-%d" 2>/dev/null) + fi + + # Fallback to day 31 if date calculation fails + if [ -z "$END_DATE" ]; then + END_DATE="${YEAR}-${MONTH_NUMBER}-31" + fi -# Parse month for date range -YEAR=$(echo $MONTH | cut -d'-' -f1) -MONTH_NUMBER=$(echo $MONTH | cut -d'-' -f2) -START_DATE="${YEAR}-${MONTH_NUMBER}-01" + # Get month name + case $MONTH_NUMBER in + 01) MONTH_NAME="January" ;; + 02) MONTH_NAME="February" ;; + 03) MONTH_NAME="March" ;; + 04) MONTH_NAME="April" ;; + 05) MONTH_NAME="May" ;; + 06) MONTH_NAME="June" ;; + 07) MONTH_NAME="July" ;; + 08) MONTH_NAME="August" ;; + 09) MONTH_NAME="September" ;; + 10) MONTH_NAME="October" ;; + 11) MONTH_NAME="November" ;; + 12) MONTH_NAME="December" ;; + esac -# Calculate last day of month correctly (accounts for Feb, 30-day months, etc.) -if [ "$OS_TYPE" = "macos" ]; then - # macOS date command - END_DATE=$(date -j -v1d -v+1m -v-1d -f "%Y-%m-%d" "${YEAR}-${MONTH_NUMBER}-01" "+%Y-%m-%d" 2>/dev/null) + REPORT_PERIOD="$MONTH_NAME $YEAR" else - # Linux date command - END_DATE=$(date -d "${YEAR}-${MONTH_NUMBER}-01 +1 month -1 day" "+%Y-%m-%d" 2>/dev/null) -fi + # Date mode - single day + SPECIFIC_DATE="$DATE_INPUT" -# Fallback to day 31 if date calculation fails -if [ -z "$END_DATE" ]; then - END_DATE="${YEAR}-${MONTH_NUMBER}-31" -fi + # Validate date format + if [[ ! $SPECIFIC_DATE =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + echo "❌ Invalid date format. Use YYYY-MM-DD (e.g., 2025-10-23)" + exit 1 + fi -# Get month name -case $MONTH_NUMBER in - 01) MONTH_NAME="January" ;; - 02) MONTH_NAME="February" ;; - 03) MONTH_NAME="March" ;; - 04) MONTH_NAME="April" ;; - 05) MONTH_NAME="May" ;; - 06) MONTH_NAME="June" ;; - 07) MONTH_NAME="July" ;; - 08) MONTH_NAME="August" ;; - 09) MONTH_NAME="September" ;; - 10) MONTH_NAME="October" ;; - 11) MONTH_NAME="November" ;; - 12) MONTH_NAME="December" ;; -esac + # For single day, START_DATE is the specific date + # END_DATE needs to be the next day because git's --until is exclusive + START_DATE="$SPECIFIC_DATE" + + # Calculate next day for END_DATE (git --until is exclusive) + if [ "$OS_TYPE" = "macos" ]; then + # macOS date command + END_DATE=$(date -j -v+1d -f "%Y-%m-%d" "$SPECIFIC_DATE" "+%Y-%m-%d" 2>/dev/null) + else + # Linux date command + END_DATE=$(date -d "$SPECIFIC_DATE +1 day" "+%Y-%m-%d" 2>/dev/null) + fi + + # Fallback if date calculation fails + if [ -z "$END_DATE" ]; then + # Manual calculation for fallback + YEAR=$(echo $SPECIFIC_DATE | cut -d'-' -f1) + MONTH_NUMBER=$(echo $SPECIFIC_DATE | cut -d'-' -f2) + DAY_NUMBER=$(echo $SPECIFIC_DATE | cut -d'-' -f3) + NEXT_DAY=$((10#$DAY_NUMBER + 1)) + END_DATE="${YEAR}-${MONTH_NUMBER}-$(printf "%02d" $NEXT_DAY)" + fi + + # Parse date components for display + YEAR=$(echo $SPECIFIC_DATE | cut -d'-' -f1) + MONTH_NUMBER=$(echo $SPECIFIC_DATE | cut -d'-' -f2) + DAY_NUMBER=$(echo $SPECIFIC_DATE | cut -d'-' -f3) + + # Get month name + case $MONTH_NUMBER in + 01) MONTH_NAME="January" ;; + 02) MONTH_NAME="February" ;; + 03) MONTH_NAME="March" ;; + 04) MONTH_NAME="April" ;; + 05) MONTH_NAME="May" ;; + 06) MONTH_NAME="June" ;; + 07) MONTH_NAME="July" ;; + 08) MONTH_NAME="August" ;; + 09) MONTH_NAME="September" ;; + 10) MONTH_NAME="October" ;; + 11) MONTH_NAME="November" ;; + 12) MONTH_NAME="December" ;; + esac + + REPORT_PERIOD="$MONTH_NAME $DAY_NUMBER, $YEAR" +fi echo "" echo "πŸ“Š Generating report for:" -echo " Month: $MONTH_NAME $YEAR ($START_DATE to $END_DATE)" +if [ "$REPORT_MODE" = "month" ]; then + echo " Period: $MONTH_NAME $YEAR (entire month)" + echo " Date Range: $START_DATE to $END_DATE" +else + echo " Period: $REPORT_PERIOD (single day)" + echo " Date: $START_DATE" +fi echo " Author: $AUTHOR" echo " GitHub: $GITHUB_USERNAME" echo " Repositories: ${#REPO_PATHS[@]} repo(s)" @@ -400,18 +511,33 @@ fi CLEAN_MONTH=$(sanitize_filename "$MONTH_NAME") CLEAN_YEAR=$(sanitize_filename "$YEAR") CLEAN_USERNAME=$(sanitize_filename "$GITHUB_USERNAME") -REPORT_FILENAME="${CLEAN_MONTH}_${CLEAN_YEAR}_${CLEAN_USERNAME}_report.md" + +# Create filename based on report mode +if [ "$REPORT_MODE" = "month" ]; then + REPORT_FILENAME="${CLEAN_MONTH}_${CLEAN_YEAR}_${CLEAN_USERNAME}_report.md" + REPORT_TITLE="Monthly Development Report - $MONTH_NAME $YEAR" +else + CLEAN_DAY=$(sanitize_filename "$DAY_NUMBER") + REPORT_FILENAME="${CLEAN_MONTH}_${CLEAN_DAY}_${CLEAN_YEAR}_${CLEAN_USERNAME}_report.md" + REPORT_TITLE="Daily Development Report - $MONTH_NAME $DAY_NUMBER, $YEAR" +fi # Set report file path REPORT_FILE="$DOWNLOADS_DIR/$REPORT_FILENAME" # Start building the markdown report +if [ "$REPORT_MODE" = "month" ]; then + REPORT_DATE_RANGE="$START_DATE to $END_DATE" +else + REPORT_DATE_RANGE="$START_DATE" +fi + cat > "$REPORT_FILE" << EOF -# πŸ“Š Monthly Development Report - $MONTH_NAME $YEAR +# πŸ“Š $REPORT_TITLE -> **Developer:** $AUTHOR -> **GitHub Username:** \`$GITHUB_USERNAME\` -> **Reporting Period:** \`$START_DATE\` to \`$END_DATE\` +> **Developer:** $AUTHOR +> **GitHub Username:** \`$GITHUB_USERNAME\` +> **Reporting Period:** \`$REPORT_DATE_RANGE\` > **Generated:** $(date +"%B %d, %Y at %H:%M") --- @@ -957,11 +1083,11 @@ fi # Clean up temp files rm -f "$TEMP_COMMIT_STATS" "$TEMP_DAY_STATS" "$TEMP_BRANCH_DATA" "$TEMP_COMMIT_DETAILS" "${TEMP_BRANCH_DATA}.user_branches" "$TEMP_BRANCH_DATES" -cat >> "$REPORT_FILE" << 'EOF' +cat >> "$REPORT_FILE" << EOF --- -*Generated by [Commit Chronicle](https://github.com/your-username/commit-chronicle) - Monthly Development Report Generator* +*Generated by [Commit Chronicle](https://github.com/your-username/commit-chronicle) - Development Report Generator* EOF # Calculate execution time From 403711a22412889c88cc49be4266cf672fa3a0c8 Mon Sep 17 00:00:00 2001 From: Ashish Patel Date: Mon, 25 May 2026 14:35:38 +0530 Subject: [PATCH 2/2] feat: rewrite commit-chronicle as a self-contained Go binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the zsh report script with a single static binary (Bubble Tea TUI). For a date window it gathers everything you did across your repos β€” git commits, commits on PRs you authored, authored PRs, and reviewed PRs β€” into one tagged, fuzzy-filterable picker, then exports a worklog (markdown/json). Highlights: - Interactive: range menu (incl. custom date selector), multi-select picker with live git-show preview, in-app editor, animated loading spinner. - Repo discovery: --repos, --root (auto-discover under a dir), and config files (~/.config/commit-chronicle/{repos,roots}); unioned + deduped. - GitHub PR/review discovery via gh, date-bounded to keep API calls sane. - Flags: --since/--from/--to/--month/--date, --author/--user, --format md|json, --all, --no-edit, --no-pr, --copy, --out. - Packages: cmd/commit-chronicle + internal/{model,config,collect,render,tui,app}. - Build/release via Makefile; CI + release GitHub Actions workflows. - README rewritten; legacy zsh scripts removed. --- .commit-chronicle.example | 16 + .github/workflows/ci.yml | 18 + .github/workflows/release.yml | 32 + .gitignore | 6 +- Makefile | 44 ++ README.md | 338 ++++------ cmd/commit-chronicle/main.go | 112 ++++ commit-chronicle | 1126 --------------------------------- go.mod | 33 + go.sum | 56 ++ internal/app/app.go | 257 ++++++++ internal/collect/collect.go | 87 +++ internal/collect/git.go | 102 +++ internal/collect/github.go | 304 +++++++++ internal/config/config.go | 165 +++++ internal/model/item.go | 64 ++ internal/model/range.go | 95 +++ internal/render/render.go | 115 ++++ internal/tui/choose.go | 65 ++ internal/tui/customrange.go | 85 +++ internal/tui/edit.go | 69 ++ internal/tui/loading.go | 95 +++ internal/tui/picker.go | 298 +++++++++ 23 files changed, 2229 insertions(+), 1353 deletions(-) create mode 100644 .commit-chronicle.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 Makefile create mode 100644 cmd/commit-chronicle/main.go delete mode 100755 commit-chronicle create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/app.go create mode 100644 internal/collect/collect.go create mode 100644 internal/collect/git.go create mode 100644 internal/collect/github.go create mode 100644 internal/config/config.go create mode 100644 internal/model/item.go create mode 100644 internal/model/range.go create mode 100644 internal/render/render.go create mode 100644 internal/tui/choose.go create mode 100644 internal/tui/customrange.go create mode 100644 internal/tui/edit.go create mode 100644 internal/tui/loading.go create mode 100644 internal/tui/picker.go diff --git a/.commit-chronicle.example b/.commit-chronicle.example new file mode 100644 index 0000000..3e6f6a5 --- /dev/null +++ b/.commit-chronicle.example @@ -0,0 +1,16 @@ +# commit-chronicle β€” repo list +# +# One git repository path per line. Lines starting with # are ignored. +# A leading ~ expands to your home directory. +# +# Use this for an explicit list of repos. Copy it to either: +# β€’ ./.commit-chronicle (per-project, in a repo) +# β€’ ~/.config/commit-chronicle/repos (global) +# +# Prefer auto-discovery? Instead of listing repos, put PARENT directories in +# ~/.config/commit-chronicle/roots (one dir per line, e.g. ~/work) +# and every git repo beneath them is found automatically. + +~/projects/my-app +~/projects/my-api +~/work/some-service diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fef82e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: ci + +on: + push: + branches: ["**"] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + - run: gofmt -l cmd internal | tee /dev/stderr | (! read) + - run: go vet ./... + - run: go build ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bdefa76 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: release + +# Tag a version (e.g. `git tag v0.1.0 && git push --tags`) to build +# cross-platform binaries and attach them to a GitHub Release. +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Build cross-platform binaries + run: make release + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index a051bed..e38f6a1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,8 @@ monthly_data_*/ # IDE .vscode/ -.idea/ \ No newline at end of file +.idea/ + +# Go build artifacts +/bin/ +/dist/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bcb07a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +BINARY := commit-chronicle +PKG := ./cmd/commit-chronicle +BINDIR := bin +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION) + +.PHONY: build install run clean test release + +## build: compile the binary for the host platform into bin/ +build: + go build -ldflags "$(LDFLAGS)" -o $(BINDIR)/$(BINARY) $(PKG) + +## install: install worklog into $GOBIN (or $GOPATH/bin) +install: + go install -ldflags "$(LDFLAGS)" $(PKG) + +## run: build and run (pass args via ARGS=...) +run: build + ./$(BINDIR)/$(BINARY) $(ARGS) + +## test: vet + build all packages +test: + go vet ./... + go build ./... + +## clean: remove build artifacts +clean: + rm -rf $(BINDIR) dist + +## release: cross-compile static binaries into dist/ +release: + @mkdir -p dist + @for target in \ + darwin/amd64 darwin/arm64 \ + linux/amd64 linux/arm64 \ + windows/amd64; do \ + os=$${target%/*}; arch=$${target#*/}; \ + ext=""; [ "$$os" = "windows" ] && ext=".exe"; \ + out=dist/$(BINARY)-$$os-$$arch$$ext; \ + echo "β†’ $$out"; \ + GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 \ + go build -ldflags "$(LDFLAGS)" -o $$out $(PKG) || exit 1; \ + done + @echo "done. binaries in dist/" diff --git a/README.md b/README.md index 1470287..13e2707 100644 --- a/README.md +++ b/README.md @@ -1,275 +1,161 @@ -# πŸ“Š Commit Chronicle +# πŸ““ commit-chronicle -**Generate beautiful development reports from your git commits - organized by branch!** +Find your commits **and** pull requests across all your repos for a date range, +pick what matters in an interactive terminal UI, and export a clean **worklog** +(Markdown or JSON). -Turn your git history into professional reports that show exactly what you worked on, with support for both monthly and daily reports, organized by feature branches, bug fixes, and more. +A single self-contained binary β€” no `node`, `python`, `fzf`, or `gum` to +install. The only requirement is **git**; **gh** (optional) unlocks PR & review +discovery. -## ✨ Features - -βœ… **Daily or Monthly Reports** - Choose between single-day or full month analysis -βœ… **Branch-Based Organization** - See commits grouped by feature branches -βœ… **Professional Markdown Reports** - Clean formatting with tables and statistics -βœ… **Multi-Repository Support** - Analyze multiple projects at once -βœ… **GitHub Integration** - Includes PR reviews and authored PRs -βœ… **Cross-Platform** - Works on macOS, Linux, and Windows (Git Bash/WSL) -βœ… **Auto-Save** - Reports saved to your Downloads folder - -## πŸš€ Quick Start for Teams - -### Prerequisites - -- **git** (required) - Usually already installed -- **gh** (optional) - GitHub CLI for PR data -- **zsh** or **bash** shell - -### Installation - -```bash -# 1. Clone the repository -git clone https://github.com/yourusername/commit-chronicle.git -cd commit-chronicle - -# 2. Make the script executable -chmod +x commit-chronicle - -# 3. Optional: Add to PATH for global access -echo 'export PATH="$PATH:'"$(pwd)"'"' >> ~/.zshrc -source ~/.zshrc -``` - -### Configuration - -Before running, configure your repository paths by editing the `commit-chronicle` script: - -```bash -# Open the script -nano commit-chronicle # or use your preferred editor - -# Find this section (around line 264): -REPO_PATHS=( - "/Users/ashish/work/forked/cx-saas-dashboard" - "/Users/ashish/work/forked/cx-saas-server" - "/Users/ashish/work/cx-partners" - "/Users/ashish/work/saas-super-admin" -) - -# Replace with YOUR repository paths: -REPO_PATHS=( - "/Users/yourname/projects/my-project" - "/Users/yourname/work/team-repo" - # Add as many repos as you need -) -``` - -**πŸ’‘ Tip:** Use `pwd` inside your project folders to get the full path. - -## πŸ“– Usage - -### Interactive Mode (Recommended) - -Simply run the script and follow the prompts: - -```bash -./commit-chronicle ``` - -You'll be asked to: -1. **Choose report type**: Monthly (1) or Daily (2) -2. **Enter date**: - - Month format: `2025-10` (for monthly) - - Date format: `2025-10-23` (for daily) -3. **Enter your name**: e.g., "John Doe" -4. **Enter GitHub username**: e.g., "johndoe" - -### Example Session - -``` -πŸ“‹ Development Report Generator -======================================== -πŸ–₯️ Platform: macos (Darwin) - -πŸ“Š Report Type: - 1) Month-based (entire month) - 2) Date-based (specific day) -Choose option [1]: 2 - -πŸ“… Date-based report selected - -πŸ“… Enter date (YYYY-MM-DD) [2025-10-23]: 2025-10-18 -πŸ‘€ Enter your full name [Ashish Patel]: John Doe -πŸ™ Enter GitHub username [ashishxcode]: johndoe - -βœ… Found repository: /Users/john/projects/my-app ----------------------------------------- -πŸ”„ Extracting commits from 1 repositories... -βœ… Report generated successfully! +range β†’ PICK (fuzzy filter + multi-select + live preview) β†’ EDIT β†’ EXPORT ``` -## πŸ“‹ What You'll Get - -### Monthly Report Example - -```markdown -# πŸ“Š Monthly Development Report - October 2025 - -> **Developer:** John Doe -> **GitHub Username:** `johndoe` -> **Reporting Period:** `2025-10-01 to 2025-10-31` - ---- - -## πŸ”„ Pull Request Reviews +For a window you choose, it gathers **everything you did**: -### PRs Reviewed by Me -**Total PRs Reviewed:** 5 +- commits from git history (matched by author, across all branches) +- commits on pull requests you authored +- pull requests you authored +- pull requests you reviewed -### PRs Authored by Me -**Total PRs Authored:** 3 +…deduped into one **tagged** picker (`commit` / `PR` / `review`). --- -## πŸ“ Commits Summary +## Install -### 🌿 Commits Organized by Branch +### Option 1 β€” `go install` (needs Go 1.24+) -##### 🌱 `my-app/feat/user-authentication` (15 commits) -``` -β”Œβ”€ 2025-10-15 14:23:45 -└─ feat: add JWT token validation - -β”Œβ”€ 2025-10-15 16:45:12 -└─ feat: implement refresh token mechanism -``` -``` - -### Daily Report Example - -```markdown -# πŸ“Š Daily Development Report - October 18, 2025 - -> **Developer:** John Doe -> **GitHub Username:** `johndoe` -> **Reporting Period:** `2025-10-18` - ---- - -## πŸ“ Commits Summary - -##### 🌱 `my-app/fix/login-bug` (3 commits) -``` -β”Œβ”€ 2025-10-18 09:15:30 -└─ fix: resolve login timeout issue - -β”Œβ”€ 2025-10-18 11:42:18 -└─ test: add unit tests for login flow -``` -``` - -## πŸ”§ GitHub CLI Setup (Optional but Recommended) - -To include PR review data, install and authenticate with GitHub CLI: - -### Installation - -**macOS:** ```bash -brew install gh +go install github.com/ashishxcode/commit-chronicle/cmd/commit-chronicle@latest ``` -**Linux:** -```bash -sudo apt install gh -# or -sudo snap install gh -``` +This drops the `commit-chronicle` binary in `$(go env GOPATH)/bin` (usually +`~/go/bin`). Make sure that's on your `PATH`: -**Windows:** ```bash -winget install GitHub.cli -# or -choco install gh +echo 'export PATH="$HOME/go/bin:$PATH"' >> ~/.zshrc # or ~/.bashrc +exec $SHELL ``` -### Authentication +### Option 2 β€” build from source ```bash -gh auth login +git clone https://github.com/ashishxcode/commit-chronicle +cd commit-chronicle +make install # builds + installs to ~/go/bin +# or: make build # just produces ./bin/commit-chronicle ``` -Follow the prompts to authenticate with your GitHub account. - -## πŸ› οΈ Advanced Configuration +### Option 3 β€” download a release binary -### Multiple Authors +Grab the binary for your OS/arch from the +[Releases](https://github.com/ashishxcode/commit-chronicle/releases) page, then: -If you use different git author names across repos, the script automatically detects and uses the most common one. - -### Custom Output Location - -Reports are saved to: -- **macOS/Linux:** `~/Downloads/` -- **Windows:** `%USERPROFILE%/Downloads/` +```bash +chmod +x commit-chronicle-* # macOS/Linux +mv commit-chronicle-* /usr/local/bin/commit-chronicle +``` -Format: `October_2025_username_report.md` or `October_18_2025_username_report.md` +Maintainers can produce all platform binaries with `make release` (output in +`dist/`). -### Cross-Platform Compatibility +--- -The script automatically detects your OS and adjusts: -- Date calculations -- Path handling -- Temp file creation -- Downloads directory location +## Usage -## ❓ Troubleshooting +Run it inside a git repo, or configure repos/roots (below) to scan many at once: -### "No commits found" +```bash +commit-chronicle # interactive: pick range β†’ pick items β†’ edit β†’ export +commit-chronicle --since "7 days ago" +commit-chronicle --month 2026-05 --copy +commit-chronicle --date 2026-05-25 --all --format json --out ./today.json +``` + +### Picker keys + +| Key | Action | +| -------------- | -------------------- | +| `↑` / `↓` | move | +| `space` / `tab`| toggle selection | +| `a` | select / clear all | +| `/` | fuzzy filter | +| `enter` | confirm selection | +| `q` / `esc` | cancel | + +In the editor: `ctrl+s` save Β· `esc` cancel. + +### Options + +``` +--since relative range, e.g. "7 days ago" +--from YYYY-MM-DD start date (inclusive) +--to YYYY-MM-DD end date (inclusive; default today) +--month YYYY-MM whole calendar month +--date YYYY-MM-DD single day +--author "Name" author to match (default: git config user.name) +--user GitHub login for PR discovery (default: gh user) +--repos a,b,c comma-separated repo paths +--root ~/work comma-separated dirs to auto-discover git repos under +--out output path (default: ~/Downloads, timestamped) +--format md|json output format (default: md) +--all select everything (skip the picker) +--no-edit skip the editor step +--no-pr skip GitHub PR + review discovery (git commits only) +--copy copy the worklog to the clipboard +-h, --help show help +``` -**Possible causes:** -1. Git author name mismatch - Check with: `git config user.name` -2. Wrong date range -3. No commits in configured repositories for that period +--- -**Fix:** Make sure the name you enter matches your git configuration. +## Configuring which repos to scan -### "Not a git repository" errors +Repo sources are **unioned** and checked in this order: -**Fix:** Update the `REPO_PATHS` array in the script with valid repository paths. +1. `--repos a,b,c` β€” explicit repo paths +2. `--root ~/work` β€” directories to auto-discover git repos under +3. `./.commit-chronicle` β€” explicit repo paths (one per line) +4. `~/.config/commit-chronicle/repos` β€” same, global +5. `~/.config/commit-chronicle/roots` β€” directories to auto-discover under +6. fallback: the current directory, if it's a git repo -### Permissions error +The most convenient setup β€” point it at the folder that holds your projects, +once: -**Fix:** Make the script executable: ```bash -chmod +x commit-chronicle +mkdir -p ~/.config/commit-chronicle +echo '~/work' > ~/.config/commit-chronicle/roots ``` -### GitHub CLI not working +Now `commit-chronicle` scans every git repo under `~/work` from anywhere, with +no flags. See [`.commit-chronicle.example`](.commit-chronicle.example) for the +file format. -**Fix:** -1. Install: `brew install gh` (macOS) or `sudo apt install gh` (Linux) -2. Authenticate: `gh auth login` -3. Verify: `gh auth status` - -## 🀝 Contributing +--- -Contributions are welcome! Feel free to: -- Report bugs via GitHub Issues -- Submit feature requests -- Create pull requests with improvements +## How it works -## πŸ“ License +- **Commits** come from `git log --all --author=` across every ref. +- **PRs / reviews** come from `gh` (the GitHub CLI). It lists your PRs in the + window, then fetches commit/review details per-PR β€” GitHub searches are + date-bounded so it only inspects PRs that could fall in range. +- Everything is keyed by hash (commits) or repo+number (PRs) and de-duplicated, + so a commit that shows up both in history and on a PR appears once. +- Output is grouped by date; commits, PRs and reviews each render as distinct, + link-bearing lines. -MIT License - Feel free to use this in your team! +No `gh`, or pass `--no-pr`, and it runs git-only. -## 🎯 Pro Tips +--- -πŸ’‘ **Quarterly Reports:** Use month mode for each month, then combine reports -πŸ’‘ **Daily Standup:** Use date mode to quickly see yesterday's work -πŸ’‘ **Performance Reviews:** Generate reports for review periods -πŸ’‘ **Team Sync:** Share reports in Slack/Teams to show progress -πŸ’‘ **Automation:** Create a cron job for weekly auto-generated reports +## Requirements ---- +- **git** (required) +- **gh**, authenticated (`gh auth login`) β€” optional, for PR & review discovery +- a clipboard tool for `--copy`: `pbcopy` (macOS), `wl-copy` or `xclip` (Linux) -**Made with ❀️ for developers who want to track their work efficiently** +## License -Need help? Open an issue on GitHub! +See [LICENSE](LICENSE). diff --git a/cmd/commit-chronicle/main.go b/cmd/commit-chronicle/main.go new file mode 100644 index 0000000..767ec35 --- /dev/null +++ b/cmd/commit-chronicle/main.go @@ -0,0 +1,112 @@ +// Command commit-chronicle: find your commits and PRs across repos in a date +// window, pick them interactively, and turn them into a worklog (markdown or +// json). A single self-contained binary β€” `git` is required, and `gh` is used +// opportunistically to discover PR and review activity. +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/ashishxcode/commit-chronicle/internal/app" +) + +// version is set at build time via -ldflags "-X main.version=…". +var version = "dev" + +func main() { + for _, a := range os.Args[1:] { + if a == "--version" || a == "-v" { + fmt.Println("commit-chronicle " + version) + return + } + } + c, err := parseFlags() + if err != nil { + if err == flag.ErrHelp { + return + } + fmt.Fprintln(os.Stderr, "❌ "+err.Error()) + os.Exit(2) + } + if err := app.Run(*c); err != nil { + fmt.Fprintln(os.Stderr, "❌ "+err.Error()) + os.Exit(1) + } +} + +func parseFlags() (*app.Config, error) { + c := &app.Config{} + fs := flag.NewFlagSet("commit-chronicle", flag.ContinueOnError) + fs.StringVar(&c.Since, "since", "", `relative range, e.g. "7 days ago"`) + fs.StringVar(&c.From, "from", "", "start date YYYY-MM-DD (inclusive)") + fs.StringVar(&c.To, "to", "", "end date YYYY-MM-DD (inclusive; default today)") + fs.StringVar(&c.Month, "month", "", "whole calendar month YYYY-MM") + fs.StringVar(&c.Date, "date", "", "single day YYYY-MM-DD") + fs.StringVar(&c.Author, "author", "", "author to match (default: git config user.name)") + fs.StringVar(&c.User, "user", "", "GitHub login for PR discovery (default: gh user)") + fs.StringVar(&c.Repos, "repos", "", "comma-separated repo paths (overrides config)") + fs.StringVar(&c.Root, "root", "", "comma-separated dirs to scan for git repos, e.g. ~/work") + fs.StringVar(&c.Out, "out", "", "output path (default: Downloads, timestamped)") + fs.StringVar(&c.Format, "format", "md", "output format: md | json") + fs.BoolVar(&c.NoEdit, "no-edit", false, "skip the editor step") + fs.BoolVar(&c.All, "all", false, "select everything (skip the picker)") + fs.BoolVar(&c.NoPR, "no-pr", false, "skip GitHub PR + review discovery") + fs.BoolVar(&c.Copy, "copy", false, "copy the worklog to the clipboard") + fs.Usage = usage + if err := fs.Parse(os.Args[1:]); err != nil { + return nil, err + } + switch c.Format { + case "md", "markdown": + c.Format = "md" + case "json": + default: + return nil, fmt.Errorf("unknown --format %q (use md or json)", c.Format) + } + return c, nil +} + +func usage() { + fmt.Fprint(os.Stderr, `πŸ““ commit-chronicle β€” pick your commits & PRs, build a worklog (single binary) + +USAGE: + commit-chronicle [OPTIONS] + +Pick a time range (interactively if no date flag), then gather everything you +did in that window β€” commits (git history + commits on PRs you authored), +PRs you authored, and PRs you reviewed β€” into one fuzzy-filterable, tagged +picker. Multi-select, optionally edit, then export to markdown/json. + +OPTIONS: + --since relative range, e.g. "7 days ago" + --from YYYY-MM-DD start date (inclusive) + --to YYYY-MM-DD end date (inclusive; default today) + --month YYYY-MM whole calendar month + --date YYYY-MM-DD single day + --author "Name" author to match (default: git config user.name) + --user GitHub login for PR discovery (default: gh user) + --repos a,b,c comma-separated repo paths (overrides config) + --root ~/work comma-separated dirs to auto-discover git repos under + --out output path (default: Downloads, timestamped) + --format md|json output format (default: md) + --all select everything (skip the picker) + --no-edit skip the editor step + --no-pr skip GitHub PR + review discovery (git commits only) + --copy copy the worklog to the clipboard + -h, --help show this help + +REPO CONFIG (unioned): --repos Β· --root Β· ./.commit-chronicle Β· + ~/.config/commit-chronicle/repos Β· ~/.config/commit-chronicle/roots Β· + (fallback) the current dir if it's a git repo + +PICKER KEYS: ↑↓ move Β· space/tab select Β· a all Β· / filter Β· enter confirm Β· q cancel + +EXAMPLES: + commit-chronicle --since "7 days ago" + commit-chronicle --month 2026-05 --copy + commit-chronicle --root ~/work --since "30 days ago" + commit-chronicle --date 2026-05-25 --all --format json --out ./today.json +`) +} diff --git a/commit-chronicle b/commit-chronicle deleted file mode 100755 index bd59ef9..0000000 --- a/commit-chronicle +++ /dev/null @@ -1,1126 +0,0 @@ -#!/usr/bin/env zsh - -# Monthly Development Report Generator -# Cross-platform script for Windows (Git Bash/WSL), Linux, and macOS -# Uses only git and gh commands to generate markdown report -# Optimized for zsh shell - -# Function to generate commit chronicle report -commit_chronicle() { - # Save current directory to return to it later - local ORIGINAL_DIR="$PWD" - - # Trap to ensure we return to original directory on exit - trap 'cd "$ORIGINAL_DIR"' EXIT - -set -e - -# Enable zsh array compatibility -setopt KSH_ARRAYS # Use 0-based array indexing like bash -setopt POSIX_ARGZERO # Set $0 to script name like bash - -# Detect operating system -detect_os() { - case "$(uname -s)" in - CYGWIN*|MINGW*|MSYS*) - echo "windows" - ;; - Linux*) - echo "linux" - ;; - Darwin*) - echo "macos" - ;; - *) - echo "unknown" - ;; - esac -} - -OS_TYPE=$(detect_os) - -# Cross-platform absolute path resolution -get_absolute_path() { - local path="$1" - if [ "$OS_TYPE" = "windows" ]; then - # Handle Windows paths in Git Bash/MSYS2 - if [[ "$path" =~ ^[A-Za-z]: ]]; then - # Already absolute Windows path - echo "$path" - elif [[ "$path" = /* ]]; then - # Unix-style absolute path in Git Bash - echo "$path" - else - # Relative path - echo "$(cd "$path" 2>/dev/null && pwd)" || echo "" - fi - else - # Linux/macOS - if [[ "$path" = /* ]]; then - echo "$path" - else - echo "$(cd "$path" 2>/dev/null && pwd)" || echo "" - fi - fi -} - -# Cross-platform line count (handle different wc implementations) -count_lines() { - if [ "$OS_TYPE" = "linux" ]; then - wc -l | sed 's/^[ \t]*//' | cut -d' ' -f1 - else - wc -l | tr -d ' ' - fi -} - -# Error handling function -handle_error() { - local error_message="$1" - local error_code="${2:-1}" - echo "❌ ERROR: $error_message" >&2 - echo "πŸ’‘ Try running with debugging: bash -x $0" >&2 - exit $error_code -} - -# Validate prerequisites -validate_prerequisites() { - # Check if git is installed - if ! command -v git &> /dev/null; then - handle_error "Git is not installed. Please install Git and try again." - fi - - # Check if we have at least one valid repository - if [ ${#REPO_PATHS[@]} -eq 0 ]; then - handle_error "No valid Git repositories found. Please check your repository paths." - fi -} - -# Validate user input -validate_input() { - local date_input="$1" - local mode="$2" - local author="$3" - local username="$4" - - if [ "$mode" = "month" ]; then - if [[ ! $date_input =~ ^[0-9]{4}-[0-9]{2}$ ]]; then - handle_error "Invalid month format '$date_input'. Use YYYY-MM (e.g., 2024-08)" - fi - elif [ "$mode" = "date" ]; then - if [[ ! $date_input =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then - handle_error "Invalid date format '$date_input'. Use YYYY-MM-DD (e.g., 2024-08-15)" - fi - else - handle_error "Invalid mode '$mode'. Must be 'month' or 'date'" - fi - - if [ -z "$author" ]; then - handle_error "Author name cannot be empty" - fi - - if [ -z "$username" ]; then - handle_error "GitHub username cannot be empty" - fi -} - -# Start timing -START_TIME=$(date +%s) - -echo "πŸ“‹ Development Report Generator" -echo "========================================" -echo "πŸ–₯️ Platform: $OS_TYPE ($(uname -s))" -echo "⏱️ Started: $(date +"%Y-%m-%d %H:%M:%S")" -echo "" - -# Ask user for extraction mode -echo "πŸ“Š Report Type:" -echo " 1) Month-based (entire month)" -echo " 2) Date-based (specific day)" -echo -n "Choose option [1]: " -read MODE_CHOICE -MODE_CHOICE=${MODE_CHOICE:-1} - -case $MODE_CHOICE in - 1) - REPORT_MODE="month" - echo "" - echo "πŸ“… Month-based report selected" - ;; - 2) - REPORT_MODE="date" - echo "" - echo "πŸ“… Date-based report selected" - ;; - *) - echo "❌ Invalid option. Defaulting to month-based." - REPORT_MODE="month" - ;; -esac - -# Interactive input collection based on mode -echo "" -if [ "$REPORT_MODE" = "month" ]; then - echo -n "πŸ“… Enter month (YYYY-MM) [$(date +"%Y-%m")]: " - read DATE_INPUT - DATE_INPUT=${DATE_INPUT:-$(date +"%Y-%m")} -else - echo -n "πŸ“… Enter date (YYYY-MM-DD) [$(date +"%Y-%m-%d")]: " - read DATE_INPUT - DATE_INPUT=${DATE_INPUT:-$(date +"%Y-%m-%d")} -fi - -echo -n "πŸ‘€ Enter your full name [Ashish Patel]: " -read AUTHOR_INPUT -AUTHOR=${AUTHOR_INPUT:-"Ashish Patel"} - -echo -n "πŸ™ Enter GitHub username [ashishxcode]: " -read GITHUB_USERNAME_INPUT -GITHUB_USERNAME=${GITHUB_USERNAME_INPUT:-"ashishxcode"} - -# Validate input -validate_input "$DATE_INPUT" "$REPORT_MODE" "$AUTHOR" "$GITHUB_USERNAME" - -# Create comprehensive author patterns for git log matching -# This handles various name/email combinations and case variations -create_author_pattern() { - local author_name="$1" - local github_username="$2" - - # Build a regex pattern that matches common author variations - # Include: exact name, email patterns with username, case variations - echo "($author_name|${github_username}@|${github_username}.*@|.*${github_username}.*)" -} - - -# Parse branch references to extract clean branch names -parse_branch_name() { - local branch_refs="$1" - # Extract the main branch name from git references like "origin/feature/ABC-123-description" - # Remove origin/, HEAD ->, and other git reference prefixes - echo "$branch_refs" | sed -E 's/.*(origin\/|HEAD -> )//g' | sed 's/,.*//g' | sed 's/^ *//g' -} - -# Find branches where user has pushed commits during the date range -find_user_branches() { - local repo_path="$1" - local author="$2" - local start_date="$3" - local end_date="$4" - - cd "$repo_path" - - # Get all branches that contain commits by the user in the date range - # This includes both local and remote branches - local user_branches=$(git for-each-ref --format='%(refname:short)' refs/heads/ refs/remotes/ | while read branch; do - # Check if this branch has commits by the user in our date range - local commit_count=$(git log "$branch" --author="$author" --regexp-ignore-case --since="$start_date" --until="$end_date" --oneline 2>/dev/null | wc -l | tr -d ' ') - if [ "$commit_count" -gt 0 ]; then - # Clean up branch name (remove origin/ prefix) - echo "$branch" | sed 's/origin\///g' | sed 's/remotes\/origin\///g' - fi - done | sort -u) - - echo "$user_branches" -} - -# Get the most likely branch where user worked on this commit -get_user_work_branch() { - local commit_hash="$1" - local repo_path="$2" - local user_branches="$3" - - cd "$repo_path" - - # First, try to find which user branch contains this commit - echo "$user_branches" | while read branch; do - if [ -n "$branch" ] && git merge-base --is-ancestor "$commit_hash" "$branch" 2>/dev/null; then - # This branch contains the commit - check if it's not just main/master - if [ "$branch" != "main" ] && [ "$branch" != "master" ]; then - echo "$branch" - return - fi - fi - done - - # Fallback to original branch detection logic - get_original_branch "$commit_hash" "$repo_path" -} - -# Get the original branch name for a commit using git name-rev -get_original_branch() { - local commit_hash="$1" - local repo_path="$2" - - # Try multiple methods to get the original branch - cd "$repo_path" - - # Method 1: Use git name-rev to find the branch tip this commit was part of - local branch_name=$(git name-rev --name-only --refs="refs/heads/*" "$commit_hash" 2>/dev/null | sed 's/~.*//g' | sed 's/\^.*//g') - - # Method 2: If that fails, try to find branches that contain this commit (excluding main/master) - if [ -z "$branch_name" ] || [ "$branch_name" = "main" ] || [ "$branch_name" = "master" ]; then - branch_name=$(git branch --contains "$commit_hash" --all 2>/dev/null | grep -v -E "(main|master)" | grep -E "(origin/|remotes/)" | head -1 | sed 's/.*origin\///g' | sed 's/^ *//g' | sed 's/remotes\/origin\///g') - fi - - # Method 3: If still no good branch, look at the commit subject for branch indicators - if [ -z "$branch_name" ] || [ "$branch_name" = "main" ] || [ "$branch_name" = "master" ]; then - local commit_message=$(git log -1 --pretty=format:"%s" "$commit_hash" 2>/dev/null) - # Look for branch patterns in PR merge messages like "Merge pull request #123 from user/branch-name" - branch_name=$(echo "$commit_message" | sed -n 's/.*from [^\/]*\/\([^)]*\).*/\1/p') - - # Look for patterns like "FEAT: something (#PR)" and extract from the hash - if [ -z "$branch_name" ]; then - local pr_number=$(echo "$commit_message" | sed -n 's/.*#\([0-9]*\).*/\1/p') - if [ -n "$pr_number" ] && command -v gh &> /dev/null; then - branch_name=$(gh pr view "$pr_number" --json headRefName --jq '.headRefName' 2>/dev/null || echo "") - fi - fi - fi - - # Clean up the branch name - branch_name=$(echo "$branch_name" | sed 's/origin\///g' | sed 's/remotes\///g' | sed 's/^ *//g' | sed 's/ *$//g') - - # If we still don't have a good branch name, default to main - if [ -z "$branch_name" ] || [ "$branch_name" = "undefined" ]; then - branch_name="main" - fi - - echo "$branch_name" -} - -# ========================================== -# πŸ“ REPOSITORY CONFIGURATION -# ========================================== -# Configure your repository paths here. Edit this section to add your repositories. -# Use absolute paths for best results, or relative paths from where you run the script. - -# Example configurations: -# REPO_PATHS=( -# "/Users/username/projects/repo1" -# "/Users/username/projects/repo2" -# "../other-project" -# ) - -# Configured repositories - edit these paths as needed -REPO_PATHS=( - "/Users/ashish/work/forked/cx-saas-dashboard" - "/Users/ashish/work/forked/cx-saas-server" - "/Users/ashish/work/cx-partners" - "/Users/ashish/work/saas-super-admin" -) - -# If no repositories configured, use current working directory -if [ ${#REPO_PATHS[@]} -eq 0 ]; then - CURRENT_ABS_PATH=$(get_absolute_path ".") - if [ -d "$CURRENT_ABS_PATH/.git" ]; then - REPO_PATHS+=("$CURRENT_ABS_PATH") - echo "πŸ“‚ Using current directory: $CURRENT_ABS_PATH" - else - echo "❌ Current directory is not a git repository" - echo "πŸ’‘ Either run this script from a git repository or configure REPO_PATHS in the script" - exit 1 - fi -else - # Validate configured repositories - VALID_REPOS=() - for REPO_PATH in "${REPO_PATHS[@]}"; do - ABS_PATH=$(get_absolute_path "$REPO_PATH") - if [ -z "$ABS_PATH" ]; then - echo "⚠️ Invalid path: $REPO_PATH (skipping)" - continue - fi - - if [ ! -d "$ABS_PATH/.git" ]; then - echo "⚠️ Not a git repository: $ABS_PATH (skipping)" - continue - fi - - VALID_REPOS+=("$ABS_PATH") - echo "βœ… Found repository: $ABS_PATH" - done - - if [ ${#VALID_REPOS[@]} -eq 0 ]; then - echo "❌ No valid git repositories found in configuration" - exit 1 - fi - - REPO_PATHS=("${VALID_REPOS[@]}") -fi - -# Validate prerequisites after repository setup -validate_prerequisites - -# Parse date input and calculate date range based on mode -if [ "$REPORT_MODE" = "month" ]; then - MONTH="$DATE_INPUT" - - # Validate month format - if [[ ! $MONTH =~ ^[0-9]{4}-[0-9]{2}$ ]]; then - echo "❌ Invalid month format. Use YYYY-MM (e.g., 2025-07)" - exit 1 - fi - - # Parse month for date range - YEAR=$(echo $MONTH | cut -d'-' -f1) - MONTH_NUMBER=$(echo $MONTH | cut -d'-' -f2) - START_DATE="${YEAR}-${MONTH_NUMBER}-01" - - # Calculate last day of month correctly (accounts for Feb, 30-day months, etc.) - if [ "$OS_TYPE" = "macos" ]; then - # macOS date command - END_DATE=$(date -j -v1d -v+1m -v-1d -f "%Y-%m-%d" "${YEAR}-${MONTH_NUMBER}-01" "+%Y-%m-%d" 2>/dev/null) - else - # Linux date command - END_DATE=$(date -d "${YEAR}-${MONTH_NUMBER}-01 +1 month -1 day" "+%Y-%m-%d" 2>/dev/null) - fi - - # Fallback to day 31 if date calculation fails - if [ -z "$END_DATE" ]; then - END_DATE="${YEAR}-${MONTH_NUMBER}-31" - fi - - # Get month name - case $MONTH_NUMBER in - 01) MONTH_NAME="January" ;; - 02) MONTH_NAME="February" ;; - 03) MONTH_NAME="March" ;; - 04) MONTH_NAME="April" ;; - 05) MONTH_NAME="May" ;; - 06) MONTH_NAME="June" ;; - 07) MONTH_NAME="July" ;; - 08) MONTH_NAME="August" ;; - 09) MONTH_NAME="September" ;; - 10) MONTH_NAME="October" ;; - 11) MONTH_NAME="November" ;; - 12) MONTH_NAME="December" ;; - esac - - REPORT_PERIOD="$MONTH_NAME $YEAR" -else - # Date mode - single day - SPECIFIC_DATE="$DATE_INPUT" - - # Validate date format - if [[ ! $SPECIFIC_DATE =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then - echo "❌ Invalid date format. Use YYYY-MM-DD (e.g., 2025-10-23)" - exit 1 - fi - - # For single day, START_DATE is the specific date - # END_DATE needs to be the next day because git's --until is exclusive - START_DATE="$SPECIFIC_DATE" - - # Calculate next day for END_DATE (git --until is exclusive) - if [ "$OS_TYPE" = "macos" ]; then - # macOS date command - END_DATE=$(date -j -v+1d -f "%Y-%m-%d" "$SPECIFIC_DATE" "+%Y-%m-%d" 2>/dev/null) - else - # Linux date command - END_DATE=$(date -d "$SPECIFIC_DATE +1 day" "+%Y-%m-%d" 2>/dev/null) - fi - - # Fallback if date calculation fails - if [ -z "$END_DATE" ]; then - # Manual calculation for fallback - YEAR=$(echo $SPECIFIC_DATE | cut -d'-' -f1) - MONTH_NUMBER=$(echo $SPECIFIC_DATE | cut -d'-' -f2) - DAY_NUMBER=$(echo $SPECIFIC_DATE | cut -d'-' -f3) - NEXT_DAY=$((10#$DAY_NUMBER + 1)) - END_DATE="${YEAR}-${MONTH_NUMBER}-$(printf "%02d" $NEXT_DAY)" - fi - - # Parse date components for display - YEAR=$(echo $SPECIFIC_DATE | cut -d'-' -f1) - MONTH_NUMBER=$(echo $SPECIFIC_DATE | cut -d'-' -f2) - DAY_NUMBER=$(echo $SPECIFIC_DATE | cut -d'-' -f3) - - # Get month name - case $MONTH_NUMBER in - 01) MONTH_NAME="January" ;; - 02) MONTH_NAME="February" ;; - 03) MONTH_NAME="March" ;; - 04) MONTH_NAME="April" ;; - 05) MONTH_NAME="May" ;; - 06) MONTH_NAME="June" ;; - 07) MONTH_NAME="July" ;; - 08) MONTH_NAME="August" ;; - 09) MONTH_NAME="September" ;; - 10) MONTH_NAME="October" ;; - 11) MONTH_NAME="November" ;; - 12) MONTH_NAME="December" ;; - esac - - REPORT_PERIOD="$MONTH_NAME $DAY_NUMBER, $YEAR" -fi - -echo "" -echo "πŸ“Š Generating report for:" -if [ "$REPORT_MODE" = "month" ]; then - echo " Period: $MONTH_NAME $YEAR (entire month)" - echo " Date Range: $START_DATE to $END_DATE" -else - echo " Period: $REPORT_PERIOD (single day)" - echo " Date: $START_DATE" -fi -echo " Author: $AUTHOR" -echo " GitHub: $GITHUB_USERNAME" -echo " Repositories: ${#REPO_PATHS[@]} repo(s)" -for repo in "${REPO_PATHS[@]}"; do - echo " - $(basename "$repo") ($repo)" -done -echo "----------------------------------------" - -# Output markdown file - create in Downloads directory (cross-platform) -get_downloads_dir() { - case "$OS_TYPE" in - "windows") - # Windows: Use USERPROFILE/Downloads - if [ -n "$USERPROFILE" ]; then - echo "$USERPROFILE/Downloads" - else - echo "." - fi - ;; - *) - # Linux/macOS: Use HOME/Downloads - echo "$HOME/Downloads" - ;; - esac -} - -# Sanitize filename components -sanitize_filename() { - local input="$1" - # Remove/replace invalid characters: < > : " | ? * \ / and control chars - echo "$input" | sed 's/[<>:"|?*\\\/[:cntrl:]]\+/_/g' | sed 's/__*/_/g' | sed 's/^_\|_$//g' -} - -DOWNLOADS_DIR=$(get_downloads_dir) -# Test write permission and create directory with fallback -if ! mkdir -p "$DOWNLOADS_DIR" 2>/dev/null; then - echo "⚠️ Cannot create Downloads directory, using current directory" - DOWNLOADS_DIR="." -elif ! touch "$DOWNLOADS_DIR/.test_write" 2>/dev/null; then - echo "⚠️ No write permission to Downloads directory, using current directory" - DOWNLOADS_DIR="." -else - rm -f "$DOWNLOADS_DIR/.test_write" 2>/dev/null -fi - -# Sanitize filename components and create report path -CLEAN_MONTH=$(sanitize_filename "$MONTH_NAME") -CLEAN_YEAR=$(sanitize_filename "$YEAR") -CLEAN_USERNAME=$(sanitize_filename "$GITHUB_USERNAME") - -# Create filename based on report mode -if [ "$REPORT_MODE" = "month" ]; then - REPORT_FILENAME="${CLEAN_MONTH}_${CLEAN_YEAR}_${CLEAN_USERNAME}_report.md" - REPORT_TITLE="Monthly Development Report - $MONTH_NAME $YEAR" -else - CLEAN_DAY=$(sanitize_filename "$DAY_NUMBER") - REPORT_FILENAME="${CLEAN_MONTH}_${CLEAN_DAY}_${CLEAN_YEAR}_${CLEAN_USERNAME}_report.md" - REPORT_TITLE="Daily Development Report - $MONTH_NAME $DAY_NUMBER, $YEAR" -fi - -# Set report file path -REPORT_FILE="$DOWNLOADS_DIR/$REPORT_FILENAME" - -# Start building the markdown report -if [ "$REPORT_MODE" = "month" ]; then - REPORT_DATE_RANGE="$START_DATE to $END_DATE" -else - REPORT_DATE_RANGE="$START_DATE" -fi - -cat > "$REPORT_FILE" << EOF -# πŸ“Š $REPORT_TITLE - -> **Developer:** $AUTHOR -> **GitHub Username:** \`$GITHUB_USERNAME\` -> **Reporting Period:** \`$REPORT_DATE_RANGE\` -> **Generated:** $(date +"%B %d, %Y at %H:%M") - ---- - -## πŸ”„ Pull Request Reviews - -EOF - -# Get GitHub repository URL -get_github_url() { - local repo_path="$1" - cd "$repo_path" - local remote_url=$(git remote get-url origin 2>/dev/null || echo "") - - if [ -n "$remote_url" ]; then - # Convert SSH to HTTPS format and remove .git suffix - echo "$remote_url" | sed 's/git@github.com:/https:\/\/github.com\//' | sed 's/\.git$//' - fi -} - -# Create GitHub commit link if possible -create_commit_link() { - local commit_hash="$1" - local repo_path="$2" - local github_url=$(get_github_url "$repo_path") - - if [ -n "$github_url" ]; then - echo "${github_url}/commit/${commit_hash}" - fi -} - -# Extract commit type from message (feat, fix, chore, etc.) -get_commit_type() { - local message="$1" - local type=$(echo "$message" | grep -oiE '^(feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert|hotfix)' | tr '[:upper:]' '[:lower:]') - if [ -n "$type" ]; then - echo "$type" - else - echo "other" - fi -} - -# Progress indicator function -show_progress() { - local step="$1" - local total="$2" - local message="$3" - local percent=$((step * 100 / total)) - local filled=$((percent / 5)) - local empty=$((20 - filled)) - - printf "\rπŸ”„ Progress: [" - printf "%*s" $filled | tr ' ' 'β–ˆ' - printf "%*s" $empty | tr ' ' 'β–‘' - printf "] %d%% - %s" $percent "$message" - - if [ $step -eq $total ]; then - echo "" - fi -} - -# Cross-platform temporary file creation -create_temp_file() { - local suffix="$1" - if [ "$OS_TYPE" = "windows" ]; then - # Windows temp directory handling - local temp_dir="${TEMP:-${TMP:-/tmp}}" - local temp_file="$temp_dir/commit_chronicle_$$_$(date +%s)_${suffix}" - touch "$temp_file" 2>/dev/null || { - echo "❌ Failed to create temp file: $temp_file" >&2 - exit 1 - } - echo "$temp_file" - else - # Use zsh-compatible mktemp - mktemp -t "commit_chronicle_${suffix}.XXXXXX" 2>/dev/null || { - # Fallback for systems without mktemp - local temp_file="/tmp/commit_chronicle_$$_$(date +%s)_${suffix}" - touch "$temp_file" - echo "$temp_file" - } - fi -} - -# Extract PR reviews first -echo "πŸ”„ Extracting PR reviews..." - -if command -v gh &> /dev/null; then - echo "### PRs Reviewed by Me" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - - # Get PRs reviewed by user with comprehensive search including all review types - TEMP_REVIEWED=$(create_temp_file "reviewed") - - # Search for PRs where user participated in reviews from all configured repositories - # gh pr list requires repository context, so we iterate through repos - for REPO_PATH in "${REPO_PATHS[@]}"; do - cd "$REPO_PATH" - REPO_NAME=$(basename "$REPO_PATH") - - # Method 1: PRs where user was explicitly listed as reviewer (approved reviews) - gh pr list --search "reviewed-by:$GITHUB_USERNAME" \ - --limit 100 --state all --json number,title,state,reviews \ - 2>/dev/null | jq -r ".[] | @base64" 2>/dev/null | while read -r pr_base64; do - if [ -z "$pr_base64" ]; then - continue - fi - - pr_json=$(echo "$pr_base64" | base64 -d 2>/dev/null) - if [ $? -ne 0 ]; then - continue - fi - - pr_number=$(echo "$pr_json" | jq -r '.number' 2>/dev/null) - pr_title=$(echo "$pr_json" | jq -r '.title' 2>/dev/null) - pr_state=$(echo "$pr_json" | jq -r '.state' 2>/dev/null) - - # Get the user's actual review submission dates - review_dates=$(echo "$pr_json" | jq -r ".reviews[]? | select(.author.login == \"$GITHUB_USERNAME\") | .submittedAt" 2>/dev/null | cut -d'T' -f1) - - # Check if any review was submitted in our date range - echo "$review_dates" | while read -r review_date; do - if [ -n "$review_date" ]; then - if [[ "$review_date" > "$START_DATE" || "$review_date" = "$START_DATE" ]] && [[ "$review_date" < "$END_DATE" || "$review_date" = "$END_DATE" ]]; then - echo "$pr_number"$'\t'"$pr_title"$'\t'"$pr_state"$'\t'"$REPO_NAME"$'\t'"$review_date"$'\t'"approved" - break - fi - fi - done - done - - # Method 2: PRs where user was involved (commented, requested changes, etc.) - gh pr list --search "involves:$GITHUB_USERNAME -author:$GITHUB_USERNAME" \ - --limit 100 --state all --json number,title,state,reviews \ - 2>/dev/null | jq -r ".[] | @base64" 2>/dev/null | while read -r pr_base64; do - if [ -z "$pr_base64" ]; then - continue - fi - - pr_json=$(echo "$pr_base64" | base64 -d 2>/dev/null) - if [ $? -ne 0 ]; then - continue - fi - - pr_number=$(echo "$pr_json" | jq -r '.number' 2>/dev/null) - pr_title=$(echo "$pr_json" | jq -r '.title' 2>/dev/null) - pr_state=$(echo "$pr_json" | jq -r '.state' 2>/dev/null) - - # Get the user's actual review submission dates - review_dates=$(echo "$pr_json" | jq -r ".reviews[]? | select(.author.login == \"$GITHUB_USERNAME\") | .submittedAt" 2>/dev/null | cut -d'T' -f1) - - # Check if any review was submitted in our date range - echo "$review_dates" | while read -r review_date; do - if [ -n "$review_date" ]; then - if [[ "$review_date" > "$START_DATE" || "$review_date" = "$START_DATE" ]] && [[ "$review_date" < "$END_DATE" || "$review_date" = "$END_DATE" ]]; then - echo "$pr_number"$'\t'"$pr_title"$'\t'"$pr_state"$'\t'"$REPO_NAME"$'\t'"$review_date"$'\t'"reviewed" - break - fi - fi - done - done - done | sort -u > "$TEMP_REVIEWED" || echo "" > "$TEMP_REVIEWED" - - if [ -s "$TEMP_REVIEWED" ]; then - # PRs are already filtered by actual review date, just format them - TEMP_FILTERED=$(create_temp_file "filtered_reviewed") - while IFS=$'\t' read -r number title state repo review_date review_type; do - if [ -n "$number" ] && [ -n "$review_date" ]; then - echo "$number"$'\t'"$title"$'\t'"$state"$'\t'"$repo"$'\t'"$review_date"$'\t'"$review_type" >> "$TEMP_FILTERED" - fi - done < "$TEMP_REVIEWED" - - if [ -s "$TEMP_FILTERED" ]; then - # Group by PR number and collect all review dates for each PR - TEMP_GROUPED=$(create_temp_file "grouped_reviews") - - # Sort by PR number and date, then process unique PRs - sort -t$'\t' -k1,1n -k5,5 "$TEMP_FILTERED" | while IFS=$'\t' read -r number title state repo review_date review_type; do - echo "$number"$'\t'"$title"$'\t'"$state"$'\t'"$repo"$'\t'"$review_date" - done | sort -u -t$'\t' -k1,1n > "$TEMP_GROUPED" - - REVIEWED_COUNT=$(cat "$TEMP_GROUPED" | cut -f1 | sort -u | count_lines) - echo "**Total PRs Reviewed:** $REVIEWED_COUNT" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - echo '```' >> "$REPORT_FILE" - - # Format PR data in codeblock matching commit style (single line) - while IFS=$'\t' read -r number title state repo review_date; do - clean_title=$(echo "$title" | sed 's/|/ /g') - clean_repo=$(echo "$repo" | sed 's/.*\///') - - # Map state to emoji - case "$state" in - "MERGED") state_icon="βœ“" ;; - "OPEN") state_icon="β—‹" ;; - "CLOSED") state_icon="βœ•" ;; - *) state_icon="β€’" ;; - esac - - printf "└─ %s | PR #%s [%s] %s %s %s\n\n" "$review_date" "$number" "$clean_repo" "$state_icon" "$state" "$clean_title" >> "$REPORT_FILE" - done < "$TEMP_GROUPED" - echo '```' >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - rm -f "$TEMP_GROUPED" - else - echo "> No PRs reviewed during this period." >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - fi - rm -f "$TEMP_FILTERED" - else - echo "> No PRs reviewed during this period." >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - fi - - echo "### PRs Authored by Me" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - - # Get PRs authored by user with detailed formatting - loop through each repository - TEMP_AUTHORED=$(create_temp_file "authored") - TEMP_PR_BRANCHES=$(create_temp_file "pr_branches") - - for REPO_PATH in "${REPO_PATHS[@]}"; do - cd "$REPO_PATH" - REPO_NAME=$(basename "$REPO_PATH") - - # Get authored PRs from this repository with branch info - gh pr list --search "author:$GITHUB_USERNAME created:$START_DATE..$END_DATE" \ - --limit 100 --state all --json number,title,state,createdAt,headRefName \ - 2>/dev/null | jq -r ".[] | [.number, .title, .state, \"$REPO_NAME\", .createdAt, .headRefName] | @tsv" 2>/dev/null >> "$TEMP_AUTHORED" - done - - if [ -s "$TEMP_AUTHORED" ]; then - # Count authored PRs first - AUTHORED_COUNT=$(cat "$TEMP_AUTHORED" | count_lines) - echo "**Total PRs Authored:** $AUTHORED_COUNT" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - - # Sort PRs by date and display as simple list - sort -t$'\t' -k5 "$TEMP_AUTHORED" | while IFS=$'\t' read -r number title state repo created_at branch_name; do - if [ -n "$number" ]; then - clean_title=$(echo "$title" | sed 's/|/ /g') - clean_repo=$(echo "$repo" | sed 's/.*\///') - created_date=$(echo "$created_at" | cut -d'T' -f1) - - # Map state to emoji - case "$state" in - "MERGED") state_icon="βœ“" ;; - "OPEN") state_icon="β—‹" ;; - "CLOSED") state_icon="βœ•" ;; - *) state_icon="β€’" ;; - esac - - printf "- %s | PR #%s [%s] %s %s %s\n" "$created_date" "$number" "$clean_repo" "$state_icon" "$state" "$clean_title" >> "$REPORT_FILE" - fi - done - - echo "" >> "$REPORT_FILE" - else - echo "> No PRs authored during this period." >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - fi - - # Clean up temp files - rm -f "$TEMP_REVIEWED" "$TEMP_AUTHORED" - -else - echo "### ⚠️ GitHub CLI Not Available" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - echo "> GitHub CLI (\`gh\`) is not installed. PR review data cannot be extracted." >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - echo "#### Installation Instructions" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - case "$OS_TYPE" in - "windows") - echo "- **Windows:** \`winget install GitHub.cli\` or \`choco install gh\`" >> "$REPORT_FILE" - ;; - "linux") - echo "- **Linux:** \`sudo apt install gh\` or \`sudo snap install gh\`" >> "$REPORT_FILE" - ;; - "macos") - echo "- **macOS:** \`brew install gh\`" >> "$REPORT_FILE" - ;; - *) - echo "- Visit [GitHub CLI](https://cli.github.com/) for installation instructions" >> "$REPORT_FILE" - ;; - esac - echo "- **All platforms:** Visit [https://cli.github.com/](https://cli.github.com/)" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" -fi - -cat >> "$REPORT_FILE" << EOF - ---- - -## πŸ“ Commits Summary - -EOF - -# Step 1: Extract commits from all repositories -echo "πŸ“ Extracting commits from ${#REPO_PATHS[@]} repositories..." - -# Initialize counters -TOTAL_COMMIT_COUNT=0 - -TEMP_COMMIT_STATS=$(create_temp_file "stats") -TEMP_DAY_STATS=$(create_temp_file "days") -TEMP_BRANCH_DATA=$(create_temp_file "branches") -TEMP_COMMIT_DETAILS=$(create_temp_file "details") - -# Create author pattern for enhanced matching -AUTHOR_PATTERN=$(create_author_pattern "$AUTHOR" "$GITHUB_USERNAME") - -# Process each repository -REPO_INDEX=0 -for REPO_PATH in "${REPO_PATHS[@]}"; do - REPO_INDEX=$((REPO_INDEX + 1)) - show_progress $REPO_INDEX ${#REPO_PATHS[@]} "Processing $(basename "$REPO_PATH")" - - cd "$REPO_PATH" - git fetch --all >/dev/null 2>&1 || true - - # Get commit count using multiple author patterns (case-insensitive) - include all commits including from merged PRs - REPO_COMMIT_COUNT_NAME=$(git log --all --full-history --author="$AUTHOR" --regexp-ignore-case --since="$START_DATE" --until="$END_DATE" --oneline 2>/dev/null | count_lines) - REPO_COMMIT_COUNT_USERNAME=$(git log --all --full-history --author="$GITHUB_USERNAME" --regexp-ignore-case --since="$START_DATE" --until="$END_DATE" --oneline 2>/dev/null | count_lines) - - # Use the higher count (some commits might be under name, others under username) - if [ "$REPO_COMMIT_COUNT_USERNAME" -gt "$REPO_COMMIT_COUNT_NAME" ]; then - REPO_COMMIT_COUNT=$REPO_COMMIT_COUNT_USERNAME - PRIMARY_AUTHOR="$GITHUB_USERNAME" - else - REPO_COMMIT_COUNT=$REPO_COMMIT_COUNT_NAME - PRIMARY_AUTHOR="$AUTHOR" - fi - TOTAL_COMMIT_COUNT=$((TOTAL_COMMIT_COUNT + REPO_COMMIT_COUNT)) - - if [ "$REPO_COMMIT_COUNT" -gt 0 ]; then - # Find branches where user actually pushed commits during this date range - USER_BRANCHES=$(find_user_branches "$REPO_PATH" "$PRIMARY_AUTHOR" "$START_DATE" "$END_DATE") - - # Store user branches for this repo in a temp file for later use - echo "REPO:$(basename "$REPO_PATH")|BRANCHES:$USER_BRANCHES" >> "${TEMP_BRANCH_DATA}.user_branches" - - # Collect commit data for branch analysis - use comprehensive approach to capture ALL commits - # This includes commits from merged PRs that might not appear in regular --all logs - git log --all --full-history --author="$PRIMARY_AUTHOR" --regexp-ignore-case --since="$START_DATE" --until="$END_DATE" \ - --pretty=format:"%H|%ai|%s|%D|$(basename "$REPO_PATH")" 2>/dev/null >> "$TEMP_COMMIT_DETAILS" - - # Collect commit statistics using primary author (case-insensitive) - include all commits with full history - git log --all --full-history --author="$PRIMARY_AUTHOR" --regexp-ignore-case --since="$START_DATE" --until="$END_DATE" \ - --pretty=format:"%ad" --date=short 2>/dev/null >> "$TEMP_COMMIT_STATS" - git log --all --full-history --author="$PRIMARY_AUTHOR" --regexp-ignore-case --since="$START_DATE" --until="$END_DATE" \ - --pretty=format:"%ad" --date=format:"%A" 2>/dev/null >> "$TEMP_DAY_STATS" - fi -done - -# Return to original directory -if [ "$OS_TYPE" = "windows" ]; then - # Windows Git Bash sometimes has issues with cd - - cd "$(dirname "$0")" 2>/dev/null || true -else - cd - >/dev/null 2>&1 || true -fi - -# Remove duplicate commits and process them to organize by branch -echo "🌿 Analyzing branch-based commit organization..." -if [ -s "$TEMP_COMMIT_DETAILS" ]; then - # Remove duplicate commits based on hash (first field) - sort "$TEMP_COMMIT_DETAILS" | uniq > "${TEMP_COMMIT_DETAILS}.tmp" - mv "${TEMP_COMMIT_DETAILS}.tmp" "$TEMP_COMMIT_DETAILS" - while IFS='|' read -r commit_hash commit_date commit_message branch_refs repo_name; do - # Get the repository path for this commit - REPO_PATH="" - for repo in "${REPO_PATHS[@]}"; do - if [ "$(basename "$repo")" = "$repo_name" ]; then - REPO_PATH="$repo" - break - fi - done - - # Get user branches for this repository - USER_BRANCHES="" - if [ -f "${TEMP_BRANCH_DATA}.user_branches" ]; then - USER_BRANCHES=$(grep "^REPO:$repo_name|" "${TEMP_BRANCH_DATA}.user_branches" 2>/dev/null | cut -d'|' -f2 | sed 's/BRANCHES://') - fi - - # Get the most likely branch where user worked on this commit - if [ -n "$REPO_PATH" ] && [ -n "$USER_BRANCHES" ]; then - clean_branch=$(get_user_work_branch "$commit_hash" "$REPO_PATH" "$USER_BRANCHES") - elif [ -n "$REPO_PATH" ]; then - # Fallback to original branch detection - clean_branch=$(get_original_branch "$commit_hash" "$REPO_PATH") - else - # Fallback to parsing branch references - clean_branch=$(parse_branch_name "$branch_refs") - fi - - # Default to "main" if no branch detected - if [ -z "$clean_branch" ]; then - clean_branch="main" - fi - - # Create branch name with repository for clarity - branch_with_repo="${repo_name}/${clean_branch}" - - # Store branch association: branch_with_repo|commit_hash|date|message|repo - echo "$branch_with_repo|$commit_hash|$commit_date|$commit_message|$repo_name" >> "$TEMP_BRANCH_DATA" - done < "$TEMP_COMMIT_DETAILS" -fi - - -if [ "$TOTAL_COMMIT_COUNT" -gt 0 ]; then - echo "### 🌿 Commits Organized by Branch" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - - # Show branches where user actually pushed commits - if [ -f "${TEMP_BRANCH_DATA}.user_branches" ]; then - echo "#### πŸš€ User's Active Branches (where commits were pushed)" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - echo '```' >> "$REPORT_FILE" - while IFS='|' read -r repo_info branches_info; do - repo_name=$(echo "$repo_info" | cut -d':' -f2) - user_branches=$(echo "$branches_info" | cut -d':' -f2) - if [ -n "$user_branches" ]; then - echo "Repository: $repo_name" >> "$REPORT_FILE" - echo "$user_branches" | tr ' ' '\n' | while read branch; do - if [ -n "$branch" ]; then - echo " πŸ“Œ $branch" >> "$REPORT_FILE" - fi - done - echo "" >> "$REPORT_FILE" - fi - done < "${TEMP_BRANCH_DATA}.user_branches" - echo '```' >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - fi - - if [ -s "$TEMP_BRANCH_DATA" ]; then - # Group commits by branch - echo "#### Commits Grouped by Branch Name" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - - # Get unique branches sorted by earliest commit date - # Create temp file with branch name and earliest date - TEMP_BRANCH_DATES=$(create_temp_file "branch_dates") - cut -d'|' -f1 "$TEMP_BRANCH_DATA" | sort | uniq | while read -r branch; do - if [ -n "$branch" ]; then - # Get earliest commit date for this branch - earliest_date=$(grep "^$branch|" "$TEMP_BRANCH_DATA" | cut -d'|' -f3 | sort | head -1) - echo "$earliest_date|$branch" >> "$TEMP_BRANCH_DATES" - fi - done - - # Sort branches by date and extract branch names - UNIQUE_BRANCHES=$(sort "$TEMP_BRANCH_DATES" | cut -d'|' -f2) - BRANCH_COUNT=$(echo "$UNIQUE_BRANCHES" | wc -l | tr -d ' ') - - echo "**Total Branches:** $BRANCH_COUNT" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - - # Process each unique branch (sorted by date) - echo "$UNIQUE_BRANCHES" | while read -r branch; do - if [ -n "$branch" ]; then - commit_count=$(grep "^$branch|" "$TEMP_BRANCH_DATA" | wc -l | tr -d ' ') - - # Get date range for this branch - branch_commits=$(grep "^$branch|" "$TEMP_BRANCH_DATA" | cut -d'|' -f3 | sort) - first_commit_date=$(echo "$branch_commits" | head -1 | cut -d' ' -f1) - last_commit_date=$(echo "$branch_commits" | tail -1 | cut -d' ' -f1) - - # Determine branch status based on commit messages (merged PRs or active) - branch_status="β—‹ active" - branch_name_only=$(echo "$branch" | cut -d'/' -f2-) - - # Check if any commit message indicates this was merged (contains PR merge pattern) - if grep "^$branch|" "$TEMP_BRANCH_DATA" | cut -d'|' -f4 | grep -qiE "(#[0-9]+|merged|merge pull request)"; then - branch_status="βœ“ merged" - fi - - # Format date range display - if [ "$first_commit_date" = "$last_commit_date" ]; then - date_range="$first_commit_date" - else - date_range="$first_commit_date β†’ $last_commit_date" - fi - - echo "##### 🌱 \`$branch\` ($commit_count commits) | $date_range | $branch_status" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - echo '```' >> "$REPORT_FILE" - - # Show commits for this branch in codeblock format with full details - sorted chronologically - grep "^$branch|" "$TEMP_BRANCH_DATA" | sort -t'|' -k3 | while IFS='|' read -r b_branch b_hash b_date b_message b_repo; do - # Clean up data for codeblock display - short_hash=$(echo "$b_hash" | cut -c1-8) - full_hash="$b_hash" - clean_message=$(echo "$b_message" | sed 's/|/ /g') - date_time=$(echo "$b_date" | cut -d' ' -f1,2) - date_only=$(echo "$b_date" | cut -d' ' -f1) - - # Get commit type for statistics - commit_type=$(get_commit_type "$clean_message") - - # Get repository path for GitHub links - COMMIT_REPO_PATH="" - for repo in "${REPO_PATHS[@]}"; do - if [ "$(basename "$repo")" = "$b_repo" ]; then - COMMIT_REPO_PATH="$repo" - break - fi - done - - # Create GitHub link if available - github_link="" - if [ -n "$COMMIT_REPO_PATH" ]; then - github_link=$(create_commit_link "$full_hash" "$COMMIT_REPO_PATH") - fi - - # Display commit with better formatting - printf "β”Œβ”€ %s\n" "$date_time" >> "$REPORT_FILE" - printf "└─ %s\n\n" "$clean_message" >> "$REPORT_FILE" - done - echo '```' >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - fi - done - - - else - echo "> No branch data found in commit history." >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - fi - -else - echo "### ℹ️ No Commits Found" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" - echo "> No commits were found for this period across all configured repositories." >> "$REPORT_FILE" - echo "> This could mean:" >> "$REPORT_FILE" - echo "> - No development activity during this period" >> "$REPORT_FILE" - echo "> - Author name/username mismatch in git configuration" >> "$REPORT_FILE" - echo "> - Repository paths need to be updated" >> "$REPORT_FILE" - echo "" >> "$REPORT_FILE" -fi - -# Clean up temp files -rm -f "$TEMP_COMMIT_STATS" "$TEMP_DAY_STATS" "$TEMP_BRANCH_DATA" "$TEMP_COMMIT_DETAILS" "${TEMP_BRANCH_DATA}.user_branches" "$TEMP_BRANCH_DATES" - -cat >> "$REPORT_FILE" << EOF - ---- - -*Generated by [Commit Chronicle](https://github.com/your-username/commit-chronicle) - Development Report Generator* -EOF - -# Calculate execution time -END_TIME=$(date +%s) -EXECUTION_TIME=$((END_TIME - START_TIME)) -MINUTES=$((EXECUTION_TIME / 60)) -SECONDS=$((EXECUTION_TIME % 60)) - -echo "βœ… Report generated successfully!" -echo "" -echo "πŸ“Š Generation Statistics:" -echo " ⏱️ Execution time: ${MINUTES}m ${SECONDS}s" -echo " πŸ“ Total commits: $TOTAL_COMMIT_COUNT" -echo " πŸ“ Repositories: ${#REPO_PATHS[@]}" -echo " πŸ“‚ Report location: $REPORT_FILE" -echo "" -echo "πŸ“„ Quick Actions:" -echo " πŸ“– View report: cat '$REPORT_FILE'" -echo " πŸ“‹ Copy to clipboard: cat '$REPORT_FILE' | pbcopy" -echo " πŸ“§ Open in editor: open '$REPORT_FILE'" -echo " 🌐 Share via GitHub: Upload to a gist or repository" -echo "" -echo "πŸ’‘ Tips:" -echo " β€’ Commit links are included for GitHub repositories" -echo " β€’ Commits are categorized by type (feat, fix, chore, etc.)" -echo " β€’ Use different date ranges for quarterly or yearly reports" - -# Return to original directory -cd "$ORIGINAL_DIR" -} - -# If script is executed directly (not sourced), run the function -# Check if the script is being executed directly rather than sourced -if [[ "${(%):-%N}" == "${0}" ]] || [[ "$0" == *"commit-chronicle" && "$0" != "-zsh" ]]; then - commit_chronicle "$@" -fi \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..72b3a6b --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module github.com/ashishxcode/commit-chronicle + +go 1.24.2 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/mattn/go-isatty v0.0.20 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..975e579 --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..f2aaa41 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,257 @@ +// Package app wires together config, collection, rendering and the TUI. +package app + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/ashishxcode/commit-chronicle/internal/collect" + "github.com/ashishxcode/commit-chronicle/internal/config" + "github.com/ashishxcode/commit-chronicle/internal/model" + "github.com/ashishxcode/commit-chronicle/internal/render" + "github.com/ashishxcode/commit-chronicle/internal/tui" + "github.com/mattn/go-isatty" +) + +// Config is the fully-parsed CLI configuration. +type Config struct { + Since, From, To, Month, Date string + Author, User, Repos, Root string + Out, Format string + NoEdit, All, Copy, NoPR bool +} + +// Run executes the whole pipeline. +func Run(c Config) error { + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is required but was not found on PATH") + } + + repos, err := config.ResolveRepos(splitCSV(c.Repos), splitCSV(c.Root)) + if err != nil { + return err + } + + author := c.Author + if author == "" { + author = gitConfigName(repos[0]) + } + if author == "" { + return fmt.Errorf("could not determine author; pass --author \"Your Name\"") + } + + interactive := isTerminal() + + rng, err := resolveRange(c, interactive) + if err != nil { + return err + } + + // GitHub login for PR/review discovery. + ghUser := c.User + if ghUser == "" && !c.NoPR && collect.HasGH() { + ghUser = ghLogin() + } + + opts := collect.Options{ + Repos: repos, + Author: author, + User: ghUser, + Range: rng, + IncludePRs: !c.NoPR, + IncludeReviews: !c.NoPR, + } + label := fmt.Sprintf(" scanning %d repo(s) for \"%s\" (%s) ", len(repos), author, rng.Label) + + var items []model.Item + if interactive { + // Animated spinner with live progress (Claude-style). + items, err = tui.RunWithSpinner(label, func(report func(string, int)) ([]model.Item, error) { + return collect.Gather(opts, report) + }) + } else { + fmt.Fprintf(os.Stderr, "πŸ”Ž%s…\n", label) + items, err = collect.Gather(opts, func(stage string, n int) { + if n > 0 { + fmt.Fprintf(os.Stderr, " β€’ %s: %d\n", stage, n) + } + }) + } + if err != nil { + return err + } + if len(items) == 0 { + return fmt.Errorf("nothing found for \"%s\" in range (%s)", author, rng.Label) + } + + // Pick + selected := items + if !c.All { + if !interactive { + return fmt.Errorf("no TTY for the picker; re-run with --all or in a terminal") + } + sel, canceled, err := tui.Pick(items, rng.Label, author) + if err != nil { + return err + } + if canceled || len(sel) == 0 { + fmt.Fprintln(os.Stderr, "nothing selected β€” bye.") + return nil + } + selected = sel + } + + meta := render.Meta{Author: author, RangeLabel: rng.Label} + + var content string + if c.Format == "json" { + content = render.JSON(selected, meta) + } else { + content = render.Markdown(selected, meta) + if !c.NoEdit && interactive { + edited, canceled, err := tui.Edit(content) + if err != nil { + return err + } + if !canceled { + content = edited + } + } + } + + out := c.Out + if out == "" { + out = defaultOutPath(c.Format) + } + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + return fmt.Errorf("creating output dir: %w", err) + } + if err := os.WriteFile(out, []byte(content), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", out, err) + } + fmt.Fprintf(os.Stderr, "βœ… %d entr%s β†’ %s\n", len(selected), plural(len(selected)), out) + + if c.Copy { + if err := copyClipboard(content); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ clipboard: %v\n", err) + } else { + fmt.Fprintln(os.Stderr, "πŸ“‹ copied to clipboard") + } + } + + if !interactive { + fmt.Print(content) + } + return nil +} + +func resolveRange(c Config, interactive bool) (model.Range, error) { + switch { + case c.Date != "": + return model.FromDates(c.Date, c.Date) + case c.Month != "": + return model.FromMonth(c.Month) + case c.From != "": + return model.FromDates(c.From, c.To) + case c.Since != "": + return model.Range{Since: c.Since, Label: "since " + c.Since}, nil + } + if !interactive { + return model.Range{Since: "30 days ago", Label: "last 30 days"}, nil + } + idx, canceled, err := tui.Choose("πŸ“… Worklog period", model.PresetNames) + if err != nil { + return model.Range{}, err + } + if canceled { + return model.Range{}, fmt.Errorf("canceled") + } + if model.PresetNames[idx] == model.CustomRangeLabel { + from, to, canceled, err := tui.CustomRange() + if err != nil { + return model.Range{}, err + } + if canceled || from == "" { + return model.Range{}, fmt.Errorf("canceled") + } + return model.FromDates(from, to) + } + return model.Preset(model.PresetNames[idx]), nil +} + +func splitCSV(s string) []string { + if s == "" { + return nil + } + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func gitConfigName(repo string) string { + out, err := exec.Command("git", "-C", repo, "config", "user.name").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func ghLogin() string { + out, err := exec.Command("gh", "api", "user", "--jq", ".login").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func defaultOutPath(format string) string { + ext := "md" + if format == "json" { + ext = "json" + } + home, _ := os.UserHomeDir() + dir := filepath.Join(home, "Downloads") + if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { + dir, _ = os.Getwd() + } + return filepath.Join(dir, fmt.Sprintf("commit-chronicle_%s.%s", time.Now().Format("20060102_150405"), ext)) +} + +func isTerminal() bool { + return isatty.IsTerminal(os.Stdin.Fd()) && isatty.IsTerminal(os.Stdout.Fd()) +} + +func copyClipboard(s string) error { + var name string + var args []string + switch { + case hasCmd("pbcopy"): + name = "pbcopy" + case hasCmd("wl-copy"): + name = "wl-copy" + case hasCmd("xclip"): + name, args = "xclip", []string{"-selection", "clipboard"} + default: + return fmt.Errorf("no clipboard tool found (pbcopy/wl-copy/xclip)") + } + cmd := exec.Command(name, args...) + cmd.Stdin = strings.NewReader(s) + return cmd.Run() +} + +func hasCmd(n string) bool { _, err := exec.LookPath(n); return err == nil } + +func plural(n int) string { + if n == 1 { + return "y" + } + return "ies" +} diff --git a/internal/collect/collect.go b/internal/collect/collect.go new file mode 100644 index 0000000..5c6a725 --- /dev/null +++ b/internal/collect/collect.go @@ -0,0 +1,87 @@ +// Package collect gathers worklog items from git history and GitHub: +// commits (by author and from authored PRs), authored PRs, and reviewed PRs. +package collect + +import ( + "sort" + + "github.com/ashishxcode/commit-chronicle/internal/model" +) + +// Options controls what Gather collects. +type Options struct { + Repos []string + Author string // git author name to match + User string // GitHub login (enables PR/review collection) + Range model.Range + IncludePRs bool // commits-from-PRs and authored-PR entries + IncludeReviews bool // reviewed-PR entries +} + +// Progress is an optional callback for status messages (may be nil). +type Progress func(stage string, count int) + +// HasGH reports whether the gh CLI is available (for the caller to decide +// whether PR/review collection is even possible). +func HasGH() bool { return hasGH() } + +// Gather collects everything requested into a de-duplicated, date-sorted slice. +// +// Sources, in order, with later sources only adding items not already seen: +// 1. git commits authored by Author across all refs +// 2. commits on the user's authored PRs (if IncludePRs && gh) +// 3. the user's authored PRs as entries (if IncludePRs && gh) +// 4. the user's reviewed PRs as entries (if IncludeReviews && gh) +func Gather(o Options, p Progress) ([]model.Item, error) { + report := func(stage string, n int) { + if p != nil { + p(stage, n) + } + } + + items := gitCommits(o.Repos, o.Author, o.Range) + report("git commits", len(items)) + + useGH := o.User != "" && hasGH() + if useGH && o.IncludePRs { + pc := prCommits(o.Repos, o.User, o.Range) + report("PR commits", len(pc)) + items = append(items, pc...) + + ap := authoredPRs(o.Repos, o.User, o.Range) + report("authored PRs", len(ap)) + items = append(items, ap...) + } + if useGH && o.IncludeReviews { + rp := reviewedPRs(o.Repos, o.User, o.Range) + report("reviewed PRs", len(rp)) + items = append(items, rp...) + } + + return dedupeSort(items), nil +} + +// dedupeSort removes duplicates by Item.ID and orders oldestβ†’newest, then by +// kind (commits, PRs, reviews), then repo. +func dedupeSort(in []model.Item) []model.Item { + seen := make(map[string]struct{}, len(in)) + out := in[:0] + for _, it := range in { + id := it.ID() + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + out = append(out, it) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Date != out[j].Date { + return out[i].Date < out[j].Date + } + if out[i].Kind != out[j].Kind { + return out[i].Kind < out[j].Kind + } + return out[i].RepoName < out[j].RepoName + }) + return out +} diff --git a/internal/collect/git.go b/internal/collect/git.go new file mode 100644 index 0000000..c3340cd --- /dev/null +++ b/internal/collect/git.go @@ -0,0 +1,102 @@ +package collect + +import ( + "os/exec" + "path/filepath" + "strings" + + "github.com/ashishxcode/commit-chronicle/internal/model" +) + +const fieldSep = "\x1f" // ASCII unit separator + +// gitCommits returns de-duplicated commits authored by `author` in the range, +// across all refs in each repo. +func gitCommits(repos []string, author string, r model.Range) []model.Item { + var items []model.Item + for _, repo := range repos { + name := filepath.Base(repo) + base := originURL(repo) + + args := []string{ + "-C", repo, "log", "--all", "--no-merges", + "--author=" + author, "--regexp-ignore-case", + "--date=short", + "--pretty=format:%h" + fieldSep + "%H" + fieldSep + "%ad" + fieldSep + "%s", + } + if r.Since != "" { + args = append(args, "--since="+r.Since) + } + if r.Until != "" { + args = append(args, "--until="+r.Until) + } + + out, err := exec.Command("git", args...).Output() + if err != nil { + continue + } + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + p := strings.SplitN(line, fieldSep, 4) + if len(p) != 4 { + continue + } + url := "" + if base != "" { + url = base + "/commit/" + p[1] + } + items = append(items, model.Item{ + Kind: model.KindCommit, + Date: p[2], + RepoName: name, + RepoPath: repo, + URL: url, + Hash: p[1], + ShortHash: p[0], + Title: p[3], + }) + } + } + return items +} + +// Preview returns `git show --stat` for a commit item (for the picker pane). +func Preview(it model.Item) string { + if it.Kind != model.KindCommit || it.Hash == "" { + return previewPR(it) + } + out, err := exec.Command("git", "-C", it.RepoPath, "show", "--stat", "--no-color", it.Hash).Output() + if err != nil { + return "(commit not present locally β€” fetch the branch to see the diff)\n\n" + previewPR(it) + } + return string(out) +} + +func previewPR(it model.Item) string { + var b strings.Builder + b.WriteString(it.Tag() + " " + it.Ref() + "\n") + b.WriteString("repo: " + it.RepoName + "\n") + if it.State != "" { + b.WriteString("state: " + it.State + "\n") + } + b.WriteString("date: " + it.Date + "\n\n") + b.WriteString(it.Title + "\n") + if it.URL != "" { + b.WriteString("\n" + it.URL + "\n") + } + return b.String() +} + +// originURL returns the https GitHub URL for a repo's origin, or "". +func originURL(repoPath string) string { + out, err := exec.Command("git", "-C", repoPath, "remote", "get-url", "origin").Output() + if err != nil { + return "" + } + url := strings.TrimSpace(string(out)) + url = strings.TrimSuffix(url, ".git") + url = strings.Replace(url, "git@github.com:", "https://github.com/", 1) + return url +} diff --git a/internal/collect/github.go b/internal/collect/github.go new file mode 100644 index 0000000..4870700 --- /dev/null +++ b/internal/collect/github.go @@ -0,0 +1,304 @@ +package collect + +import ( + "encoding/json" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/ashishxcode/commit-chronicle/internal/model" +) + +// maxPRs bounds how many PRs we inspect per repo, to keep API calls sane. +const maxPRs = 150 + +// hasGH reports whether the gh CLI is available. +func hasGH() bool { + _, err := exec.LookPath("gh") + return err == nil +} + +// --- gh JSON shapes ------------------------------------------------------- + +type ghNum struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + URL string `json:"url"` + CreatedAt string `json:"createdAt"` +} + +type ghCommit struct { + OID string `json:"oid"` + MessageHeadline string `json:"messageHeadline"` + AuthoredDate string `json:"authoredDate"` +} + +type ghReview struct { + State string `json:"state"` + SubmittedAt string `json:"submittedAt"` + Author struct { + Login string `json:"login"` + } `json:"author"` +} + +// prCommits returns commits on the user's authored PRs (KindCommit), so commits +// the plain author filter misses (different email, unfetched branch) are kept. +func prCommits(repos []string, user string, r model.Range) []model.Item { + since, until := parseDay(r.Since), parseDay(r.Until) + var items []model.Item + for _, repo := range repos { + slug := repoSlug(repo) + if slug == "" { + continue + } + name := slug[strings.Index(slug, "/")+1:] + base := originURL(repo) + for _, n := range listPRNumbers(slug, "author:"+user+dateQualifier(r)) { + for _, c := range viewPRCommits(slug, n) { + day, ok := isoDay(c.AuthoredDate) + if !ok || !inRange(day, since, until) { + continue + } + short := c.OID + if len(short) > 8 { + short = short[:8] + } + url := "" + if base != "" { + url = base + "/commit/" + c.OID + } + items = append(items, model.Item{ + Kind: model.KindCommit, + Date: day.Format("2006-01-02"), + RepoName: name, + RepoPath: repo, + URL: url, + Hash: c.OID, + ShortHash: short, + Title: c.MessageHeadline, + }) + } + } + } + return items +} + +// authoredPRs returns PRs the user opened, created within the range (KindPR). +func authoredPRs(repos []string, user string, r model.Range) []model.Item { + since, until := parseDay(r.Since), parseDay(r.Until) + var items []model.Item + for _, repo := range repos { + slug := repoSlug(repo) + if slug == "" { + continue + } + name := slug[strings.Index(slug, "/")+1:] + raw, err := exec.Command("gh", "pr", "list", "-R", slug, + "--search", "author:"+user+dateQualifier(r), "--state", "all", "--limit", "200", + "--json", "number,title,state,url,createdAt").Output() + if err != nil { + continue + } + var prs []ghNum + if json.Unmarshal(raw, &prs) != nil { + continue + } + for _, pr := range prs { + day, ok := isoDay(pr.CreatedAt) + if !ok || !inRange(day, since, until) { + continue + } + items = append(items, model.Item{ + Kind: model.KindPR, + Date: day.Format("2006-01-02"), + RepoName: name, + RepoPath: repo, + URL: pr.URL, + Number: pr.Number, + State: pr.State, + Title: pr.Title, + }) + } + } + return items +} + +// reviewedPRs returns PRs the user reviewed within the range (KindReview). +// Dated by the user's review submission time, one review per PR (earliest in range). +func reviewedPRs(repos []string, user string, r model.Range) []model.Item { + since, until := parseDay(r.Since), parseDay(r.Until) + var items []model.Item + for _, repo := range repos { + slug := repoSlug(repo) + if slug == "" { + continue + } + name := slug[strings.Index(slug, "/")+1:] + + // List PRs the user reviewed (numbers + meta only β€” cheap). + raw, err := exec.Command("gh", "pr", "list", "-R", slug, + "--search", "reviewed-by:"+user+dateQualifier(r), "--state", "all", "--limit", "200", + "--json", "number,title,state,url").Output() + if err != nil { + continue + } + var prs []ghNum + if json.Unmarshal(raw, &prs) != nil { + continue + } + if len(prs) > maxPRs { + prs = prs[:maxPRs] + } + for _, pr := range prs { + day, ok := reviewDayInRange(slug, pr.Number, user, since, until) + if !ok { + continue + } + items = append(items, model.Item{ + Kind: model.KindReview, + Date: day, + RepoName: name, + RepoPath: repo, + URL: pr.URL, + Number: pr.Number, + State: pr.State, + Title: pr.Title, + }) + } + } + return items +} + +// --- gh call helpers ------------------------------------------------------ + +func listPRNumbers(slug, search string) []int { + raw, err := exec.Command("gh", "pr", "list", "-R", slug, + "--search", search, "--state", "all", "--limit", "200", + "--json", "number").Output() + if err != nil { + return nil + } + var nums []ghNum + if json.Unmarshal(raw, &nums) != nil { + return nil + } + if len(nums) > maxPRs { + nums = nums[:maxPRs] + } + out := make([]int, len(nums)) + for i, n := range nums { + out[i] = n.Number + } + return out +} + +func viewPRCommits(slug string, number int) []ghCommit { + raw, err := exec.Command("gh", "pr", "view", strconv.Itoa(number), + "-R", slug, "--json", "commits").Output() + if err != nil { + return nil + } + var v struct { + Commits []ghCommit `json:"commits"` + } + if json.Unmarshal(raw, &v) != nil { + return nil + } + return v.Commits +} + +// reviewDayInRange returns the date of the user's earliest in-range review on a PR. +func reviewDayInRange(slug string, number int, user string, since, until time.Time) (string, bool) { + raw, err := exec.Command("gh", "pr", "view", strconv.Itoa(number), + "-R", slug, "--json", "reviews").Output() + if err != nil { + return "", false + } + var v struct { + Reviews []ghReview `json:"reviews"` + } + if json.Unmarshal(raw, &v) != nil { + return "", false + } + best := "" + for _, rv := range v.Reviews { + if !strings.EqualFold(rv.Author.Login, user) { + continue + } + day, ok := isoDay(rv.SubmittedAt) + if !ok || !inRange(day, since, until) { + continue + } + d := day.Format("2006-01-02") + if best == "" || d < best { + best = d + } + } + return best, best != "" +} + +// repoSlug returns "owner/repo" for a repo's GitHub origin, or "". +func repoSlug(repoPath string) string { + out, err := exec.Command("git", "-C", repoPath, "remote", "get-url", "origin").Output() + if err != nil { + return "" + } + url := strings.TrimSpace(string(out)) + url = strings.TrimSuffix(url, ".git") + switch { + case strings.HasPrefix(url, "git@github.com:"): + url = strings.TrimPrefix(url, "git@github.com:") + case strings.Contains(url, "github.com/"): + url = url[strings.Index(url, "github.com/")+len("github.com/"):] + default: + return "" + } + if parts := strings.SplitN(url, "/", 2); len(parts) == 2 && parts[0] != "" && parts[1] != "" { + return parts[0] + "/" + parts[1] + } + return "" +} + +// --- date helpers --------------------------------------------------------- + +// dateQualifier returns a GitHub search suffix bounding PRs to the window by +// last-updated date, so we don't fan out gh calls over out-of-range PRs. Empty +// when Since isn't a concrete date (e.g. a relative "7 days ago"). +func dateQualifier(r model.Range) string { + if parseDay(r.Since).IsZero() { + return "" + } + if !parseDay(r.Until).IsZero() { + return " updated:" + r.Since + ".." + r.Until + } + return " updated:>=" + r.Since +} + +func parseDay(s string) time.Time { + if s == "" || s == "now" { + return time.Time{} + } + t, _ := time.Parse("2006-01-02", s) + return t +} + +func isoDay(s string) (time.Time, bool) { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, false + } + y, m, d := t.Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC), true +} + +func inRange(day, since, until time.Time) bool { + if !since.IsZero() && day.Before(since) { + return false + } + if !until.IsZero() && !day.Before(until) { // until exclusive + return false + } + return true +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..173b130 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,165 @@ +// Package config resolves the list of git repositories to scan. +package config + +import ( + "bufio" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// skipDirs are never descended into during root discovery. +var skipDirs = map[string]bool{ + "node_modules": true, "vendor": true, ".Trash": true, + "Library": true, ".cache": true, "dist": true, "build": true, +} + +// ResolveRepos returns validated git repository paths from explicit paths, +// root directories to scan, and config files. +// +// Sources (unioned, then de-duplicated): +// - explicit repo paths (e.g. --repos) +// - repos discovered under root dirs (e.g. --root, or the `roots` config) +// - ./.commit-chronicle (repo paths, one per line) +// - $XDG_CONFIG_HOME/commit-chronicle/repos (repo paths) +// - $XDG_CONFIG_HOME/commit-chronicle/roots (root dirs to scan) +// +// If none of those yield anything, the current directory is used when it is a +// git repo. +func ResolveRepos(explicit, roots []string) ([]string, error) { + var candidates []string + candidates = append(candidates, explicit...) + + // Root dirs: from the caller plus the roots config file. + allRoots := append([]string{}, roots...) + if rs, ok := linesFromFile(xdgPath("roots")); ok { + allRoots = append(allRoots, rs...) + } + for _, root := range allRoots { + candidates = append(candidates, discoverRepos(expand(root))...) + } + + // Explicit repo-path config files. + if c, ok := linesFromFile(".commit-chronicle"); ok { + candidates = append(candidates, c...) + } + if c, ok := linesFromFile(xdgPath("repos")); ok { + candidates = append(candidates, c...) + } + + // Fallback: the current directory. + if len(candidates) == 0 { + cwd, err := os.Getwd() + if err == nil && isGitRepo(cwd) { + candidates = []string{cwd} + } + } + if len(candidates) == 0 { + return nil, fmt.Errorf("no repos found\n" + + " configure via --root ~/work, --repos, ./.commit-chronicle,\n" + + " or ~/.config/commit-chronicle/{repos,roots}") + } + + // Validate + de-duplicate (preserve discovery order). + seen := make(map[string]bool) + var valid []string + for _, c := range candidates { + p := expand(c) + if seen[p] { + continue + } + seen[p] = true + if isGitRepo(p) { + valid = append(valid, p) + } else { + fmt.Fprintf(os.Stderr, "⚠️ skipping (not a git repo): %s\n", p) + } + } + if len(valid) == 0 { + return nil, fmt.Errorf("no valid git repositories to scan") + } + return valid, nil +} + +// discoverRepos walks root and returns every git repo beneath it (not +// descending into a repo once found, nor into heavy/build directories). +func discoverRepos(root string) []string { + var repos []string + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return nil //nolint:nilerr // unreadable dirs are skipped, not fatal + } + base := filepath.Base(path) + if path != root && (skipDirs[base] || strings.HasPrefix(base, ".")) { + return fs.SkipDir + } + if isGitRepo(path) { + repos = append(repos, path) + // Stop descending into a nested repo (submodules etc.), but if the + // root dir is itself a repo, keep going so sibling repos beneath it + // (e.g. ~/work/forked/{a,b,c}) are still discovered. + if path != root { + return fs.SkipDir + } + } + return nil + }) + sort.Strings(repos) + return repos +} + +func xdgPath(name string) string { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, _ := os.UserHomeDir() + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "commit-chronicle", name) +} + +func linesFromFile(path string) ([]string, bool) { + f, err := os.Open(path) + if err != nil { + return nil, false + } + defer f.Close() + + var out []string + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if i := strings.Index(line, "#"); i >= 0 { + line = line[:i] + } + if line = strings.TrimSpace(line); line != "" { + out = append(out, line) + } + } + return out, len(out) > 0 +} + +func expand(p string) string { + p = strings.TrimSpace(p) + if strings.HasPrefix(p, "~") { + home, _ := os.UserHomeDir() + p = filepath.Join(home, strings.TrimPrefix(p, "~")) + } + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p +} + +func isGitRepo(path string) bool { + if fi, err := os.Stat(filepath.Join(path, ".git")); err == nil { + _ = fi + return true + } + // Fall back to git for worktrees / unusual layouts. + out, err := exec.Command("git", "-C", path, "rev-parse", "--is-inside-work-tree").Output() + return err == nil && strings.TrimSpace(string(out)) == "true" +} diff --git a/internal/model/item.go b/internal/model/item.go new file mode 100644 index 0000000..663d59e --- /dev/null +++ b/internal/model/item.go @@ -0,0 +1,64 @@ +// Package model defines the domain types shared across commit-chronicle: +// the unified worklog Item and the date Range. +package model + +import "fmt" + +// Kind distinguishes the sources that feed a worklog. +type Kind int + +const ( + KindCommit Kind = iota // a git commit (history or PR head) + KindPR // a pull request the user authored + KindReview // a pull request the user reviewed +) + +// Item is one selectable, renderable entry in a worklog. A single struct +// covers commits and PRs so the picker and renderers stay uniform. +type Item struct { + Kind Kind + Date string // YYYY-MM-DD used for grouping and sorting + RepoName string + RepoPath string + URL string // canonical link (commit or PR), may be empty + + // Commit-only + Hash string + ShortHash string + + // PR/Review-only + Number int + State string // OPEN | MERGED | CLOSED + + // Common payload: commit subject or PR title + Title string +} + +// ID is the de-duplication key. Commits dedupe by hash; PRs/reviews by +// kind+repo+number so an authored PR and a reviewed PR never collide. +func (i Item) ID() string { + if i.Kind == KindCommit { + return "c:" + i.Hash + } + return fmt.Sprintf("%d:%s#%d", i.Kind, i.RepoName, i.Number) +} + +// Tag is the short label shown in the picker. +func (i Item) Tag() string { + switch i.Kind { + case KindPR: + return "PR" + case KindReview: + return "review" + default: + return "commit" + } +} + +// Ref is the compact identifier (short hash or #number). +func (i Item) Ref() string { + if i.Kind == KindCommit { + return i.ShortHash + } + return fmt.Sprintf("#%d", i.Number) +} diff --git a/internal/model/range.go b/internal/model/range.go new file mode 100644 index 0000000..d002dbe --- /dev/null +++ b/internal/model/range.go @@ -0,0 +1,95 @@ +package model + +import ( + "fmt" + "time" +) + +const isoDate = "2006-01-02" + +// Range is a reporting window. Until is exclusive (git semantics). Since/Until +// are YYYY-MM-DD where possible (a relative Since like "7 days ago" is allowed +// but disables PR date-filtering precision). +type Range struct { + Since string + Until string + Label string +} + +// FromDates builds an inclusive [from, to] range (to defaults to today). +func FromDates(from, to string) (Range, error) { + fd, err := time.Parse(isoDate, from) + if err != nil { + return Range{}, fmt.Errorf("bad from-date %q (use YYYY-MM-DD)", from) + } + if to == "" { + to = time.Now().Format(isoDate) + } + td, err := time.Parse(isoDate, to) + if err != nil { + return Range{}, fmt.Errorf("bad to-date %q (use YYYY-MM-DD)", to) + } + label := from + " β†’ " + to + if from == to { + label = from + } + return Range{ + Since: fd.Format(isoDate), + Until: td.AddDate(0, 0, 1).Format(isoDate), // make 'to' inclusive + Label: label, + }, nil +} + +// FromMonth builds a whole-calendar-month range from YYYY-MM. +func FromMonth(month string) (Range, error) { + t, err := time.Parse("2006-01", month) + if err != nil { + return Range{}, fmt.Errorf("bad month %q (use YYYY-MM)", month) + } + start := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.Local) + next := start.AddDate(0, 1, 0) + return Range{ + Since: start.Format(isoDate), + Until: next.Format(isoDate), + Label: start.Format("January 2006"), + }, nil +} + +// Preset maps an interactive menu choice to a concrete range. +func Preset(choice string) Range { + now := time.Now() + today := now.Format(isoDate) + switch choice { + case "Today": + r, _ := FromDates(today, today) + return r + case "Yesterday": + y := now.AddDate(0, 0, -1).Format(isoDate) + r, _ := FromDates(y, y) + return r + case "Last 7 days": + r, _ := FromDates(now.AddDate(0, 0, -6).Format(isoDate), today) + r.Label = "last 7 days" + return r + case "Last 30 days": + r, _ := FromDates(now.AddDate(0, 0, -29).Format(isoDate), today) + r.Label = "last 30 days" + return r + case "This month": + start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local) + return Range{Since: start.Format(isoDate), Label: start.Format("January 2006")} + case "This year": + start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.Local) + return Range{Since: start.Format(isoDate), Label: fmt.Sprintf("%d", now.Year())} + default: + r, _ := FromDates(now.AddDate(0, 0, -6).Format(isoDate), today) + r.Label = "last 7 days" + return r + } +} + +// CustomRangeLabel is the menu entry that triggers the custom date selector. +const CustomRangeLabel = "Custom range…" + +// PresetNames are the interactive range options, in display order. +var PresetNames = []string{"Today", "Yesterday", "Last 7 days", "Last 30 days", "This month", "This year", CustomRangeLabel} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..a52aae1 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,115 @@ +// Package render turns selected worklog items into markdown or json. +package render + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/ashishxcode/commit-chronicle/internal/model" +) + +// Meta is the worklog header information. +type Meta struct { + Author string + RangeLabel string +} + +func stateIcon(state string) string { + switch state { + case "MERGED": + return "βœ“" + case "OPEN": + return "β—‹" + case "CLOSED": + return "βœ•" + default: + return "β€’" + } +} + +// Markdown renders items grouped by date. Commits, PRs and reviews each get a +// distinct, link-bearing line so nothing is ambiguous. +func Markdown(items []model.Item, m Meta) string { + var b strings.Builder + fmt.Fprintf(&b, "# πŸ““ Worklog β€” %s\n\n", m.RangeLabel) + fmt.Fprintf(&b, "> **Author:** %s \n", m.Author) + fmt.Fprintf(&b, "> **Generated:** %s \n", time.Now().Format("2006-01-02 15:04")) + fmt.Fprintf(&b, "> **Entries:** %d (%s)\n\n", len(items), counts(items)) + b.WriteString("\n") + + cur := "" + for _, it := range items { + if it.Date != cur { + cur = it.Date + fmt.Fprintf(&b, "\n## %s\n\n", it.Date) + } + b.WriteString(line(it)) + } + b.WriteString("\n") + return b.String() +} + +func line(it model.Item) string { + link := func(text string) string { + if it.URL != "" { + return fmt.Sprintf("[%s](%s)", text, it.URL) + } + return text + } + switch it.Kind { + case model.KindPR: + return fmt.Sprintf("- **PR %s** %s %s _(%s)_ β€” %s\n", + link(it.Ref()), stateIcon(it.State), it.Title, it.State, it.RepoName) + case model.KindReview: + return fmt.Sprintf("- **Reviewed PR %s** %s %s _(%s)_ β€” %s\n", + link(it.Ref()), stateIcon(it.State), it.Title, it.State, it.RepoName) + default: // commit + return fmt.Sprintf("- %s _(%s)_\n", it.Title, + link(it.RepoName+"@"+it.ShortHash)) + } +} + +func counts(items []model.Item) string { + var c, p, r int + for _, it := range items { + switch it.Kind { + case model.KindCommit: + c++ + case model.KindPR: + p++ + case model.KindReview: + r++ + } + } + return fmt.Sprintf("%d commits, %d PRs, %d reviews", c, p, r) +} + +type jsonItem struct { + Kind string `json:"kind"` + Date string `json:"date"` + Repo string `json:"repo"` + Title string `json:"title"` + URL string `json:"url"` + Hash string `json:"hash,omitempty"` + Number int `json:"number,omitempty"` + State string `json:"state,omitempty"` +} + +// JSON renders items as a JSON array. +func JSON(items []model.Item, _ Meta) string { + out := make([]jsonItem, 0, len(items)) + for _, it := range items { + out = append(out, jsonItem{ + Kind: it.Tag(), Date: it.Date, Repo: it.RepoName, + Title: it.Title, URL: it.URL, Hash: it.Hash, + Number: it.Number, State: it.State, + }) + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return "[]\n" + } + return string(data) + "\n" +} diff --git a/internal/tui/choose.go b/internal/tui/choose.go new file mode 100644 index 0000000..8b83422 --- /dev/null +++ b/internal/tui/choose.go @@ -0,0 +1,65 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type chooseModel struct { + title string + options []string + cursor int + chosen int + canceled bool +} + +// Choose shows a single-select vertical menu and returns the chosen index. +func Choose(title string, options []string) (int, bool, error) { + m := chooseModel{title: title, options: options, chosen: -1} + res, err := tea.NewProgram(m).Run() + if err != nil { + return 0, false, err + } + fm := res.(chooseModel) + return fm.chosen, fm.canceled, nil +} + +func (m chooseModel) Init() tea.Cmd { return nil } + +func (m chooseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if k, ok := msg.(tea.KeyMsg); ok { + switch k.String() { + case "ctrl+c", "q", "esc": + m.canceled = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.options)-1 { + m.cursor++ + } + case "enter": + m.chosen = m.cursor + return m, tea.Quit + } + } + return m, nil +} + +func (m chooseModel) View() string { + var b strings.Builder + b.WriteString(titleSty.Render(m.title) + "\n\n") + for i, opt := range m.options { + if i == m.cursor { + b.WriteString(curSty.Render("❯ "+opt) + "\n") + } else { + b.WriteString(lipgloss.NewStyle().Render(" "+opt) + "\n") + } + } + b.WriteString("\n" + dimSty.Render("↑↓ move Β· enter select Β· q cancel")) + return b.String() +} diff --git a/internal/tui/customrange.go b/internal/tui/customrange.go new file mode 100644 index 0000000..2a8ca4d --- /dev/null +++ b/internal/tui/customrange.go @@ -0,0 +1,85 @@ +package tui + +import ( + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type customRangeModel struct { + inputs []textinput.Model + focus int + canceled bool + submit bool +} + +// CustomRange prompts for a from/to date range. Returns (from, to, canceled). +// `to` may be empty (caller defaults it to today). +func CustomRange() (string, string, bool, error) { + from := textinput.New() + from.Prompt = "From (YYYY-MM-DD): " + from.Placeholder = time.Now().AddDate(0, 0, -7).Format("2006-01-02") + from.CharLimit = 10 + from.Focus() + + to := textinput.New() + to.Prompt = "To (YYYY-MM-DD): " + to.Placeholder = time.Now().Format("2006-01-02") + " (blank = today)" + to.CharLimit = 10 + + m := customRangeModel{inputs: []textinput.Model{from, to}} + res, err := tea.NewProgram(m).Run() + if err != nil { + return "", "", false, err + } + fm := res.(customRangeModel) + if fm.canceled || !fm.submit { + return "", "", true, nil + } + return strings.TrimSpace(fm.inputs[0].Value()), strings.TrimSpace(fm.inputs[1].Value()), false, nil +} + +func (m customRangeModel) Init() tea.Cmd { return textinput.Blink } + +func (m customRangeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if k, ok := msg.(tea.KeyMsg); ok { + switch k.String() { + case "ctrl+c", "esc": + m.canceled = true + return m, tea.Quit + case "enter": + // Submit from the last field; otherwise advance. + if m.focus == len(m.inputs)-1 { + m.submit = true + return m, tea.Quit + } + m.focus++ + case "tab", "down": + m.focus = (m.focus + 1) % len(m.inputs) + case "shift+tab", "up": + m.focus = (m.focus - 1 + len(m.inputs)) % len(m.inputs) + } + for i := range m.inputs { + if i == m.focus { + m.inputs[i].Focus() + } else { + m.inputs[i].Blur() + } + } + } + var cmd tea.Cmd + m.inputs[m.focus], cmd = m.inputs[m.focus].Update(msg) + return m, cmd +} + +func (m customRangeModel) View() string { + var b strings.Builder + b.WriteString(titleSty.Render("πŸ“… Custom date range") + "\n\n") + for i := range m.inputs { + b.WriteString(m.inputs[i].View() + "\n") + } + b.WriteString("\n" + dimSty.Render("tab switch Β· enter next/confirm Β· esc cancel")) + return b.String() +} diff --git a/internal/tui/edit.go b/internal/tui/edit.go new file mode 100644 index 0000000..6481add --- /dev/null +++ b/internal/tui/edit.go @@ -0,0 +1,69 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" +) + +type editModel struct { + ta textarea.Model + canceled bool + width int + height int +} + +// Edit opens an in-app editor pre-filled with text. +// Ctrl+S / Ctrl+D saves; Esc cancels. Returns the edited text. +func Edit(text string) (string, bool, error) { + ta := textarea.New() + ta.SetValue(text) + ta.CharLimit = 0 + ta.ShowLineNumbers = true + // SetValue leaves the cursor at the end; start at the top instead. + for ta.Line() > 0 { + ta.CursorUp() + } + ta.CursorStart() + ta.Focus() + + m := editModel{ta: ta} + res, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + return text, false, err + } + fm := res.(editModel) + if fm.canceled { + return text, true, nil + } + return fm.ta.Value(), false, nil +} + +func (m editModel) Init() tea.Cmd { return textarea.Blink } + +func (m editModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.ta.SetWidth(msg.Width - 2) + m.ta.SetHeight(msg.Height - 2) + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "ctrl+s", "ctrl+d": + return m, tea.Quit + case "esc": + m.canceled = true + return m, tea.Quit + } + } + m.ta, cmd = m.ta.Update(msg) + return m, cmd +} + +func (m editModel) View() string { + help := dimSty.Render("ctrl+s save Β· esc cancel") + return strings.Join([]string{m.ta.View(), help}, "\n") +} diff --git a/internal/tui/loading.go b/internal/tui/loading.go new file mode 100644 index 0000000..acb6d29 --- /dev/null +++ b/internal/tui/loading.go @@ -0,0 +1,95 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/ashishxcode/commit-chronicle/internal/model" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// WorkFn does the gathering, reporting progress via the report callback. +type WorkFn func(report func(stage string, n int)) ([]model.Item, error) + +type statusMsg struct { + stage string + n int +} +type doneMsg struct { + items []model.Item + err error +} + +type loadingModel struct { + sp spinner.Model + label string + stages []string + total int + done bool + items []model.Item + err error +} + +// RunWithSpinner shows an animated spinner with live progress while work runs +// in the background, then returns its result. +func RunWithSpinner(label string, work WorkFn) ([]model.Item, error) { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(cBlue) + + m := loadingModel{sp: sp, label: label} + p := tea.NewProgram(m) + + go func() { + items, err := work(func(stage string, n int) { + p.Send(statusMsg{stage: stage, n: n}) + }) + p.Send(doneMsg{items: items, err: err}) + }() + + res, err := p.Run() + if err != nil { + return nil, err + } + fm := res.(loadingModel) + return fm.items, fm.err +} + +func (m loadingModel) Init() tea.Cmd { return m.sp.Tick } + +func (m loadingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case statusMsg: + if msg.n > 0 { + m.stages = append(m.stages, fmt.Sprintf("%s: %d", msg.stage, msg.n)) + m.total += msg.n + } + return m, nil + case doneMsg: + m.done = true + m.items = msg.items + m.err = msg.err + return m, tea.Quit + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.err = fmt.Errorf("canceled") + return m, tea.Quit + } + } + var cmd tea.Cmd + m.sp, cmd = m.sp.Update(msg) + return m, cmd +} + +func (m loadingModel) View() string { + if m.done { + return "" // cleared once the picker takes over + } + head := m.sp.View() + titleSty.Render(m.label) + if len(m.stages) == 0 { + return head + dimSty.Render(" …") + } + return head + "\n" + dimSty.Render(" "+strings.Join(m.stages, " Β· ")) +} diff --git a/internal/tui/picker.go b/internal/tui/picker.go new file mode 100644 index 0000000..3200d25 --- /dev/null +++ b/internal/tui/picker.go @@ -0,0 +1,298 @@ +// Package tui implements the interactive screens (range, picker, editor). +package tui + +import ( + "fmt" + "strings" + + "github.com/ashishxcode/commit-chronicle/internal/collect" + "github.com/ashishxcode/commit-chronicle/internal/model" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + cAccent = lipgloss.Color("13") + cDim = lipgloss.Color("245") + cSel = lipgloss.Color("10") + cBlue = lipgloss.Color("12") + titleSty = lipgloss.NewStyle().Bold(true).Foreground(cAccent) + dimSty = lipgloss.NewStyle().Foreground(cDim) + selSty = lipgloss.NewStyle().Foreground(cSel) + curSty = lipgloss.NewStyle().Bold(true).Foreground(cBlue) + boxSty = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cDim) + tagSty = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) +) + +type pickerModel struct { + items []model.Item + filtered []int + selected map[string]bool // keyed by Item.ID() + cursor int + top int + filter textinput.Model + filtering bool + preview viewport.Model + previewCache map[string]string + width int + height int + rangeLabel string + author string + canceled bool + ready bool +} + +// Pick runs the interactive multi-select picker over commits and PRs. +func Pick(items []model.Item, rangeLabel, author string) ([]model.Item, bool, error) { + ti := textinput.New() + ti.Placeholder = "type to filter…" + ti.Prompt = "filter ❯ " + + m := pickerModel{ + items: items, + selected: make(map[string]bool), + previewCache: make(map[string]string), + filter: ti, + rangeLabel: rangeLabel, + author: author, + } + m.applyFilter() + + res, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + return nil, false, err + } + fm := res.(pickerModel) + if fm.canceled { + return nil, true, nil + } + var out []model.Item + for _, it := range items { + if fm.selected[it.ID()] { + out = append(out, it) + } + } + return out, false, nil +} + +func (m pickerModel) Init() tea.Cmd { return nil } + +func (m *pickerModel) applyFilter() { + q := strings.ToLower(strings.TrimSpace(m.filter.Value())) + tokens := strings.Fields(q) + m.filtered = m.filtered[:0] + for i, it := range m.items { + hay := strings.ToLower(it.Tag() + " " + it.Date + " " + it.RepoName + " " + it.Ref() + " " + it.Title) + ok := true + for _, t := range tokens { + if !strings.Contains(hay, t) { + ok = false + break + } + } + if ok { + m.filtered = append(m.filtered, i) + } + } + if m.cursor >= len(m.filtered) { + m.cursor = max(0, len(m.filtered)-1) + } + m.clampScroll() +} + +func (m *pickerModel) listRows() int { + r := m.height - 5 + if r < 3 { + r = 3 + } + return r +} + +func (m *pickerModel) clampScroll() { + rows := m.listRows() + if m.cursor < m.top { + m.top = m.cursor + } + if m.cursor >= m.top+rows { + m.top = m.cursor - rows + 1 + } + if m.top < 0 { + m.top = 0 + } +} + +func (m *pickerModel) updatePreview() { + if len(m.filtered) == 0 { + m.preview.SetContent(dimSty.Render("no items match the filter")) + return + } + it := m.items[m.filtered[m.cursor]] + body, ok := m.previewCache[it.ID()] + if !ok { + body = collect.Preview(it) + m.previewCache[it.ID()] = body + } + m.preview.SetContent(body) + m.preview.GotoTop() +} + +func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + listW := m.width/2 - 2 + prevW := m.width - listW - 4 + if !m.ready { + m.preview = viewport.New(prevW, m.listRows()) + m.ready = true + } else { + m.preview.Width = prevW + m.preview.Height = m.listRows() + } + m.filter.Width = m.width - 12 + m.clampScroll() + m.updatePreview() + return m, nil + + case tea.KeyMsg: + if m.filtering { + switch msg.String() { + case "enter", "esc": + m.filtering = false + m.filter.Blur() + return m, nil + default: + var cmd tea.Cmd + m.filter, cmd = m.filter.Update(msg) + m.applyFilter() + m.updatePreview() + return m, cmd + } + } + + switch msg.String() { + case "ctrl+c", "q", "esc": + m.canceled = true + return m, tea.Quit + case "/": + m.filtering = true + m.filter.Focus() + return m, textinput.Blink + case "up", "k": + if m.cursor > 0 { + m.cursor-- + m.clampScroll() + m.updatePreview() + } + case "down", "j": + if m.cursor < len(m.filtered)-1 { + m.cursor++ + m.clampScroll() + m.updatePreview() + } + case "pgup": + m.preview.HalfViewUp() + case "pgdown": + m.preview.HalfViewDown() + case " ", "tab": + if len(m.filtered) > 0 { + id := m.items[m.filtered[m.cursor]].ID() + m.selected[id] = !m.selected[id] + if m.cursor < len(m.filtered)-1 { + m.cursor++ + m.clampScroll() + m.updatePreview() + } + } + case "a": + allSel := true + for _, idx := range m.filtered { + if !m.selected[m.items[idx].ID()] { + allSel = false + break + } + } + for _, idx := range m.filtered { + m.selected[m.items[idx].ID()] = !allSel + } + case "enter": + return m, tea.Quit + } + } + return m, nil +} + +func (m pickerModel) selCount() int { + n := 0 + for _, v := range m.selected { + if v { + n++ + } + } + return n +} + +func (m pickerModel) View() string { + if !m.ready { + return "loading…" + } + listW := m.width/2 - 2 + + header := titleSty.Render("πŸ““ commit-chronicle") + " " + + dimSty.Render(fmt.Sprintf("%s Β· %s Β· %d/%d shown Β· %d selected", + m.author, m.rangeLabel, len(m.filtered), len(m.items), m.selCount())) + + rows := m.listRows() + var lines []string + end := min(m.top+rows, len(m.filtered)) + for i := m.top; i < end; i++ { + it := m.items[m.filtered[i]] + box := "[ ]" + if m.selected[it.ID()] { + box = "[x]" + } + // Plain text first so truncation counts real characters. + line := fmt.Sprintf("%s %-7s %s %-16s %-8s %s", + box, it.Tag(), it.Date, truncate(it.RepoName, 16), it.Ref(), it.Title) + line = truncate(line, listW-3) + switch { + case i == m.cursor: + line = curSty.Render("❯ " + line) + case m.selected[it.ID()]: + line = selSty.Render(" " + line) + default: + line = " " + line + } + lines = append(lines, line) + } + for len(lines) < rows { + lines = append(lines, "") + } + listBox := boxSty.Width(listW).Height(rows).Render(strings.Join(lines, "\n")) + prevBox := boxSty.Width(m.preview.Width).Height(rows).Render(m.preview.View()) + body := lipgloss.JoinHorizontal(lipgloss.Top, listBox, prevBox) + + filterLine := dimSty.Render("filter: ") + m.filter.View() + if !m.filtering && m.filter.Value() == "" { + filterLine = dimSty.Render("press / to filter") + } + help := dimSty.Render("↑↓ move Β· space/tab select Β· a all Β· / filter Β· enter confirm Β· q cancel") + + return strings.Join([]string{header, filterLine, body, help}, "\n") +} + +func truncate(s string, n int) string { + if n <= 0 { + return "" + } + r := []rune(s) + if len(r) <= n { + return s + } + if n <= 1 { + return string(r[:n]) + } + return string(r[:n-1]) + "…" +}