Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 = <plugin_name>`
- 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`
64 changes: 64 additions & 0 deletions diagnose_local.sh
Original file line number Diff line number Diff line change
@@ -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"
45 changes: 35 additions & 10 deletions did/plugins/jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 """
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down
54 changes: 54 additions & 0 deletions fix_storypoints.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading