
Sprint Retrospective
- 68 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
Sprint Retrospective is a Claude skill that mines git history and PR metadata to generate data-driven sprint retrospective reports with velocity, contributor and code-churn analytics.
About
Sprint Retrospective is a data-driven retrospective facilitator that mines git history, PR metadata and commit patterns to generate sprint retrospective reports. It computes velocity metrics (throughput, cycle time, lead time, deploy frequency), per-contributor insights (work sessions, focus areas, specialization), and code churn hotspots, then generates a full retro report. Engineering teams use it to run retrospectives grounded in real repository data and to compare one sprint against the previous one.
- Mines git history to produce data-driven sprint retrospective reports
- Computes velocity, cycle/lead time, contributor insights and code churn hotspots
- Detects work sessions and compares sprint-over-sprint with delta indicators
Sprint Retrospective by the numbers
- 68 all-time installs (skills.sh)
- Ranked #1,510 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
sprint-retrospective capabilities & compatibility
Free; runs local Python scripts against your git history, no API keys.
- Capabilities
- runbook generator
- Works with
- github
- Use cases
- project management · data analysis
- Pricing
- Free
What sprint-retrospective says it does
Data-driven sprint retrospectives with velocity analytics, contributor insights, code quality trends, and actionable improvement recommendations.
The agent acts as a data-driven retrospective facilitator that mines git history, PR metadata, and commit patterns to generate comprehensive sprint retrospective reports.
Gap threshold default: 45 minutes. Commits within the gap belong to the same session.
npx skills add https://github.com/borghei/claude-skills --skill sprint-retrospectiveAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 68 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Generate data-driven sprint retrospectives from git history: velocity, contributor insights and code churn.
Who is it for?
Engineering teams running retrospectives grounded in real velocity, contributor and code-health data.
When should I use this skill?
You need a data-driven sprint retrospective, velocity analysis, contributor insights or code churn hotspots from git history.
What you get
A comprehensive retrospective report with velocity, cycle/lead time, contributor patterns and churn hotspots, plus sprint-over-sprint deltas.
- Velocity analysis
- Contributor insights report
- Code churn/hotspot analysis
By the numbers
- 4 Python scripts (velocity, contributor, churn, report)
- 3 session types (deep work >50 min, focused 20-50 min, micro <20 min)
- default 45-minute session gap threshold
Files
Sprint Retrospective Expert
The agent acts as a data-driven retrospective facilitator that mines git history, PR metadata, and commit patterns to generate comprehensive sprint retrospective reports. It goes beyond simple commit counts — analyzing velocity trends, contributor work patterns, code health indicators, and team collaboration dynamics to surface actionable insights.
Keywords
sprint retrospective, velocity analytics, contributor insights, code churn, work sessions, cycle time, lead time, throughput, burndown, team health, collaboration metrics, bus factor, refactor ratio, hotspot analysis, conventional commits, session detection, deep work, improvement tracking
Quick Start
# 1. Sprint velocity analysis (last 14 days)
python scripts/velocity_analyzer.py --days 14 --format json > velocity.json
# 2. Contributor deep dive
python scripts/contributor_insights.py --days 14 --format json > contributors.json
# 3. Code churn analysis
python scripts/code_churn_analyzer.py --days 14 --format json > churn.json
# 4. Generate full retrospective report
python scripts/retro_report_generator.py \
--velocity velocity.json \
--contributors contributors.json \
--churn churn.json \
--sprint-name "Sprint 23" \
--output retro_sprint_23.md
# One-liner: full pipeline
python scripts/velocity_analyzer.py --days 14 -f json > /tmp/v.json && \
python scripts/contributor_insights.py --days 14 -f json > /tmp/c.json && \
python scripts/code_churn_analyzer.py --days 14 -f json > /tmp/ch.json && \
python scripts/retro_report_generator.py -v /tmp/v.json -c /tmp/c.json -u /tmp/ch.json -s "Sprint 23"Core Workflows
1. Sprint Velocity Analysis
Analyze throughput, cycle time, and delivery patterns across the sprint window.
# Default: last 7 days
python scripts/velocity_analyzer.py
# Custom range
python scripts/velocity_analyzer.py --since 2026-03-04 --until 2026-03-18
# Compare against previous period
python scripts/velocity_analyzer.py --days 14 --compare-previous
# JSON output for pipeline
python scripts/velocity_analyzer.py --days 14 --format jsonMetrics computed:
| Metric | Description |
|---|---|
| Total Commits | Raw commit count in window |
| LOC Added / Removed / Net | Lines of code delta |
| PRs Merged | Pull requests merged (via merge commit detection) |
| Avg PR Size | Average lines changed per PR |
| Throughput | Commits per day |
| Cycle Time | Avg time from first commit on branch to merge |
| Lead Time | Avg time from commit to production (main branch) |
| Deploy Frequency | Merges to main per day |
| Commit Type Breakdown | feat/fix/docs/refactor/test/chore distribution |
| Hourly Distribution | Commit activity by hour of day |
Session Detection:
The analyzer detects work sessions using configurable gap thresholds:
| Session Type | Duration | Interpretation |
|---|---|---|
| Deep Work | >50 min | Sustained focused coding |
| Focused | 20-50 min | Standard development sessions |
| Micro | <20 min | Quick fixes, reviews, hotfixes |
Gap threshold default: 45 minutes. Commits within the gap belong to the same session.
Trend Comparison:
When --compare-previous is enabled, the tool compares the current window against the immediately preceding window of equal length and computes deltas with directional indicators.
2. Contributor Deep Dive
Per-person analysis of contributions, work patterns, and specialization areas.
# All contributors, last 14 days
python scripts/contributor_insights.py --days 14
# Single contributor focus
python scripts/contributor_insights.py --days 14 --author "jane@example.com"
# Include collaboration metrics
python scripts/contributor_insights.py --days 14 --collaborationPer-contributor metrics:
- Commits, LOC added/removed, files touched
- Peak working hours (hourly heatmap)
- Session analysis (deep work ratio, session count)
- Focus areas by directory and file type
- Specialization detection: frontend / backend / infrastructure / docs / tests / data
- Consistency score (how evenly distributed are commits across the sprint)
- Collaboration: co-authored commits, cross-directory work
Specialization Detection Rules:
| Category | File Patterns |
|---|---|
| Frontend | *.tsx, *.jsx, *.vue, *.svelte, *.css, *.scss, *.html |
| Backend | *.py, *.go, *.rs, *.java, *.rb, *.php, *.cs |
| Infrastructure | Dockerfile, *.yml, *.yaml, terraform/*, k8s/*, .github/* |
| Documentation | *.md, *.rst, *.txt, docs/* |
| Tests | *test*, *spec*, __tests__/* |
| Data | *.sql, *.json, *.csv, migrations/* |
3. Code Quality Trends
Identify churn hotspots, refactoring candidates, and code health indicators.
# Churn analysis
python scripts/code_churn_analyzer.py --days 14
# Top 20 hotspots
python scripts/code_churn_analyzer.py --days 14 --top 20
# Filter by directory
python scripts/code_churn_analyzer.py --days 14 --path src/
# Detect oscillation (files changed back and forth)
python scripts/code_churn_analyzer.py --days 14 --detect-oscillationCode Health Indicators:
| Indicator | Calculation | Healthy Range |
|---|---|---|
| Churn Rate | Changes per file per day | <0.5 |
| Hotspot Concentration | % of changes in top 10% files | <40% |
| Test-to-Production Ratio | Test file changes / production file changes | >0.3 |
| Refactor Frequency | refactor commits / total commits | 10-25% |
| Oscillation Score | Files with >3 change-revert cycles | <5% of files |
| Directory Spread | Unique directories changed / total directories | Context-dependent |
Hotspot Analysis:
Files are ranked by a composite score: changes * unique_authors * recency_weight. High scores indicate files that are:
- Changed frequently (unstable or central)
- Touched by multiple people (potential conflict zone)
- Recently active (not historical noise)
4. Team Health Assessment
Evaluate collaboration patterns, review dynamics, and knowledge distribution.
# Team health from contributor data
python scripts/contributor_insights.py --days 14 --collaboration --format jsonCollaboration Metrics:
| Metric | Description | Target |
|---|---|---|
| Review Coverage | % of PRs with at least one review | >90% |
| Cross-team PRs | PRs touching multiple team areas | Healthy: 10-30% |
| Knowledge Distribution | Files touched by only 1 person | <30% (bus factor) |
| Review Turnaround | Avg time from PR open to first review | <4 hours |
| Co-authored Commits | Commits with Co-authored-by trailers | Context-dependent |
Bus Factor Analysis:
For each directory, the tool computes how many contributors have touched files. Directories with only 1 contributor are flagged as knowledge silos.
5. Improvement Tracking
Track action items from previous retrospectives and measure follow-through.
# Generate report with action item tracking
python scripts/retro_report_generator.py \
--velocity velocity.json \
--contributors contributors.json \
--churn churn.json \
--previous-retro retro_sprint_22.md \
--sprint-name "Sprint 23"
# Compare two sprints
python scripts/retro_report_generator.py \
--velocity velocity_current.json \
--contributors contributors_current.json \
--churn churn_current.json \
--previous-velocity velocity_previous.json \
--sprint-name "Sprint 23"The report generator extracts action items from previous retro reports (marked with - [ ] or - [x]) and includes a follow-through section showing completion status.
Tools
| Tool | Purpose | Key Flags |
|---|---|---|
velocity_analyzer.py | Sprint throughput, cycle time, sessions | --days, --since/--until, --compare-previous, --gap-minutes |
contributor_insights.py | Per-person metrics, specialization, patterns | --days, --author, --collaboration |
code_churn_analyzer.py | File hotspots, churn rate, oscillation | --days, --top, --path, --detect-oscillation |
retro_report_generator.py | Markdown report generation | --velocity, --contributors, --churn, --previous-retro |
All tools support:
--format text|json(default: text)--days Nfor time window (default: 7)--since YYYY-MM-DD --until YYYY-MM-DDfor custom ranges--repo /path/to/repoto analyze a specific repository (default: cwd)
Time Windows
| Window | Use Case |
|---|---|
| 7 days | Weekly retrospectives, iteration reviews |
| 14 days | Standard 2-week sprint retrospectives |
| 30 days | Monthly health checks, PI reviews |
| Custom range | Release retrospectives, incident post-mortems |
| Sprint comparison | Progress tracking between sprints |
Velocity Benchmarks
See references/velocity_benchmarks.md for industry benchmarks by team size, healthy velocity patterns, and when velocity metrics mislead.
Session Analysis Deep Dive
Session detection uses timestamp gaps between consecutive commits by the same author:
1. Sort commits by author and timestamp 2. If gap between consecutive commits > threshold (default 45min), start new session 3. Classify session by total duration 4. Compute per-author session profile
Why this matters: A team doing 90% micro-sessions may be context-switching too much. A healthy ratio is roughly 40% deep work, 40% focused, 20% micro.
Code Health Deep Dive
Churn analysis identifies:
- Hotspots: Files changed most frequently — candidates for refactoring or splitting
- Oscillation: Files where lines are added then removed repeatedly — signals unclear requirements or design churn
- Test Coverage Proxy: Ratio of test file changes to production file changes — declining ratio signals growing tech debt
- Refactor Signal: High proportion of
refactor:commits in a file indicates active improvement
Team Collaboration Deep Dive
Knowledge distribution analysis flags:
- Single-owner files: Only one person has ever modified them — bus factor risk
- High-contention files: Many authors, frequent changes — coordination overhead
- Cross-boundary work: Commits spanning multiple directories — integration work
State Persistence & Trend Tracking
Snapshot Storage
Save retro data after each sprint for historical comparison:
# Save sprint snapshot (auto-names by sprint)
python scripts/retro_report_generator.py -v velocity.json -c contributors.json -u churn.json \
-s "Sprint 23" --save .retro-history/
# Compare two sprints
python scripts/retro_report_generator.py -v velocity.json -c contributors.json -u churn.json \
-s "Sprint 24" --previous-velocity .retro-history/sprint-23-velocity.jsonStorage: .retro-history/{sprint-name}-{type}.json — velocity, contributors, churn snapshots per sprint.
Trend Analysis
After 3+ sprints, the report generator includes:
- Velocity trajectory — throughput and cycle time trending up, down, or stable
- Sprint-over-sprint deltas — percentage changes with directional indicators
- Streak tracking — consecutive sprints with improving velocity, shrinking cycle time, or growing test ratio
- Attention alerts — categories that degraded 3+ sprints in a row
Action Item Carry-Over
The report generator parses previous retro reports for unchecked action items:
- Scans
- [ ]checkboxes in prior.retro-history/markdown files - Carries forward incomplete items into the new report's "Outstanding Actions" section
- Tracks completion rate: "3 of 5 action items from Sprint 22 completed (60%)"
---
Narrative Generation Guidelines
A retrospective report is not a data dump — it tells the story of the sprint.
Structure
1. Tweetable summary (1 sentence) — the sprint in a tweet. Example: "Sprint 24 shipped 42 commits with +8K LOC, closing 3 epics — highest feat velocity this quarter, but cycle time crept up 15%." 2. Executive summary (2-3 sentences) — data-driven, actionable, no filler 3. Velocity dashboard — metrics table with deltas vs previous 4. Commit type distribution — ASCII bar chart 5. Work session analysis — deep/focused/micro breakdown with implications 6. Contributor spotlights — per-person metrics, specialization, peak hours 7. Code health indicators — churn rate, hotspots, test ratio, refactor frequency 8. Action items — 3-5 specific, measurable improvements for next sprint 9. Outstanding actions — carry-over from previous retros
Tone Guidelines
- Celebratory for wins — "Highest feature velocity in 4 sprints" not "Feature velocity increased"
- Constructive for improvements — "Cycle time crept up, suggesting review bottleneck" not "Cycle time is bad"
- Never blame-oriented — Frame around systems and processes, not individuals
- Data-dense — Every claim backed by a specific number. No "we shipped a lot" — say "42 commits, +8,247 LOC"
- Word count target — 1500-3000 words for a full report. Enough depth to be actionable, short enough to be read.
---
Integration Points
With Scrum Master Skill (project-management/scrum-master/)
# Use scrum-master capacity data alongside retro velocity
python scripts/velocity_analyzer.py --repo . --days 14 -f json > velocity.json
# Cross-reference with sprint capacity planning
python ../scrum-master/scripts/sprint_capacity_calculator.py team.jsonWith Senior PM Skill (project-management/senior-pm/)
# Feed retro insights into stakeholder reports
python scripts/retro_report_generator.py -v velocity.json -c contributors.json -u churn.json -s "Sprint 24" -o retro.md
# Reference in PM executive reporting via senior-pm stakeholder toolsWith Delivery Manager Skill (project-management/delivery-manager/)
# Retro metrics inform release planning
# Velocity trends help delivery managers forecast sprint capacity
python scripts/velocity_analyzer.py --days 30 --compare-previous -f jsonWith Agile Coach Skill (project-management/agile-coach/)
The agile coach uses retro trend data to identify systemic patterns:
- Declining deep work sessions → suggest focus time blocks
- Rising cycle time → investigate review process
- Low test ratio → recommend TDD adoption sprint
CI/CD Integration
# .github/workflows/sprint-retro.yml
name: Sprint Retrospective
on:
schedule:
- cron: '0 9 * * 5' # Every Friday at 9am
workflow_dispatch:
inputs:
days:
description: 'Sprint length in days'
default: '14'
jobs:
retrospective:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: |
python scripts/velocity_analyzer.py --days ${{ inputs.days || '14' }} -f json > velocity.json
python scripts/contributor_insights.py --days ${{ inputs.days || '14' }} -f json > contributors.json
python scripts/code_churn_analyzer.py --days ${{ inputs.days || '14' }} -f json > churn.json
python scripts/retro_report_generator.py -v velocity.json -c contributors.json -u churn.json -s "Sprint $(date +%V)"---
Retrospective Facilitation
See references/retrospective_facilitation.md for:
- 8 retrospective formats (Start/Stop/Continue, 4Ls, Sailboat, DAKI, etc.)
- Facilitation techniques for remote and in-person teams
- Anti-patterns to avoid (blame game, scope creep, no follow-through)
- Psychological safety frameworks
---
Output Examples
Velocity Dashboard
Sprint Velocity Report — Sprint 24 (Mar 4-18, 2026)
═══════════════════════════════════════════════════════
Throughput: 8.2 commits/day (↑ 12% vs prev)
LOC Net: +2,847 lines (↓ 5% vs prev)
PRs Merged: 14 (↑ 17% vs prev)
Avg PR Size: 203 lines (↓ 8% — smaller PRs!)
Cycle Time: 18.3 hours (↑ 2h — review bottleneck?)
Deploy Frequency: 1.0/day (stable)
Commit Types:
feat ████████████░░░░░░░░ 42%
fix ██████░░░░░░░░░░░░░░ 18%
docs ████░░░░░░░░░░░░░░░░ 14%
refactor ███░░░░░░░░░░░░░░░░░ 12%
test ██░░░░░░░░░░░░░░░░░░ 8%
chore ██░░░░░░░░░░░░░░░░░░ 6%Contributor Spotlight
Contributor: jane@example.com
Commits: 34 | LOC: +1,204 / -387 | Files: 28
Sessions: 12 (deep: 5, focused: 4, micro: 3)
Peak Hours: 10am-12pm, 2pm-4pm
Specialization: Backend (67%), Tests (22%), Docs (11%)
Consistency: 0.82 (highly consistent)Code Churn Analysis
File Hotspots (ranked by churn score)
─────────────────────────────────────────────────
File Chg Auth Score
───────────────────────────── ──── ──── ──────
src/api/routes.ts 12 3 11.2 ██████████
src/models/user.ts 8 2 7.4 ███████░░░
README.md 7 1 5.8 █████░░░░░
Refactoring Candidates:
src/api/routes.ts — high churn (0.8/day), 3 authors, consider splittingFull Report (generated by pipeline)
# Sprint Retrospective — Sprint 24
> Sprint 24 shipped 42 commits with +2.8K LOC across 14 PRs — strongest
> feature velocity this quarter, but cycle time crept up 15% suggesting
> a review bottleneck.
## Executive Summary
This sprint delivered 42 commits across 3 contributors, merging 14 PRs
with a net change of +2,847 lines. Feature work dominated at 42% of commits.
Cycle time increased to 18.3 hours (+2h vs Sprint 23), correlating with
larger average PR sizes in the auth migration epic.
## Velocity Dashboard
| Metric | Sprint 24 | Sprint 23 | Delta |
|-----------------|-----------|-----------|---------|
| Commits | 42 | 38 | +10.5% |
| LOC Net | +2,847 | +2,996 | -5.0% |
| PRs Merged | 14 | 12 | +16.7% |
| Cycle Time | 18.3h | 16.1h | +13.7% |
## Action Items
- [ ] Investigate review bottleneck — cycle time up 2h
- [ ] Split src/api/routes.ts — highest churn file (12 changes)
- [ ] Increase test ratio — currently 0.08, target 0.15---
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
| velocity_analyzer.py returns zero commits | Wrong date range, repo not fetched with full history, or branch filter excluding commits | Verify --since/--until dates; ensure git fetch --all was run; check --repo path points to a valid git repo |
| Session detection shows 100% micro sessions | Session gap threshold too low, or all commits are atomic (one-line changes) | Increase --gap-minutes from 45 to 60-90; micro-heavy patterns may genuinely indicate context-switching |
| Contributor specialization shows "other" for all files | File extensions not matching any SPECIALIZATION_PATTERNS category | Check if your codebase uses non-standard extensions; the tool classifies by extension and path patterns |
| Code churn hotspots dominated by generated files | Auto-generated files (lock files, builds, migrations) inflate churn scores | Use --path src/ to filter to source code, or add generated file patterns to .gitignore |
| Retro report generator produces empty sections | Input JSON files contain {} or data keys do not match expected schema | Verify velocity JSON has total_commits, loc, sessions keys; run individual tools first to confirm output |
| Previous action items not detected in carry-over | Action items in prior retro not formatted as - [ ] or - [x] markdown checkboxes | Ensure previous retro follows the standard checkbox format; the parser requires - [ ] prefix |
| Cycle time estimate is zero or unrealistically low | No merge commits found in the period, or all work merged via squash without branch history | Cycle time requires merge commits; squash-merge workflows lose branch-to-merge timing data |
Success Criteria
- Retrospective reports consistently generated within 5 minutes of sprint end using the 4-tool pipeline
- Velocity trends tracked over 3+ sprints with sprint-over-sprint delta comparison
- Code churn hotspots identified and addressed, reducing top-file churn rate below 0.5/day
- Test-to-production ratio maintained above 0.3 (healthy range)
- Deep work session ratio maintained above 30% of total sessions
- Action item completion rate from previous retros tracked and exceeds 60%
- Bus factor risks (single-owner directories) reduced sprint-over-sprint
Scope & Limitations
In Scope:
- Git history analysis for velocity, contributor, and code churn metrics
- Session detection using commit timestamp gap analysis
- Commit type classification via conventional commit prefix parsing
- Markdown report generation with executive summary, dashboards, and action item tracking
- Sprint-over-sprint comparison with directional deltas
- Bus factor and knowledge silo identification
Out of Scope:
- Sprint planning and capacity calculation (see
scrum-master/skill) - JSON-based sprint data analysis with planned vs. completed points (see
scrum-master/velocity_analyzer.py) - Product-level OKR tracking or roadmap management (see
execution/skills) - Code quality analysis beyond churn (no static analysis, no test coverage measurement)
- Jira/Linear ticket-level cycle time (this skill uses git merge commits as proxy)
Important Caveats:
- All metrics are derived from git history only. Teams using squash merges lose branch-level cycle time data.
- Session detection is a heuristic based on commit timestamps; it does not measure actual focused work time.
- The Scrum Guide 2020 de-emphasized velocity as a required artifact. This skill treats velocity as a diagnostic signal, not a performance target. Flow metrics (cycle time, throughput, WIP) are first-class citizens alongside traditional velocity measures.
- Retrospective facilitation formats (4Ls, Starfish, Sailboat, DAKI) rotate every 3-5 sprints to prevent staleness. See
references/retrospective_facilitation.mdfor format selection guidance.
Integration Points
| Integration | Direction | Description |
|---|---|---|
scrum-master/ | Complements | Git-based velocity supplements JSON-based sprint data analysis; cross-reference for fuller picture |
senior-pm/ | Feeds into | Retro velocity trends inform executive reporting and portfolio health dashboards |
delivery-manager/ | Feeds into | Velocity trends help delivery managers forecast sprint capacity and release timing |
agile-coach/ | Feeds into | Retro trend data identifies systemic patterns for coaching interventions |
execution/release-notes/ | Feeds into | Sprint commit data and type distribution inform release note generation |
| CI/CD Workflows | Automated | GitHub Actions workflow runs the 4-tool pipeline on a cron schedule (see CI/CD Integration section) |
.retro-history/ | Bidirectional | Save sprint snapshots for trend tracking; load previous snapshots for comparison |
Tool Reference
velocity_analyzer.py
Analyzes git history for sprint velocity metrics including throughput, cycle time, session detection, and commit type breakdown.
| Flag | Type | Default | Description |
|---|---|---|---|
--days | int | 7 | Number of days to analyze |
--since | string | (none) | Start date YYYY-MM-DD, overrides --days |
--until | string | today | End date YYYY-MM-DD |
--compare-previous | flag | off | Compare against preceding period of equal length |
--gap-minutes | int | 45 | Session gap threshold in minutes |
--repo | string | . | Path to git repository |
-f, --format | choice | text | Output format: text or json |
contributor_insights.py
Per-contributor analysis of commits, LOC, work patterns, specialization detection, and collaboration metrics.
| Flag | Type | Default | Description |
|---|---|---|---|
--days | int | 7 | Number of days to analyze |
--since | string | (none) | Start date YYYY-MM-DD |
--until | string | today | End date YYYY-MM-DD |
--author | string | (none) | Filter to specific author (partial match) |
--collaboration | flag | off | Include bus factor and knowledge silo metrics |
--gap-minutes | int | 45 | Session gap threshold in minutes |
--repo | string | . | Path to git repository |
-f, --format | choice | text | Output format: text or json |
code_churn_analyzer.py
Identifies file hotspots, calculates churn rates, detects oscillation patterns, and flags refactoring candidates.
| Flag | Type | Default | Description |
|---|---|---|---|
--days | int | 7 | Number of days to analyze |
--since | string | (none) | Start date YYYY-MM-DD |
--until | string | today | End date YYYY-MM-DD |
--top | int | 15 | Number of top hotspots to display |
--path | string | (none) | Filter to files under this path prefix |
--detect-oscillation | flag | off | Enable add/remove oscillation pattern detection |
--repo | string | . | Path to git repository |
-f, --format | choice | text | Output format: text or json |
retro_report_generator.py
Assembles a comprehensive markdown retrospective report from velocity, contributor, and churn analysis data.
| Flag | Type | Default | Description |
|---|---|---|---|
-v, --velocity | string | (required) | Path to velocity analysis JSON file |
-c, --contributors | string | (none) | Path to contributor insights JSON file |
-u, --churn | string | (none) | Path to code churn analysis JSON file |
-s, --sprint-name | string | Sprint | Sprint name for report title |
--previous-retro | string | (none) | Path to previous retro markdown for action item tracking |
--previous-velocity | string | (none) | Path to previous velocity JSON for comparison |
-o, --output | string | stdout | Output file path |
--save | string | (none) | Save sprint snapshot to directory (e.g., .retro-history/) |
---
Last Updated: 2026-03-18 Version: 2.0.0 Status: Production-ready — 4 Python tools, 2 reference guides, 2 asset templates
Sprint Retrospective — {{SPRINT_NAME}}
Period: {{START_DATE}} to {{END_DATE}} Facilitator: {{FACILITATOR}} Participants: {{PARTICIPANT_COUNT}} Generated: {{GENERATION_DATE}}
---
Executive Summary
{{EXECUTIVE_SUMMARY}}
Velocity Dashboard
| Metric | This Sprint | Previous | Delta |
|---|---|---|---|
| Commits | {{COMMITS}} | {{PREV_COMMITS}} | {{DELTA_COMMITS}} |
| LOC Net | {{LOC_NET}} | {{PREV_LOC_NET}} | {{DELTA_LOC_NET}} |
| PRs Merged | {{PRS_MERGED}} | {{PREV_PRS}} | {{DELTA_PRS}} |
| Avg PR Size | {{AVG_PR_SIZE}} | {{PREV_PR_SIZE}} | {{DELTA_PR_SIZE}} |
| Throughput | {{THROUGHPUT}}/day | {{PREV_THROUGHPUT}}/day | {{DELTA_THROUGHPUT}} |
| Cycle Time | {{CYCLE_TIME}} | {{PREV_CYCLE}} | {{DELTA_CYCLE}} |
Commit Type Distribution
{{COMMIT_TYPE_CHART}}
Hourly Activity
{{HOURLY_CHART}}
Contributor Spotlights
{{CONTRIBUTOR_SECTIONS}}
Code Health
Hotspots (Top 10)
| File | Changes | Authors | Churn Score |
|---|
{{HOTSPOT_ROWS}}
Health Indicators
| Indicator | Value | Status |
|---|---|---|
| Churn Rate | {{CHURN_RATE}} | {{CHURN_STATUS}} |
| Test-to-Prod Ratio | {{TEST_RATIO}} | {{TEST_STATUS}} |
| Refactor Frequency | {{REFACTOR_FREQ}} | {{REFACTOR_STATUS}} |
| Hotspot Concentration | {{HOTSPOT_CONC}} | {{HOTSPOT_STATUS}} |
Previous Action Items
{{PREVIOUS_ACTION_ITEMS}}
Discussion Notes
What Went Well
- {{WELL_1}}
- {{WELL_2}}
- {{WELL_3}}
What Could Improve
- {{IMPROVE_1}}
- {{IMPROVE_2}}
- {{IMPROVE_3}}
Key Decisions
- {{DECISION_1}}
- {{DECISION_2}}
Action Items
- [ ] {{ACTION_1}} — Owner: {{OWNER_1}} — Due: {{DUE_1}}
- [ ] {{ACTION_2}} — Owner: {{OWNER_2}} — Due: {{DUE_2}}
- [ ] {{ACTION_3}} — Owner: {{OWNER_3}} — Due: {{DUE_3}}
ROTI (Return on Time Invested)
Average: {{ROTI_SCORE}} / 5.0
---
Generated by sprint-retrospective skill v2.0.0
{
"sprint": {
"name": "Sprint 23",
"start_date": "2026-03-04",
"end_date": "2026-03-18",
"team_size": 5
},
"velocity": {
"total_commits": 115,
"loc_added": 4823,
"loc_removed": 1976,
"loc_net": 2847,
"prs_merged": 14,
"avg_pr_size_loc": 203,
"throughput_per_day": 8.2,
"cycle_time_hours": 18.3,
"lead_time_hours": 22.1,
"deploy_frequency_per_day": 1.0,
"commit_types": {
"feat": 48,
"fix": 21,
"docs": 16,
"refactor": 14,
"test": 9,
"chore": 7
},
"sessions": {
"deep_work": 18,
"focused": 24,
"micro": 15
}
},
"contributors": [
{
"email": "alice@example.com",
"commits": 34,
"loc_added": 1204,
"loc_removed": 387,
"files_touched": 28,
"peak_hours": [10, 11, 14, 15],
"specialization": {"backend": 0.67, "tests": 0.22, "docs": 0.11},
"sessions": {"deep_work": 5, "focused": 4, "micro": 3},
"consistency_score": 0.82
},
{
"email": "bob@example.com",
"commits": 28,
"loc_added": 1567,
"loc_removed": 823,
"files_touched": 35,
"peak_hours": [9, 10, 11, 13, 14],
"specialization": {"frontend": 0.55, "backend": 0.30, "infra": 0.15},
"sessions": {"deep_work": 6, "focused": 5, "micro": 2},
"consistency_score": 0.75
},
{
"email": "carol@example.com",
"commits": 25,
"loc_added": 987,
"loc_removed": 412,
"files_touched": 22,
"peak_hours": [11, 14, 15, 16],
"specialization": {"backend": 0.48, "data": 0.32, "tests": 0.20},
"sessions": {"deep_work": 4, "focused": 7, "micro": 3},
"consistency_score": 0.88
}
],
"churn": {
"hotspots": [
{"file": "src/api/handlers.py", "changes": 12, "authors": 3, "churn_score": 8.4},
{"file": "src/models/user.py", "changes": 8, "authors": 2, "churn_score": 5.2},
{"file": "src/ui/dashboard.tsx", "changes": 7, "authors": 2, "churn_score": 4.8}
],
"churn_rate": 0.34,
"test_to_prod_ratio": 0.38,
"refactor_frequency": 0.12,
"hotspot_concentration": 0.32
},
"previous_action_items": [
{"text": "Reduce average PR size below 250 LOC", "status": "done"},
{"text": "Add integration tests for auth module", "status": "in_progress"},
{"text": "Schedule weekly knowledge-sharing sessions", "status": "not_started"}
]
}
Retrospective Facilitation Guide
Expert knowledge base for running effective sprint retrospectives — formats, facilitation techniques, anti-patterns, and psychological safety frameworks.
Retrospective Formats
1. Start / Stop / Continue
Best for: Teams new to retros, quick sessions (<30 min)
| Column | Prompt |
|---|---|
| Start | What should we begin doing next sprint? |
| Stop | What should we stop doing? |
| Continue | What is working well and should continue? |
Facilitation tip: Time-box each column to 5 minutes of silent writing, then 5 minutes of discussion.
2. 4Ls (Liked, Learned, Lacked, Longed For)
Best for: Teams that want both emotional and practical reflection
- Liked: What went well? What did you enjoy?
- Learned: What new knowledge or skills did you gain?
- Lacked: What was missing? What resources or support were needed?
- Longed For: What do you wish had happened?
3. Sailboat
Best for: Visual thinkers, longer retros (45-60 min)
- Wind (propellers): What pushed us forward?
- Anchor (drag): What slowed us down?
- Rocks (risks): What risks lie ahead?
- Island (goal): Where are we trying to go?
- Sun (appreciation): What made this sprint bright?
4. DAKI (Drop, Add, Keep, Improve)
Best for: Action-oriented teams, process improvement focus
- Drop: What should we stop doing entirely?
- Add: What new practice should we adopt?
- Keep: What practices are working and should stay?
- Improve: What existing practices need adjustment?
5. Mad / Sad / Glad
Best for: Teams needing to surface emotions, after difficult sprints
- Mad: What frustrated you?
- Sad: What disappointed you?
- Glad: What made you happy?
Facilitation tip: This format works best when followed by a root-cause discussion on the top "Mad" items.
6. Starfish
Best for: Nuanced feedback, teams beyond basic Start/Stop/Continue
Five categories on a starfish diagram: 1. Keep Doing 2. More Of 3. Less Of 4. Stop Doing 5. Start Doing
7. Timeline Retrospective
Best for: Long sprints (3-4 weeks), sprints with significant events
Draw the sprint timeline on a board. Team members place events (positive and negative) along the timeline. Discuss patterns, clusters, and cause-effect relationships.
8. Lean Coffee Retrospective
Best for: Self-organizing teams, when facilitator wants minimal structure
1. Team members write topics on sticky notes (2 min) 2. Brief explanation of each topic (30 sec each) 3. Dot-vote to prioritize (2 votes per person) 4. Discuss top-voted topics (5 min each, extend by vote)
Facilitation Techniques
Setting the Stage (5 minutes)
- Check-in round: One word describing your sprint experience
- Prime directive: "Regardless of what we discover, we understand and truly believe that everyone did the best job they could, given what they knew at the time."
- Working agreements: Remind the team of retro ground rules
Gathering Data (15 minutes)
- Silent brainstorming first (prevents anchoring bias)
- Use timers to keep writing phases focused
- Encourage specific examples over vague observations
- Dot-voting to surface top themes (2-3 votes per person)
Generating Insights (15 minutes)
- "Five Whys" on top-voted items to find root causes
- Affinity mapping to group related observations
- Focus on systems and processes, never individuals
- Ask "What conditions led to this?" rather than "Who caused this?"
Deciding Actions (10 minutes)
- Limit to 2-3 action items per retro (more leads to dilution)
- Every action item needs an owner and a deadline
- Actions should be SMART: Specific, Measurable, Achievable, Relevant, Time-bound
- Review previous action items before creating new ones
Closing (5 minutes)
- Appreciation round: each person thanks one teammate
- Return on Time Invested (ROTI) vote: 1-5 scale
- Confirm action item owners and review date
Anti-Patterns to Avoid
1. The Blame Game
Symptom: Discussion devolves into finger-pointing at individuals. Fix: Enforce the prime directive. Redirect to systemic causes. Use "What conditions..." framing.
2. The Echo Chamber
Symptom: Same feedback every sprint, no new insights surface. Fix: Rotate retro formats. Invite guest facilitators. Use data (from velocity_analyzer.py) to surface non-obvious patterns.
3. No Follow-Through
Symptom: Action items from last retro are forgotten or incomplete. Fix: Start every retro by reviewing previous action items. Use retro_report_generator.py with --previous-retro to track completion.
4. The Monologue
Symptom: One person dominates discussion; others disengage. Fix: Use silent writing phases. Round-robin sharing. Anonymous submission tools.
5. Scope Creep
Symptom: Retro turns into a planning meeting or architecture discussion. Fix: Park off-topic items in a "parking lot." Strict time-boxing. Facilitator redirects.
6. Skipping the Retro
Symptom: Team cancels retros when "nothing went wrong" or they're "too busy." Fix: Retros are for amplifying what works, not just fixing problems. Schedule as recurring, non-negotiable.
7. Metrics Without Context
Symptom: Velocity numbers used to judge or pressure the team. Fix: Velocity is a planning tool, not a performance metric. Always pair numbers with qualitative discussion.
8. Superficial Action Items
Symptom: Actions like "communicate better" or "be more careful." Fix: Demand specificity. "Communicate better" becomes "Add a 5-minute async standup post in Slack by 10am daily."
Remote Retrospective Best Practices
Tooling
- Use collaborative boards (Miro, FigJam, Retrium) for visual exercises
- Video on during discussion phases for non-verbal cues
- Anonymous input options for sensitive topics
- Timer visible to all participants
Engagement Techniques
- Smaller breakout groups (3-4 people) for initial discussion, then regroup
- Asynchronous pre-work: gather observations before the meeting
- Use reactions/emojis for quick agreement signals
- Rotate facilitator role to maintain engagement
Time Adjustments
- Remote retros need 10-15% more time than in-person
- Add explicit transition moments between phases
- Build in 2-minute breaks for sessions over 45 minutes
- Use countdown timers for writing phases
Inclusion
- Record the session for absent team members (with consent)
- Share the retro report (via retro_report_generator.py) within 24 hours
- Allow async additions for 24 hours after the retro
- Accommodate time zones — rotate meeting times if needed
Psychological Safety in Retrospectives
The Foundation
Psychological safety (Edmondson, 1999) is the belief that one will not be punished or humiliated for speaking up with ideas, questions, concerns, or mistakes. It is the single strongest predictor of effective retrospectives.
Building Safety
1. Model vulnerability: Facilitator shares their own mistakes first 2. Normalize disagreement: "It's okay to see this differently" 3. Separate observation from judgment: Describe what happened before evaluating it 4. Celebrate learning from failure: "What did this teach us?" 5. Confidentiality agreement: What's said in retro stays in retro (except action items)
Safety Signals to Watch
| Signal | Healthy | Concerning |
|---|---|---|
| Participation | Everyone contributes | 1-2 people silent |
| Feedback type | Mix of positive and negative | Only positive or only negative |
| Specificity | Concrete examples shared | Vague, hedged statements |
| Risk-taking | Novel ideas proposed | Only "safe" suggestions |
| Body language | Relaxed, engaged | Crossed arms, cameras off |
When Safety Is Low
- Switch to anonymous input methods
- Use 1-on-1 pre-retro conversations to surface concerns
- Address the safety issue directly (meta-retro)
- Consider bringing in an external facilitator
- Start with appreciations to build positive atmosphere
Measuring Safety Over Time
Track these proxy metrics across retros:
- Number of unique contributors to discussion
- Ratio of improvement items to appreciation items
- Action item completion rate (indicates trust in the process)
- ROTI scores trend (indicates perceived value)
---
Last Updated: 2026-03-18
Velocity Benchmarks & Interpretation Guide
Industry benchmarks, healthy patterns, and guidance for interpreting sprint velocity metrics.
Benchmarks by Team Size
These benchmarks represent median ranges from industry surveys (DORA, State of DevOps, Accelerate). They are guidelines, not targets — every team's context is different.
Small Team (2-4 engineers)
| Metric | Healthy Range | Warning |
|---|---|---|
| Commits/day (team) | 4-12 | <2 or >20 (noise) |
| PRs merged/week | 5-15 | <3 (bottleneck) |
| Avg PR size (LOC) | 50-200 | >500 (too large) |
| Cycle time (commit→merge) | 2-8 hours | >24 hours |
| Deploy frequency | 1-5/week | <1/week |
| Deep work session ratio | 35-50% | <20% |
Medium Team (5-9 engineers)
| Metric | Healthy Range | Warning |
|---|---|---|
| Commits/day (team) | 8-25 | <5 or >40 |
| PRs merged/week | 10-30 | <5 |
| Avg PR size (LOC) | 50-250 | >400 |
| Cycle time (commit→merge) | 4-16 hours | >48 hours |
| Deploy frequency | 2-10/week | <1/week |
| Review turnaround | 1-4 hours | >8 hours |
Large Team (10+ engineers)
| Metric | Healthy Range | Warning |
|---|---|---|
| Commits/day (team) | 15-50 | <10 |
| PRs merged/week | 20-60 | <10 |
| Avg PR size (LOC) | 50-200 | >300 (review burden) |
| Cycle time (commit→merge) | 4-24 hours | >72 hours |
| Deploy frequency | 5-25/week | <2/week |
| Knowledge distribution (bus factor) | >2 per area | 1 (critical risk) |
Healthy vs Unhealthy Velocity Patterns
Healthy Patterns
1. Steady throughput with gradual improvement — Velocity stays within a 20% band sprint-to-sprint, trending slightly upward over quarters.
2. Balanced commit types — Mix of feat (30-50%), fix (15-25%), refactor (10-20%), test (5-15%), docs (5-10%). Indicates sustainable development.
3. Declining cycle time — PRs merge faster over time. Signals improving review culture and smaller PR sizes.
4. High deep work ratio — 35-50% of sessions are deep work (>50 min). Indicates focused, uninterrupted development time.
5. Consistent PR sizes — Average PR size stays below 300 LOC with low variance. Small PRs review faster and ship safer.
Unhealthy Patterns
1. Velocity spikes — Sudden 50%+ increases followed by crashes. Usually means crunch followed by recovery or technical debt payment.
2. Zero refactor commits — No refactor: type commits for 3+ sprints. Technical debt is accumulating silently.
3. Monotonically increasing velocity — Teams rarely get faster forever. If velocity only goes up, the definition of "done" may be weakening.
4. 90%+ feature commits — No time allocated for fixes, refactoring, or documentation. Unsustainable pace.
5. Growing cycle time — PRs taking longer to merge each sprint. Signals review bottleneck, unclear ownership, or too-large PRs.
6. Single-contributor dominance — One person responsible for 60%+ of commits. Bus factor = 1.
7. All micro-sessions — >70% of sessions are under 20 minutes. Indicates excessive context switching, meetings, or interruptions.
Sprint Predictability
Measuring Predictability
Coefficient of Variation (CV): Standard deviation of velocity divided by mean velocity across sprints.
| CV Range | Predictability | Interpretation |
|---|---|---|
| <15% | High | Reliable sprint planning possible |
| 15-25% | Moderate | Plan with buffer, forecasts useful |
| 25-40% | Low | Significant variability, wide confidence intervals |
| >40% | Very low | Planning is guesswork; investigate root causes |
Improving Predictability
- Break work into smaller stories (reduces per-item variance)
- Consistent sprint length (no variable-length sprints)
- Protect the sprint from scope injection
- Account for PTO, holidays, on-call rotations in capacity
- Track and reduce carryover items
Burndown / Burnup Interpretation
Burndown Warning Signs
- Flat line for 3+ days: Blocked work or underestimated stories
- Upward slope mid-sprint: Scope creep or discovered work
- Cliff at sprint end: Batch completion — stories not truly incremental
- Staircase pattern: Large stories completing all at once instead of daily progress
Burnup Advantages
Burnup charts show both scope and completion, making scope changes visible. Preferred for:
- Sprints where requirements evolve
- Stakeholder communication (shows added scope explicitly)
- Long-running releases spanning multiple sprints
When Velocity Metrics Mislead
Velocity Is NOT a Performance Metric
Velocity measures team throughput for planning purposes. Using it to evaluate individual or team performance creates perverse incentives:
- Inflated story points (Goodhart's Law)
- Avoiding refactoring and testing (reduces "velocity")
- Splitting work into trivial commits to inflate counts
Contexts Where Raw Velocity Misleads
1. New team or new domain — Velocity will be low while the team learns. This is expected. 2. Major refactoring sprint — Velocity may drop while improving long-term health. Track refactor commits separately. 3. Onboarding sprint — New team members reduce short-term velocity while increasing long-term capacity. 4. Infrastructure/tooling sprint — Investment sprints have lower feature velocity but enable future acceleration. 5. Post-incident sprint — Incident response and prevention work may not show up in standard metrics.
Better Combined Metrics
Instead of velocity alone, consider:
| Combined Metric | What It Shows |
|---|---|
| Velocity + Defect Rate | Are we shipping fast AND reliably? |
| Throughput + Cycle Time | Are we delivering more items faster? |
| Commit Count + Test Ratio | Are we building features with quality? |
| LOC + Churn Rate | Are we adding stable code? |
| Deploy Frequency + MTTR | How fast do we ship and recover? |
---
Last Updated: 2026-03-18
#!/usr/bin/env python3
"""
Code Churn Analyzer
Identifies file hotspots, calculates churn rates, detects code oscillation,
maps churn to directories/modules, and flags potential refactoring candidates.
Usage:
python code_churn_analyzer.py --days 14
python code_churn_analyzer.py --days 14 --top 20 --detect-oscillation
python code_churn_analyzer.py --days 14 --path src/ --format json
Standard library only.
"""
import argparse
import json
import subprocess
import sys
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any
# --- Git Helpers ---
def run_git(args: list[str], repo: str = ".") -> str:
cmd = ["git", "-C", repo] + args
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
return ""
return result.stdout.strip()
def parse_datetime(iso_str: str) -> datetime:
clean = iso_str.strip()
if len(clean) > 19 and (clean[-3] == ":" and (clean[-6] == "+" or clean[-6] == "-")):
clean = clean[:-3] + clean[-2:]
try:
return datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S%z")
except ValueError:
try:
return datetime.strptime(clean[:19], "%Y-%m-%dT%H:%M:%S")
except ValueError:
return datetime.now()
# --- Data Collection ---
def get_file_changes(since: str, until: str, repo: str = ".",
path_filter: str | None = None) -> list[dict]:
"""Get per-commit file-level change data."""
log = run_git([
"log", "--all", f"--since={since}", f"--until={until}",
"--pretty=format:COMMIT|%H|%ae|%aI|%s", "--numstat", "--no-merges"
], repo)
if not log:
return []
commits = []
current = None
for line in log.split("\n"):
line = line.strip()
if not line:
continue
if line.startswith("COMMIT|"):
if current:
commits.append(current)
parts = line.split("|", 4)
if len(parts) < 5:
current = None
continue
current = {
"hash": parts[1],
"author": parts[2],
"date": parts[3],
"subject": parts[4],
"files": [],
}
elif current and "\t" in line:
parts = line.split("\t")
if len(parts) >= 3:
filepath = parts[2]
if path_filter and not filepath.startswith(path_filter):
continue
try:
added = int(parts[0]) if parts[0] != "-" else 0
removed = int(parts[1]) if parts[1] != "-" else 0
except ValueError:
added = 0
removed = 0
current["files"].append({
"path": filepath,
"added": added,
"removed": removed,
})
if current:
commits.append(current)
return commits
# --- Hotspot Analysis ---
def compute_hotspots(commits: list[dict], total_days: int, top_n: int = 15) -> list[dict]:
"""Compute file hotspots ranked by composite churn score."""
file_data = defaultdict(lambda: {
"changes": 0,
"authors": set(),
"total_added": 0,
"total_removed": 0,
"last_changed": None,
"commit_dates": [],
})
for c in commits:
commit_dt = parse_datetime(c["date"])
for f in c.get("files", []):
path = f["path"]
fd = file_data[path]
fd["changes"] += 1
fd["authors"].add(c["author"])
fd["total_added"] += f["added"]
fd["total_removed"] += f["removed"]
fd["commit_dates"].append(commit_dt)
if fd["last_changed"] is None or commit_dt > fd["last_changed"]:
fd["last_changed"] = commit_dt
# Compute recency weight (more recent = higher weight)
now = datetime.now()
hotspots = []
for path, fd in file_data.items():
author_count = len(fd["authors"])
changes = fd["changes"]
# Recency: days since last change, mapped to 0-1
if fd["last_changed"]:
try:
last = fd["last_changed"].replace(tzinfo=None)
days_ago = (now - last).days
except (TypeError, AttributeError):
days_ago = total_days
else:
days_ago = total_days
recency = max(0, 1 - (days_ago / max(total_days, 1)))
# Composite score
churn_score = round(changes * (1 + 0.3 * (author_count - 1)) * (0.5 + 0.5 * recency), 1)
churn_rate = round(changes / max(total_days, 1), 2)
hotspots.append({
"file": path,
"changes": changes,
"authors": sorted(fd["authors"]),
"author_count": author_count,
"loc_added": fd["total_added"],
"loc_removed": fd["total_removed"],
"loc_churn": fd["total_added"] + fd["total_removed"],
"churn_rate": churn_rate,
"churn_score": churn_score,
})
hotspots.sort(key=lambda x: -x["churn_score"])
return hotspots[:top_n]
# --- Directory Churn ---
def compute_directory_churn(commits: list[dict]) -> list[dict]:
"""Aggregate churn by top-level directory."""
dir_data = defaultdict(lambda: {
"changes": 0, "files": set(), "authors": set(),
"loc_added": 0, "loc_removed": 0,
})
for c in commits:
for f in c.get("files", []):
path = f["path"]
parts = path.split("/")
directory = parts[0] if len(parts) > 1 else "."
dd = dir_data[directory]
dd["changes"] += 1
dd["files"].add(path)
dd["authors"].add(c["author"])
dd["loc_added"] += f["added"]
dd["loc_removed"] += f["removed"]
result = []
for directory, dd in dir_data.items():
result.append({
"directory": directory,
"changes": dd["changes"],
"unique_files": len(dd["files"]),
"authors": sorted(dd["authors"]),
"author_count": len(dd["authors"]),
"loc_added": dd["loc_added"],
"loc_removed": dd["loc_removed"],
})
result.sort(key=lambda x: -x["changes"])
return result
# --- Oscillation Detection ---
def detect_oscillation(commits: list[dict], threshold: int = 3) -> list[dict]:
"""Detect files with add-remove oscillation patterns.
Looks for files where lines are added then removed (or vice versa) in
alternating commits, indicating unclear requirements or design churn.
"""
# Track per-file add/remove patterns in chronological order
file_patterns = defaultdict(list)
# Sort commits by date
sorted_commits = sorted(commits, key=lambda c: c["date"])
for c in sorted_commits:
for f in c.get("files", []):
path = f["path"]
added = f["added"]
removed = f["removed"]
if added > removed:
file_patterns[path].append("add")
elif removed > added:
file_patterns[path].append("remove")
else:
file_patterns[path].append("neutral")
oscillating = []
for path, patterns in file_patterns.items():
if len(patterns) < threshold:
continue
# Count direction changes
changes = 0
for i in range(1, len(patterns)):
if patterns[i] != patterns[i - 1] and patterns[i] != "neutral" and patterns[i - 1] != "neutral":
changes += 1
if changes >= threshold - 1:
oscillating.append({
"file": path,
"total_changes": len(patterns),
"direction_changes": changes,
"pattern": patterns,
"oscillation_ratio": round(changes / max(len(patterns) - 1, 1), 2),
})
oscillating.sort(key=lambda x: -x["direction_changes"])
return oscillating
# --- Code Health Indicators ---
def classify_commit_type(subject: str) -> str:
s = subject.lower().strip()
prefixes = [
("feat", "feat"), ("fix", "fix"), ("docs", "docs"),
("refactor", "refactor"), ("test", "test"), ("chore", "chore"),
("style", "style"), ("perf", "perf"), ("ci", "ci"),
("build", "build"), ("revert", "revert"),
]
for prefix, label in prefixes:
if s.startswith(prefix + ":") or s.startswith(prefix + "("):
return label
return "other"
def is_test_file(path: str) -> bool:
p = path.lower()
test_indicators = ["test_", "_test.", ".test.", ".spec.", "_spec.", "__tests__/", "/test/", "/tests/", "/spec/"]
return any(ind in p for ind in test_indicators)
def compute_health_indicators(commits: list[dict], hotspots: list[dict],
total_days: int) -> dict:
"""Compute aggregate code health indicators."""
total_commits = len(commits)
# Commit type counts
type_counts = defaultdict(int)
for c in commits:
t = classify_commit_type(c["subject"])
type_counts[t] += 1
# Refactor frequency
refactor_freq = round(type_counts.get("refactor", 0) / max(total_commits, 1), 2)
# Test-to-production ratio
test_changes = 0
prod_changes = 0
all_files = set()
for c in commits:
for f in c.get("files", []):
all_files.add(f["path"])
total_loc = f["added"] + f["removed"]
if is_test_file(f["path"]):
test_changes += total_loc
else:
prod_changes += total_loc
test_ratio = round(test_changes / max(prod_changes, 1), 2)
# Hotspot concentration
total_file_changes = sum(1 for c in commits for _ in c.get("files", []))
if hotspots and total_file_changes > 0:
top_10_pct_count = max(1, len(all_files) // 10)
top_changes = sum(h["changes"] for h in hotspots[:top_10_pct_count])
hotspot_concentration = round(top_changes / total_file_changes, 2)
else:
hotspot_concentration = 0.0
# Overall churn rate
churn_rate = round(total_file_changes / max(total_days, 1) / max(len(all_files), 1), 2)
return {
"churn_rate": churn_rate,
"hotspot_concentration": hotspot_concentration,
"test_to_prod_ratio": test_ratio,
"refactor_frequency": refactor_freq,
"total_files_changed": len(all_files),
"total_commits": total_commits,
"commit_type_distribution": dict(sorted(type_counts.items(), key=lambda x: -x[1])),
}
# --- Refactoring Candidates ---
def identify_refactoring_candidates(hotspots: list[dict], health: dict) -> list[dict]:
"""Identify files that are strong candidates for refactoring."""
candidates = []
for h in hotspots:
reasons = []
if h["churn_rate"] > 0.5:
reasons.append(f"high churn rate ({h['churn_rate']}/day)")
if h["author_count"] >= 3:
reasons.append(f"touched by {h['author_count']} authors (coordination overhead)")
if h["loc_churn"] > 500:
reasons.append(f"high LOC churn ({h['loc_churn']} lines)")
if h["changes"] >= 8:
reasons.append(f"changed {h['changes']} times (instability)")
if len(reasons) >= 2:
candidates.append({
"file": h["file"],
"churn_score": h["churn_score"],
"reasons": reasons,
"recommendation": "Consider splitting, refactoring, or stabilizing this file.",
})
return candidates
# --- Main Analysis ---
def analyze_churn(since: str, until: str, repo: str,
path_filter: str | None = None,
top_n: int = 15,
oscillation: bool = False) -> dict:
"""Run full churn analysis."""
commits = get_file_changes(since, until, repo, path_filter)
try:
d_since = datetime.strptime(since, "%Y-%m-%d")
d_until = datetime.strptime(until, "%Y-%m-%d")
total_days = max((d_until - d_since).days, 1)
except ValueError:
total_days = 7
hotspots = compute_hotspots(commits, total_days, top_n)
dir_churn = compute_directory_churn(commits)
health = compute_health_indicators(commits, hotspots, total_days)
candidates = identify_refactoring_candidates(hotspots, health)
result = {
"period": {"since": since, "until": until, "days": total_days},
"hotspots": hotspots,
"directory_churn": dir_churn,
"health_indicators": health,
"refactoring_candidates": candidates,
}
if oscillation:
osc = detect_oscillation(commits)
result["oscillation"] = osc
# Add oscillation score to health
total_files = health["total_files_changed"]
osc_pct = round(len(osc) / max(total_files, 1) * 100, 1)
result["health_indicators"]["oscillation_pct"] = osc_pct
return result
# --- Text Formatting ---
def bar_chart(value: float, max_value: float, width: int = 20) -> str:
if max_value == 0:
return " " * width
filled = min(round((value / max_value) * width), width)
return "\u2588" * filled + "\u2591" * (width - filled)
def status_indicator(value: float, good_max: float, warn_max: float) -> str:
if value <= good_max:
return "OK"
elif value <= warn_max:
return "WARN"
else:
return "HIGH"
def format_text(data: dict) -> str:
p = data["period"]
lines = [
f"Code Churn Analysis ({p['since']} to {p['until']})",
"=" * 65,
"",
]
# Health indicators
h = data["health_indicators"]
lines.append("Health Indicators")
lines.append("-" * 40)
lines.append(f" Churn Rate: {h['churn_rate']:<8} [{status_indicator(h['churn_rate'], 0.3, 0.5)}]")
lines.append(f" Hotspot Concentration: {h['hotspot_concentration']:<8} [{status_indicator(h['hotspot_concentration'], 0.3, 0.5)}]")
lines.append(f" Test-to-Prod Ratio: {h['test_to_prod_ratio']:<8} [{'OK' if h['test_to_prod_ratio'] >= 0.3 else 'LOW'}]")
lines.append(f" Refactor Frequency: {h['refactor_frequency']:<8} [{'OK' if 0.08 <= h['refactor_frequency'] <= 0.3 else 'CHECK'}]")
if "oscillation_pct" in h:
lines.append(f" Oscillation: {h['oscillation_pct']}% [{status_indicator(h['oscillation_pct'], 3, 8)}]")
lines.append(f" Total Files Changed: {h['total_files_changed']}")
lines.append(f" Total Commits: {h['total_commits']}")
lines.append("")
# Hotspots
hotspots = data["hotspots"]
if hotspots:
max_score = hotspots[0]["churn_score"] if hotspots else 1
lines.append("File Hotspots (ranked by churn score)")
lines.append("-" * 65)
lines.append(f" {'File':<40} {'Chg':>4} {'Auth':>4} {'Score':>6}")
lines.append(f" {'':->40} {'':->4} {'':->4} {'':->6}")
for h in hotspots:
name = h["file"]
if len(name) > 38:
name = "..." + name[-35:]
chart = bar_chart(h["churn_score"], max_score, 10)
lines.append(f" {name:<40} {h['changes']:>4} {h['author_count']:>4} {h['churn_score']:>6} {chart}")
lines.append("")
# Directory churn
dir_churn = data["directory_churn"]
if dir_churn:
lines.append("Directory Churn")
lines.append("-" * 50)
max_dc = dir_churn[0]["changes"] if dir_churn else 1
for d in dir_churn[:10]:
chart = bar_chart(d["changes"], max_dc, 15)
lines.append(f" {d['directory'] + '/':<25} {d['changes']:>5} changes {d['unique_files']:>3} files {chart}")
lines.append("")
# Refactoring candidates
candidates = data.get("refactoring_candidates", [])
if candidates:
lines.append("Refactoring Candidates")
lines.append("-" * 50)
for c in candidates:
lines.append(f" {c['file']}")
for r in c["reasons"]:
lines.append(f" - {r}")
lines.append(f" >> {c['recommendation']}")
lines.append("")
# Oscillation
osc = data.get("oscillation", [])
if osc:
lines.append("Oscillating Files (add/remove churn)")
lines.append("-" * 50)
for o in osc[:10]:
lines.append(f" {o['file']}")
lines.append(f" Changes: {o['total_changes']}, Direction switches: {o['direction_changes']}, Ratio: {o['oscillation_ratio']}")
lines.append("")
return "\n".join(lines)
# --- CLI ---
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Code Churn Analyzer — hotspots, oscillation, and refactoring candidates"
)
parser.add_argument("--days", type=int, default=7,
help="Number of days to analyze (default: 7)")
parser.add_argument("--since", type=str, default=None,
help="Start date (YYYY-MM-DD)")
parser.add_argument("--until", type=str, default=None,
help="End date (YYYY-MM-DD)")
parser.add_argument("--top", type=int, default=15,
help="Number of top hotspots to show (default: 15)")
parser.add_argument("--path", type=str, default=None,
help="Filter to files under this path prefix")
parser.add_argument("--detect-oscillation", action="store_true",
help="Detect files with add/remove oscillation patterns")
parser.add_argument("--repo", type=str, default=".",
help="Path to git repository")
parser.add_argument("-f", "--format", choices=["text", "json"], default="text",
help="Output format (default: text)")
return parser.parse_args()
def main():
args = parse_args()
if args.until:
until_date = args.until
else:
until_date = datetime.now().strftime("%Y-%m-%d")
if args.since:
since_date = args.since
else:
d_until = datetime.strptime(until_date, "%Y-%m-%d")
d_since = d_until - timedelta(days=args.days)
since_date = d_since.strftime("%Y-%m-%d")
check = run_git(["rev-parse", "--is-inside-work-tree"], args.repo)
if check != "true":
print(f"Error: '{args.repo}' is not a git repository.", file=sys.stderr)
sys.exit(1)
data = analyze_churn(
since_date, until_date, args.repo,
path_filter=args.path,
top_n=args.top,
oscillation=args.detect_oscillation,
)
if args.format == "json":
print(json.dumps(data, indent=2, default=str))
else:
print(format_text(data))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Contributor Insights Analyzer
Per-contributor breakdown of commits, LOC, work patterns, specialization
detection, session analysis, and collaboration metrics from git history.
Usage:
python contributor_insights.py --days 14
python contributor_insights.py --days 14 --author "jane@example.com"
python contributor_insights.py --days 14 --collaboration --format json
Standard library only.
"""
import argparse
import json
import subprocess
import sys
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any
# --- Git Helpers ---
def run_git(args: list[str], repo: str = ".") -> str:
cmd = ["git", "-C", repo] + args
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
return ""
return result.stdout.strip()
def parse_datetime(iso_str: str) -> datetime:
clean = iso_str.strip()
if len(clean) > 19 and (clean[-3] == ":" and (clean[-6] == "+" or clean[-6] == "-")):
clean = clean[:-3] + clean[-2:]
try:
return datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S%z")
except ValueError:
try:
return datetime.strptime(clean[:19], "%Y-%m-%dT%H:%M:%S")
except ValueError:
return datetime.now()
# --- Data Collection ---
def get_commits_with_files(since: str, until: str, repo: str = ".") -> list[dict]:
"""Get commits with file-level numstat data."""
log = run_git([
"log", "--all", f"--since={since}", f"--until={until}",
"--pretty=format:COMMIT|%H|%ae|%aI|%s", "--numstat", "--no-merges"
], repo)
if not log:
return []
commits = []
current = None
for line in log.split("\n"):
line = line.strip()
if not line:
continue
if line.startswith("COMMIT|"):
if current:
commits.append(current)
parts = line.split("|", 4)
if len(parts) < 5:
current = None
continue
current = {
"hash": parts[1],
"author": parts[2],
"date": parts[3],
"subject": parts[4],
"files": [],
}
elif current and "\t" in line:
parts = line.split("\t")
if len(parts) >= 3:
try:
added = int(parts[0]) if parts[0] != "-" else 0
removed = int(parts[1]) if parts[1] != "-" else 0
except ValueError:
added = 0
removed = 0
current["files"].append({
"path": parts[2],
"added": added,
"removed": removed,
})
if current:
commits.append(current)
return commits
def get_coauthor_info(since: str, until: str, repo: str = ".") -> list[dict]:
"""Get commits with co-author trailers."""
log = run_git([
"log", "--all", f"--since={since}", f"--until={until}",
"--pretty=format:%H|%ae|%b", "--no-merges"
], repo)
if not log:
return []
coauthored = []
for line in log.split("\n"):
if "Co-authored-by" in line or "Co-Authored-By" in line:
parts = line.split("|", 2)
if len(parts) >= 3:
coauthored.append({
"hash": parts[0],
"author": parts[1],
"body": parts[2],
})
return coauthored
# --- Specialization Detection ---
SPECIALIZATION_PATTERNS = {
"frontend": {
"extensions": {".tsx", ".jsx", ".vue", ".svelte", ".css", ".scss", ".sass", ".less", ".html"},
"paths": {"src/ui", "src/components", "src/pages", "frontend/", "client/", "web/"},
},
"backend": {
"extensions": {".py", ".go", ".rs", ".java", ".rb", ".php", ".cs", ".scala", ".kt"},
"paths": {"src/api", "src/server", "backend/", "server/", "api/"},
},
"infrastructure": {
"extensions": {".yml", ".yaml", ".tf", ".hcl", ".toml"},
"paths": {"terraform/", "k8s/", "kubernetes/", ".github/", "infra/", "deploy/", "ci/"},
"filenames": {"Dockerfile", "docker-compose.yml", "Makefile", "Jenkinsfile"},
},
"documentation": {
"extensions": {".md", ".rst", ".txt", ".adoc"},
"paths": {"docs/", "documentation/", "wiki/"},
},
"tests": {
"extensions": set(),
"paths": {"test/", "tests/", "__tests__/", "spec/", "e2e/"},
"patterns": {"test_", "_test.", ".test.", ".spec.", "_spec."},
},
"data": {
"extensions": {".sql", ".json", ".csv", ".parquet"},
"paths": {"migrations/", "data/", "seeds/", "fixtures/"},
},
}
def detect_specialization(filepath: str) -> str:
"""Classify a file path into a specialization category."""
fp_lower = filepath.lower()
filename = filepath.split("/")[-1]
# Check tests first (pattern-based, overrides extension)
test_pats = SPECIALIZATION_PATTERNS["tests"]
for p in test_pats.get("paths", set()):
if p in fp_lower:
return "tests"
for pat in test_pats.get("patterns", set()):
if pat in fp_lower:
return "tests"
# Check other categories
for category, rules in SPECIALIZATION_PATTERNS.items():
if category == "tests":
continue
# Check filenames
for fn in rules.get("filenames", set()):
if filename == fn:
return category
# Check paths
for path in rules.get("paths", set()):
if path in fp_lower:
return category
# Check extensions
for ext in rules.get("extensions", set()):
if fp_lower.endswith(ext):
return category
return "other"
# --- Session Detection ---
def detect_sessions(timestamps: list[datetime], gap_minutes: int = 45) -> dict:
"""Detect work sessions from sorted timestamps."""
if not timestamps:
return {"deep_work": 0, "focused": 0, "micro": 0, "total": 0, "details": []}
timestamps.sort()
gap = timedelta(minutes=gap_minutes)
sessions = []
start = timestamps[0]
end = timestamps[0]
for i in range(1, len(timestamps)):
if timestamps[i] - end > gap:
sessions.append((start, end))
start = timestamps[i]
end = timestamps[i]
else:
end = timestamps[i]
sessions.append((start, end))
result = {"deep_work": 0, "focused": 0, "micro": 0, "total": len(sessions), "details": []}
for s, e in sessions:
dur = (e - s).total_seconds() / 60
if dur > 50:
stype = "deep_work"
elif dur >= 20:
stype = "focused"
else:
stype = "micro"
result[stype] += 1
result["details"].append({
"start": s.isoformat(),
"end": e.isoformat(),
"duration_min": round(dur, 1),
"type": stype,
})
return result
# --- Consistency Score ---
def compute_consistency(commit_dates: list[datetime], total_days: int) -> float:
"""Compute consistency score (0-1). Higher = more evenly distributed commits across days."""
if not commit_dates or total_days <= 0:
return 0.0
day_counts = defaultdict(int)
for dt in commit_dates:
day_key = dt.strftime("%Y-%m-%d")
day_counts[day_key] += 1
active_days = len(day_counts)
if total_days <= 1:
return 1.0 if active_days > 0 else 0.0
# Ratio of active days to total days, weighted by evenness
coverage = active_days / total_days
if active_days <= 1:
return coverage * 0.5
# Coefficient of variation of daily commit counts (lower = more even)
counts = list(day_counts.values())
mean_c = sum(counts) / len(counts)
variance = sum((c - mean_c) ** 2 for c in counts) / len(counts)
std_c = variance ** 0.5
cv = std_c / mean_c if mean_c > 0 else 0
evenness = max(0, 1 - cv) # CV of 0 = perfectly even
return round(coverage * 0.6 + evenness * 0.4, 2)
# --- Collaboration Metrics ---
def compute_collaboration(commits: list[dict]) -> dict:
"""Compute collaboration metrics from commit data."""
# Files touched by each author
author_files = defaultdict(set)
# Directories touched by each author
author_dirs = defaultdict(set)
# All files and their authors
file_authors = defaultdict(set)
for c in commits:
author = c["author"]
for f in c.get("files", []):
path = f["path"]
author_files[author].add(path)
file_authors[path].add(author)
parts = path.split("/")
if len(parts) > 1:
directory = "/".join(parts[:-1])
author_dirs[author].add(directory)
# Knowledge silos: files touched by only 1 person
total_files = len(file_authors)
single_owner = sum(1 for authors in file_authors.values() if len(authors) == 1)
silo_pct = round(single_owner / total_files * 100, 1) if total_files > 0 else 0
# Cross-directory work per author
cross_dir = {}
for author, dirs in author_dirs.items():
cross_dir[author] = len(dirs)
# Bus factor per directory
dir_authors = defaultdict(set)
for path, authors in file_authors.items():
parts = path.split("/")
if len(parts) > 1:
directory = "/".join(parts[:-1])
dir_authors[directory].update(authors)
bus_factor_risks = []
for directory, authors in dir_authors.items():
if len(authors) == 1:
bus_factor_risks.append({
"directory": directory,
"sole_author": list(authors)[0],
})
return {
"total_files_touched": total_files,
"single_owner_files": single_owner,
"single_owner_pct": silo_pct,
"bus_factor_risks": bus_factor_risks[:20], # Top 20
"author_directory_spread": cross_dir,
}
# --- Main Analysis ---
def analyze_contributors(since: str, until: str, repo: str,
author_filter: str | None = None,
collaboration: bool = False,
gap_minutes: int = 45) -> dict:
"""Run full contributor analysis."""
commits = get_commits_with_files(since, until, repo)
try:
d_since = datetime.strptime(since, "%Y-%m-%d")
d_until = datetime.strptime(until, "%Y-%m-%d")
total_days = max((d_until - d_since).days, 1)
except ValueError:
total_days = 7
# Group by author
by_author = defaultdict(list)
for c in commits:
by_author[c["author"]].append(c)
if author_filter:
filtered = {}
for author, clist in by_author.items():
if author_filter.lower() in author.lower():
filtered[author] = clist
by_author = filtered
contributors = []
for author, author_commits in sorted(by_author.items(), key=lambda x: -len(x[1])):
loc_added = 0
loc_removed = 0
files_touched = set()
hourly = defaultdict(int)
spec_counts = defaultdict(int)
timestamps = []
for c in author_commits:
dt = parse_datetime(c["date"])
timestamps.append(dt)
hourly[dt.hour] += 1
for f in c.get("files", []):
loc_added += f["added"]
loc_removed += f["removed"]
files_touched.add(f["path"])
cat = detect_specialization(f["path"])
spec_counts[cat] += 1
# Specialization percentages
total_spec = sum(spec_counts.values()) or 1
specialization = {
k: round(v / total_spec, 2)
for k, v in sorted(spec_counts.items(), key=lambda x: -x[1])
}
# Peak hours (top 4)
sorted_hours = sorted(hourly.items(), key=lambda x: -x[1])
peak_hours = [h for h, _ in sorted_hours[:4]]
# Sessions
sessions = detect_sessions(timestamps, gap_minutes)
# Consistency
consistency = compute_consistency(timestamps, total_days)
# Focus areas (top directories)
dir_counts = defaultdict(int)
for f_path in files_touched:
parts = f_path.split("/")
if len(parts) > 1:
dir_counts[parts[0]] += 1
else:
dir_counts["."] += 1
top_dirs = sorted(dir_counts.items(), key=lambda x: -x[1])[:5]
contributors.append({
"author": author,
"commits": len(author_commits),
"loc_added": loc_added,
"loc_removed": loc_removed,
"loc_net": loc_added - loc_removed,
"files_touched": len(files_touched),
"peak_hours": peak_hours,
"hourly_distribution": dict(sorted(hourly.items())),
"specialization": specialization,
"sessions": {
"deep_work": sessions["deep_work"],
"focused": sessions["focused"],
"micro": sessions["micro"],
"total": sessions["total"],
},
"consistency_score": consistency,
"top_directories": [{"dir": d, "files": c} for d, c in top_dirs],
})
result = {
"period": {"since": since, "until": until, "days": total_days},
"contributor_count": len(contributors),
"contributors": contributors,
}
if collaboration:
result["collaboration"] = compute_collaboration(commits)
return result
# --- Text Formatting ---
def bar_chart(value: int, max_value: int, width: int = 20) -> str:
if max_value == 0:
return " " * width
filled = min(round((value / max_value) * width), width)
return "\u2588" * filled + "\u2591" * (width - filled)
def format_text(data: dict) -> str:
p = data["period"]
lines = [
f"Contributor Insights ({p['since']} to {p['until']})",
"=" * 60,
f"Contributors: {data['contributor_count']}",
"",
]
for c in data["contributors"]:
lines.append("-" * 60)
lines.append(f"Contributor: {c['author']}")
lines.append(f" Commits: {c['commits']} | LOC: +{c['loc_added']:,} / -{c['loc_removed']:,} (net: {c['loc_net']:+,}) | Files: {c['files_touched']}")
# Sessions
s = c["sessions"]
total_s = s["total"] or 1
lines.append(f" Sessions: {s['total']} (deep: {s['deep_work']}, focused: {s['focused']}, micro: {s['micro']})")
# Peak hours
if c["peak_hours"]:
hours_str = ", ".join(f"{h}:00" for h in c["peak_hours"])
lines.append(f" Peak Hours: {hours_str}")
# Specialization
if c["specialization"]:
specs = [f"{k} ({v:.0%})" for k, v in list(c["specialization"].items())[:4]]
lines.append(f" Specialization: {', '.join(specs)}")
# Top directories
if c["top_directories"]:
dirs = [f"{d['dir']}/ ({d['files']})" for d in c["top_directories"][:3]]
lines.append(f" Top Directories: {', '.join(dirs)}")
# Consistency
lines.append(f" Consistency: {c['consistency_score']:.2f}")
# Hourly heatmap
hourly = c.get("hourly_distribution", {})
if hourly:
max_h = max(hourly.values()) if hourly else 1
lines.append(" Hourly Activity:")
for hour in range(24):
count = hourly.get(hour, 0)
if count > 0:
lines.append(f" {hour:02d}:00 {bar_chart(count, max_h, 15)} {count}")
lines.append("")
# Collaboration section
collab = data.get("collaboration")
if collab:
lines.append("=" * 60)
lines.append("Collaboration Metrics")
lines.append("-" * 40)
lines.append(f" Total Files Touched: {collab['total_files_touched']}")
lines.append(f" Single-Owner Files: {collab['single_owner_files']} ({collab['single_owner_pct']}%)")
if collab["bus_factor_risks"]:
lines.append(f" Bus Factor Risks ({len(collab['bus_factor_risks'])} directories):")
for risk in collab["bus_factor_risks"][:10]:
lines.append(f" {risk['directory']}/ — sole author: {risk['sole_author']}")
return "\n".join(lines)
# --- CLI ---
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Contributor Insights — per-person analysis from git history"
)
parser.add_argument("--days", type=int, default=7,
help="Number of days to analyze (default: 7)")
parser.add_argument("--since", type=str, default=None,
help="Start date (YYYY-MM-DD)")
parser.add_argument("--until", type=str, default=None,
help="End date (YYYY-MM-DD)")
parser.add_argument("--author", type=str, default=None,
help="Filter to a specific author (partial match)")
parser.add_argument("--collaboration", action="store_true",
help="Include collaboration and bus factor metrics")
parser.add_argument("--gap-minutes", type=int, default=45,
help="Session gap threshold in minutes (default: 45)")
parser.add_argument("--repo", type=str, default=".",
help="Path to git repository")
parser.add_argument("-f", "--format", choices=["text", "json"], default="text",
help="Output format (default: text)")
return parser.parse_args()
def main():
args = parse_args()
if args.until:
until_date = args.until
else:
until_date = datetime.now().strftime("%Y-%m-%d")
if args.since:
since_date = args.since
else:
d_until = datetime.strptime(until_date, "%Y-%m-%d")
d_since = d_until - timedelta(days=args.days)
since_date = d_since.strftime("%Y-%m-%d")
check = run_git(["rev-parse", "--is-inside-work-tree"], args.repo)
if check != "true":
print(f"Error: '{args.repo}' is not a git repository.", file=sys.stderr)
sys.exit(1)
data = analyze_contributors(
since_date, until_date, args.repo,
author_filter=args.author,
collaboration=args.collaboration,
gap_minutes=args.gap_minutes,
)
if args.format == "json":
print(json.dumps(data, indent=2, default=str))
else:
print(format_text(data))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Retrospective Report Generator
Takes velocity, contributor, and churn analysis data (as JSON files) and
generates a comprehensive markdown retrospective report with executive
summary, velocity dashboard, contributor spotlights, code health section,
sprint comparison, and action item tracking.
Usage:
python retro_report_generator.py \\
--velocity velocity.json \\
--contributors contributors.json \\
--churn churn.json \\
--sprint-name "Sprint 23"
python retro_report_generator.py \\
-v velocity.json -c contributors.json -u churn.json \\
--previous-retro retro_sprint_22.md \\
--previous-velocity velocity_prev.json \\
-s "Sprint 23" -o retro_sprint_23.md
Standard library only.
"""
import argparse
import json
import re
import sys
from datetime import datetime
from typing import Any
# --- Data Loading ---
def load_json(path: str) -> dict:
"""Load a JSON file and return the parsed data."""
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Warning: Could not load {path}: {e}", file=sys.stderr)
return {}
def load_text(path: str) -> str:
"""Load a text file."""
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return ""
# --- Previous Retro Parsing ---
def extract_action_items(retro_text: str) -> list[dict]:
"""Extract action items from a previous retrospective markdown file."""
items = []
for line in retro_text.split("\n"):
line = line.strip()
# Match markdown checkboxes: - [ ] or - [x]
match = re.match(r"^-\s*\[([ xX])\]\s*(.+)$", line)
if match:
checked = match.group(1).lower() == "x"
text = match.group(2).strip()
# Try to extract owner and due date
owner = ""
due = ""
owner_match = re.search(r"Owner:\s*(\S+)", text)
if owner_match:
owner = owner_match.group(1)
due_match = re.search(r"Due:\s*(\S+)", text)
if due_match:
due = due_match.group(1)
# Clean text
clean_text = re.sub(r"\s*[-—]\s*Owner:.*$", "", text).strip()
clean_text = re.sub(r"\s*[-—]\s*Due:.*$", "", clean_text).strip()
items.append({
"text": clean_text,
"done": checked,
"owner": owner,
"due": due,
})
return items
# --- Bar Charts ---
def text_bar(value: int, max_value: int, width: int = 20) -> str:
"""Generate a unicode bar chart segment."""
if max_value == 0:
return " " * width
filled = min(round((value / max_value) * width), width)
return "\u2588" * filled + "\u2591" * (width - filled)
def md_bar(value: int, max_value: int, width: int = 15) -> str:
"""Generate a markdown-friendly bar using block chars."""
if max_value == 0:
return ""
filled = min(round((value / max_value) * width), width)
return "`" + "\u2588" * filled + "\u2591" * (width - filled) + "`"
# --- Delta Formatting ---
def fmt_delta(current: float, previous: float) -> str:
"""Format a delta between two values."""
if previous == 0:
return "N/A"
pct = ((current - previous) / abs(previous)) * 100
if pct > 0:
return f"+{pct:.0f}%"
elif pct < 0:
return f"{pct:.0f}%"
return "0%"
def delta_emoji(current: float, previous: float, higher_is_better: bool = True) -> str:
"""Return a direction indicator (no emoji, text-based)."""
if previous == 0:
return ""
diff = current - previous
if abs(diff) < 0.01:
return "(stable)"
if higher_is_better:
return "(up)" if diff > 0 else "(down)"
else:
return "(down - good)" if diff < 0 else "(up - check)"
# --- Report Sections ---
def generate_header(sprint_name: str, velocity: dict) -> str:
"""Generate report header."""
# Extract period from velocity data
v = velocity.get("current", velocity)
period = v.get("period", {})
since = period.get("since", "unknown")
until = period.get("until", "unknown")
now = datetime.now().strftime("%Y-%m-%d %H:%M")
return f"""# Sprint Retrospective — {sprint_name}
**Period:** {since} to {until}
**Generated:** {now}
**Tool:** sprint-retrospective v2.0.0
---
"""
def generate_tweetable_summary(velocity: dict, churn: dict) -> str:
"""Generate a one-line tweetable summary of the sprint."""
v = velocity.get("current", velocity)
total_commits = v.get("total_commits", 0)
loc_net = v.get("loc", {}).get("net", 0)
prs = v.get("total_merges", 0)
# Find dominant commit type
types = v.get("commit_types", {})
dominant = max(types.items(), key=lambda x: x[1])[0] if types else "mixed"
# Churn health
health = churn.get("health_indicators", {})
test_ratio = health.get("test_to_prod_ratio", 0)
parts = [f"{total_commits} commits"]
if loc_net != 0:
parts.append(f"{loc_net:+,} LOC")
if prs > 0:
parts.append(f"{prs} PRs merged")
flavor = ""
if dominant == "feat":
flavor = "feature-heavy sprint"
elif dominant == "fix":
flavor = "bug-fix focused sprint"
elif dominant == "docs":
flavor = "documentation-heavy sprint"
elif dominant == "refactor":
flavor = "refactoring sprint"
else:
flavor = "balanced sprint"
if test_ratio < 0.1:
flavor += " with low test coverage"
summary = f"> {', '.join(parts)} — {flavor}."
return summary + "\n"
def generate_executive_summary(velocity: dict, contributors: dict, churn: dict) -> str:
"""Generate an executive summary from the data."""
v = velocity.get("current", velocity)
c_data = contributors
ch = churn
lines = ["## Executive Summary", ""]
# Key metrics
total_commits = v.get("total_commits", 0)
throughput = v.get("throughput_per_day", 0)
prs = v.get("total_merges", 0)
loc_net = v.get("loc", {}).get("net", 0)
authors = v.get("author_count", 0)
cycle_time = v.get("cycle_time_hours", 0)
lines.append(f"This sprint delivered **{total_commits} commits** across **{authors} contributors**, "
f"merging **{prs} PRs** with a net change of **{loc_net:+,} lines**. "
f"Throughput was **{throughput} commits/day** with an average cycle time of **{cycle_time} hours**.")
lines.append("")
# Sessions summary
sessions = v.get("sessions", {})
total_sessions = sessions.get("total", 0) or 1
deep_pct = round(sessions.get("deep_work", 0) / total_sessions * 100)
lines.append(f"Work pattern analysis: **{deep_pct}% deep work sessions**, "
f"{sessions.get('focused', 0)} focused sessions, "
f"{sessions.get('micro', 0)} micro sessions.")
lines.append("")
# Code health summary
health = ch.get("health_indicators", {})
churn_rate = health.get("churn_rate", 0)
test_ratio = health.get("test_to_prod_ratio", 0)
refactor_freq = health.get("refactor_frequency", 0)
health_notes = []
if churn_rate > 0.5:
health_notes.append("elevated churn rate")
if test_ratio < 0.3:
health_notes.append("low test-to-production ratio")
if refactor_freq < 0.08:
health_notes.append("low refactoring activity")
if refactor_freq > 0.3:
health_notes.append("high refactoring activity")
if health_notes:
lines.append(f"**Attention areas:** {', '.join(health_notes)}.")
else:
lines.append("**Code health indicators are within healthy ranges.**")
lines.append("")
# Comparison if available
comparison = velocity.get("comparison")
if comparison:
lines.append("### Sprint-over-Sprint Comparison")
lines.append("")
lines.append("| Metric | Current | Previous | Delta |")
lines.append("|--------|---------|----------|-------|")
for label, vals in comparison.items():
lines.append(f"| {label} | {vals['current']} | {vals['previous']} | {vals['delta']} |")
lines.append("")
return "\n".join(lines)
def generate_velocity_dashboard(velocity: dict) -> str:
"""Generate velocity metrics dashboard."""
v = velocity.get("current", velocity)
lines = ["## Velocity Dashboard", ""]
# Core metrics table
loc = v.get("loc", {})
lines.append("| Metric | Value |")
lines.append("|--------|-------|")
lines.append(f"| Total Commits | {v.get('total_commits', 0)} |")
lines.append(f"| LOC Added | +{loc.get('added', 0):,} |")
lines.append(f"| LOC Removed | -{loc.get('removed', 0):,} |")
lines.append(f"| LOC Net | {loc.get('net', 0):+,} |")
lines.append(f"| PRs Merged | {v.get('total_merges', 0)} |")
lines.append(f"| Avg PR Size | {v.get('avg_pr_size_loc', 0)} LOC |")
lines.append(f"| Throughput | {v.get('throughput_per_day', 0)} commits/day |")
lines.append(f"| Cycle Time | {v.get('cycle_time_hours', 0)} hours |")
lines.append(f"| Deploy Frequency | {v.get('deploy_frequency_per_day', 0)}/day |")
lines.append(f"| Unique Authors | {v.get('author_count', 0)} |")
lines.append("")
# Commit type breakdown
ct = v.get("commit_types", {})
if ct:
total_ct = sum(ct.values()) or 1
max_ct = max(ct.values()) if ct else 1
lines.append("### Commit Type Distribution")
lines.append("")
lines.append("```")
for ctype, count in sorted(ct.items(), key=lambda x: -x[1]):
pct = round(count / total_ct * 100)
bar = text_bar(count, max_ct, 20)
lines.append(f" {ctype:<12} {bar} {pct:>3}% ({count})")
lines.append("```")
lines.append("")
# Hourly distribution
hourly = v.get("hourly_distribution", {})
if hourly:
# Convert string keys to int if needed
hourly_int = {int(k): v for k, v in hourly.items()}
max_h = max(hourly_int.values()) if hourly_int else 1
lines.append("### Hourly Activity")
lines.append("")
lines.append("```")
for hour in range(24):
count = hourly_int.get(hour, 0)
if count > 0:
bar = text_bar(count, max_h, 25)
lines.append(f" {hour:02d}:00 {bar} {count}")
lines.append("```")
lines.append("")
# Session breakdown
s = v.get("sessions", {})
if s:
total_s = s.get("total", 0) or 1
lines.append("### Work Sessions")
lines.append("")
lines.append("| Session Type | Count | Percentage |")
lines.append("|-------------|-------|------------|")
lines.append(f"| Deep Work (>50min) | {s.get('deep_work', 0)} | {round(s.get('deep_work', 0)/total_s*100)}% |")
lines.append(f"| Focused (20-50min) | {s.get('focused', 0)} | {round(s.get('focused', 0)/total_s*100)}% |")
lines.append(f"| Micro (<20min) | {s.get('micro', 0)} | {round(s.get('micro', 0)/total_s*100)}% |")
lines.append(f"| **Total** | **{s.get('total', 0)}** | **100%** |")
lines.append("")
return "\n".join(lines)
def generate_contributor_spotlights(contributors: dict) -> str:
"""Generate per-contributor spotlight sections."""
lines = ["## Contributor Spotlights", ""]
contribs = contributors.get("contributors", [])
if not contribs:
lines.append("No contributor data available.")
return "\n".join(lines)
for c in contribs:
lines.append(f"### {c['author']}")
lines.append("")
lines.append(f"- **Commits:** {c['commits']} | **LOC:** +{c['loc_added']:,} / -{c['loc_removed']:,} (net: {c.get('loc_net', c['loc_added'] - c['loc_removed']):+,}) | **Files:** {c['files_touched']}")
# Sessions
s = c.get("sessions", {})
if s:
lines.append(f"- **Sessions:** {s.get('total', 0)} (deep: {s.get('deep_work', 0)}, focused: {s.get('focused', 0)}, micro: {s.get('micro', 0)})")
# Peak hours
ph = c.get("peak_hours", [])
if ph:
hours_str = ", ".join(f"{h}:00" for h in ph)
lines.append(f"- **Peak Hours:** {hours_str}")
# Specialization
spec = c.get("specialization", {})
if spec:
specs = [f"{k} ({v:.0%})" for k, v in list(spec.items())[:4]]
lines.append(f"- **Specialization:** {', '.join(specs)}")
# Top directories
dirs = c.get("top_directories", [])
if dirs:
dir_str = ", ".join(f"`{d['dir']}/` ({d['files']})" for d in dirs[:3])
lines.append(f"- **Top Directories:** {dir_str}")
# Consistency
lines.append(f"- **Consistency Score:** {c.get('consistency_score', 0):.2f}")
lines.append("")
# Collaboration
collab = contributors.get("collaboration")
if collab:
lines.append("### Collaboration Metrics")
lines.append("")
lines.append(f"- **Total Files Touched:** {collab['total_files_touched']}")
lines.append(f"- **Single-Owner Files:** {collab['single_owner_files']} ({collab['single_owner_pct']}%)")
risks = collab.get("bus_factor_risks", [])
if risks:
lines.append(f"- **Bus Factor Risks:** {len(risks)} directories with single owner")
lines.append("")
lines.append("| Directory | Sole Author |")
lines.append("|-----------|-------------|")
for r in risks[:10]:
lines.append(f"| `{r['directory']}/` | {r['sole_author']} |")
lines.append("")
return "\n".join(lines)
def generate_code_health(churn: dict) -> str:
"""Generate code health section from churn data."""
lines = ["## Code Health", ""]
health = churn.get("health_indicators", {})
if health:
lines.append("### Health Indicators")
lines.append("")
lines.append("| Indicator | Value | Status |")
lines.append("|-----------|-------|--------|")
cr = health.get("churn_rate", 0)
cr_status = "OK" if cr <= 0.3 else ("WARN" if cr <= 0.5 else "HIGH")
lines.append(f"| Churn Rate | {cr} | {cr_status} |")
hc = health.get("hotspot_concentration", 0)
hc_status = "OK" if hc <= 0.3 else ("WARN" if hc <= 0.5 else "HIGH")
lines.append(f"| Hotspot Concentration | {hc} | {hc_status} |")
tr = health.get("test_to_prod_ratio", 0)
tr_status = "OK" if tr >= 0.3 else "LOW"
lines.append(f"| Test-to-Prod Ratio | {tr} | {tr_status} |")
rf = health.get("refactor_frequency", 0)
rf_status = "OK" if 0.08 <= rf <= 0.3 else "CHECK"
lines.append(f"| Refactor Frequency | {rf} | {rf_status} |")
if "oscillation_pct" in health:
op = health["oscillation_pct"]
op_status = "OK" if op <= 3 else ("WARN" if op <= 8 else "HIGH")
lines.append(f"| Oscillation | {op}% | {op_status} |")
lines.append("")
# Hotspots table
hotspots = churn.get("hotspots", [])
if hotspots:
lines.append("### File Hotspots")
lines.append("")
lines.append("| Rank | File | Changes | Authors | Churn Score |")
lines.append("|------|------|---------|---------|-------------|")
for i, h in enumerate(hotspots[:15], 1):
name = h["file"]
if len(name) > 45:
name = "..." + name[-42:]
lines.append(f"| {i} | `{name}` | {h['changes']} | {h['author_count']} | {h['churn_score']} |")
lines.append("")
# Directory churn
dir_churn = churn.get("directory_churn", [])
if dir_churn:
lines.append("### Directory Activity")
lines.append("")
lines.append("| Directory | Changes | Files | Authors |")
lines.append("|-----------|---------|-------|---------|")
for d in dir_churn[:10]:
lines.append(f"| `{d['directory']}/` | {d['changes']} | {d['unique_files']} | {d['author_count']} |")
lines.append("")
# Refactoring candidates
candidates = churn.get("refactoring_candidates", [])
if candidates:
lines.append("### Refactoring Candidates")
lines.append("")
for c in candidates:
lines.append(f"**`{c['file']}`** (score: {c['churn_score']})")
for r in c["reasons"]:
lines.append(f"- {r}")
lines.append(f"- *{c['recommendation']}*")
lines.append("")
# Oscillation
osc = churn.get("oscillation", [])
if osc:
lines.append("### Oscillating Files")
lines.append("")
lines.append("Files with repeated add/remove cycles, indicating possible requirement churn:")
lines.append("")
lines.append("| File | Changes | Direction Switches | Oscillation Ratio |")
lines.append("|------|---------|-------------------|-------------------|")
for o in osc[:10]:
lines.append(f"| `{o['file']}` | {o['total_changes']} | {o['direction_changes']} | {o['oscillation_ratio']} |")
lines.append("")
return "\n".join(lines)
def generate_previous_items_section(items: list[dict]) -> str:
"""Generate section tracking previous action items."""
if not items:
return ""
lines = ["## Previous Action Items", ""]
done_count = sum(1 for i in items if i["done"])
total = len(items)
completion = round(done_count / total * 100) if total > 0 else 0
lines.append(f"**Completion Rate:** {done_count}/{total} ({completion}%)")
lines.append("")
for item in items:
check = "x" if item["done"] else " "
text = item["text"]
if item["owner"]:
text += f" -- Owner: {item['owner']}"
if item["due"]:
text += f" -- Due: {item['due']}"
lines.append(f"- [{check}] {text}")
lines.append("")
return "\n".join(lines)
def generate_action_items_section() -> str:
"""Generate empty action items section for facilitator to fill."""
return """## New Action Items
*Fill in during the retrospective session.*
- [ ] (action item 1) -- Owner: TBD -- Due: TBD
- [ ] (action item 2) -- Owner: TBD -- Due: TBD
- [ ] (action item 3) -- Owner: TBD -- Due: TBD
"""
def generate_discussion_section() -> str:
"""Generate discussion notes section for facilitator to fill."""
return """## Discussion Notes
### What Went Well
- (to be filled during retro)
### What Could Improve
- (to be filled during retro)
### Key Decisions
- (to be filled during retro)
"""
def generate_footer() -> str:
return """---
*Generated by sprint-retrospective skill v2.0.0*
"""
# --- Main Report Assembly ---
def generate_report(velocity: dict, contributors: dict, churn: dict,
sprint_name: str, previous_retro_text: str = "",
previous_velocity: dict | None = None) -> str:
"""Assemble the full retrospective report."""
sections = []
# If velocity has comparison data, use it directly
# If previous_velocity provided, compute comparison
if previous_velocity and "comparison" not in velocity:
v_current = velocity.get("current", velocity)
v_prev = previous_velocity.get("current", previous_velocity)
comparison = {}
keys = [
("total_commits", "Commits"),
("total_merges", "PRs Merged"),
("throughput_per_day", "Throughput"),
("cycle_time_hours", "Cycle Time"),
("deploy_frequency_per_day", "Deploy Frequency"),
("avg_pr_size_loc", "Avg PR Size"),
]
for key, label in keys:
cur = v_current.get(key, 0)
prev = v_prev.get(key, 0)
comparison[label] = {
"current": cur,
"previous": prev,
"delta": fmt_delta(cur, prev),
}
velocity = {"current": v_current, "previous": v_prev, "comparison": comparison}
sections.append(generate_header(sprint_name, velocity))
# Tweetable summary
sections.append(generate_tweetable_summary(velocity, churn))
sections.append(generate_executive_summary(velocity, contributors, churn))
sections.append(generate_velocity_dashboard(velocity))
sections.append(generate_contributor_spotlights(contributors))
sections.append(generate_code_health(churn))
# Previous action items
if previous_retro_text:
items = extract_action_items(previous_retro_text)
if items:
sections.append(generate_previous_items_section(items))
sections.append(generate_discussion_section())
sections.append(generate_action_items_section())
sections.append(generate_footer())
return "\n".join(sections)
# --- CLI ---
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Retrospective Report Generator — comprehensive markdown reports"
)
parser.add_argument("-v", "--velocity", type=str, required=True,
help="Path to velocity analysis JSON file")
parser.add_argument("-c", "--contributors", type=str, default=None,
help="Path to contributor insights JSON file")
parser.add_argument("-u", "--churn", type=str, default=None,
help="Path to code churn analysis JSON file")
parser.add_argument("-s", "--sprint-name", type=str, default="Sprint",
help="Sprint name for the report title")
parser.add_argument("--previous-retro", type=str, default=None,
help="Path to previous retrospective markdown (for action item tracking)")
parser.add_argument("--previous-velocity", type=str, default=None,
help="Path to previous period velocity JSON (for comparison)")
parser.add_argument("-o", "--output", type=str, default=None,
help="Output file path (default: stdout)")
parser.add_argument("--save", type=str, default=None,
help="Save sprint snapshot to directory (e.g., .retro-history/)")
return parser.parse_args()
def main():
args = parse_args()
velocity = load_json(args.velocity)
contributors = load_json(args.contributors) if args.contributors else {}
churn = load_json(args.churn) if args.churn else {}
previous_retro_text = ""
if args.previous_retro:
previous_retro_text = load_text(args.previous_retro)
previous_velocity = None
if args.previous_velocity:
previous_velocity = load_json(args.previous_velocity)
report = generate_report(
velocity, contributors, churn,
sprint_name=args.sprint_name,
previous_retro_text=previous_retro_text,
previous_velocity=previous_velocity,
)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(report)
print(f"Report written to {args.output}", file=sys.stderr)
else:
print(report)
# Word count
word_count = len(report.split())
print(f"\n[Word count: {word_count}]", file=sys.stderr)
# Save snapshot if requested
if args.save:
from pathlib import Path
save_dir = Path(args.save)
save_dir.mkdir(parents=True, exist_ok=True)
slug = args.sprint_name.lower().replace(" ", "-")
# Save velocity, contributors, churn snapshots
for name, data in [("velocity", velocity), ("contributors", contributors), ("churn", churn)]:
path = save_dir / f"{slug}-{name}.json"
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
# Save report markdown
report_path = save_dir / f"{slug}-report.md"
with open(report_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"Sprint snapshot saved to {save_dir}/{slug}-*.json", file=sys.stderr)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Sprint Velocity Analyzer
Analyzes git history to compute sprint velocity metrics including throughput,
cycle time, session detection, commit type breakdown, and hourly distribution.
Supports multiple time windows and trend comparison against previous periods.
Usage:
python velocity_analyzer.py --days 14
python velocity_analyzer.py --since 2026-03-04 --until 2026-03-18
python velocity_analyzer.py --days 14 --compare-previous --format json
Standard library only. Uses subprocess for git commands.
"""
import argparse
import json
import re
import subprocess
import sys
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any
# --- Git Data Collection ---
def run_git(args: list[str], repo: str = ".") -> str:
"""Run a git command and return stdout."""
cmd = ["git", "-C", repo] + args
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
return ""
return result.stdout.strip()
def get_commits(since: str, until: str, repo: str = ".") -> list[dict]:
"""Retrieve commits within a date range."""
fmt = "%H|%ae|%aI|%s"
log = run_git([
"log", "--all", f"--since={since}", f"--until={until}",
f"--pretty=format:{fmt}", "--no-merges"
], repo)
if not log:
return []
commits = []
for line in log.split("\n"):
parts = line.split("|", 3)
if len(parts) < 4:
continue
commits.append({
"hash": parts[0],
"author": parts[1],
"date": parts[2],
"subject": parts[3],
})
return commits
def get_merge_commits(since: str, until: str, repo: str = ".") -> list[dict]:
"""Retrieve merge commits (proxy for PRs merged)."""
fmt = "%H|%ae|%aI|%s"
log = run_git([
"log", "--all", f"--since={since}", f"--until={until}",
f"--pretty=format:{fmt}", "--merges"
], repo)
if not log:
return []
merges = []
for line in log.split("\n"):
parts = line.split("|", 3)
if len(parts) < 4:
continue
merges.append({
"hash": parts[0],
"author": parts[1],
"date": parts[2],
"subject": parts[3],
})
return merges
def get_loc_stats(since: str, until: str, repo: str = ".") -> dict:
"""Get lines of code added/removed in the period."""
log = run_git([
"log", "--all", f"--since={since}", f"--until={until}",
"--pretty=format:", "--numstat", "--no-merges"
], repo)
added = 0
removed = 0
if not log:
return {"added": 0, "removed": 0, "net": 0}
for line in log.split("\n"):
line = line.strip()
if not line:
continue
parts = line.split("\t")
if len(parts) < 3:
continue
try:
a = int(parts[0])
r = int(parts[1])
added += a
removed += r
except ValueError:
continue # binary files show '-'
return {"added": added, "removed": removed, "net": added - removed}
def get_pr_loc_stats(merges: list[dict], repo: str = ".") -> list[int]:
"""Get LOC per merge commit (PR size proxy)."""
sizes = []
for m in merges:
stat = run_git(["diff", "--shortstat", f"{m['hash']}^..{m['hash']}"], repo)
if not stat:
continue
total = 0
for part in stat.split(","):
part = part.strip()
if "insertion" in part:
try:
total += int(part.split()[0])
except ValueError:
pass
elif "deletion" in part:
try:
total += int(part.split()[0])
except ValueError:
pass
if total > 0:
sizes.append(total)
return sizes
# --- Parsing & Classification ---
def parse_datetime(iso_str: str) -> datetime:
"""Parse ISO 8601 datetime string robustly.
Handles formats: 2026-03-18T12:00:00+05:30, 2026-03-18T12:00:00+0530,
2026-03-18T12:00:00Z, 2026-03-18T12:00:00
"""
clean = iso_str.strip()
# Handle 'Z' suffix (UTC)
if clean.endswith("Z"):
clean = clean[:-1] + "+0000"
# Remove colon in timezone offset for Python < 3.11 compat
# Match patterns like +05:30 or -05:30 at end of string
tz_match = re.search(r'([+-]\d{2}):(\d{2})$', clean)
if tz_match:
clean = clean[:tz_match.start()] + tz_match.group(1) + tz_match.group(2)
try:
return datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S%z")
except ValueError:
pass
# Try without timezone
try:
return datetime.strptime(clean[:19], "%Y-%m-%dT%H:%M:%S")
except ValueError:
pass
# Last resort: try common date-only format
try:
return datetime.strptime(clean[:10], "%Y-%m-%d")
except ValueError:
return datetime.now()
def classify_commit_type(subject: str) -> str:
"""Classify commit by conventional commit type."""
s = subject.lower().strip()
prefixes = [
("feat", "feat"), ("fix", "fix"), ("docs", "docs"),
("refactor", "refactor"), ("test", "test"), ("chore", "chore"),
("style", "style"), ("perf", "perf"), ("ci", "ci"),
("build", "build"), ("revert", "revert"),
]
for prefix, label in prefixes:
if s.startswith(prefix + ":") or s.startswith(prefix + "("):
return label
return "other"
# --- Session Detection ---
def detect_sessions(commits: list[dict], gap_minutes: int = 45) -> dict:
"""Detect work sessions per author using gap threshold."""
by_author = defaultdict(list)
for c in commits:
dt = parse_datetime(c["date"])
by_author[c["author"]].append(dt)
sessions = {"deep_work": 0, "focused": 0, "micro": 0}
all_sessions = []
gap = timedelta(minutes=gap_minutes)
for author, times in by_author.items():
times.sort()
if not times:
continue
session_start = times[0]
session_end = times[0]
for i in range(1, len(times)):
if times[i] - session_end > gap:
# Close previous session
duration = (session_end - session_start).total_seconds() / 60
all_sessions.append({
"author": author,
"start": session_start,
"end": session_end,
"duration_min": duration,
})
session_start = times[i]
session_end = times[i]
else:
session_end = times[i]
# Close final session
duration = (session_end - session_start).total_seconds() / 60
all_sessions.append({
"author": author,
"start": session_start,
"end": session_end,
"duration_min": duration,
})
for s in all_sessions:
d = s["duration_min"]
if d > 50:
sessions["deep_work"] += 1
elif d >= 20:
sessions["focused"] += 1
else:
sessions["micro"] += 1
return {
"deep_work": sessions["deep_work"],
"focused": sessions["focused"],
"micro": sessions["micro"],
"total": len(all_sessions),
"details": [
{
"author": s["author"],
"start": s["start"].isoformat(),
"end": s["end"].isoformat(),
"duration_min": round(s["duration_min"], 1),
"type": "deep_work" if s["duration_min"] > 50 else ("focused" if s["duration_min"] >= 20 else "micro"),
}
for s in all_sessions
],
}
# --- Hourly Distribution ---
def hourly_distribution(commits: list[dict]) -> dict[int, int]:
"""Count commits by hour of day."""
hours = defaultdict(int)
for c in commits:
dt = parse_datetime(c["date"])
hours[dt.hour] += 1
return dict(sorted(hours.items()))
# --- Cycle Time Estimation ---
def estimate_cycle_time(merges: list[dict], commits: list[dict], repo: str = ".") -> float:
"""Estimate average cycle time (hours) from branch commits to merge."""
if not merges:
return 0.0
cycle_times = []
commit_dates = {c["hash"]: parse_datetime(c["date"]) for c in commits}
for m in merges:
merge_dt = parse_datetime(m["date"])
# Get commits in the merge
parents = run_git(["log", "--pretty=format:%H|%aI", f"{m['hash']}^..{m['hash']}", "--no-merges"], repo)
if not parents:
continue
earliest = merge_dt
for line in parents.split("\n"):
parts = line.split("|", 1)
if len(parts) < 2:
continue
cdt = parse_datetime(parts[1])
if cdt < earliest:
earliest = cdt
if earliest < merge_dt:
hours = (merge_dt - earliest).total_seconds() / 3600
if hours > 0:
cycle_times.append(hours)
if not cycle_times:
return 0.0
return sum(cycle_times) / len(cycle_times)
# --- Main Analysis ---
def analyze_period(since: str, until: str, repo: str, gap_minutes: int) -> dict:
"""Run full velocity analysis for a time period."""
commits = get_commits(since, until, repo)
merges = get_merge_commits(since, until, repo)
loc = get_loc_stats(since, until, repo)
pr_sizes = get_pr_loc_stats(merges, repo)
# Parse date range for day count
try:
d_since = datetime.strptime(since, "%Y-%m-%d")
d_until = datetime.strptime(until, "%Y-%m-%d")
days = max((d_until - d_since).days, 1)
except ValueError:
days = 7
# Commit type breakdown
type_counts = defaultdict(int)
for c in commits:
t = classify_commit_type(c["subject"])
type_counts[t] += 1
# Sessions
sessions = detect_sessions(commits, gap_minutes)
# Hourly
hourly = hourly_distribution(commits)
# Cycle time
cycle_time = estimate_cycle_time(merges, commits, repo)
# PR metrics
avg_pr_size = int(round(sum(pr_sizes) / len(pr_sizes))) if pr_sizes else 0
throughput = round(len(commits) / days, 1) if days > 0 else 0
deploy_freq = round(len(merges) / days, 1) if days > 0 else 0
# Unique authors
authors = list(set(c["author"] for c in commits))
return {
"period": {"since": since, "until": until, "days": days},
"total_commits": len(commits),
"total_merges": len(merges),
"loc": loc,
"avg_pr_size_loc": int(avg_pr_size),
"throughput_per_day": throughput,
"cycle_time_hours": round(cycle_time, 1),
"deploy_frequency_per_day": deploy_freq,
"commit_types": dict(sorted(type_counts.items(), key=lambda x: -x[1])),
"hourly_distribution": hourly,
"sessions": {
"deep_work": sessions["deep_work"],
"focused": sessions["focused"],
"micro": sessions["micro"],
"total": sessions["total"],
},
"unique_authors": authors,
"author_count": len(authors),
}
# --- Comparison ---
def compute_delta(current: float, previous: float) -> str:
"""Compute percentage delta with direction indicator."""
if previous == 0:
return "N/A"
pct = ((current - previous) / previous) * 100
arrow = "+" if pct >= 0 else ""
return f"{arrow}{pct:.0f}%"
def compare_periods(current: dict, previous: dict) -> dict:
"""Compare two period analyses and produce deltas."""
deltas = {}
compare_keys = [
("total_commits", "Commits"),
("total_merges", "PRs Merged"),
("throughput_per_day", "Throughput"),
("cycle_time_hours", "Cycle Time"),
("deploy_frequency_per_day", "Deploy Frequency"),
("avg_pr_size_loc", "Avg PR Size"),
]
for key, label in compare_keys:
cur = current.get(key, 0)
prev = previous.get(key, 0)
deltas[label] = {
"current": cur,
"previous": prev,
"delta": compute_delta(cur, prev),
}
# LOC comparison
deltas["LOC Net"] = {
"current": current.get("loc", {}).get("net", 0),
"previous": previous.get("loc", {}).get("net", 0),
"delta": compute_delta(
current.get("loc", {}).get("net", 0),
previous.get("loc", {}).get("net", 0)
),
}
return deltas
# --- Text Formatting ---
def bar_chart(value: int, max_value: int, width: int = 20) -> str:
"""Create a simple text bar chart."""
if max_value == 0:
return " " * width
filled = round((value / max_value) * width)
filled = min(filled, width)
return "\u2588" * filled + "\u2591" * (width - filled)
def format_text(data: dict, comparison: dict | None = None) -> str:
"""Format velocity data as human-readable text."""
p = data["period"]
lines = [
f"Sprint Velocity Report ({p['since']} to {p['until']})",
"=" * 55,
"",
]
# Summary metrics
if comparison:
lines.append(f"{'Metric':<25} {'Current':>10} {'Previous':>10} {'Delta':>10}")
lines.append("-" * 55)
for label, vals in comparison.items():
cur = vals["current"]
prev = vals["previous"]
delta = vals["delta"]
lines.append(f"{label:<25} {str(cur):>10} {str(prev):>10} {delta:>10}")
else:
lines.append(f" Throughput: {data['throughput_per_day']} commits/day")
lines.append(f" LOC Net: {data['loc']['net']:+,} lines (added: {data['loc']['added']:,}, removed: {data['loc']['removed']:,})")
lines.append(f" PRs Merged: {data['total_merges']}")
lines.append(f" Avg PR Size: {data['avg_pr_size_loc']} LOC")
lines.append(f" Cycle Time: {data['cycle_time_hours']} hours")
lines.append(f" Deploy Frequency: {data['deploy_frequency_per_day']}/day")
lines.append(f" Unique Authors: {data['author_count']}")
# Commit types
lines.extend(["", "Commit Types:", "-" * 40])
ct = data.get("commit_types", {})
max_ct = max(ct.values()) if ct else 1
total_ct = sum(ct.values()) if ct else 1
for ctype, count in sorted(ct.items(), key=lambda x: -x[1]):
pct = round(count / total_ct * 100) if total_ct else 0
lines.append(f" {ctype:<12} {bar_chart(count, max_ct)} {pct:>3}% ({count})")
# Hourly distribution
lines.extend(["", "Hourly Activity:", "-" * 40])
hourly = data.get("hourly_distribution", {})
max_h = max(hourly.values()) if hourly else 1
for hour in range(24):
count = hourly.get(hour, 0)
if count > 0:
lines.append(f" {hour:02d}:00 {bar_chart(count, max_h, 30)} {count}")
# Sessions
s = data.get("sessions", {})
total_s = s.get("total", 0) or 1
lines.extend(["", "Work Sessions:", "-" * 40])
lines.append(f" Deep Work (>50min): {s.get('deep_work', 0):>4} ({round(s.get('deep_work', 0)/total_s*100)}%)")
lines.append(f" Focused (20-50min): {s.get('focused', 0):>4} ({round(s.get('focused', 0)/total_s*100)}%)")
lines.append(f" Micro (<20min): {s.get('micro', 0):>4} ({round(s.get('micro', 0)/total_s*100)}%)")
lines.append(f" Total Sessions: {s.get('total', 0):>4}")
return "\n".join(lines)
# --- CLI ---
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Sprint Velocity Analyzer — analyze git history for velocity metrics"
)
parser.add_argument("--days", type=int, default=7,
help="Number of days to analyze (default: 7)")
parser.add_argument("--since", type=str, default=None,
help="Start date (YYYY-MM-DD), overrides --days")
parser.add_argument("--until", type=str, default=None,
help="End date (YYYY-MM-DD), defaults to today")
parser.add_argument("--compare-previous", action="store_true",
help="Compare against the previous period of equal length")
parser.add_argument("--gap-minutes", type=int, default=45,
help="Session gap threshold in minutes (default: 45)")
parser.add_argument("--repo", type=str, default=".",
help="Path to git repository (default: current directory)")
parser.add_argument("-f", "--format", choices=["text", "json"], default="text",
help="Output format (default: text)")
return parser.parse_args()
def main():
args = parse_args()
# Determine date range
if args.until:
until_date = args.until
else:
until_date = datetime.now().strftime("%Y-%m-%d")
if args.since:
since_date = args.since
try:
d_since = datetime.strptime(since_date, "%Y-%m-%d")
d_until = datetime.strptime(until_date, "%Y-%m-%d")
days = (d_until - d_since).days
except ValueError:
days = args.days
else:
days = args.days
d_until = datetime.strptime(until_date, "%Y-%m-%d")
d_since = d_until - timedelta(days=days)
since_date = d_since.strftime("%Y-%m-%d")
# Validate repo
check = run_git(["rev-parse", "--is-inside-work-tree"], args.repo)
if check != "true":
print(f"Error: '{args.repo}' is not a git repository.", file=sys.stderr)
sys.exit(1)
# Analyze current period
current = analyze_period(since_date, until_date, args.repo, args.gap_minutes)
# Optionally analyze previous period
comparison = None
previous = None
if args.compare_previous:
prev_until = since_date
prev_since = (d_since - timedelta(days=days)).strftime("%Y-%m-%d")
previous = analyze_period(prev_since, prev_until, args.repo, args.gap_minutes)
comparison = compare_periods(current, previous)
# Output
if args.format == "json":
output = {"current": current}
if comparison:
output["previous"] = previous
output["comparison"] = comparison
print(json.dumps(output, indent=2, default=str))
else:
print(format_text(current, comparison))
if __name__ == "__main__":
main()
Related skills
FAQ
What data does it use?
Git history, PR metadata and commit patterns over a configurable window (default 7-14 days).
What does it measure?
Velocity, cycle time, lead time, deploy frequency, contributor work sessions and specialization, and code churn hotspots.