Last active
January 16, 2026 13:39
-
-
Save mordonez/31d8bdcb8c8915539344ac9ceefac5b3 to your computer and use it in GitHub Desktop.
GitHub Issues Time Calculator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| GitHub Issues Time Calculator | |
| ============================== | |
| Calculate total time spent from GitHub issues based on a custom time label pattern. | |
| Perfect for teams tracking time in issue descriptions using formats like: | |
| "⏲️ Time Spent: 6h 30m" or custom labels like "Tiempo consumido: 2h" | |
| Requirements: | |
| - gh CLI installed and authenticated (https://cli.github.com/) | |
| - Python 3.8+ (uses walrus operator :=) | |
| Default Pattern: | |
| Matches: "⏲️ Time Spent: Xh Ym", "⏲️ Time Spent: Xh", "⏲️ Time Spent: Ym" | |
| Examples: "6h 30m", "6h", "30m" | |
| Usage: | |
| python3 gh_time_calculator.py <github_issues_url> [time_label] | |
| Examples: | |
| # All issues in a repository | |
| python3 gh_time_calculator.py 'https://github.com/owner/repo/issues?q=is:issue+state:all' | |
| # Open issues only | |
| python3 gh_time_calculator.py 'https://github.com/owner/repo/issues?q=is:issue+state:open' | |
| # Specific issues (e.g., #167) | |
| python3 gh_time_calculator.py 'https://github.com/owner/repo/issues?q=is:issue+%23167' | |
| # Issues with specific labels | |
| python3 gh_time_calculator.py 'https://github.com/owner/repo/issues?q=is:issue+label:bug' | |
| # Custom time labels (script auto-generates regex) | |
| python3 gh_time_calculator.py <url> 'Total time:' | |
| python3 gh_time_calculator.py <url> 'Tiempo consumido:' | |
| How it works: | |
| 1. Parses the GitHub issues URL to extract repository and query filters | |
| 2. Uses gh CLI to fetch all matching issues via GitHub API (with pagination) | |
| 3. Extracts time values from issue bodies using regex pattern | |
| 4. Calculates and displays total time summary | |
| Note: | |
| The script automatically chooses between GitHub's list API (faster, for simple | |
| queries) or search API (for complex filters like labels, text search, etc.) | |
| Author: mordonez | |
| License: MIT | |
| """ | |
| import subprocess, json, re, os, sys | |
| from urllib.parse import urlparse, parse_qs, unquote, quote | |
| ANSI_RE = re.compile(r'\x1b\[[0-9;]*m') | |
| def usage(): | |
| print("Usage: python3 gh_time_calculator.py <github_issues_url> [time_label]") | |
| print("Example: python3 gh_time_calculator.py 'https://github.com/owner/repo/issues?q=is:issue+state:open'") | |
| print("Custom label: python3 gh_time_calculator.py <url> 'Total time:'") | |
| def build_pattern(label): | |
| time_label = re.escape(label) if label else r'⏲️ Time Spent:' | |
| return re.compile(rf'{time_label}\s*(?:(\d+)h)?(?:\s*(\d+)m)?') | |
| def parse_url(issues_url): | |
| url = urlparse(issues_url) | |
| repo = '/'.join(url.path.split('/')[1:3]) | |
| query = unquote(parse_qs(url.query).get('q', ['is:issue state:all'])[0]) | |
| return repo, query | |
| def build_api_path(repo, query): | |
| clean = re.sub(r'\bis:issue\b|\bstate:\w+\b', '', query).strip() | |
| if clean: | |
| return f"search/issues?q={quote(f'repo:{repo} {query}')}" , True | |
| state = re.search(r'state:(\w+)', query) | |
| return f"repos/{repo}/issues?state={state.group(1) if state else 'all'}", False | |
| def fetch_issues(api_path, use_search): | |
| env = os.environ.copy() | |
| env.update({'NO_COLOR': '1', 'GH_PAGER': ''}) | |
| issues, page = [], 1 | |
| while True: | |
| proc = subprocess.Popen( | |
| ["gh", "api", f"{api_path}&per_page=100&page={page}"], | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| env=env, | |
| ) | |
| stdout, _ = proc.communicate() | |
| if proc.returncode != 0 or not stdout.strip(): | |
| break | |
| try: | |
| data = json.loads(ANSI_RE.sub('', stdout)) | |
| items = data.get('items', data) if use_search else data | |
| if not items: | |
| break | |
| issues.extend(items) | |
| if len(items) < 100: | |
| break | |
| page += 1 | |
| except json.JSONDecodeError: | |
| break | |
| return issues | |
| def total_minutes(issues, pattern): | |
| total = 0 | |
| count = 0 | |
| for issue in issues: | |
| body = issue.get('body') or '' | |
| match = pattern.search(body) | |
| if not match: | |
| continue | |
| hours = int(match.group(1) or 0) | |
| minutes = int(match.group(2) or 0) | |
| total += hours * 60 + minutes | |
| count += 1 | |
| return total, count | |
| def main(): | |
| if len(sys.argv) < 2: | |
| usage() | |
| sys.exit(1) | |
| pattern = build_pattern(sys.argv[2] if len(sys.argv) > 2 else None) | |
| repo, query = parse_url(sys.argv[1]) | |
| api_path, use_search = build_api_path(repo, query) | |
| print("\nFetching issues...", end=" ", flush=True) | |
| issues = fetch_issues(api_path, use_search) | |
| print("OK\n") | |
| total, count = total_minutes(issues, pattern) | |
| print("=" * 60) | |
| print(f" TOTAL TIME: {total // 60}h {total % 60}m ({total:,} minutes)") | |
| print(f" Issues with time: {count}/{len(issues)}") | |
| print("=" * 60 + "\n") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment