diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..45d81f1a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,156 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`did` is a command-line tool for gathering status report data from various development tools and services. It queries APIs (Jira, GitHub, GitLab, Bugzilla, etc.) and local repositories to summarize what a user accomplished in a given time period. + +## Common Development Commands + +### Testing +```bash +# Run all tests in parallel +make test + +# Run smoke tests (CLI tests only) +make smoke + +# Run tests with coverage +make coverage + +# Run functional tests (requires --functional flag and online resources) +pytest --functional tests/ + +# Run a single test file +DID_DIR=tmp pytest tests/test_cli.py + +# Run a specific test +DID_DIR=tmp pytest tests/plugins/test_jira.py::test_config_gss_auth +``` + +**Note**: Tests require `DID_DIR` environment variable set to a temporary directory. The `make test` target handles this automatically. + +### Code Quality +```bash +# Install pre-commit hooks +make hooks + +# Run all pre-commit checks manually +pre-commit run --all-files + +# Run specific linters +flake8 # Style checking (max line length: 88) +pylint # Code analysis +mypy # Type checking +codespell # Spell checking +``` + +### Documentation +```bash +# Build HTML documentation +make docs + +# Build man page +make man +``` + +### Installation & Packaging +```bash +# Install in development mode +pip install -e . + +# Install with specific plugin dependencies +pip install -e .[jira] +pip install -e .[all] + +# Build RPM package +make rpm + +# Build Python wheel +make wheel +``` + +## Architecture + +### Plugin System + +The core architecture is built around a plugin system where each plugin represents a different tool/service (Jira, GitHub, Bugzilla, etc.). + +**Key components:** + +- **`did/base.py`** - Core classes: `Config`, `Date`, `User`, and base exceptions +- **`did/stats.py`** - `Stats` and `StatsGroup` base classes that all plugins inherit from +- **`did/cli.py`** - Command-line argument parsing and main execution loop +- **`did/utils.py`** - Shared utilities: logging, plugin loading, color output +- **`did/plugins/`** - Individual plugin implementations + +**Plugin structure:** +Each plugin module (e.g., `did/plugins/jira.py`) contains: +1. A main `StatsGroup` subclass (e.g., `JiraStats`) that groups related stats +2. Multiple `Stats` subclasses for different report types (e.g., `JiraCreated`, `JiraResolved`) +3. An `order` attribute defining position in the final report (see `did/plugins/__init__.py`) + +**How plugins are loaded:** +- Plugins are auto-discovered from the `did/plugins/` directory +- Plugin name must match the `type` field in config sections +- Each plugin's `StatsGroup` adds its stats to the user report +- Custom plugins can be loaded by adding paths to the `[general]` section in config + +### Configuration System + +Configuration is read from `~/.did/config` (INI format): +- `[general]` section contains email, width, and other global settings +- Each plugin has its own section with `type = ` +- Plugins define their own config options (auth, URLs, filters, etc.) + +### Stats Collection Flow + +1. CLI parses arguments and loads configuration +2. `UserStats` (from `stats.py`) instantiates all enabled plugins +3. Each plugin's `fetch()` method queries its API/data source +4. Results are collected into the plugin's `stats` list +5. Output is formatted and displayed (text/markdown/wiki formats) + +### Date Handling + +The `Date` class in `did/base.py` provides flexible date parsing: +- Natural language: "today", "yesterday", "last week", "last friday" +- Explicit ranges: `--since YYYY-MM-DD --until YYYY-MM-DD` +- Period helpers: "this week", "last month", "this quarter", "last year" + +### Testing Structure + +- **Unit tests**: `tests/test_*.py` - Test core functionality +- **Plugin tests**: `tests/plugins/test_*.py` - Test individual plugins +- **Functional tests**: Marked with `@pytest.mark.functional` - Require `--functional` flag +- **Test configs**: Embedded in test files as strings, not external files +- **Fixtures**: Defined in `conftest.py` - Adds `--functional` option to pytest + +## Type Hints + +This codebase uses type hints and enforces them with mypy. When modifying code: +- Add type hints to new functions and methods +- Use `Optional[T]` for nullable values +- Import types from `typing` module as needed +- Plugin-specific types may require additional dependencies (see `setup.py` extras_require) + +## Pre-commit Hooks + +The project uses pre-commit with: +- `autopep8` - Auto-formatting (max line length: 88) +- `isort` - Import sorting +- `flake8` - Style enforcement +- `pylint` - Code analysis +- `mypy` - Type checking +- `codespell` - Spell checking + +Changes must pass all checks before committing. Run `make hooks` to install them. + +## Important Conventions + +- **Error handling**: Plugins catch API errors in `Stats.check()` and set `self.error = True` +- **Logging**: Use `from did.utils import log` and appropriate levels (LOG_DEBUG, LOG_INFO, etc.) +- **Plugin order**: Defined by the `order` class attribute (see `did/plugins/__init__.py`) +- **Authentication**: Plugins support various auth types (GSS/Kerberos, token, basic auth) +- **Config validation**: Plugins validate required config in `__init__` and raise `ConfigError` diff --git a/diagnose_local.sh b/diagnose_local.sh new file mode 100755 index 00000000..1bff8941 --- /dev/null +++ b/diagnose_local.sh @@ -0,0 +1,64 @@ +#!/bin/bash +echo "=== Story Points Diagnostic ===" + +echo -e "\n1. Which Python is being used?" +which python3 +python3 --version + +echo -e "\n2. Which did is being used?" +which did +head -1 $(which did) + +echo -e "\n3. Where is did installed?" +pip show did 2>/dev/null | grep Location || pip3 show did | grep Location + +echo -e "\n4. Is story_points code in the file?" +grep -c "self.story_points" ~/did/did/plugins/jira.py + +echo -e "\n5. What does Python import?" +python3 << 'EOF' +import did.plugins.jira +import inspect +print(f"Loading from: {inspect.getfile(did.plugins.jira)}") + +# Check if the Issue class has story_points in __init__ +import dis +print("\nChecking Issue.__init__ bytecode for 'story_points':") +code = did.plugins.jira.Issue.__init__.__code__ +if 'story_points' in code.co_names: + print("✓ story_points found in bytecode") +else: + print("✗ story_points NOT in bytecode - OLD VERSION CACHED!") + +# Try to create an issue with story points +from unittest.mock import Mock +try: + mock_issue = { + 'key': 'TEST-1', + 'fields': { + 'summary': 'Test', + 'comment': {'comments': []}, + 'customfield_12310243': 5.0 + } + } + mock_parent = Mock() + mock_parent.options = Mock() + mock_parent.options.format = 'text' + mock_parent.prefix = None + issue = did.plugins.jira.Issue(mock_issue, parent=mock_parent) + if hasattr(issue, 'story_points'): + print(f"✓ Issue has story_points: {issue.story_points}") + else: + print("✗ Issue does NOT have story_points attribute") +except Exception as e: + print(f"✗ Error: {e}") +EOF + +echo -e "\n6. Finding ALL cached bytecode:" +find ~/.local -name "*.pyc" -path "*/did/*" 2>/dev/null | head -10 + +echo -e "\n=== SOLUTION ===" +echo "If story_points is NOT in bytecode, run:" +echo " rm -rf ~/.local/lib/python*/site-packages/__pycache__" +echo " python3 -m compileall -f ~/did/did/plugins/jira.py" +echo " did last week" diff --git a/did/plugins/jira.py b/did/plugins/jira.py index 6d07860b..d89443cc 100644 --- a/did/plugins/jira.py +++ b/did/plugins/jira.py @@ -141,6 +141,7 @@ def __init__(self, issue=None, parent=None): self.key = issue["key"] self.summary = issue["fields"]["summary"] self.comments = issue["fields"]["comment"]["comments"] + self.story_points = issue["fields"].get("customfield_12310243") if "changelog" in issue: self.histories = issue["changelog"]["histories"] else: @@ -155,10 +156,13 @@ def __init__(self, issue=None, parent=None): def __str__(self): """ Jira key and summary for displaying """ label = f"{self.prefix}-{self.identifier}" + story_points_str = "" + if self.story_points is not None: + story_points_str = f" ({self.story_points} SP)" if self.options.format == "markdown": href = f"{self.parent.url}/browse/{self.issue['key']}" - return f"[{label}]({href}) - {self.summary}" - return f"{label} - {self.summary}" + return f"[{label}]({href}) - {self.summary}{story_points_str}" + return f"{label} - {self.summary}{story_points_str}" def __eq__(self, other): """ Compare issues by key """ @@ -174,7 +178,7 @@ def search(query, stats, expand="", timeout=TIMEOUT): encoded_query = urllib.parse.urlencode( { "jql": query, - "fields": "summary,comment", + "fields": "summary,comment,customfield_12310243", "maxResults": MAX_RESULTS, "startAt": batch * MAX_RESULTS, "expand": expand @@ -272,7 +276,28 @@ def changed(self, user, options): # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -class JiraCreated(Stats): +class JiraStatsBase(Stats): + """ Base class for JIRA stats with Story Points support """ + + def header(self): + """ Show summary header with story points total """ + from did.utils import item + # Show question mark instead of count when errors encountered + count = "? (error encountered)" if self.error else len(self.stats) + # Calculate total story points + total_sp = sum( + issue.story_points for issue in self.stats + if issue.story_points is not None + ) + # Format the header based on whether we have story points + if total_sp > 0: + header_text = f"{self.name}: {count} ({total_sp} SP)" + else: + header_text = f"{self.name}: {count}" + item(header_text, options=self.options) + + +class JiraCreated(JiraStatsBase): """ Created issues """ def fetch(self): @@ -292,7 +317,7 @@ def fetch(self): log.info("[%s] done issues created", self.option) -class JiraCommented(Stats): +class JiraCommented(JiraStatsBase): """ Commented issues """ def fetch(self): @@ -324,7 +349,7 @@ def fetch(self): log.info("[%s] done issues commented", self.option) -class JiraUpdated(Stats): +class JiraUpdated(JiraStatsBase): """ Updated issues """ def fetch(self): @@ -347,7 +372,7 @@ def fetch(self): log.info("[%s] done issues updated", self.option) -class JiraResolved(Stats): +class JiraResolved(JiraStatsBase): """ Resolved issues """ def fetch(self): @@ -367,7 +392,7 @@ def fetch(self): log.info("[%s] done issues resolved", self.option) -class JiraTested(Stats): +class JiraTested(JiraStatsBase): """ Tested issues """ def fetch(self): @@ -387,7 +412,7 @@ def fetch(self): log.info("[%s] done issues tested", self.option) -class JiraContributed(Stats): +class JiraContributed(JiraStatsBase): """ Contributed issues """ def fetch(self): @@ -407,7 +432,7 @@ def fetch(self): log.info("[%s] done issues contributed to", self.option) -class JiraTransition(Stats): +class JiraTransition(JiraStatsBase): """ Issues transitioned to specified state """ def fetch(self): diff --git a/fix_storypoints.sh b/fix_storypoints.sh new file mode 100755 index 00000000..2eaa89d5 --- /dev/null +++ b/fix_storypoints.sh @@ -0,0 +1,54 @@ +#!/bin/bash +set -e + +echo "=== Fixing Story Points Installation ===" + +echo -e "\n1. Removing all old installations..." +pip uninstall -y did 2>/dev/null || true +rm -rf ~/.local/lib/python3.13/site-packages/did* +rm -rf ~/.local/lib/python3.*/site-packages/did* + +echo -e "\n2. Clearing all Python caches..." +find ~/did -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true +find ~/did -type f -name "*.pyc" -delete 2>/dev/null || true +find ~/.local -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true +python3 -m pip cache purge 2>/dev/null || true + +echo -e "\n3. Reinstalling did from modified source..." +cd ~/did +pip install --user --force-reinstall --no-cache-dir -e . + +echo -e "\n4. Verifying installation..." +pip show did | grep Location + +echo -e "\n5. Verifying story_points code exists..." +python3 << 'EOF' +import sys +sys.dont_write_bytecode = True +from did.plugins.jira import Issue +from unittest.mock import Mock + +# Test with mock data +mock_issue = { + 'key': 'TEST-123', + 'fields': { + 'summary': 'Test issue', + 'comment': {'comments': []}, + 'customfield_12310243': 5.0 + } +} +mock_parent = Mock() +mock_parent.options = Mock() +mock_parent.options.format = 'text' +mock_parent.prefix = None + +issue = Issue(mock_issue, parent=mock_parent) +print(f"✓ Story Points extracted: {issue.story_points}") +print(f"✓ Display format: {issue}") +EOF + +echo -e "\n6. Testing with 'did last week'..." +python3 -B $(which did) last week 2>&1 | grep -A3 "Issues commented\|Issues resolved" || echo "No issues found in last week" + +echo -e "\n=== Installation Complete ===" +echo "Now run 'did last week' from your terminal" diff --git a/test_story_points.sh b/test_story_points.sh new file mode 100755 index 00000000..83215646 --- /dev/null +++ b/test_story_points.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Test script to verify Story Points installation + +echo "=== Checking Python path ===" +which python3 + +echo -e "\n=== Checking did installation ===" +pip show did | grep Location + +echo -e "\n=== Checking if jira.py has story_points code ===" +grep -c "story_points" ~/did/did/plugins/jira.py + +echo -e "\n=== Clearing Python cache ===" +find ~/did -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null +find ~/did -type f -name "*.pyc" -delete 2>/dev/null +echo "Cache cleared" + +echo -e "\n=== Testing Story Points extraction ===" +python3 -c " +from did.plugins.jira import Issue +from unittest.mock import Mock + +mock_issue = { + 'key': 'TEST-123', + 'fields': { + 'summary': 'Test', + 'comment': {'comments': []}, + 'customfield_12310243': 5.0 + } +} +mock_parent = Mock() +mock_parent.options = Mock() +mock_parent.options.format = 'text' +mock_parent.prefix = None + +issue = Issue(mock_issue, parent=mock_parent) +print(f'Story Points: {issue.story_points}') +print(f'Display: {issue}') +" + +echo -e "\n=== Running did for last week ===" +did last week | grep -A3 "Issues commented\|Issues resolved"