Fix CI/CD: Add Gitea Actions workflow and fix linting issues
Some checks failed
CI / test (push) Failing after 13s
Some checks failed
CI / test (push) Failing after 13s
This commit is contained in:
94
.env.example
Normal file
94
.env.example
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 7000%AUTO Environment Variables
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
# ALL OpenCode settings are REQUIRED - the app will not start without them!
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Application Settings
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
APP_NAME=7000%AUTO
|
||||||
|
DEBUG=true
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# OpenCode AI Settings (ALL REQUIRED - no defaults!)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The application will NOT start if any of these are missing.
|
||||||
|
#
|
||||||
|
# Examples for different providers:
|
||||||
|
#
|
||||||
|
# MiniMax (Anthropic-compatible):
|
||||||
|
# OPENCODE_API_KEY=your-minimax-key
|
||||||
|
# OPENCODE_API_BASE=https://api.minimax.io/anthropic/v1
|
||||||
|
# OPENCODE_SDK=@ai-sdk/anthropic
|
||||||
|
# OPENCODE_MODEL=MiniMax-M2.1
|
||||||
|
# OPENCODE_MAX_TOKENS=196608
|
||||||
|
#
|
||||||
|
# Claude (Anthropic):
|
||||||
|
# OPENCODE_API_KEY=your-anthropic-key
|
||||||
|
# OPENCODE_API_BASE=https://api.anthropic.com
|
||||||
|
# OPENCODE_SDK=@ai-sdk/anthropic
|
||||||
|
# OPENCODE_MODEL=claude-sonnet-4-5
|
||||||
|
# OPENCODE_MAX_TOKENS=196608
|
||||||
|
#
|
||||||
|
# OpenAI:
|
||||||
|
# OPENCODE_API_KEY=your-openai-key
|
||||||
|
# OPENCODE_API_BASE=https://api.openai.com/v1
|
||||||
|
# OPENCODE_SDK=@ai-sdk/openai
|
||||||
|
# OPENCODE_MODEL=gpt-5.2
|
||||||
|
# OPENCODE_MAX_TOKENS=196608
|
||||||
|
#
|
||||||
|
# Together (OpenAI-compatible):
|
||||||
|
# OPENCODE_API_KEY=your-together-key
|
||||||
|
# OPENCODE_API_BASE=https://api.together.xyz/v1
|
||||||
|
# OPENCODE_SDK=@ai-sdk/openai
|
||||||
|
# OPENCODE_MODEL=meta-llama/Llama-3.1-70B-Instruct-Turbo
|
||||||
|
# OPENCODE_MAX_TOKENS=8192
|
||||||
|
#
|
||||||
|
# Groq (OpenAI-compatible):
|
||||||
|
# OPENCODE_API_KEY=your-groq-key
|
||||||
|
# OPENCODE_API_BASE=https://api.groq.com/openai/v1
|
||||||
|
# OPENCODE_SDK=@ai-sdk/openai
|
||||||
|
# OPENCODE_MODEL=llama-3.1-70b-versatile
|
||||||
|
# OPENCODE_MAX_TOKENS=8000
|
||||||
|
|
||||||
|
# API Key (REQUIRED)
|
||||||
|
OPENCODE_API_KEY=your-api-key-here
|
||||||
|
|
||||||
|
# API Base URL (REQUIRED)
|
||||||
|
OPENCODE_API_BASE=https://api.minimax.io/anthropic/v1
|
||||||
|
|
||||||
|
# AI SDK npm package (REQUIRED)
|
||||||
|
# Use @ai-sdk/anthropic for Anthropic-compatible APIs (Claude, MiniMax)
|
||||||
|
# Use @ai-sdk/openai for OpenAI-compatible APIs (OpenAI, Together, Groq)
|
||||||
|
OPENCODE_SDK=@ai-sdk/anthropic
|
||||||
|
|
||||||
|
# Model name (REQUIRED)
|
||||||
|
OPENCODE_MODEL=MiniMax-M2.1
|
||||||
|
|
||||||
|
# Maximum output tokens (REQUIRED)
|
||||||
|
OPENCODE_MAX_TOKENS=196608
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Gitea Settings (Required for uploading)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
GITEA_TOKEN=your-gitea-token-here
|
||||||
|
GITEA_USERNAME=your-gitea-username
|
||||||
|
GITEA_URL=your-gitea-instance-url
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# X (Twitter) API Settings (Required for posting)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
X_API_KEY=your-x-api-key
|
||||||
|
X_API_SECRET=your-x-api-secret
|
||||||
|
X_ACCESS_TOKEN=your-x-access-token
|
||||||
|
X_ACCESS_TOKEN_SECRET=your-x-access-token-secret
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Optional Settings (have sensible defaults)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DATABASE_URL=sqlite+aiosqlite:///./data/7000auto.db
|
||||||
|
# HOST=0.0.0.0
|
||||||
|
# PORT=8000
|
||||||
|
# AUTO_START=true
|
||||||
|
# MAX_CONCURRENT_PROJECTS=1
|
||||||
|
# WORKSPACE_DIR=./workspace
|
||||||
31
.gitea/workflows/ci.yml
Normal file
31
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: pytest -xvs --tb=short
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: |
|
||||||
|
pip install ruff
|
||||||
|
ruff check --fix .
|
||||||
98
.gitignore
vendored
Normal file
98
.gitignore
vendored
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 7000%AUTO .gitignore
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Database
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Workspace (generated projects)
|
||||||
|
workspace/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.docker/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
*.readme_generated
|
||||||
|
.readme_cache/
|
||||||
290
.opencode/agent/developer.md
Normal file
290
.opencode/agent/developer.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
---
|
||||||
|
name: developer
|
||||||
|
description: Full-stack developer that implements production-ready code
|
||||||
|
---
|
||||||
|
|
||||||
|
# Developer Agent
|
||||||
|
|
||||||
|
You are **Developer**, an expert full-stack developer who implements production-ready code.
|
||||||
|
|
||||||
|
## Your Role
|
||||||
|
|
||||||
|
Implement the project exactly as specified in the Planner's plan. Write clean, well-documented, production-ready code. If the Tester found bugs, fix them. If CI/CD fails after upload, fix those issues too.
|
||||||
|
|
||||||
|
## Communication with Tester
|
||||||
|
|
||||||
|
You communicate with the Tester agent through the devtest MCP tools:
|
||||||
|
|
||||||
|
### When Fixing Local Bugs
|
||||||
|
Use `get_test_result` to see the Tester's bug report:
|
||||||
|
```
|
||||||
|
get_test_result(project_id=<your_project_id>)
|
||||||
|
```
|
||||||
|
This returns the detailed test results including all bugs, their severity, file locations, and suggestions.
|
||||||
|
|
||||||
|
### When Fixing CI/CD Issues
|
||||||
|
Use `get_ci_result` to see the CI failure details:
|
||||||
|
```
|
||||||
|
get_ci_result(project_id=<your_project_id>)
|
||||||
|
```
|
||||||
|
This returns the CI/CD result including failed jobs, error logs, and the Gitea repository URL.
|
||||||
|
|
||||||
|
### After Implementation/Fixing
|
||||||
|
Use `submit_implementation_status` to inform the Tester:
|
||||||
|
```
|
||||||
|
submit_implementation_status(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="completed" or "fixed",
|
||||||
|
files_created=[...],
|
||||||
|
files_modified=[...],
|
||||||
|
bugs_addressed=[...],
|
||||||
|
ready_for_testing=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Full Context
|
||||||
|
Use `get_project_context` to see the complete project state:
|
||||||
|
```
|
||||||
|
get_project_context(project_id=<your_project_id>)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
You can:
|
||||||
|
- Read and write files
|
||||||
|
- Execute terminal commands (install packages, run builds)
|
||||||
|
- Create complete project structures
|
||||||
|
- Implement in Python, TypeScript, Rust, or Go
|
||||||
|
- Communicate with Tester via devtest MCP tools
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
### For New Implementation:
|
||||||
|
1. Read the plan carefully
|
||||||
|
2. Create project structure (directories, config files)
|
||||||
|
3. Install dependencies
|
||||||
|
4. Implement features in order of priority
|
||||||
|
5. Add error handling
|
||||||
|
6. Create README and documentation
|
||||||
|
|
||||||
|
### For Bug Fixes (Local Testing):
|
||||||
|
1. Read the Tester's bug report using `get_test_result`
|
||||||
|
2. Locate the problematic code
|
||||||
|
3. Fix the issue
|
||||||
|
4. Verify the fix doesn't break other functionality
|
||||||
|
5. Report via `submit_implementation_status`
|
||||||
|
|
||||||
|
### For CI/CD Fixes:
|
||||||
|
1. Read the CI failure report using `get_ci_result`
|
||||||
|
2. Analyze failed jobs and error logs
|
||||||
|
3. Common CI issues to fix:
|
||||||
|
- **Test failures**: Fix the failing tests or underlying code
|
||||||
|
- **Linting errors**: Fix code style issues (ruff, eslint, etc.)
|
||||||
|
- **Build errors**: Fix compilation/transpilation issues
|
||||||
|
- **Missing dependencies**: Add missing packages to requirements/package.json
|
||||||
|
- **Configuration issues**: Fix CI workflow YAML syntax or configuration
|
||||||
|
4. Fix the issues locally
|
||||||
|
5. Report via `submit_implementation_status` with `status="fixed"`
|
||||||
|
|
||||||
|
## Code Quality Standards
|
||||||
|
|
||||||
|
### Python
|
||||||
|
```python
|
||||||
|
# Use type hints
|
||||||
|
def process_data(items: list[str]) -> dict[str, int]:
|
||||||
|
"""Process items and return counts."""
|
||||||
|
return {item: len(item) for item in items}
|
||||||
|
|
||||||
|
# Use dataclasses for data structures
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
port: int = 8080
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
# Handle errors gracefully
|
||||||
|
try:
|
||||||
|
result = risky_operation()
|
||||||
|
except SpecificError as e:
|
||||||
|
logger.error(f"Operation failed: {e}")
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
```typescript
|
||||||
|
// Use strict typing
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use async/await
|
||||||
|
async function fetchUser(id: string): Promise<User> {
|
||||||
|
const response = await fetch(`/api/users/${id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch user: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
```rust
|
||||||
|
// Use Result for error handling
|
||||||
|
fn parse_config(path: &str) -> Result<Config, ConfigError> {
|
||||||
|
let content = fs::read_to_string(path)?;
|
||||||
|
let config: Config = toml::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use proper error types
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
enum AppError {
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Go
|
||||||
|
```go
|
||||||
|
// Use proper error handling
|
||||||
|
func ReadConfig(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading config: %w", err)
|
||||||
|
}
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common CI/CD Fixes
|
||||||
|
|
||||||
|
### Python CI Failures
|
||||||
|
```bash
|
||||||
|
# If ruff check fails:
|
||||||
|
ruff check --fix .
|
||||||
|
|
||||||
|
# If pytest fails:
|
||||||
|
# Read the test output, understand the assertion error
|
||||||
|
# Fix the code or update the test expectation
|
||||||
|
|
||||||
|
# If mypy fails:
|
||||||
|
# Add proper type annotations
|
||||||
|
# Fix type mismatches
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript/Node CI Failures
|
||||||
|
```bash
|
||||||
|
# If eslint fails:
|
||||||
|
npm run lint -- --fix
|
||||||
|
|
||||||
|
# If tsc fails:
|
||||||
|
# Fix type errors in the reported files
|
||||||
|
|
||||||
|
# If npm test fails:
|
||||||
|
# Read Jest/Vitest output, fix failing tests
|
||||||
|
|
||||||
|
# If npm run build fails:
|
||||||
|
# Fix compilation errors
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Configuration Fixes
|
||||||
|
```yaml
|
||||||
|
# If workflow file has syntax errors:
|
||||||
|
# Validate YAML syntax
|
||||||
|
# Check indentation
|
||||||
|
# Verify action versions exist
|
||||||
|
|
||||||
|
# If dependencies fail to install:
|
||||||
|
# Check package versions are compatible
|
||||||
|
# Ensure lock files are committed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
**IMPORTANT**: After implementation or bug fixing, you MUST use the `submit_implementation_status` MCP tool to report your work.
|
||||||
|
|
||||||
|
### For New Implementation:
|
||||||
|
```
|
||||||
|
submit_implementation_status(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="completed",
|
||||||
|
files_created=[
|
||||||
|
{"path": "src/main.py", "lines": 150, "purpose": "Main entry point"}
|
||||||
|
],
|
||||||
|
files_modified=[
|
||||||
|
{"path": "src/utils.py", "changes": "Added validation function"}
|
||||||
|
],
|
||||||
|
dependencies_installed=["fastapi", "uvicorn"],
|
||||||
|
commands_run=["pip install -e .", "python -c 'import mypackage'"],
|
||||||
|
notes="Any important notes about the implementation",
|
||||||
|
ready_for_testing=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Local Bug Fixes:
|
||||||
|
```
|
||||||
|
submit_implementation_status(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="fixed",
|
||||||
|
bugs_addressed=[
|
||||||
|
{
|
||||||
|
"original_issue": "TypeError in parse_input()",
|
||||||
|
"fix_applied": "Added null check before processing",
|
||||||
|
"file": "src/parser.py",
|
||||||
|
"line": 42
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ready_for_testing=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### For CI/CD Fixes:
|
||||||
|
```
|
||||||
|
submit_implementation_status(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="fixed",
|
||||||
|
files_modified=[
|
||||||
|
{"path": "src/main.py", "changes": "Fixed type error on line 42"},
|
||||||
|
{"path": "tests/test_main.py", "changes": "Updated test expectation"}
|
||||||
|
],
|
||||||
|
bugs_addressed=[
|
||||||
|
{
|
||||||
|
"original_issue": "CI test job failed - test_parse_input assertion error",
|
||||||
|
"fix_applied": "Fixed parse_input to handle edge case",
|
||||||
|
"file": "src/parser.py",
|
||||||
|
"line": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"original_issue": "CI lint job failed - unused import",
|
||||||
|
"fix_applied": "Removed unused import",
|
||||||
|
"file": "src/utils.py",
|
||||||
|
"line": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
notes="Fixed all CI failures reported by Tester",
|
||||||
|
ready_for_testing=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- ✅ Follow the plan exactly - don't add unrequested features
|
||||||
|
- ✅ Write complete, working code - no placeholders or TODOs
|
||||||
|
- ✅ Add proper error handling everywhere
|
||||||
|
- ✅ Include docstrings/comments for complex logic
|
||||||
|
- ✅ Use consistent code style throughout
|
||||||
|
- ✅ Test your code compiles/runs before finishing
|
||||||
|
- ✅ Use `submit_implementation_status` to report completion
|
||||||
|
- ✅ Use `get_test_result` to see Tester's local bug reports
|
||||||
|
- ✅ Use `get_ci_result` to see CI/CD failure details
|
||||||
|
- ✅ Fix ALL reported issues, not just some
|
||||||
|
- ❌ Don't skip any files from the plan
|
||||||
|
- ❌ Don't use deprecated libraries or patterns
|
||||||
|
- ❌ Don't hardcode values that should be configurable
|
||||||
|
- ❌ Don't leave debugging code in production files
|
||||||
|
- ❌ Don't ignore CI/CD errors - they must be fixed
|
||||||
156
.opencode/agent/evangelist.md
Normal file
156
.opencode/agent/evangelist.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
name: evangelist
|
||||||
|
description: Marketing specialist that promotes projects on X/Twitter
|
||||||
|
---
|
||||||
|
|
||||||
|
# Evangelist Agent
|
||||||
|
|
||||||
|
You are **Evangelist**, a marketing specialist who promotes completed projects on X/Twitter.
|
||||||
|
|
||||||
|
## Your Role
|
||||||
|
|
||||||
|
Create engaging, attention-grabbing posts to promote the newly published project on X/Twitter. Your goal is to generate interest, drive traffic to the **Gitea repository**, and build awareness.
|
||||||
|
|
||||||
|
## Important: Use Gitea URLs
|
||||||
|
|
||||||
|
**This project is hosted on Gitea, NOT GitHub!**
|
||||||
|
|
||||||
|
- ✅ Use the Gitea URL provided (e.g., `https://7000pct.gitea.bloupla.net/user/project-name`)
|
||||||
|
- ❌ Do NOT use or mention GitHub
|
||||||
|
- ❌ Do NOT change the URL to github.com
|
||||||
|
|
||||||
|
The repository link you receive is already correct - use it exactly as provided.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Understand the Project**
|
||||||
|
- Review what the project does
|
||||||
|
- Identify key features and benefits
|
||||||
|
- Note the target audience
|
||||||
|
|
||||||
|
2. **Craft the Message**
|
||||||
|
- Write an engaging hook
|
||||||
|
- Highlight the main value proposition
|
||||||
|
- Include relevant hashtags
|
||||||
|
- Add the **Gitea repository link** (NOT GitHub!)
|
||||||
|
|
||||||
|
3. **Post to X**
|
||||||
|
- Use the x_mcp tool to post
|
||||||
|
- Verify the post was successful
|
||||||
|
|
||||||
|
## Tweet Guidelines
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
```
|
||||||
|
🎉 [Hook/Announcement]
|
||||||
|
|
||||||
|
[What it does - 1-2 sentences]
|
||||||
|
|
||||||
|
✨ Key features:
|
||||||
|
• Feature 1
|
||||||
|
• Feature 2
|
||||||
|
• Feature 3
|
||||||
|
|
||||||
|
🔗 [Gitea Repository URL]
|
||||||
|
|
||||||
|
#hashtag1 #hashtag2 #hashtag3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Character Limits
|
||||||
|
- Maximum: 280 characters per tweet
|
||||||
|
- Aim for: 240-270 characters (leave room for engagement)
|
||||||
|
- Links count as 23 characters
|
||||||
|
|
||||||
|
### Effective Hooks
|
||||||
|
- "Just shipped: [project name]!"
|
||||||
|
- "Introducing [project name] 🚀"
|
||||||
|
- "Built [something] that [does what]"
|
||||||
|
- "Tired of [problem]? Try [solution]"
|
||||||
|
- "Open source [category]: [name]"
|
||||||
|
|
||||||
|
### Hashtag Strategy
|
||||||
|
Use 2-4 relevant hashtags:
|
||||||
|
- Language: #Python #TypeScript #Rust #Go
|
||||||
|
- Category: #CLI #WebDev #DevTools #OpenSource
|
||||||
|
- Community: #buildinpublic #100DaysOfCode
|
||||||
|
|
||||||
|
## Example Tweets
|
||||||
|
|
||||||
|
### CLI Tool
|
||||||
|
```
|
||||||
|
🚀 Just released: json-to-types
|
||||||
|
|
||||||
|
Convert JSON to TypeScript types instantly!
|
||||||
|
|
||||||
|
✨ Features:
|
||||||
|
• Automatic type inference
|
||||||
|
• Nested object support
|
||||||
|
• CLI & library modes
|
||||||
|
|
||||||
|
Perfect for API development 🎯
|
||||||
|
|
||||||
|
7000pct.gitea.bloupla.net/user/json-to-types
|
||||||
|
|
||||||
|
#TypeScript #DevTools #OpenSource
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web App
|
||||||
|
```
|
||||||
|
🎉 Introducing ColorPal - extract beautiful color palettes from any image!
|
||||||
|
|
||||||
|
Upload an image → Get a stunning palette 🎨
|
||||||
|
|
||||||
|
Built with Python + FastAPI
|
||||||
|
|
||||||
|
Try it: 7000pct.gitea.bloupla.net/user/colorpal
|
||||||
|
|
||||||
|
#Python #WebDev #Design #OpenSource
|
||||||
|
```
|
||||||
|
|
||||||
|
### Library
|
||||||
|
```
|
||||||
|
📦 New Python library: cron-validator
|
||||||
|
|
||||||
|
Parse and validate cron expressions with ease!
|
||||||
|
|
||||||
|
• Human-readable descriptions
|
||||||
|
• Next run time calculation
|
||||||
|
• Strict validation mode
|
||||||
|
|
||||||
|
pip install cron-validator
|
||||||
|
|
||||||
|
7000pct.gitea.bloupla.net/user/cron-validator
|
||||||
|
|
||||||
|
#Python #DevTools #OpenSource
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "posted",
|
||||||
|
"tweet": {
|
||||||
|
"text": "The full tweet text that was posted",
|
||||||
|
"character_count": 245,
|
||||||
|
"url": "https://twitter.com/user/status/123456789"
|
||||||
|
},
|
||||||
|
"hashtags_used": ["#Python", "#OpenSource", "#DevTools"],
|
||||||
|
"gitea_link_included": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- ✅ Keep under 280 characters
|
||||||
|
- ✅ Include the **Gitea repository link** (NOT GitHub!)
|
||||||
|
- ✅ Use 2-4 relevant hashtags
|
||||||
|
- ✅ Use emojis to make it visually appealing
|
||||||
|
- ✅ Highlight the main benefit/value
|
||||||
|
- ✅ Be enthusiastic but authentic
|
||||||
|
- ✅ Use the exact URL provided to you
|
||||||
|
- ❌ Don't use clickbait or misleading claims
|
||||||
|
- ❌ Don't spam hashtags (max 4)
|
||||||
|
- ❌ Don't make the tweet too long/cluttered
|
||||||
|
- ❌ Don't forget the link!
|
||||||
|
- ❌ **Don't change Gitea URLs to GitHub URLs!**
|
||||||
|
- ❌ **Don't mention GitHub when the project is on Gitea!**
|
||||||
69
.opencode/agent/ideator.md
Normal file
69
.opencode/agent/ideator.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
name: ideator
|
||||||
|
description: Discovers innovative project ideas from multiple sources
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ideator Agent
|
||||||
|
|
||||||
|
You are **Ideator**, an AI agent specialized in discovering innovative software project ideas.
|
||||||
|
|
||||||
|
## Your Role
|
||||||
|
|
||||||
|
Search multiple sources (arXiv papers, Reddit, X/Twitter, Hacker News, Product Hunt) to find trending topics and innovative ideas, then generate ONE unique project idea that hasn't been done before.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Search Sources**: Use the search_mcp tools to query each source:
|
||||||
|
- `search_arxiv` - Find recent CS/AI papers with practical applications
|
||||||
|
- `search_reddit` - Check r/programming, r/webdev, r/learnprogramming for trends
|
||||||
|
- `search_hackernews` - Find trending tech discussions
|
||||||
|
- `search_producthunt` - See what products are launching
|
||||||
|
|
||||||
|
2. **Analyze Trends**: Identify patterns, gaps, and opportunities in the market
|
||||||
|
|
||||||
|
3. **Check Duplicates**: Use `database_mcp.get_previous_ideas` to see what ideas have already been generated. NEVER repeat an existing idea.
|
||||||
|
|
||||||
|
4. **Generate Idea**: Create ONE concrete, implementable project idea
|
||||||
|
|
||||||
|
## Submitting Your Idea
|
||||||
|
|
||||||
|
When you have finalized your idea, you MUST use the **submit_idea** tool to save it to the database.
|
||||||
|
|
||||||
|
The `project_id` will be provided to you in the task prompt. Call `submit_idea` with:
|
||||||
|
- `project_id`: The project ID provided in your task (required)
|
||||||
|
- `title`: Short project name (required)
|
||||||
|
- `description`: Detailed description of what the project does (required)
|
||||||
|
- `source`: Where you found inspiration - arxiv, reddit, x, hn, or ph (required)
|
||||||
|
- `tech_stack`: List of technologies like ["python", "fastapi"]
|
||||||
|
- `target_audience`: Who would use this (developers, students, etc.)
|
||||||
|
- `key_features`: List of key features
|
||||||
|
- `complexity`: low, medium, or high
|
||||||
|
- `estimated_time`: Estimated time like "2-4 hours"
|
||||||
|
- `inspiration`: Brief note on what inspired this idea
|
||||||
|
|
||||||
|
**Your task is complete when you successfully call submit_idea with the project_id.**
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- ✅ Generate only ONE idea per run
|
||||||
|
- ✅ Must be fully implementable by an AI developer in a few hours
|
||||||
|
- ✅ Prefer: CLI tools, web apps, libraries, utilities, developer tools
|
||||||
|
- ✅ Ideas should be useful, interesting, and shareable
|
||||||
|
- ❌ Avoid ideas requiring paid external APIs
|
||||||
|
- ❌ Avoid ideas requiring external hardware
|
||||||
|
- ❌ Avoid overly complex ideas (full social networks, games with graphics, etc.)
|
||||||
|
- ❌ Never repeat an idea from the database
|
||||||
|
|
||||||
|
## Good Idea Examples
|
||||||
|
|
||||||
|
- A CLI tool that converts JSON to TypeScript types
|
||||||
|
- A web app that generates color palettes from images
|
||||||
|
- A Python library for parsing and validating cron expressions
|
||||||
|
- A browser extension that summarizes GitHub PRs
|
||||||
|
|
||||||
|
## Bad Idea Examples
|
||||||
|
|
||||||
|
- A full e-commerce platform (too complex)
|
||||||
|
- A mobile app (requires specific SDKs)
|
||||||
|
- An AI chatbot using GPT-4 API (requires paid API)
|
||||||
|
- A game with 3D graphics (too complex)
|
||||||
82
.opencode/agent/planner.md
Normal file
82
.opencode/agent/planner.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: planner
|
||||||
|
description: Creates comprehensive implementation plans for projects
|
||||||
|
---
|
||||||
|
|
||||||
|
# Planner Agent
|
||||||
|
|
||||||
|
You are **Planner**, an expert technical architect who creates detailed, actionable implementation plans.
|
||||||
|
|
||||||
|
## Your Role
|
||||||
|
|
||||||
|
Take the project idea from Ideator and create a comprehensive implementation plan that a Developer agent can follow exactly. Your plans must be complete, specific, and technically sound.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Understand the Idea**: Analyze the project requirements thoroughly
|
||||||
|
2. **Research**: Use search tools to find best practices, libraries, and patterns
|
||||||
|
3. **Design Architecture**: Plan the system structure and data flow
|
||||||
|
4. **Create Plan**: Output a detailed, step-by-step implementation guide
|
||||||
|
|
||||||
|
## Submitting Your Plan
|
||||||
|
|
||||||
|
When you have finalized your implementation plan, you MUST use the **submit_plan** tool to save it to the database.
|
||||||
|
|
||||||
|
The `project_id` will be provided to you in the task prompt. Call `submit_plan` with:
|
||||||
|
- `project_id`: The project ID provided in your task (required)
|
||||||
|
- `project_name`: kebab-case project name (required)
|
||||||
|
- `overview`: 2-3 sentence summary of what will be built (required)
|
||||||
|
- `display_name`: Human readable project name
|
||||||
|
- `tech_stack`: Dict with language, runtime, framework, and key_dependencies
|
||||||
|
- `file_structure`: Dict with root_files and directories arrays
|
||||||
|
- `features`: List of feature dicts with name, priority, description, implementation_notes
|
||||||
|
- `implementation_steps`: Ordered list of step dicts with step number, title, description, tasks
|
||||||
|
- `testing_strategy`: Dict with unit_tests, integration_tests, test_files, test_commands
|
||||||
|
- `configuration`: Dict with env_variables and config_files
|
||||||
|
- `error_handling`: Dict with common_errors list
|
||||||
|
- `readme_sections`: List of README section titles
|
||||||
|
|
||||||
|
**Your task is complete when you successfully call submit_plan with the project_id.**
|
||||||
|
|
||||||
|
## Planning Guidelines
|
||||||
|
|
||||||
|
### Language Selection
|
||||||
|
- **Python**: Best for CLI tools, data processing, APIs, scripts
|
||||||
|
- **TypeScript**: Best for web apps, Node.js services, browser extensions
|
||||||
|
- **Rust**: Best for performance-critical CLI tools, system utilities
|
||||||
|
- **Go**: Best for networking tools, concurrent services
|
||||||
|
|
||||||
|
### Architecture Principles
|
||||||
|
- Keep it simple - avoid over-engineering
|
||||||
|
- Single responsibility for each file/module
|
||||||
|
- Clear separation of concerns
|
||||||
|
- Minimal external dependencies
|
||||||
|
- Easy to test and maintain
|
||||||
|
|
||||||
|
### File Structure Rules
|
||||||
|
- Flat structure for small projects (<5 files)
|
||||||
|
- Nested structure for larger projects
|
||||||
|
- Tests mirror source structure
|
||||||
|
- Configuration at root level
|
||||||
|
|
||||||
|
## Quality Checklist
|
||||||
|
|
||||||
|
Before outputting, verify:
|
||||||
|
- [ ] All features have clear implementation notes
|
||||||
|
- [ ] File structure is complete and logical
|
||||||
|
- [ ] Dependencies are specific and necessary
|
||||||
|
- [ ] Steps are ordered correctly
|
||||||
|
- [ ] Estimated times are realistic
|
||||||
|
- [ ] Testing strategy is practical
|
||||||
|
- [ ] Error handling is comprehensive
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- ✅ Be extremely specific - no ambiguity
|
||||||
|
- ✅ Include ALL files that need to be created
|
||||||
|
- ✅ Provide exact package versions when possible
|
||||||
|
- ✅ Order implementation steps logically
|
||||||
|
- ✅ Keep scope manageable for AI implementation
|
||||||
|
- ❌ Don't over-engineer simple solutions
|
||||||
|
- ❌ Don't include unnecessary dependencies
|
||||||
|
- ❌ Don't leave any "TBD" or "TODO" items
|
||||||
320
.opencode/agent/tester.md
Normal file
320
.opencode/agent/tester.md
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
---
|
||||||
|
name: tester
|
||||||
|
description: QA engineer that validates code quality and functionality
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tester Agent
|
||||||
|
|
||||||
|
You are **Tester**, an expert QA engineer who validates code quality and functionality.
|
||||||
|
|
||||||
|
## Your Role
|
||||||
|
|
||||||
|
Test the code implemented by Developer. Run linting, type checking, tests, and builds. Report results through the devtest MCP tools so Developer can see exactly what needs to be fixed.
|
||||||
|
|
||||||
|
**Additionally**, after Uploader uploads code to Gitea, verify that Gitea Actions CI/CD passes successfully.
|
||||||
|
|
||||||
|
## Communication with Developer
|
||||||
|
|
||||||
|
You communicate with the Developer agent through the devtest MCP tools:
|
||||||
|
|
||||||
|
### Checking Implementation Status
|
||||||
|
Use `get_implementation_status` to see what Developer did:
|
||||||
|
```
|
||||||
|
get_implementation_status(project_id=<your_project_id>)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Submitting Test Results (REQUIRED)
|
||||||
|
After running tests, you MUST use `submit_test_result` to report:
|
||||||
|
```
|
||||||
|
submit_test_result(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="PASS" or "FAIL",
|
||||||
|
summary="Brief description of results",
|
||||||
|
checks_performed=[...],
|
||||||
|
bugs=[...], # If any
|
||||||
|
ready_for_upload=True # Only if PASS
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Full Context
|
||||||
|
Use `get_project_context` to see the complete project state:
|
||||||
|
```
|
||||||
|
get_project_context(project_id=<your_project_id>)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Communication with Uploader (CI/CD Verification)
|
||||||
|
|
||||||
|
After Uploader pushes code, verify Gitea Actions CI/CD status:
|
||||||
|
|
||||||
|
### Checking Upload Status
|
||||||
|
Use `get_upload_status` to see what Uploader did:
|
||||||
|
```
|
||||||
|
get_upload_status(project_id=<your_project_id>)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking Gitea Actions Status
|
||||||
|
Use `get_latest_workflow_status` to check CI/CD:
|
||||||
|
```
|
||||||
|
get_latest_workflow_status(repo="project-name", branch="main")
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns status: "passed", "failed", "pending", or "none"
|
||||||
|
|
||||||
|
### Getting Failed Job Details
|
||||||
|
If CI failed, use `get_workflow_run_jobs` for details:
|
||||||
|
```
|
||||||
|
get_workflow_run_jobs(repo="project-name", run_id=<run_id>)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Submitting CI Result (REQUIRED after CI check)
|
||||||
|
After checking CI/CD, you MUST use `submit_ci_result`:
|
||||||
|
```
|
||||||
|
submit_ci_result(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="PASS" or "FAIL" or "PENDING",
|
||||||
|
repo_name="project-name",
|
||||||
|
gitea_url="https://7000pct.gitea.bloupla.net/user/project-name",
|
||||||
|
run_id=123,
|
||||||
|
run_url="https://7000pct.gitea.bloupla.net/user/project-name/actions/runs/123",
|
||||||
|
summary="Brief description",
|
||||||
|
failed_jobs=[...], # If failed
|
||||||
|
error_logs="..." # If failed
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Process
|
||||||
|
|
||||||
|
### Local Testing (Before Upload)
|
||||||
|
|
||||||
|
1. **Static Analysis**
|
||||||
|
- Run linter (ruff, eslint, clippy, golangci-lint)
|
||||||
|
- Run type checker (mypy, tsc, cargo check)
|
||||||
|
- Check for security issues
|
||||||
|
|
||||||
|
2. **Build Verification**
|
||||||
|
- Verify the project builds/compiles
|
||||||
|
- Check all dependencies resolve correctly
|
||||||
|
|
||||||
|
3. **Functional Testing**
|
||||||
|
- Run unit tests
|
||||||
|
- Run integration tests
|
||||||
|
- Test main functionality manually if needed
|
||||||
|
|
||||||
|
4. **Code Review**
|
||||||
|
- Check for obvious bugs
|
||||||
|
- Verify error handling exists
|
||||||
|
- Ensure code matches the plan
|
||||||
|
|
||||||
|
### CI/CD Verification (After Upload)
|
||||||
|
|
||||||
|
1. **Check Upload Status**
|
||||||
|
- Use `get_upload_status` to get repo info
|
||||||
|
|
||||||
|
2. **Wait for CI to Start**
|
||||||
|
- CI may take a moment to trigger after push
|
||||||
|
|
||||||
|
3. **Check Workflow Status**
|
||||||
|
- Use `get_latest_workflow_status`
|
||||||
|
- If "pending", wait and check again
|
||||||
|
- If "passed", CI is successful
|
||||||
|
- If "failed", get details
|
||||||
|
|
||||||
|
4. **Report CI Result**
|
||||||
|
- Use `submit_ci_result` with detailed info
|
||||||
|
- Include failed job names and error logs if failed
|
||||||
|
|
||||||
|
## Commands by Language
|
||||||
|
|
||||||
|
### Python
|
||||||
|
```bash
|
||||||
|
# Linting
|
||||||
|
ruff check .
|
||||||
|
# or: flake8 .
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
mypy src/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Build check
|
||||||
|
pip install -e . --dry-run
|
||||||
|
python -c "import package_name"
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript/JavaScript
|
||||||
|
```bash
|
||||||
|
# Linting
|
||||||
|
npm run lint
|
||||||
|
# or: eslint src/
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
npm test
|
||||||
|
# or: npx vitest
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
```bash
|
||||||
|
# Check (fast compile check)
|
||||||
|
cargo check
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Build
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Go
|
||||||
|
```bash
|
||||||
|
# Vet
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Build
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
### Local Testing - If All Tests Pass
|
||||||
|
|
||||||
|
```
|
||||||
|
submit_test_result(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="PASS",
|
||||||
|
summary="All tests passed successfully",
|
||||||
|
checks_performed=[
|
||||||
|
{"check": "linting", "result": "pass", "details": "No issues found"},
|
||||||
|
{"check": "type_check", "result": "pass", "details": "No type errors"},
|
||||||
|
{"check": "unit_tests", "result": "pass", "details": "15/15 tests passed"},
|
||||||
|
{"check": "build", "result": "pass", "details": "Build successful"}
|
||||||
|
],
|
||||||
|
code_quality={
|
||||||
|
"error_handling": "adequate",
|
||||||
|
"documentation": "good",
|
||||||
|
"test_coverage": "acceptable"
|
||||||
|
},
|
||||||
|
ready_for_upload=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Testing - If Tests Fail
|
||||||
|
|
||||||
|
```
|
||||||
|
submit_test_result(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="FAIL",
|
||||||
|
summary="Found 2 critical issues that must be fixed",
|
||||||
|
checks_performed=[
|
||||||
|
{"check": "linting", "result": "pass", "details": "No issues"},
|
||||||
|
{"check": "type_check", "result": "fail", "details": "3 type errors"},
|
||||||
|
{"check": "unit_tests", "result": "fail", "details": "2/10 tests failed"},
|
||||||
|
{"check": "build", "result": "pass", "details": "Build successful"}
|
||||||
|
],
|
||||||
|
bugs=[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"severity": "critical", # critical|high|medium|low
|
||||||
|
"type": "type_error", # type_error|runtime_error|logic_error|test_failure
|
||||||
|
"file": "src/main.py",
|
||||||
|
"line": 42,
|
||||||
|
"issue": "Clear description of what's wrong",
|
||||||
|
"error_message": "Actual error output from the tool",
|
||||||
|
"suggestion": "How to fix this issue"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ready_for_upload=False
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD Verification - If CI Passed
|
||||||
|
|
||||||
|
```
|
||||||
|
submit_ci_result(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="PASS",
|
||||||
|
repo_name="project-name",
|
||||||
|
gitea_url="https://7000pct.gitea.bloupla.net/user/project-name",
|
||||||
|
run_id=123,
|
||||||
|
run_url="https://7000pct.gitea.bloupla.net/user/project-name/actions/runs/123",
|
||||||
|
summary="All CI checks passed - tests, linting, and build succeeded"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD Verification - If CI Failed
|
||||||
|
|
||||||
|
```
|
||||||
|
submit_ci_result(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="FAIL",
|
||||||
|
repo_name="project-name",
|
||||||
|
gitea_url="https://7000pct.gitea.bloupla.net/user/project-name",
|
||||||
|
run_id=123,
|
||||||
|
run_url="https://7000pct.gitea.bloupla.net/user/project-name/actions/runs/123",
|
||||||
|
summary="CI failed: test job failed with 2 test failures",
|
||||||
|
failed_jobs=[
|
||||||
|
{
|
||||||
|
"name": "test",
|
||||||
|
"conclusion": "failure",
|
||||||
|
"steps": [
|
||||||
|
{"name": "Run tests", "conclusion": "failure"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
error_logs="FAILED tests/test_main.py::test_parse_input - AssertionError: expected 'foo' but got 'bar'"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Severity Guidelines
|
||||||
|
|
||||||
|
- **Critical**: Prevents compilation/running, crashes, security vulnerabilities
|
||||||
|
- **High**: Major functionality broken, data corruption possible
|
||||||
|
- **Medium**: Feature doesn't work as expected, poor UX
|
||||||
|
- **Low**: Minor issues, style problems, non-critical warnings
|
||||||
|
|
||||||
|
## PASS Criteria
|
||||||
|
|
||||||
|
### Local Testing
|
||||||
|
The project is ready for upload when:
|
||||||
|
- ✅ No linting errors (warnings acceptable)
|
||||||
|
- ✅ No type errors
|
||||||
|
- ✅ All tests pass
|
||||||
|
- ✅ Project builds successfully
|
||||||
|
- ✅ Main functionality works
|
||||||
|
- ✅ No critical or high severity bugs
|
||||||
|
|
||||||
|
### CI/CD Verification
|
||||||
|
The project is ready for promotion when:
|
||||||
|
- ✅ Gitea Actions workflow completed
|
||||||
|
- ✅ All CI jobs passed (status: "success")
|
||||||
|
- ✅ No workflow failures or timeouts
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- ✅ Run ALL applicable checks, not just some
|
||||||
|
- ✅ Provide specific file and line numbers for bugs
|
||||||
|
- ✅ Give actionable suggestions for fixes
|
||||||
|
- ✅ Be thorough but fair - don't fail for minor style issues
|
||||||
|
- ✅ Test the actual main functionality, not just run tests
|
||||||
|
- ✅ ALWAYS use `submit_test_result` for local testing
|
||||||
|
- ✅ ALWAYS use `submit_ci_result` for CI/CD verification
|
||||||
|
- ✅ Include error logs when CI fails
|
||||||
|
- ❌ Don't mark as PASS if there are critical bugs
|
||||||
|
- ❌ Don't be overly strict on warnings
|
||||||
|
- ❌ Don't report the same bug multiple times
|
||||||
|
- ❌ Don't forget to include the project_id in tool calls
|
||||||
228
.opencode/agent/uploader.md
Normal file
228
.opencode/agent/uploader.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
---
|
||||||
|
name: uploader
|
||||||
|
description: DevOps engineer that publishes projects to Gitea
|
||||||
|
---
|
||||||
|
|
||||||
|
# Uploader Agent
|
||||||
|
|
||||||
|
You are **Uploader**, a DevOps engineer who publishes completed projects to Gitea.
|
||||||
|
|
||||||
|
## Your Role
|
||||||
|
|
||||||
|
Take the completed, tested project and publish it to Gitea with proper documentation, CI/CD workflows, and release configuration. After uploading, notify the Tester to verify CI/CD status.
|
||||||
|
|
||||||
|
## Communication with Other Agents
|
||||||
|
|
||||||
|
### Notifying Tester After Upload
|
||||||
|
After uploading, use `submit_upload_status` to inform the Tester:
|
||||||
|
```
|
||||||
|
submit_upload_status(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="completed",
|
||||||
|
repo_name="project-name",
|
||||||
|
gitea_url="https://7000pct.gitea.bloupla.net/username/project-name",
|
||||||
|
files_pushed=["README.md", "src/main.py", ...],
|
||||||
|
commit_sha="abc1234"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Re-uploading After CI Fixes
|
||||||
|
When Developer has fixed CI/CD issues, use `get_ci_result` to see what was fixed:
|
||||||
|
```
|
||||||
|
get_ci_result(project_id=<your_project_id>)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then push only the changed files and notify Tester again.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
### Initial Upload
|
||||||
|
1. **Create Repository**
|
||||||
|
- Create a new public repository on Gitea
|
||||||
|
- Use a clean, descriptive name (kebab-case)
|
||||||
|
- Add a good description
|
||||||
|
|
||||||
|
2. **Prepare Documentation**
|
||||||
|
- Write comprehensive README.md
|
||||||
|
- Include installation, usage, and examples
|
||||||
|
- Add badges for build status, version, etc.
|
||||||
|
|
||||||
|
3. **Set Up CI/CD**
|
||||||
|
- Create Gitea Actions workflow
|
||||||
|
- Configure automated testing
|
||||||
|
- Set up release automation if applicable
|
||||||
|
|
||||||
|
4. **Push Code**
|
||||||
|
- Push all project files
|
||||||
|
- Create initial release/tag if ready
|
||||||
|
|
||||||
|
5. **Notify Tester**
|
||||||
|
- Use `submit_upload_status` tool to notify Tester
|
||||||
|
- Include the Gitea repository URL
|
||||||
|
|
||||||
|
### Re-upload After CI Fix
|
||||||
|
1. **Check What Was Fixed**
|
||||||
|
- Use `get_ci_result` to see CI failure details
|
||||||
|
- Use `get_implementation_status` to see Developer's fixes
|
||||||
|
|
||||||
|
2. **Push Fixes**
|
||||||
|
- Push only the modified files
|
||||||
|
- Use meaningful commit message (e.g., "fix: resolve CI test failures")
|
||||||
|
|
||||||
|
3. **Notify Tester**
|
||||||
|
- Use `submit_upload_status` again
|
||||||
|
- Tester will re-check CI/CD status
|
||||||
|
|
||||||
|
## README Template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Project Name
|
||||||
|
|
||||||
|
Brief description of what this project does.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✨ Feature 1
|
||||||
|
- 🚀 Feature 2
|
||||||
|
- 🔧 Feature 3
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install project-name
|
||||||
|
# or
|
||||||
|
npm install project-name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from project import main
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Describe any configuration options.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions welcome! Please read the contributing guidelines.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gitea Actions Templates
|
||||||
|
|
||||||
|
### Python Project
|
||||||
|
```yaml
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- run: pip install -e ".[dev]"
|
||||||
|
- run: pytest tests/ -v
|
||||||
|
- run: ruff check .
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript Project
|
||||||
|
```yaml
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release Workflow
|
||||||
|
```yaml
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Create Release
|
||||||
|
uses: https://gitea.com/actions/release-action@main
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
dist/**
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
After initial upload:
|
||||||
|
```
|
||||||
|
submit_upload_status(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="completed",
|
||||||
|
repo_name="repo-name",
|
||||||
|
gitea_url="https://7000pct.gitea.bloupla.net/username/repo-name",
|
||||||
|
files_pushed=["README.md", "src/main.py", ".gitea/workflows/ci.yml"],
|
||||||
|
commit_sha="abc1234",
|
||||||
|
message="Initial upload with CI/CD workflow"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
After re-upload (CI fix):
|
||||||
|
```
|
||||||
|
submit_upload_status(
|
||||||
|
project_id=<your_project_id>,
|
||||||
|
status="completed",
|
||||||
|
repo_name="repo-name",
|
||||||
|
gitea_url="https://7000pct.gitea.bloupla.net/username/repo-name",
|
||||||
|
files_pushed=["src/main.py", "tests/test_main.py"],
|
||||||
|
commit_sha="def5678",
|
||||||
|
message="Fixed CI test failures"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- ✅ Always create a comprehensive README
|
||||||
|
- ✅ Include LICENSE file (default: MIT)
|
||||||
|
- ✅ Add .gitignore appropriate for the language
|
||||||
|
- ✅ Set up CI workflow for automated testing
|
||||||
|
- ✅ Create meaningful commit messages
|
||||||
|
- ✅ Use semantic versioning for releases
|
||||||
|
- ✅ ALWAYS use `submit_upload_status` after uploading
|
||||||
|
- ✅ Use Gitea URLs (not GitHub URLs)
|
||||||
|
- ❌ Don't push sensitive data (API keys, secrets)
|
||||||
|
- ❌ Don't create private repositories (must be public)
|
||||||
|
- ❌ Don't skip documentation
|
||||||
|
- ❌ Don't forget to notify Tester after upload
|
||||||
39
.pre-commit-config.yaml
Normal file
39
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.5.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-added-large-files
|
||||||
|
- id: detect-private-key
|
||||||
|
- id: fix-byte-order-marker
|
||||||
|
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 24.1.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
language_version: python3.9
|
||||||
|
|
||||||
|
- repo: https://github.com/pycqa/isort
|
||||||
|
rev: 5.13.2
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
|
||||||
|
- repo: https://github.com/pycqa/flake8
|
||||||
|
rev: 7.0.0
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.8.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
additional_dependencies: [types-requests, types-pyyaml]
|
||||||
|
args: [--ignore-missing-imports, --disallow-untyped-defs]
|
||||||
|
|
||||||
|
ci:
|
||||||
|
autofix_commit_msg: |
|
||||||
|
style: pre-commit fixes
|
||||||
|
autofix_pr_body: |
|
||||||
|
{{$message}}
|
||||||
48
CHANGELOG.md
Normal file
48
CHANGELOG.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2024-01-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release of Auto README Generator CLI
|
||||||
|
- Project structure analysis
|
||||||
|
- Multi-language support (Python, JavaScript, Go, Rust)
|
||||||
|
- Dependency detection from various format files
|
||||||
|
- Tree-sitter based code analysis
|
||||||
|
- Jinja2 template system for README generation
|
||||||
|
- Interactive customization mode
|
||||||
|
- GitHub Actions workflow generation
|
||||||
|
- Configuration file support (.readmerc)
|
||||||
|
- Comprehensive test suite
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Automatic README.md generation
|
||||||
|
- Support for multiple project types
|
||||||
|
- Configurable templates
|
||||||
|
- Interactive prompts
|
||||||
|
- Git integration
|
||||||
|
- Pretty console output with Rich
|
||||||
|
|
||||||
|
### Supported File Types
|
||||||
|
|
||||||
|
- Python: `.py`, `.pyi` files
|
||||||
|
- JavaScript: `.js`, `.jsx`, `.mjs`, `.cjs` files
|
||||||
|
- TypeScript: `.ts`, `.tsx` files
|
||||||
|
- Go: `.go` files
|
||||||
|
- Rust: `.rs` files
|
||||||
|
|
||||||
|
### Dependency Parsers
|
||||||
|
|
||||||
|
- requirements.txt
|
||||||
|
- pyproject.toml
|
||||||
|
- package.json
|
||||||
|
- go.mod
|
||||||
|
- Cargo.toml
|
||||||
56
Dockerfile
Normal file
56
Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 7000%AUTO - AI Autonomous Development System
|
||||||
|
# Dockerfile for Railway deployment
|
||||||
|
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONPATH=/app \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js 20.x (LTS) via NodeSource for OpenCode CLI
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||||
|
apt-get install -y nodejs && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install OpenCode CLI globally via npm
|
||||||
|
RUN npm install -g opencode-ai
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p /app/data /app/workspace /app/logs
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN useradd -m -u 1000 appuser && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "main.py"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Auto README Team
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
252
README.md
Normal file
252
README.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# Auto README Generator CLI
|
||||||
|
|
||||||
|
[](https://pypi.org/project/auto-readme-cli/)
|
||||||
|
[](https://pypi.org/project/auto-readme-cli/)
|
||||||
|
[](https://opensource.org/licenses/MIT/)
|
||||||
|
|
||||||
|
A powerful CLI tool that automatically generates comprehensive README.md files by analyzing your project structure, dependencies, code patterns, and imports.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Automatic Project Analysis**: Scans directory structure to identify files, folders, and patterns
|
||||||
|
- **Multi-Language Support**: Supports Python, JavaScript, Go, and Rust projects
|
||||||
|
- **Dependency Detection**: Parses requirements.txt, package.json, go.mod, and Cargo.toml
|
||||||
|
- **Code Analysis**: Uses tree-sitter to extract functions, classes, and imports
|
||||||
|
- **Template-Based Generation**: Creates well-formatted README files using Jinja2 templates
|
||||||
|
- **Interactive Mode**: Customize your README with interactive prompts
|
||||||
|
- **GitHub Actions Integration**: Generate workflows for automatic README updates
|
||||||
|
- **Configuration Files**: Use `.readmerc` files to customize generation behavior
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From PyPI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install auto-readme-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/auto-readme-cli.git
|
||||||
|
cd auto-readme-cli
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Generate a README for your project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
auto-readme generate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate with specific options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
auto-readme generate --input /path/to/project --output README.md --template base
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
auto-readme generate --interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preview README without writing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
auto-readme preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### generate
|
||||||
|
|
||||||
|
Generate a README.md file for your project.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
auto-readme generate [OPTIONS]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `-i, --input DIRECTORY` | Input directory to analyze (default: current directory) |
|
||||||
|
| `-o, --output FILE` | Output file path (default: README.md) |
|
||||||
|
| `-I, --interactive` | Run in interactive mode |
|
||||||
|
| `-t, --template TEMPLATE` | Template to use (base, minimal, detailed) |
|
||||||
|
| `-c, --config FILE` | Path to configuration file |
|
||||||
|
| `--github-actions` | Generate GitHub Actions workflow |
|
||||||
|
| `-f, --force` | Force overwrite existing README |
|
||||||
|
| `--dry-run` | Preview without writing file |
|
||||||
|
|
||||||
|
### preview
|
||||||
|
|
||||||
|
Preview the generated README without writing to file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
auto-readme preview [OPTIONS]
|
||||||
|
```
|
||||||
|
|
||||||
|
### analyze
|
||||||
|
|
||||||
|
Analyze a project and display information.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
auto-readme analyze [PATH]
|
||||||
|
```
|
||||||
|
|
||||||
|
### init-config
|
||||||
|
|
||||||
|
Generate a template configuration file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
auto-readme init-config --output .readmerc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Configuration File (.readmerc)
|
||||||
|
|
||||||
|
Create a `.readmerc` file in your project root to customize README generation:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
project_name: "My Project"
|
||||||
|
description: "A brief description of your project"
|
||||||
|
template: "base"
|
||||||
|
interactive: false
|
||||||
|
|
||||||
|
sections:
|
||||||
|
order:
|
||||||
|
- title
|
||||||
|
- description
|
||||||
|
- overview
|
||||||
|
- installation
|
||||||
|
- usage
|
||||||
|
- features
|
||||||
|
- api
|
||||||
|
- contributing
|
||||||
|
- license
|
||||||
|
|
||||||
|
custom_fields:
|
||||||
|
author: "Your Name"
|
||||||
|
email: "your.email@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### pyproject.toml Configuration
|
||||||
|
|
||||||
|
You can also configure auto-readme in your `pyproject.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.auto-readme]
|
||||||
|
filename = "README.md"
|
||||||
|
sections = ["title", "description", "installation", "usage", "api"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Languages
|
||||||
|
|
||||||
|
| Language | Markers | Dependency Files |
|
||||||
|
|----------|---------|-----------------|
|
||||||
|
| Python | pyproject.toml, setup.py, requirements.txt | requirements.txt, pyproject.toml |
|
||||||
|
| JavaScript | package.json | package.json |
|
||||||
|
| TypeScript | package.json, tsconfig.json | package.json |
|
||||||
|
| Go | go.mod | go.mod |
|
||||||
|
| Rust | Cargo.toml | Cargo.toml |
|
||||||
|
|
||||||
|
## Template System
|
||||||
|
|
||||||
|
The tool uses Jinja2 templates for README generation. Built-in templates:
|
||||||
|
|
||||||
|
- **base**: Standard README with all sections
|
||||||
|
- **minimal**: Basic README with essential information
|
||||||
|
- **detailed**: Comprehensive README with extensive documentation
|
||||||
|
|
||||||
|
### Custom Templates
|
||||||
|
|
||||||
|
You can create custom templates by placing `.md.j2` files in a `templates` directory and specifying the path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
auto-readme generate --template /path/to/custom_template.md.j2
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub Actions Integration
|
||||||
|
|
||||||
|
Generate a GitHub Actions workflow to automatically update your README:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
auto-readme generate --github-actions
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `.github/workflows/readme-update.yml` that runs on:
|
||||||
|
- Push to main/master branch
|
||||||
|
- Changes to source files
|
||||||
|
- Manual workflow dispatch
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
auto-readme-cli/
|
||||||
|
├── src/
|
||||||
|
│ └── auto_readme/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── cli.py # Main CLI interface
|
||||||
|
│ ├── models/ # Data models
|
||||||
|
│ ├── parsers/ # Dependency parsers
|
||||||
|
│ ├── analyzers/ # Code analyzers
|
||||||
|
│ ├── templates/ # Jinja2 templates
|
||||||
|
│ ├── utils/ # Utility functions
|
||||||
|
│ ├── config/ # Configuration handling
|
||||||
|
│ ├── interactive/ # Interactive wizard
|
||||||
|
│ └── github/ # GitHub Actions integration
|
||||||
|
├── tests/ # Test suite
|
||||||
|
├── pyproject.toml
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Setting up Development Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/auto-readme-cli.git
|
||||||
|
cd auto-readme-cli
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -xvs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Formatting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
black src/ tests/
|
||||||
|
isort src/ tests/
|
||||||
|
flake8 src/ tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated with ❤️ by Auto README Generator CLI*
|
||||||
121
config.py
Normal file
121
config.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
7000%AUTO Configuration Module
|
||||||
|
Environment-based configuration using Pydantic Settings
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic import Field
|
||||||
|
from typing import Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings loaded from environment variables"""
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_NAME: str = "7000%AUTO"
|
||||||
|
DEBUG: bool = False
|
||||||
|
LOG_LEVEL: str = "INFO"
|
||||||
|
|
||||||
|
# OpenCode AI Settings (Required - no defaults)
|
||||||
|
# Users MUST set these environment variables:
|
||||||
|
# OPENCODE_API_KEY - API key for your AI provider
|
||||||
|
# OPENCODE_API_BASE - API base URL (e.g. https://api.minimax.io/anthropic/v1)
|
||||||
|
# OPENCODE_SDK - AI SDK npm package (e.g. @ai-sdk/anthropic, @ai-sdk/openai)
|
||||||
|
# OPENCODE_MODEL - Model name (e.g. MiniMax-M2.1, gpt-4o)
|
||||||
|
# OPENCODE_MAX_TOKENS - Max output tokens (e.g. 196608)
|
||||||
|
OPENCODE_API_KEY: str = Field(default="", description="API key for your AI provider (REQUIRED)")
|
||||||
|
OPENCODE_API_BASE: str = Field(default="", description="API base URL (REQUIRED)")
|
||||||
|
OPENCODE_SDK: str = Field(default="", description="AI SDK npm package (REQUIRED, e.g. @ai-sdk/anthropic, @ai-sdk/openai)")
|
||||||
|
OPENCODE_MODEL: str = Field(default="", description="Model name to use (REQUIRED)")
|
||||||
|
OPENCODE_MAX_TOKENS: int = Field(default=0, description="Maximum output tokens for AI responses (REQUIRED)")
|
||||||
|
|
||||||
|
# OpenCode Server
|
||||||
|
OPENCODE_SERVER_URL: Optional[str] = Field(default=None, description="OpenCode server URL (default: http://127.0.0.1:18080)")
|
||||||
|
|
||||||
|
# Gitea
|
||||||
|
GITEA_TOKEN: str = Field(default="", description="Gitea Personal Access Token")
|
||||||
|
GITEA_USERNAME: Optional[str] = Field(default=None, description="Gitea username for repo creation")
|
||||||
|
GITEA_URL: str = Field(default="https://7000pct.gitea.bloupla.net", description="Gitea server URL")
|
||||||
|
|
||||||
|
# X (Twitter) API
|
||||||
|
X_API_KEY: str = Field(default="", description="X API Key (Consumer Key)")
|
||||||
|
X_API_SECRET: str = Field(default="", description="X API Secret (Consumer Secret)")
|
||||||
|
X_ACCESS_TOKEN: str = Field(default="", description="X Access Token")
|
||||||
|
X_ACCESS_TOKEN_SECRET: str = Field(default="", description="X Access Token Secret")
|
||||||
|
X_BEARER_TOKEN: Optional[str] = Field(default=None, description="X Bearer Token for API v2")
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = Field(
|
||||||
|
default="sqlite+aiosqlite:///./data/7000auto.db",
|
||||||
|
description="Database connection URL"
|
||||||
|
)
|
||||||
|
DATABASE_ECHO: bool = Field(default=False, description="Echo SQL queries")
|
||||||
|
|
||||||
|
# Workspace
|
||||||
|
WORKSPACE_DIR: Path = Field(
|
||||||
|
default=Path("./workspace"),
|
||||||
|
description="Directory for project workspaces"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Web Server
|
||||||
|
HOST: str = Field(default="0.0.0.0", description="Server host")
|
||||||
|
PORT: int = Field(default=8000, description="Server port")
|
||||||
|
|
||||||
|
# Orchestrator
|
||||||
|
AUTO_START: bool = Field(default=True, description="Auto-start orchestrator on boot")
|
||||||
|
MAX_CONCURRENT_PROJECTS: int = Field(default=1, description="Max concurrent projects")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
case_sensitive = True
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
def ensure_directories(self):
|
||||||
|
"""Create necessary directories"""
|
||||||
|
self.WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
Path("./data").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_gitea_configured(self) -> bool:
|
||||||
|
return bool(self.GITEA_TOKEN)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_x_configured(self) -> bool:
|
||||||
|
return all([
|
||||||
|
self.X_API_KEY,
|
||||||
|
self.X_API_SECRET,
|
||||||
|
self.X_ACCESS_TOKEN,
|
||||||
|
self.X_ACCESS_TOKEN_SECRET
|
||||||
|
])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_opencode_configured(self) -> bool:
|
||||||
|
"""Check if all required OpenCode settings are configured"""
|
||||||
|
return all([
|
||||||
|
self.OPENCODE_API_KEY,
|
||||||
|
self.OPENCODE_API_BASE,
|
||||||
|
self.OPENCODE_SDK,
|
||||||
|
self.OPENCODE_MODEL,
|
||||||
|
self.OPENCODE_MAX_TOKENS > 0,
|
||||||
|
])
|
||||||
|
|
||||||
|
def get_missing_opencode_settings(self) -> list[str]:
|
||||||
|
"""Return list of missing required OpenCode settings"""
|
||||||
|
missing = []
|
||||||
|
if not self.OPENCODE_API_KEY:
|
||||||
|
missing.append("OPENCODE_API_KEY")
|
||||||
|
if not self.OPENCODE_API_BASE:
|
||||||
|
missing.append("OPENCODE_API_BASE")
|
||||||
|
if not self.OPENCODE_SDK:
|
||||||
|
missing.append("OPENCODE_SDK")
|
||||||
|
if not self.OPENCODE_MODEL:
|
||||||
|
missing.append("OPENCODE_MODEL")
|
||||||
|
if self.OPENCODE_MAX_TOKENS <= 0:
|
||||||
|
missing.append("OPENCODE_MAX_TOKENS")
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
# Global settings instance
|
||||||
|
settings = Settings()
|
||||||
77
database/__init__.py
Normal file
77
database/__init__.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
7000%AUTO Database Module
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .models import Base, Idea, Project, AgentLog, IdeaSource, ProjectStatus, LogType
|
||||||
|
|
||||||
|
from .db import (
|
||||||
|
init_db,
|
||||||
|
close_db,
|
||||||
|
get_db,
|
||||||
|
async_session_factory,
|
||||||
|
# Idea operations
|
||||||
|
create_idea,
|
||||||
|
get_idea_by_id,
|
||||||
|
get_unused_ideas,
|
||||||
|
mark_idea_used,
|
||||||
|
# Project operations
|
||||||
|
create_project,
|
||||||
|
get_project_by_id,
|
||||||
|
get_active_project,
|
||||||
|
update_project_status,
|
||||||
|
get_project_idea_json,
|
||||||
|
get_project_plan_json,
|
||||||
|
set_project_idea_json,
|
||||||
|
set_project_plan_json,
|
||||||
|
# DevTest operations (Developer-Tester communication)
|
||||||
|
get_project_test_result_json,
|
||||||
|
set_project_test_result_json,
|
||||||
|
get_project_implementation_status_json,
|
||||||
|
set_project_implementation_status_json,
|
||||||
|
clear_project_devtest_state,
|
||||||
|
# Logging
|
||||||
|
add_agent_log,
|
||||||
|
get_recent_logs,
|
||||||
|
get_project_logs,
|
||||||
|
# Stats
|
||||||
|
get_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Models
|
||||||
|
"Base",
|
||||||
|
"Idea",
|
||||||
|
"Project",
|
||||||
|
"AgentLog",
|
||||||
|
"IdeaSource",
|
||||||
|
"ProjectStatus",
|
||||||
|
"LogType",
|
||||||
|
# DB operations
|
||||||
|
"init_db",
|
||||||
|
"close_db",
|
||||||
|
"get_db",
|
||||||
|
"async_session_factory",
|
||||||
|
"create_idea",
|
||||||
|
"get_idea_by_id",
|
||||||
|
"get_unused_ideas",
|
||||||
|
"mark_idea_used",
|
||||||
|
"create_project",
|
||||||
|
"get_project_by_id",
|
||||||
|
"get_active_project",
|
||||||
|
"update_project_status",
|
||||||
|
"get_project_idea_json",
|
||||||
|
"get_project_plan_json",
|
||||||
|
"set_project_idea_json",
|
||||||
|
"set_project_plan_json",
|
||||||
|
# DevTest operations
|
||||||
|
"get_project_test_result_json",
|
||||||
|
"set_project_test_result_json",
|
||||||
|
"get_project_implementation_status_json",
|
||||||
|
"set_project_implementation_status_json",
|
||||||
|
"clear_project_devtest_state",
|
||||||
|
# Logging
|
||||||
|
"add_agent_log",
|
||||||
|
"get_recent_logs",
|
||||||
|
"get_project_logs",
|
||||||
|
"get_stats",
|
||||||
|
]
|
||||||
602
database/db.py
Normal file
602
database/db.py
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
"""
|
||||||
|
7000%AUTO Database Operations
|
||||||
|
Async SQLAlchemy database setup and CRUD operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
from .models import Base, Idea, Project, AgentLog, ProjectStatus, LogType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global engine and session factory
|
||||||
|
_engine = None
|
||||||
|
_session_factory = None
|
||||||
|
|
||||||
|
|
||||||
|
def async_session_factory():
|
||||||
|
"""Get the async session factory for direct use"""
|
||||||
|
if _session_factory is None:
|
||||||
|
raise RuntimeError("Database not initialized. Call init_db() first.")
|
||||||
|
return _session_factory()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db(database_url: Optional[str] = None):
|
||||||
|
"""Initialize database engine and create tables"""
|
||||||
|
global _engine, _session_factory
|
||||||
|
|
||||||
|
if database_url is None:
|
||||||
|
from config import settings
|
||||||
|
database_url = settings.DATABASE_URL
|
||||||
|
|
||||||
|
# Convert postgres:// to postgresql+asyncpg:// if needed
|
||||||
|
if database_url.startswith("postgres://"):
|
||||||
|
database_url = database_url.replace("postgres://", "postgresql+asyncpg://", 1)
|
||||||
|
elif database_url.startswith("postgresql://") and "+asyncpg" not in database_url:
|
||||||
|
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
|
||||||
|
_engine = create_async_engine(
|
||||||
|
database_url,
|
||||||
|
echo=False,
|
||||||
|
pool_pre_ping=True
|
||||||
|
)
|
||||||
|
|
||||||
|
_session_factory = async_sessionmaker(
|
||||||
|
_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tables
|
||||||
|
async with _engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db():
|
||||||
|
"""Close database connection"""
|
||||||
|
global _engine, _session_factory
|
||||||
|
if _engine:
|
||||||
|
await _engine.dispose()
|
||||||
|
_engine = None
|
||||||
|
_session_factory = None
|
||||||
|
logger.info("Database connection closed")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def get_db():
|
||||||
|
"""Get database session context manager"""
|
||||||
|
if _session_factory is None:
|
||||||
|
raise RuntimeError("Database not initialized. Call init_db() first.")
|
||||||
|
|
||||||
|
async with _session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Idea CRUD Operations
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def create_idea(
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
source: str,
|
||||||
|
session: Optional[AsyncSession] = None
|
||||||
|
) -> Idea:
|
||||||
|
"""Create a new idea"""
|
||||||
|
async def _create(s: AsyncSession) -> Idea:
|
||||||
|
idea = Idea(
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
source=source if isinstance(source, str) else source.value
|
||||||
|
)
|
||||||
|
s.add(idea)
|
||||||
|
await s.flush()
|
||||||
|
await s.refresh(idea)
|
||||||
|
return idea
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _create(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _create(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_idea_by_id(idea_id: int, session: Optional[AsyncSession] = None) -> Optional[Idea]:
|
||||||
|
"""Get idea by ID"""
|
||||||
|
async def _get(s: AsyncSession) -> Optional[Idea]:
|
||||||
|
result = await s.execute(select(Idea).where(Idea.id == idea_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_unused_ideas(
|
||||||
|
limit: int = 10,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
session: Optional[AsyncSession] = None
|
||||||
|
) -> List[Idea]:
|
||||||
|
"""Get unused ideas"""
|
||||||
|
async def _get(s: AsyncSession) -> List[Idea]:
|
||||||
|
query = select(Idea).where(Idea.used == False)
|
||||||
|
if source:
|
||||||
|
query = query.where(Idea.source == source)
|
||||||
|
query = query.order_by(Idea.created_at.desc()).limit(limit)
|
||||||
|
result = await s.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_idea_used(idea_id: int, session: Optional[AsyncSession] = None) -> bool:
|
||||||
|
"""Mark an idea as used"""
|
||||||
|
async def _mark(s: AsyncSession) -> bool:
|
||||||
|
result = await s.execute(select(Idea).where(Idea.id == idea_id))
|
||||||
|
idea = result.scalar_one_or_none()
|
||||||
|
if idea:
|
||||||
|
idea.used = True
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _mark(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _mark(s)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Project CRUD Operations
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def create_project(
|
||||||
|
idea_id: int,
|
||||||
|
name: str,
|
||||||
|
plan_json: Optional[dict] = None,
|
||||||
|
session: Optional[AsyncSession] = None
|
||||||
|
) -> Project:
|
||||||
|
"""Create a new project"""
|
||||||
|
async def _create(s: AsyncSession) -> Project:
|
||||||
|
project = Project(
|
||||||
|
idea_id=idea_id,
|
||||||
|
name=name,
|
||||||
|
plan_json=plan_json,
|
||||||
|
status=ProjectStatus.IDEATION.value
|
||||||
|
)
|
||||||
|
s.add(project)
|
||||||
|
await s.flush()
|
||||||
|
await s.refresh(project)
|
||||||
|
return project
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _create(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _create(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_by_id(project_id: int, session: Optional[AsyncSession] = None) -> Optional[Project]:
|
||||||
|
"""Get project by ID"""
|
||||||
|
async def _get(s: AsyncSession) -> Optional[Project]:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_project(session: Optional[AsyncSession] = None) -> Optional[Project]:
|
||||||
|
"""Get the currently active project (not completed/failed)"""
|
||||||
|
async def _get(s: AsyncSession) -> Optional[Project]:
|
||||||
|
query = select(Project).where(
|
||||||
|
Project.status.notin_([ProjectStatus.COMPLETED.value, ProjectStatus.FAILED.value])
|
||||||
|
).order_by(Project.created_at.desc()).limit(1)
|
||||||
|
result = await s.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_project_status(
|
||||||
|
project_id: int,
|
||||||
|
status: str,
|
||||||
|
gitea_url: Optional[str] = None,
|
||||||
|
x_post_url: Optional[str] = None,
|
||||||
|
dev_test_iterations: Optional[int] = None,
|
||||||
|
ci_test_iterations: Optional[int] = None,
|
||||||
|
current_agent: Optional[str] = None,
|
||||||
|
plan_json: Optional[dict] = None,
|
||||||
|
idea_json: Optional[dict] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
session: Optional[AsyncSession] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Update project status and optional fields"""
|
||||||
|
async def _update(s: AsyncSession) -> bool:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
project.status = status if isinstance(status, str) else status.value
|
||||||
|
if gitea_url is not None:
|
||||||
|
project.gitea_url = gitea_url
|
||||||
|
if x_post_url is not None:
|
||||||
|
project.x_post_url = x_post_url
|
||||||
|
if dev_test_iterations is not None:
|
||||||
|
project.dev_test_iterations = dev_test_iterations
|
||||||
|
if ci_test_iterations is not None:
|
||||||
|
project.ci_test_iterations = ci_test_iterations
|
||||||
|
if current_agent is not None:
|
||||||
|
project.current_agent = current_agent
|
||||||
|
if plan_json is not None:
|
||||||
|
project.plan_json = plan_json
|
||||||
|
if idea_json is not None:
|
||||||
|
project.idea_json = idea_json
|
||||||
|
if name is not None:
|
||||||
|
project.name = name
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _update(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _update(s)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AgentLog CRUD Operations
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def add_agent_log(
|
||||||
|
project_id: int,
|
||||||
|
agent_name: str,
|
||||||
|
message: str,
|
||||||
|
log_type: str = LogType.INFO.value,
|
||||||
|
session: Optional[AsyncSession] = None
|
||||||
|
) -> AgentLog:
|
||||||
|
"""Add an agent log entry"""
|
||||||
|
async def _add(s: AsyncSession) -> AgentLog:
|
||||||
|
log = AgentLog(
|
||||||
|
project_id=project_id,
|
||||||
|
agent_name=agent_name,
|
||||||
|
message=message,
|
||||||
|
log_type=log_type if isinstance(log_type, str) else log_type.value
|
||||||
|
)
|
||||||
|
s.add(log)
|
||||||
|
await s.flush()
|
||||||
|
await s.refresh(log)
|
||||||
|
return log
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _add(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _add(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_recent_logs(
|
||||||
|
limit: int = 50,
|
||||||
|
log_type: Optional[str] = None,
|
||||||
|
session: Optional[AsyncSession] = None
|
||||||
|
) -> List[AgentLog]:
|
||||||
|
"""Get recent logs across all projects"""
|
||||||
|
async def _get(s: AsyncSession) -> List[AgentLog]:
|
||||||
|
query = select(AgentLog)
|
||||||
|
if log_type:
|
||||||
|
query = query.where(AgentLog.log_type == log_type)
|
||||||
|
query = query.order_by(AgentLog.created_at.desc()).limit(limit)
|
||||||
|
result = await s.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_logs(
|
||||||
|
project_id: int,
|
||||||
|
limit: int = 100,
|
||||||
|
log_type: Optional[str] = None,
|
||||||
|
session: Optional[AsyncSession] = None
|
||||||
|
) -> List[AgentLog]:
|
||||||
|
"""Get logs for a specific project"""
|
||||||
|
async def _get(s: AsyncSession) -> List[AgentLog]:
|
||||||
|
query = select(AgentLog).where(AgentLog.project_id == project_id)
|
||||||
|
if log_type:
|
||||||
|
query = query.where(AgentLog.log_type == log_type)
|
||||||
|
query = query.order_by(AgentLog.created_at.desc()).limit(limit)
|
||||||
|
result = await s.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Statistics
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def get_project_idea_json(project_id: int, session: Optional[AsyncSession] = None) -> Optional[dict]:
|
||||||
|
"""Get the submitted idea JSON for a project"""
|
||||||
|
async def _get(s: AsyncSession) -> Optional[dict]:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
return project.idea_json
|
||||||
|
return None
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_plan_json(project_id: int, session: Optional[AsyncSession] = None) -> Optional[dict]:
|
||||||
|
"""Get the submitted plan JSON for a project"""
|
||||||
|
async def _get(s: AsyncSession) -> Optional[dict]:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
return project.plan_json
|
||||||
|
return None
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_project_idea_json(project_id: int, idea_json: dict, session: Optional[AsyncSession] = None) -> bool:
|
||||||
|
"""Set the idea JSON for a project (called by MCP submit_idea)"""
|
||||||
|
async def _set(s: AsyncSession) -> bool:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
project.idea_json = idea_json
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _set(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _set(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_project_plan_json(project_id: int, plan_json: dict, session: Optional[AsyncSession] = None) -> bool:
|
||||||
|
"""Set the plan JSON for a project (called by MCP submit_plan)"""
|
||||||
|
async def _set(s: AsyncSession) -> bool:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
project.plan_json = plan_json
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _set(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _set(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_test_result_json(project_id: int, session: Optional[AsyncSession] = None) -> Optional[dict]:
|
||||||
|
"""Get the submitted test result JSON for a project"""
|
||||||
|
async def _get(s: AsyncSession) -> Optional[dict]:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
return project.test_result_json
|
||||||
|
return None
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_project_test_result_json(project_id: int, test_result_json: dict, session: Optional[AsyncSession] = None) -> bool:
|
||||||
|
"""Set the test result JSON for a project (called by MCP submit_test_result)"""
|
||||||
|
async def _set(s: AsyncSession) -> bool:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
project.test_result_json = test_result_json
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _set(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _set(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_implementation_status_json(project_id: int, session: Optional[AsyncSession] = None) -> Optional[dict]:
|
||||||
|
"""Get the submitted implementation status JSON for a project"""
|
||||||
|
async def _get(s: AsyncSession) -> Optional[dict]:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
return project.implementation_status_json
|
||||||
|
return None
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_project_implementation_status_json(project_id: int, implementation_status_json: dict, session: Optional[AsyncSession] = None) -> bool:
|
||||||
|
"""Set the implementation status JSON for a project (called by MCP submit_implementation_status)"""
|
||||||
|
async def _set(s: AsyncSession) -> bool:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
project.implementation_status_json = implementation_status_json
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _set(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _set(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_project_devtest_state(project_id: int, session: Optional[AsyncSession] = None) -> bool:
|
||||||
|
"""Clear test result and implementation status for a new dev-test iteration"""
|
||||||
|
async def _clear(s: AsyncSession) -> bool:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
project.test_result_json = None
|
||||||
|
project.implementation_status_json = None
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _clear(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _clear(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_ci_result_json(project_id: int, session: Optional[AsyncSession] = None) -> Optional[dict]:
|
||||||
|
"""Get the submitted CI result JSON for a project"""
|
||||||
|
async def _get(s: AsyncSession) -> Optional[dict]:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
return project.ci_result_json
|
||||||
|
return None
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_project_ci_result_json(project_id: int, ci_result_json: dict, session: Optional[AsyncSession] = None) -> bool:
|
||||||
|
"""Set the CI result JSON for a project (called by MCP submit_ci_result)"""
|
||||||
|
async def _set(s: AsyncSession) -> bool:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
project.ci_result_json = ci_result_json
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _set(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _set(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_upload_status_json(project_id: int, session: Optional[AsyncSession] = None) -> Optional[dict]:
|
||||||
|
"""Get the submitted upload status JSON for a project"""
|
||||||
|
async def _get(s: AsyncSession) -> Optional[dict]:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
return project.upload_status_json
|
||||||
|
return None
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_project_upload_status_json(project_id: int, upload_status_json: dict, session: Optional[AsyncSession] = None) -> bool:
|
||||||
|
"""Set the upload status JSON for a project (called by MCP submit_upload_status)"""
|
||||||
|
async def _set(s: AsyncSession) -> bool:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
project.upload_status_json = upload_status_json
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _set(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _set(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_project_ci_state(project_id: int, session: Optional[AsyncSession] = None) -> bool:
|
||||||
|
"""Clear CI result and upload status for a new CI iteration"""
|
||||||
|
async def _clear(s: AsyncSession) -> bool:
|
||||||
|
result = await s.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
project.ci_result_json = None
|
||||||
|
project.upload_status_json = None
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _clear(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _clear(s)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_stats(session: Optional[AsyncSession] = None) -> dict:
|
||||||
|
"""Get database statistics"""
|
||||||
|
async def _get(s: AsyncSession) -> dict:
|
||||||
|
ideas_count = await s.execute(select(func.count(Idea.id)))
|
||||||
|
projects_count = await s.execute(select(func.count(Project.id)))
|
||||||
|
completed_count = await s.execute(
|
||||||
|
select(func.count(Project.id)).where(Project.status == ProjectStatus.COMPLETED.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_ideas": ideas_count.scalar() or 0,
|
||||||
|
"total_projects": projects_count.scalar() or 0,
|
||||||
|
"completed_projects": completed_count.scalar() or 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if session:
|
||||||
|
return await _get(session)
|
||||||
|
else:
|
||||||
|
async with get_db() as s:
|
||||||
|
return await _get(s)
|
||||||
93
database/models.py
Normal file
93
database/models.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
7000%AUTO Database Models
|
||||||
|
SQLAlchemy ORM models for projects, ideas, and logs
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Base class for all models"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IdeaSource(str, Enum):
|
||||||
|
"""Sources for project ideas"""
|
||||||
|
ARXIV = "arxiv"
|
||||||
|
REDDIT = "reddit"
|
||||||
|
X = "x"
|
||||||
|
HN = "hn"
|
||||||
|
PH = "ph"
|
||||||
|
SYSTEM = "system"
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectStatus(str, Enum):
|
||||||
|
"""Project workflow status"""
|
||||||
|
IDEATION = "ideation"
|
||||||
|
PLANNING = "planning"
|
||||||
|
DEVELOPMENT = "development"
|
||||||
|
TESTING = "testing"
|
||||||
|
UPLOADING = "uploading"
|
||||||
|
PROMOTING = "promoting"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class LogType(str, Enum):
|
||||||
|
"""Types of agent logs"""
|
||||||
|
INFO = "info"
|
||||||
|
ERROR = "error"
|
||||||
|
OUTPUT = "output"
|
||||||
|
DEBUG = "debug"
|
||||||
|
|
||||||
|
|
||||||
|
class Idea(Base):
|
||||||
|
"""Generated project ideas"""
|
||||||
|
__tablename__ = "ideas"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(200))
|
||||||
|
description: Mapped[str] = mapped_column(Text)
|
||||||
|
source: Mapped[str] = mapped_column(String(20)) # arxiv, reddit, x, hn, ph
|
||||||
|
used: Mapped[bool] = mapped_column(default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
"""Projects being developed"""
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
idea_id: Mapped[int] = mapped_column(ForeignKey("ideas.id"))
|
||||||
|
name: Mapped[str] = mapped_column(String(200))
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default=ProjectStatus.IDEATION.value)
|
||||||
|
idea_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # Submitted idea data from MCP
|
||||||
|
plan_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
test_result_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # Submitted test result from Tester MCP
|
||||||
|
implementation_status_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # Submitted status from Developer MCP
|
||||||
|
gitea_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
x_post_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
ci_result_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # CI/CD result from Tester
|
||||||
|
upload_status_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # Upload status from Uploader
|
||||||
|
ci_test_iterations: Mapped[int] = mapped_column(default=0) # Uploader-Tester-Developer CI loop iterations
|
||||||
|
dev_test_iterations: Mapped[int] = mapped_column(default=0)
|
||||||
|
current_agent: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||||
|
completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentLog(Base):
|
||||||
|
"""Logs from agent activities"""
|
||||||
|
__tablename__ = "agent_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"))
|
||||||
|
agent_name: Mapped[str] = mapped_column(String(50))
|
||||||
|
message: Mapped[str] = mapped_column(Text)
|
||||||
|
log_type: Mapped[str] = mapped_column(String(20), default=LogType.INFO.value)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||||
34
local-ai-commit-reviewer/.gitea/workflows/ci.yml
Normal file
34
local-ai-commit-reviewer/.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.10'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run Ruff linting
|
||||||
|
run: ruff check src tests
|
||||||
|
|
||||||
|
- name: Run MyPy type checking
|
||||||
|
run: mypy src
|
||||||
|
|
||||||
|
- name: Run pytest
|
||||||
|
run: pytest tests/ -v --tb=short
|
||||||
138
local-ai-commit-reviewer/.gitignore
vendored
Normal file
138
local-ai-commit-reviewer/.gitignore
vendored
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
.aicr.yaml
|
||||||
|
*.log
|
||||||
19
local-ai-commit-reviewer/CHANGELOG.md
Normal file
19
local-ai-commit-reviewer/CHANGELOG.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.0] - 2024-01-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of Local AI Commit Reviewer CLI
|
||||||
|
- Support for Ollama LLM backend
|
||||||
|
- Staged change analysis and review
|
||||||
|
- Pre-commit hook integration
|
||||||
|
- Multiple strictness levels (permissive, balanced, strict)
|
||||||
|
- Multi-language support (Python, JavaScript, TypeScript, Go, Rust, Java, C, C++)
|
||||||
|
- Rich terminal output with syntax highlighting
|
||||||
|
- JSON and Markdown export capabilities
|
||||||
|
- Configuration management with Pydantic validation
|
||||||
21
local-ai-commit-reviewer/LICENSE
Normal file
21
local-ai-commit-reviewer/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Local AI Commit Reviewer Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
192
local-ai-commit-reviewer/README.md
Normal file
192
local-ai-commit-reviewer/README.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# Local AI Commit Reviewer CLI
|
||||||
|
|
||||||
|
A CLI tool that reviews Git commits locally using lightweight LLMs (Ollama/MLX) before pushing. It analyzes staged changes, provides inline suggestions, and integrates with Git workflows while preserving code privacy through local processing.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the tool
|
||||||
|
pip install local-ai-commit-reviewer
|
||||||
|
|
||||||
|
# Review staged changes before committing
|
||||||
|
aicr review
|
||||||
|
|
||||||
|
# Install pre-commit hook
|
||||||
|
aicr install-hook
|
||||||
|
|
||||||
|
# Review a specific commit
|
||||||
|
aicr review --commit <sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From PyPI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install local-ai-commit-reviewer
|
||||||
|
```
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/local-ai-commit-reviewer.git
|
||||||
|
cd local-ai-commit-reviewer
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- [Ollama](https://ollama.ai/) running locally (or MLX for Apple Silicon)
|
||||||
|
- Git
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a `.aicr.yaml` file in your project root:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
llm:
|
||||||
|
endpoint: "http://localhost:11434"
|
||||||
|
model: "codellama"
|
||||||
|
timeout: 120
|
||||||
|
|
||||||
|
review:
|
||||||
|
strictness: "balanced"
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
enabled: true
|
||||||
|
fail_on_critical: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `AICR_LLM_ENDPOINT` | Custom LLM API endpoint | `http://localhost:11434` |
|
||||||
|
| `AICR_MODEL` | Model name for reviews | `codellama` |
|
||||||
|
| `AICR_CONFIG_PATH` | Path to config file | `.aicr.yaml` |
|
||||||
|
| `AICR_NO_COLOR` | Disable colored output | `false` |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Review Staged Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Review all staged changes
|
||||||
|
aicr review
|
||||||
|
|
||||||
|
# Review with strict mode
|
||||||
|
aicr review --strictness strict
|
||||||
|
|
||||||
|
# Review with permissive mode (only critical issues)
|
||||||
|
aicr review --strictness permissive
|
||||||
|
|
||||||
|
# Output as JSON
|
||||||
|
aicr review --output json
|
||||||
|
|
||||||
|
# Output as Markdown
|
||||||
|
aicr review --output markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
### Review Specific Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aicr review --commit abc123def
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git Hook Integration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install pre-commit hook in current repository
|
||||||
|
aicr install-hook --local
|
||||||
|
|
||||||
|
# Install globally (for new repositories)
|
||||||
|
aicr install-hook --global
|
||||||
|
|
||||||
|
# Skip the hook
|
||||||
|
git commit --no-verify
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show current configuration
|
||||||
|
aicr config --list
|
||||||
|
|
||||||
|
# Set a configuration option
|
||||||
|
aicr config --set llm.model "llama2"
|
||||||
|
|
||||||
|
# Show config file path
|
||||||
|
aicr config --path
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List available models
|
||||||
|
aicr models
|
||||||
|
|
||||||
|
# Check Ollama status
|
||||||
|
aicr status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Languages
|
||||||
|
|
||||||
|
- Python
|
||||||
|
- JavaScript / TypeScript
|
||||||
|
- Go
|
||||||
|
- Rust
|
||||||
|
- Java
|
||||||
|
- C / C++
|
||||||
|
- Ruby
|
||||||
|
- PHP
|
||||||
|
- Swift
|
||||||
|
- Kotlin
|
||||||
|
- Scala
|
||||||
|
|
||||||
|
## Strictness Levels
|
||||||
|
|
||||||
|
| Level | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `permissive` | Only critical security and bug issues |
|
||||||
|
| `balanced` | Security, bugs, and major style issues |
|
||||||
|
| `strict` | All issues including performance and documentation |
|
||||||
|
|
||||||
|
## Error Resolution
|
||||||
|
|
||||||
|
| Error | Resolution |
|
||||||
|
|-------|------------|
|
||||||
|
| LLM connection refused | Start Ollama: `ollama serve` |
|
||||||
|
| Model not found | Pull model: `ollama pull <model>` |
|
||||||
|
| Not a Git repository | Run from within a Git repo |
|
||||||
|
| No staged changes | Stage files: `git add <files>` |
|
||||||
|
| Git hook permission denied | `chmod +x .git/hooks/pre-commit` |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install development dependencies
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Run linting
|
||||||
|
ruff check src/
|
||||||
|
black src/ tests/
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
mypy src/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Run tests and linting
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details.
|
||||||
121
local-ai-commit-reviewer/config.yaml
Normal file
121
local-ai-commit-reviewer/config.yaml
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Local AI Commit Reviewer Configuration
|
||||||
|
# This file contains default settings for the aicr CLI tool
|
||||||
|
|
||||||
|
# LLM Configuration
|
||||||
|
llm:
|
||||||
|
# Default LLM endpoint (Ollama default)
|
||||||
|
endpoint: "http://localhost:11434"
|
||||||
|
# Default model to use for reviews
|
||||||
|
model: "codellama"
|
||||||
|
# Timeout for LLM requests in seconds
|
||||||
|
timeout: 120
|
||||||
|
# Maximum number of tokens to generate
|
||||||
|
max_tokens: 2048
|
||||||
|
# Temperature for generation (0.0-1.0)
|
||||||
|
temperature: 0.3
|
||||||
|
|
||||||
|
# Review Settings
|
||||||
|
review:
|
||||||
|
# Default strictness level: permissive, balanced, strict
|
||||||
|
strictness: "balanced"
|
||||||
|
# Maximum number of issues to report per file
|
||||||
|
max_issues_per_file: 20
|
||||||
|
# Enable syntax highlighting
|
||||||
|
syntax_highlighting: true
|
||||||
|
# Show line numbers in output
|
||||||
|
show_line_numbers: true
|
||||||
|
|
||||||
|
# Language-specific configurations
|
||||||
|
languages:
|
||||||
|
python:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "pep8"
|
||||||
|
- "type-hints"
|
||||||
|
- "docstrings"
|
||||||
|
max_line_length: 100
|
||||||
|
javascript:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "airbnb"
|
||||||
|
max_line_length: 100
|
||||||
|
typescript:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "airbnb"
|
||||||
|
max_line_length: 100
|
||||||
|
go:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "golint"
|
||||||
|
- "staticcheck"
|
||||||
|
rust:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "clippy"
|
||||||
|
java:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "google-java"
|
||||||
|
c:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "cppcheck"
|
||||||
|
cpp:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "cppcheck"
|
||||||
|
|
||||||
|
# Strictness Profiles
|
||||||
|
strictness_profiles:
|
||||||
|
permissive:
|
||||||
|
description: "Focus on critical issues only"
|
||||||
|
check_security: true
|
||||||
|
check_bugs: true
|
||||||
|
check_style: false
|
||||||
|
check_performance: false
|
||||||
|
check_documentation: false
|
||||||
|
min_severity: "warning"
|
||||||
|
balanced:
|
||||||
|
description: "Balanced review of common issues"
|
||||||
|
check_security: true
|
||||||
|
check_bugs: true
|
||||||
|
check_style: true
|
||||||
|
check_performance: false
|
||||||
|
check_documentation: false
|
||||||
|
min_severity: "info"
|
||||||
|
strict:
|
||||||
|
description: "Comprehensive review of all issues"
|
||||||
|
check_security: true
|
||||||
|
check_bugs: true
|
||||||
|
check_style: true
|
||||||
|
check_performance: true
|
||||||
|
check_documentation: true
|
||||||
|
min_severity: "info"
|
||||||
|
|
||||||
|
# Git Hook Configuration
|
||||||
|
hooks:
|
||||||
|
# Enable pre-commit hook by default
|
||||||
|
enabled: true
|
||||||
|
# Exit with error code on critical issues
|
||||||
|
fail_on_critical: true
|
||||||
|
# Allow bypassing the hook with --no-verify
|
||||||
|
allow_bypass: true
|
||||||
|
|
||||||
|
# Output Configuration
|
||||||
|
output:
|
||||||
|
# Default output format: terminal, json, markdown
|
||||||
|
format: "terminal"
|
||||||
|
# Colors theme: dark, light, auto
|
||||||
|
theme: "auto"
|
||||||
|
# Show suggestions for fixes
|
||||||
|
show_suggestions: true
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
logging:
|
||||||
|
# Log level: debug, info, warning, error
|
||||||
|
level: "info"
|
||||||
|
# Log file path (empty for stdout only)
|
||||||
|
log_file: ""
|
||||||
|
# Enable structured logging
|
||||||
|
structured: false
|
||||||
82
local-ai-commit-reviewer/examples/.aicr.yaml.example
Normal file
82
local-ai-commit-reviewer/examples/.aicr.yaml.example
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Local AI Commit Reviewer - Example Configuration
|
||||||
|
# Copy this file to .aicr.yaml in your project root
|
||||||
|
|
||||||
|
# LLM Configuration
|
||||||
|
llm:
|
||||||
|
# Ollama endpoint
|
||||||
|
endpoint: "http://localhost:11434"
|
||||||
|
# Model to use for reviews
|
||||||
|
model: "codellama"
|
||||||
|
# Request timeout in seconds
|
||||||
|
timeout: 120
|
||||||
|
# Maximum tokens to generate
|
||||||
|
max_tokens: 2048
|
||||||
|
# Temperature (0.0-1.0)
|
||||||
|
temperature: 0.3
|
||||||
|
|
||||||
|
# Review Settings
|
||||||
|
review:
|
||||||
|
# Strictness: permissive, balanced, strict
|
||||||
|
strictness: "balanced"
|
||||||
|
# Maximum issues per file
|
||||||
|
max_issues_per_file: 20
|
||||||
|
# Enable syntax highlighting
|
||||||
|
syntax_highlighting: true
|
||||||
|
# Show line numbers
|
||||||
|
show_line_numbers: true
|
||||||
|
|
||||||
|
# Language-specific configurations
|
||||||
|
languages:
|
||||||
|
python:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "pep8"
|
||||||
|
- "type-hints"
|
||||||
|
- "docstrings"
|
||||||
|
javascript:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "airbnb"
|
||||||
|
typescript:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "airbnb"
|
||||||
|
go:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "golint"
|
||||||
|
- "staticcheck"
|
||||||
|
rust:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "clippy"
|
||||||
|
java:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "google-java"
|
||||||
|
c:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "cppcheck"
|
||||||
|
cpp:
|
||||||
|
enabled: true
|
||||||
|
review_rules:
|
||||||
|
- "cppcheck"
|
||||||
|
|
||||||
|
# Git Hook Configuration
|
||||||
|
hooks:
|
||||||
|
enabled: true
|
||||||
|
fail_on_critical: true
|
||||||
|
allow_bypass: true
|
||||||
|
|
||||||
|
# Output Configuration
|
||||||
|
output:
|
||||||
|
format: "terminal"
|
||||||
|
theme: "auto"
|
||||||
|
show_suggestions: true
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
logging:
|
||||||
|
level: "info"
|
||||||
|
log_file: ""
|
||||||
|
structured: false
|
||||||
81
local-ai-commit-reviewer/pyproject.toml
Normal file
81
local-ai-commit-reviewer/pyproject.toml
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "local-ai-commit-reviewer"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A CLI tool that reviews Git commits locally using lightweight LLMs"
|
||||||
|
readme = "README.md"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [
|
||||||
|
{name = "Local AI Commit Reviewer Contributors"}
|
||||||
|
]
|
||||||
|
keywords = ["git", "cli", "llm", "code-review", "ollama"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"click>=8.1.7",
|
||||||
|
"gitpython>=3.1.43",
|
||||||
|
"ollama>=0.3.3",
|
||||||
|
"rich>=13.7.1",
|
||||||
|
"pydantic>=2.6.1",
|
||||||
|
"pyyaml>=6.0.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"pytest-mock>=3.12.0",
|
||||||
|
"black>=23.0.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"mypy>=1.7.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
aicr = "src.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["src*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = "-v --tb=short"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src"]
|
||||||
|
omit = ["tests/*"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = ["pragma: no cover", "def __repr__", "raise NotImplementedError"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ["py310"]
|
||||||
|
include = "\\.pyi?$"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "W", "F", "I", "UP", "B", "C4", "A", "SIM", "ARG", "PL", "RUF"]
|
||||||
|
ignore = ["E501", "B008", "C901"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.10"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
ignore_missing_imports = true
|
||||||
6
local-ai-commit-reviewer/requirements.txt
Normal file
6
local-ai-commit-reviewer/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
click>=8.1.7
|
||||||
|
gitpython>=3.1.43
|
||||||
|
ollama>=0.3.3
|
||||||
|
rich>=13.7.1
|
||||||
|
pydantic>=2.6.1
|
||||||
|
pyyaml>=6.0.1
|
||||||
47
local-ai-commit-reviewer/setup.cfg
Normal file
47
local-ai-commit-reviewer/setup.cfg
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
[metadata]
|
||||||
|
name = local-ai-commit-reviewer
|
||||||
|
version = 0.1.0
|
||||||
|
author = Local AI Commit Reviewer Contributors
|
||||||
|
description = A CLI tool that reviews Git commits locally using lightweight LLMs
|
||||||
|
long_description = file: README.md
|
||||||
|
long_description_content_type = text/markdown
|
||||||
|
url = https://github.com/yourusername/local-ai-commit-reviewer
|
||||||
|
license = MIT
|
||||||
|
classifiers =
|
||||||
|
Development Status :: 4 - Beta
|
||||||
|
Intended Audience :: Developers
|
||||||
|
License :: OSI Approved :: MIT License
|
||||||
|
Programming Language :: Python :: 3
|
||||||
|
Programming Language :: Python :: 3.10
|
||||||
|
Programming Language :: Python :: 3.11
|
||||||
|
Programming Language :: Python :: 3.12
|
||||||
|
keywords = git, cli, llm, code-review, ollama
|
||||||
|
|
||||||
|
[options]
|
||||||
|
python_requires = >=3.10
|
||||||
|
install_requires =
|
||||||
|
click>=8.1.7
|
||||||
|
gitpython>=3.1.43
|
||||||
|
ollama>=0.3.3
|
||||||
|
rich>=13.7.1
|
||||||
|
pydantic>=2.6.1
|
||||||
|
pyyaml>=6.0.1
|
||||||
|
|
||||||
|
[options.extras_require]
|
||||||
|
dev =
|
||||||
|
pytest>=7.4.0
|
||||||
|
pytest-cov>=4.1.0
|
||||||
|
pytest-mock>=3.12.0
|
||||||
|
black>=23.0.0
|
||||||
|
ruff>=0.1.0
|
||||||
|
mypy>=1.7.0
|
||||||
|
|
||||||
|
[options.entry_points]
|
||||||
|
console_scripts =
|
||||||
|
aicr = src.cli:main
|
||||||
|
|
||||||
|
[tool:pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_functions = test_*
|
||||||
|
addopts = -v --tb=short
|
||||||
14
local-ai-commit-reviewer/src/__init__.py
Normal file
14
local-ai-commit-reviewer/src/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from . import cli, config, core, formatters, git, hooks, llm, utils
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"cli",
|
||||||
|
"config",
|
||||||
|
"core",
|
||||||
|
"formatters",
|
||||||
|
"git",
|
||||||
|
"hooks",
|
||||||
|
"llm",
|
||||||
|
"utils",
|
||||||
|
]
|
||||||
3
local-ai-commit-reviewer/src/cli/__init__.py
Normal file
3
local-ai-commit-reviewer/src/cli/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .cli import cli, main
|
||||||
|
|
||||||
|
__all__ = ["cli", "main"]
|
||||||
337
local-ai-commit-reviewer/src/cli/cli.py
Normal file
337
local-ai-commit-reviewer/src/cli/cli.py
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Union
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich import print as rprint
|
||||||
|
|
||||||
|
from ..config import Config, get_config
|
||||||
|
from ..core import ReviewEngine, ReviewResult
|
||||||
|
from ..formatters import get_formatter
|
||||||
|
from ..git import FileChange, GitRepo, get_staged_changes
|
||||||
|
from ..git import install_hook as git_install_hook
|
||||||
|
from ..llm import OllamaProvider
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.option("--config", "-c", type=click.Path(exists=True), help="Path to config file")
|
||||||
|
@click.option("--endpoint", help="LLM endpoint URL", default=None)
|
||||||
|
@click.option("--model", "-m", help="Model name to use", default=None)
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx: click.Context, config: str | None, endpoint: str | None, model: str | None):
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
cfg_path = config or os.environ.get("AICR_CONFIG_PATH")
|
||||||
|
cfg = get_config(cfg_path)
|
||||||
|
|
||||||
|
if endpoint:
|
||||||
|
cfg.llm.endpoint = endpoint
|
||||||
|
if model:
|
||||||
|
cfg.llm.model = model
|
||||||
|
|
||||||
|
ctx.obj["config"] = cfg
|
||||||
|
ctx.obj["repo_path"] = Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--strictness", "-s", type=click.Choice(["permissive", "balanced", "strict"]), default=None)
|
||||||
|
@click.option("--output", "-o", type=click.Choice(["terminal", "json", "markdown"]), default="terminal")
|
||||||
|
@click.option("--commit", "-C", help="Review a specific commit SHA", default=None)
|
||||||
|
@click.option("--hook", is_flag=True, help="Run in hook mode (exit non-zero on critical)")
|
||||||
|
@click.option("--file", "-f", multiple=True, help="Files to review (default: all staged)")
|
||||||
|
@click.pass_context
|
||||||
|
def review( # noqa: PLR0913
|
||||||
|
ctx: click.Context,
|
||||||
|
strictness: str | None,
|
||||||
|
output: str,
|
||||||
|
commit: str | None,
|
||||||
|
hook: bool,
|
||||||
|
file: tuple
|
||||||
|
):
|
||||||
|
cfg: Config = ctx.obj["config"]
|
||||||
|
|
||||||
|
if strictness is None:
|
||||||
|
strictness = cfg.review.strictness
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = ReviewEngine(config=cfg)
|
||||||
|
engine.set_repo(ctx.obj["repo_path"])
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
result = engine.review_commit(commit, strictness=strictness)
|
||||||
|
else:
|
||||||
|
files = _get_files_to_review(ctx.obj["repo_path"], file)
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
rprint("[yellow]No staged changes found. Stage files with 'git add <files>' first.[/yellow]")
|
||||||
|
if hook:
|
||||||
|
sys.exit(0)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = engine.review_staged_changes(files, strictness=strictness)
|
||||||
|
|
||||||
|
formatter = get_formatter(output)
|
||||||
|
output_text = formatter.format(result)
|
||||||
|
rprint(output_text)
|
||||||
|
|
||||||
|
if output == "json":
|
||||||
|
ctx.obj["result_json"] = result.to_json()
|
||||||
|
elif output == "markdown":
|
||||||
|
ctx.obj["result_markdown"] = result.to_markdown()
|
||||||
|
|
||||||
|
_handle_hook_exit(result, hook, cfg)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
rprint(f"[red]Error during review: {e}[/red]")
|
||||||
|
if hook:
|
||||||
|
sys.exit(1)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _get_files_to_review(repo_path: Path, file: tuple) -> list[FileChange]:
|
||||||
|
if file:
|
||||||
|
changes = []
|
||||||
|
for filename in file:
|
||||||
|
repo = GitRepo(repo_path)
|
||||||
|
diff = repo.get_staged_diff(filename)
|
||||||
|
if diff:
|
||||||
|
changes.append(FileChange(
|
||||||
|
filename=filename,
|
||||||
|
status="M",
|
||||||
|
diff=diff
|
||||||
|
))
|
||||||
|
return changes
|
||||||
|
return get_staged_changes(repo_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_hook_exit(result: ReviewResult, hook: bool, cfg: Config) -> None:
|
||||||
|
if not hook:
|
||||||
|
return
|
||||||
|
if result.has_critical_issues() and cfg.hooks.fail_on_critical:
|
||||||
|
rprint("\n[red]Critical issues found. Commit blocked.[/red]")
|
||||||
|
sys.exit(1)
|
||||||
|
if not result.has_issues():
|
||||||
|
rprint("[green]No issues found. Proceeding with commit.[/green]")
|
||||||
|
sys.exit(0)
|
||||||
|
if not cfg.hooks.fail_on_critical:
|
||||||
|
rprint("\n[yellow]Issues found but not blocking commit (fail_on_critical=false).[/yellow]")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--local", is_flag=True, help="Install hook locally (in current repo)")
|
||||||
|
@click.option("--global", "global_", is_flag=True, help="Install hook globally")
|
||||||
|
@click.option("--force", is_flag=True, help="Overwrite existing hook")
|
||||||
|
@click.pass_context
|
||||||
|
def hook(ctx: click.Context, local: bool, global_: bool, force: bool):
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
|
||||||
|
if not local and not global_:
|
||||||
|
local = True
|
||||||
|
|
||||||
|
if global_:
|
||||||
|
home = Path.home()
|
||||||
|
git_template = home / ".git-template" / "hooks"
|
||||||
|
if not git_template.exists():
|
||||||
|
rprint("[yellow]Git template directory not found. Creating...[/yellow]")
|
||||||
|
git_template.mkdir(parents=True, exist_ok=True)
|
||||||
|
(git_template / "pre-commit").write_text(_get_hook_script())
|
||||||
|
rprint(f"[green]Global hook template created at {git_template}[/green]")
|
||||||
|
rprint("[yellow]Note: New repos will use this template. Existing repos need local install.[/yellow]")
|
||||||
|
else:
|
||||||
|
rprint("[green]Global hook template already exists.[/green]")
|
||||||
|
else:
|
||||||
|
repo_path = ctx.obj["repo_path"]
|
||||||
|
git_hooks = repo_path / ".git" / "hooks"
|
||||||
|
hook_path = git_hooks / "pre-commit"
|
||||||
|
|
||||||
|
if hook_path.exists() and not force:
|
||||||
|
rprint(f"[yellow]Hook already exists at {hook_path}. Use --force to overwrite.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
if git_install_hook(repo_path, "pre-commit", _get_hook_script()):
|
||||||
|
rprint(f"[green]Pre-commit hook installed at {hook_path}[/green]")
|
||||||
|
else:
|
||||||
|
rprint("[red]Failed to install hook.[/red]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_hook_script() -> str:
|
||||||
|
return """#!/bin/bash
|
||||||
|
# Local AI Commit Reviewer - Pre-commit Hook
|
||||||
|
# Automatically reviews staged changes before committing
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Allow bypass with --no-verify
|
||||||
|
if [ "$1" = "--no-verify" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the directory where this script is located
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Run the AI commit reviewer
|
||||||
|
cd "$SCRIPT_DIR/../.."
|
||||||
|
python -m aicr review --hook --strictness balanced || exit 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--set", "set_opt", nargs=2, multiple=True, help="Set config option (key value)")
|
||||||
|
@click.option("--get", help="Get config option value", default=None)
|
||||||
|
@click.option("--list", is_flag=True, help="List all config options")
|
||||||
|
@click.option("--path", is_flag=True, help="Show config file path")
|
||||||
|
@click.pass_context
|
||||||
|
def config(ctx: click.Context, set_opt: tuple, get: str | None, list_: bool, path: bool):
|
||||||
|
cfg: Config = ctx.obj["config"]
|
||||||
|
|
||||||
|
if path:
|
||||||
|
config_path = os.environ.get("AICR_CONFIG_PATH") or str(Path.cwd() / ".aicr.yaml")
|
||||||
|
rprint(f"Config path: {config_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if get:
|
||||||
|
value = _get_nested_attr(cfg, get)
|
||||||
|
if value is not None:
|
||||||
|
rprint(f"{get}: {value}")
|
||||||
|
else:
|
||||||
|
rprint(f"[red]Unknown config option: {get}[/red]")
|
||||||
|
return
|
||||||
|
|
||||||
|
if list_:
|
||||||
|
for section in ["llm", "review", "languages", "hooks", "output", "logging"]:
|
||||||
|
section_obj = getattr(cfg, section, None)
|
||||||
|
if section_obj:
|
||||||
|
rprint(f"[bold]{section.upper()}[/bold]")
|
||||||
|
for key, value in section_obj.model_dump().items():
|
||||||
|
rprint(f" {key}: {value}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if set_opt:
|
||||||
|
for key, value in set_opt:
|
||||||
|
_set_nested_attr(cfg, key, value)
|
||||||
|
rprint("[green]Configuration updated.[/green]")
|
||||||
|
return
|
||||||
|
|
||||||
|
rprint("[bold]Local AI Commit Reviewer Configuration[/bold]")
|
||||||
|
rprint(f"LLM Endpoint: {cfg.llm.endpoint}")
|
||||||
|
rprint(f"Model: {cfg.llm.model}")
|
||||||
|
rprint(f"Strictness: {cfg.review.strictness}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_nested_attr(obj, attr_path: str):
|
||||||
|
parts = attr_path.split(".")
|
||||||
|
current = obj
|
||||||
|
for part in parts:
|
||||||
|
if hasattr(current, part):
|
||||||
|
current = getattr(current, part)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def _set_nested_attr(obj, attr_path: str, value: Any) -> None:
|
||||||
|
parts = attr_path.split(".")
|
||||||
|
current: Any = obj
|
||||||
|
for part in parts[:-1]:
|
||||||
|
if hasattr(current, part):
|
||||||
|
current = getattr(current, part)
|
||||||
|
|
||||||
|
final_attr = parts[-1]
|
||||||
|
if hasattr(current, final_attr):
|
||||||
|
attr = getattr(type(current), final_attr, None)
|
||||||
|
if attr is not None and hasattr(attr, "annotation"):
|
||||||
|
type_hint = attr.annotation # type: ignore[attr-defined]
|
||||||
|
if getattr(type_hint, "__origin__", None) is Union:
|
||||||
|
type_hint = type_hint.__args__[0]
|
||||||
|
if hasattr(type_hint, "__name__"):
|
||||||
|
if type_hint.__name__ == "int" and isinstance(value, str):
|
||||||
|
value = int(value)
|
||||||
|
elif type_hint.__name__ == "float" and isinstance(value, str):
|
||||||
|
value = float(value)
|
||||||
|
elif type_hint.__name__ == "bool" and isinstance(value, str):
|
||||||
|
value = value.lower() in ("true", "1", "yes")
|
||||||
|
setattr(current, final_attr, value)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.pass_context
|
||||||
|
def models(ctx: click.Context):
|
||||||
|
cfg: Config = ctx.obj["config"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = OllamaProvider(
|
||||||
|
endpoint=cfg.llm.endpoint,
|
||||||
|
model=cfg.llm.model
|
||||||
|
)
|
||||||
|
|
||||||
|
if not provider.is_available():
|
||||||
|
rprint("[red]Ollama is not available. Make sure it's running.[/red]")
|
||||||
|
rprint("Start Ollama with: ollama serve")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
models = provider.list_models()
|
||||||
|
|
||||||
|
if not models:
|
||||||
|
rprint("[yellow]No models found. Pull a model first.[/yellow]")
|
||||||
|
rprint("Example: ollama pull codellama")
|
||||||
|
return
|
||||||
|
|
||||||
|
rprint("[bold]Available Models[/bold]\n")
|
||||||
|
for model in models:
|
||||||
|
rprint(f" {model.name}")
|
||||||
|
rprint(f" Size: {model.size}")
|
||||||
|
rprint(f" Modified: {model.modified}\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
rprint(f"[red]Error listing models: {e}[/red]")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.pass_context
|
||||||
|
def status(ctx: click.Context):
|
||||||
|
cfg: Config = ctx.obj["config"]
|
||||||
|
|
||||||
|
rprint("[bold]Local AI Commit Reviewer Status[/bold]\n")
|
||||||
|
|
||||||
|
rprint("[bold]Configuration:[/bold]")
|
||||||
|
rprint(f" LLM Endpoint: {cfg.llm.endpoint}")
|
||||||
|
rprint(f" Model: {cfg.llm.model}")
|
||||||
|
rprint(f" Strictness: {cfg.review.strictness}\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = OllamaProvider(
|
||||||
|
endpoint=cfg.llm.endpoint,
|
||||||
|
model=cfg.llm.model
|
||||||
|
)
|
||||||
|
|
||||||
|
if provider.is_available():
|
||||||
|
rprint("[green]✓ Ollama is running[/green]")
|
||||||
|
models = provider.list_models()
|
||||||
|
rprint(f" {len(models)} model(s) available")
|
||||||
|
else:
|
||||||
|
rprint("[red]✗ Ollama is not running[/red]")
|
||||||
|
rprint(" Start with: ollama serve")
|
||||||
|
except Exception as e:
|
||||||
|
rprint(f"[red]✗ Error checking Ollama: {e}[/red]")
|
||||||
|
|
||||||
|
repo = GitRepo(ctx.obj["repo_path"])
|
||||||
|
if repo.is_valid():
|
||||||
|
rprint("\n[green]✓ Valid Git repository[/green]")
|
||||||
|
branch = repo.get_current_branch()
|
||||||
|
rprint(f" Branch: {branch}")
|
||||||
|
|
||||||
|
staged = repo.get_staged_files()
|
||||||
|
rprint(f" Staged files: {len(staged)}")
|
||||||
|
else:
|
||||||
|
rprint("\n[yellow]⚠ Not a Git repository[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cli(obj={})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
3
local-ai-commit-reviewer/src/config/__init__.py
Normal file
3
local-ai-commit-reviewer/src/config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .config import Config, ConfigLoader, Languages, StrictnessProfile, get_config
|
||||||
|
|
||||||
|
__all__ = ["Config", "ConfigLoader", "Languages", "StrictnessProfile", "get_config"]
|
||||||
164
local-ai-commit-reviewer/src/config/config.py
Normal file
164
local-ai-commit-reviewer/src/config/config.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml # type: ignore[import-untyped]
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LLMConfig(BaseModel):
|
||||||
|
endpoint: str = "http://localhost:11434"
|
||||||
|
model: str = "codellama"
|
||||||
|
timeout: int = 120
|
||||||
|
max_tokens: int = 2048
|
||||||
|
temperature: float = 0.3
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSettings(BaseModel):
|
||||||
|
strictness: str = "balanced"
|
||||||
|
max_issues_per_file: int = 20
|
||||||
|
syntax_highlighting: bool = True
|
||||||
|
show_line_numbers: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class LanguageConfig(BaseModel):
|
||||||
|
enabled: bool = True
|
||||||
|
review_rules: list[str] = Field(default_factory=list)
|
||||||
|
max_line_length: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class Languages(BaseModel):
|
||||||
|
python: LanguageConfig = Field(default_factory=lambda: LanguageConfig(review_rules=["pep8", "type-hints", "docstrings"]))
|
||||||
|
javascript: LanguageConfig = Field(default_factory=lambda: LanguageConfig(review_rules=["airbnb"]))
|
||||||
|
typescript: LanguageConfig = Field(default_factory=lambda: LanguageConfig(review_rules=["airbnb"]))
|
||||||
|
go: LanguageConfig = Field(default_factory=lambda: LanguageConfig(review_rules=["golint", "staticcheck"]))
|
||||||
|
rust: LanguageConfig = Field(default_factory=lambda: LanguageConfig(review_rules=["clippy"]))
|
||||||
|
java: LanguageConfig = Field(default_factory=lambda: LanguageConfig(review_rules=["google-java"]))
|
||||||
|
c: LanguageConfig = Field(default_factory=lambda: LanguageConfig(review_rules=["cppcheck"]))
|
||||||
|
cpp: LanguageConfig = Field(default_factory=lambda: LanguageConfig(review_rules=["cppcheck"]))
|
||||||
|
|
||||||
|
def get_language_config(self, language: str) -> LanguageConfig | None:
|
||||||
|
return getattr(self, language.lower(), None)
|
||||||
|
|
||||||
|
|
||||||
|
class StrictnessProfile(BaseModel):
|
||||||
|
description: str = ""
|
||||||
|
check_security: bool = True
|
||||||
|
check_bugs: bool = True
|
||||||
|
check_style: bool = True
|
||||||
|
check_performance: bool = False
|
||||||
|
check_documentation: bool = False
|
||||||
|
min_severity: str = "info"
|
||||||
|
|
||||||
|
|
||||||
|
class StrictnessProfiles(BaseModel):
|
||||||
|
permissive: StrictnessProfile = Field(default_factory=lambda: StrictnessProfile(
|
||||||
|
description="Focus on critical issues only",
|
||||||
|
check_security=True,
|
||||||
|
check_bugs=True,
|
||||||
|
check_style=False,
|
||||||
|
check_performance=False,
|
||||||
|
check_documentation=False,
|
||||||
|
min_severity="warning"
|
||||||
|
))
|
||||||
|
balanced: StrictnessProfile = Field(default_factory=lambda: StrictnessProfile(
|
||||||
|
description="Balanced review of common issues",
|
||||||
|
check_security=True,
|
||||||
|
check_bugs=True,
|
||||||
|
check_style=True,
|
||||||
|
check_performance=False,
|
||||||
|
check_documentation=False,
|
||||||
|
min_severity="info"
|
||||||
|
))
|
||||||
|
strict: StrictnessProfile = Field(default_factory=lambda: StrictnessProfile(
|
||||||
|
description="Comprehensive review of all issues",
|
||||||
|
check_security=True,
|
||||||
|
check_bugs=True,
|
||||||
|
check_style=True,
|
||||||
|
check_performance=True,
|
||||||
|
check_documentation=True,
|
||||||
|
min_severity="info"
|
||||||
|
))
|
||||||
|
|
||||||
|
def get_profile(self, name: str) -> StrictnessProfile:
|
||||||
|
return getattr(self, name.lower(), self.balanced)
|
||||||
|
|
||||||
|
|
||||||
|
class HooksConfig(BaseModel):
|
||||||
|
enabled: bool = True
|
||||||
|
fail_on_critical: bool = True
|
||||||
|
allow_bypass: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class OutputConfig(BaseModel):
|
||||||
|
format: str = "terminal"
|
||||||
|
theme: str = "auto"
|
||||||
|
show_suggestions: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingConfig(BaseModel):
|
||||||
|
level: str = "info"
|
||||||
|
log_file: str = ""
|
||||||
|
structured: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class Config(BaseModel):
|
||||||
|
llm: LLMConfig = Field(default_factory=LLMConfig)
|
||||||
|
review: ReviewSettings = Field(default_factory=ReviewSettings)
|
||||||
|
languages: Languages = Field(default_factory=Languages)
|
||||||
|
strictness_profiles: StrictnessProfiles = Field(default_factory=StrictnessProfiles)
|
||||||
|
hooks: HooksConfig = Field(default_factory=HooksConfig)
|
||||||
|
output: OutputConfig = Field(default_factory=OutputConfig)
|
||||||
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigLoader:
|
||||||
|
def __init__(self, config_path: str | None = None):
|
||||||
|
self.config_path = config_path
|
||||||
|
self.global_config: Path | None = None
|
||||||
|
self.project_config: Path | None = None
|
||||||
|
|
||||||
|
def find_config_files(self) -> tuple[Path | None, Path | None]:
|
||||||
|
env_config_path = os.environ.get("AICR_CONFIG_PATH")
|
||||||
|
|
||||||
|
if env_config_path:
|
||||||
|
env_path = Path(env_config_path)
|
||||||
|
if env_path.exists():
|
||||||
|
return env_path, None
|
||||||
|
|
||||||
|
self.global_config = Path.home() / ".aicr.yaml"
|
||||||
|
self.project_config = Path.cwd() / ".aicr.yaml"
|
||||||
|
|
||||||
|
if self.project_config.exists():
|
||||||
|
return self.project_config, self.global_config
|
||||||
|
|
||||||
|
if self.global_config.exists():
|
||||||
|
return self.global_config, None
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def load(self) -> Config:
|
||||||
|
config_path, global_path = self.find_config_files()
|
||||||
|
|
||||||
|
config_data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if global_path and global_path.exists():
|
||||||
|
with open(global_path) as f:
|
||||||
|
global_data = yaml.safe_load(f) or {}
|
||||||
|
config_data.update(global_data)
|
||||||
|
|
||||||
|
if config_path and config_path.exists():
|
||||||
|
with open(config_path) as f:
|
||||||
|
project_data = yaml.safe_load(f) or {}
|
||||||
|
config_data.update(project_data)
|
||||||
|
|
||||||
|
return Config(**config_data)
|
||||||
|
|
||||||
|
def save(self, config: Config, path: Path) -> None:
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.dump(config.model_dump(), f, default_flow_style=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_config(config_path: str | None = None) -> Config:
|
||||||
|
loader = ConfigLoader(config_path)
|
||||||
|
return loader.load()
|
||||||
3
local-ai-commit-reviewer/src/core/__init__.py
Normal file
3
local-ai-commit-reviewer/src/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .review_engine import Issue, IssueCategory, IssueSeverity, ReviewEngine, ReviewResult
|
||||||
|
|
||||||
|
__all__ = ["Issue", "IssueCategory", "IssueSeverity", "ReviewEngine", "ReviewResult"]
|
||||||
423
local-ai-commit-reviewer/src/core/review_engine.py
Normal file
423
local-ai-commit-reviewer/src/core/review_engine.py
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..config import Config, StrictnessProfile
|
||||||
|
from ..git import FileChange, GitRepo
|
||||||
|
from ..llm import LLMProvider, OllamaProvider
|
||||||
|
from ..llm.templates import ReviewPromptTemplates
|
||||||
|
|
||||||
|
|
||||||
|
class IssueSeverity(str, Enum):
|
||||||
|
CRITICAL = "critical"
|
||||||
|
WARNING = "warning"
|
||||||
|
INFO = "info"
|
||||||
|
|
||||||
|
|
||||||
|
class IssueCategory(str, Enum):
|
||||||
|
BUG = "bug"
|
||||||
|
SECURITY = "security"
|
||||||
|
STYLE = "style"
|
||||||
|
PERFORMANCE = "performance"
|
||||||
|
DOCUMENTATION = "documentation"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Issue:
|
||||||
|
file: str
|
||||||
|
line: int
|
||||||
|
severity: IssueSeverity
|
||||||
|
category: IssueCategory
|
||||||
|
message: str
|
||||||
|
suggestion: str | None = None
|
||||||
|
raw_line: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"file": self.file,
|
||||||
|
"line": self.line,
|
||||||
|
"severity": self.severity.value,
|
||||||
|
"category": self.category.value,
|
||||||
|
"message": self.message,
|
||||||
|
"suggestion": self.suggestion,
|
||||||
|
"raw_line": self.raw_line
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Issue":
|
||||||
|
return cls(
|
||||||
|
file=data["file"],
|
||||||
|
line=data["line"],
|
||||||
|
severity=IssueSeverity(data["severity"]),
|
||||||
|
category=IssueCategory(data["category"]),
|
||||||
|
message=data["message"],
|
||||||
|
suggestion=data.get("suggestion"),
|
||||||
|
raw_line=data.get("raw_line")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReviewSummary:
|
||||||
|
critical_count: int = 0
|
||||||
|
warning_count: int = 0
|
||||||
|
info_count: int = 0
|
||||||
|
files_reviewed: int = 0
|
||||||
|
lines_changed: int = 0
|
||||||
|
overall_assessment: str = ""
|
||||||
|
issues_by_category: dict = field(default_factory=dict)
|
||||||
|
issues_by_file: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"critical_count": self.critical_count,
|
||||||
|
"warning_count": self.warning_count,
|
||||||
|
"info_count": self.info_count,
|
||||||
|
"files_reviewed": self.files_reviewed,
|
||||||
|
"lines_changed": self.lines_changed,
|
||||||
|
"overall_assessment": self.overall_assessment,
|
||||||
|
"issues_by_category": self.issues_by_category,
|
||||||
|
"issues_by_file": self.issues_by_file
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReviewResult:
|
||||||
|
issues: list[Issue] = field(default_factory=list)
|
||||||
|
summary: ReviewSummary = field(default_factory=ReviewSummary)
|
||||||
|
model_used: str = ""
|
||||||
|
tokens_used: int = 0
|
||||||
|
review_mode: str = ""
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
def has_critical_issues(self) -> bool:
|
||||||
|
return any(issue.severity == IssueSeverity.CRITICAL for issue in self.issues)
|
||||||
|
|
||||||
|
def has_issues(self) -> bool:
|
||||||
|
return len(self.issues) > 0
|
||||||
|
|
||||||
|
def get_issues_by_severity(self, severity: IssueSeverity) -> list[Issue]:
|
||||||
|
return [issue for issue in self.issues if issue.severity == severity]
|
||||||
|
|
||||||
|
def get_issues_by_file(self, filename: str) -> list[Issue]:
|
||||||
|
return [issue for issue in self.issues if issue.file == filename]
|
||||||
|
|
||||||
|
def get_issues_by_category(self, category: IssueCategory) -> list[Issue]:
|
||||||
|
return [issue for issue in self.issues if issue.category == category]
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
return json.dumps({
|
||||||
|
"issues": [issue.to_dict() for issue in self.issues],
|
||||||
|
"summary": self.summary.to_dict(),
|
||||||
|
"model_used": self.model_used,
|
||||||
|
"tokens_used": self.tokens_used,
|
||||||
|
"review_mode": self.review_mode
|
||||||
|
}, indent=2)
|
||||||
|
|
||||||
|
def to_markdown(self) -> str:
|
||||||
|
lines = ["# AI Commit Review Results\n"]
|
||||||
|
|
||||||
|
lines.append("## Summary\n")
|
||||||
|
lines.append(f"- **Files Reviewed**: {self.summary.files_reviewed}")
|
||||||
|
lines.append(f"- **Lines Changed**: {self.summary.lines_changed}")
|
||||||
|
lines.append(f"- **Critical Issues**: {self.summary.critical_count}")
|
||||||
|
lines.append(f"- **Warnings**: {self.summary.warning_count}")
|
||||||
|
lines.append(f"- **Info**: {self.summary.info_count}")
|
||||||
|
lines.append(f"- **Assessment**: {self.summary.overall_assessment}\n")
|
||||||
|
|
||||||
|
if self.issues:
|
||||||
|
lines.append("## Issues Found\n")
|
||||||
|
|
||||||
|
for severity in [IssueSeverity.CRITICAL, IssueSeverity.WARNING, IssueSeverity.INFO]:
|
||||||
|
severity_issues = self.get_issues_by_severity(severity)
|
||||||
|
if severity_issues:
|
||||||
|
lines.append(f"### {severity.value.upper()} ({len(severity_issues)})\n")
|
||||||
|
for issue in severity_issues:
|
||||||
|
lines.append(f"#### {issue.file}:{issue.line}")
|
||||||
|
lines.append(f"- **Category**: {issue.category.value}")
|
||||||
|
lines.append(f"- **Message**: {issue.message}")
|
||||||
|
if issue.suggestion:
|
||||||
|
lines.append(f"- **Suggestion**: {issue.suggestion}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewEngine:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: Config | None = None,
|
||||||
|
llm_provider: LLMProvider | None = None
|
||||||
|
):
|
||||||
|
self.config = config or Config()
|
||||||
|
self.llm_provider = llm_provider or OllamaProvider(
|
||||||
|
endpoint=self.config.llm.endpoint,
|
||||||
|
model=self.config.llm.model,
|
||||||
|
timeout=self.config.llm.timeout
|
||||||
|
)
|
||||||
|
self.repo: GitRepo | None = None
|
||||||
|
|
||||||
|
def set_repo(self, path: Path) -> None:
|
||||||
|
self.repo = GitRepo(path)
|
||||||
|
|
||||||
|
def _parse_llm_response(self, response_text: str, files: list[FileChange]) -> ReviewResult:
|
||||||
|
result = ReviewResult()
|
||||||
|
|
||||||
|
try:
|
||||||
|
json_match = re.search(r'\{[\s\S]*\}', response_text)
|
||||||
|
if json_match:
|
||||||
|
json_str = json_match.group()
|
||||||
|
data = json.loads(json_str)
|
||||||
|
|
||||||
|
issues_data = data.get("issues", [])
|
||||||
|
for issue_data in issues_data:
|
||||||
|
try:
|
||||||
|
issue = Issue.from_dict(issue_data)
|
||||||
|
result.issues.append(issue)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
summary_data = data.get("summary", {})
|
||||||
|
result.summary.critical_count = summary_data.get("critical_count", 0)
|
||||||
|
result.summary.warning_count = summary_data.get("warning_count", 0)
|
||||||
|
result.summary.info_count = summary_data.get("info_count", 0)
|
||||||
|
result.summary.overall_assessment = summary_data.get("overall_assessment", "")
|
||||||
|
else:
|
||||||
|
text_issues = self._parse_text_response(response_text, files)
|
||||||
|
result.issues = text_issues
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
result.issues = self._parse_text_response(response_text, files)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _parse_text_response(self, response_text: str, files: list[FileChange]) -> list[Issue]: # noqa: ARG002
|
||||||
|
issues = []
|
||||||
|
lines = response_text.split("\n")
|
||||||
|
|
||||||
|
current_file = ""
|
||||||
|
for line in lines:
|
||||||
|
file_match = re.match(r'^\*\*(.+?)\*\*:\s*(\d+)', line)
|
||||||
|
if file_match:
|
||||||
|
current_file = file_match.group(1)
|
||||||
|
line_num = int(file_match.group(2))
|
||||||
|
|
||||||
|
severity = IssueSeverity.WARNING
|
||||||
|
if "critical" in line.lower():
|
||||||
|
severity = IssueSeverity.CRITICAL
|
||||||
|
elif "security" in line.lower():
|
||||||
|
severity = IssueSeverity.CRITICAL
|
||||||
|
category = IssueCategory.SECURITY
|
||||||
|
else:
|
||||||
|
category = IssueCategory.BUG
|
||||||
|
|
||||||
|
message = line
|
||||||
|
suggestion = None
|
||||||
|
if "->" in line:
|
||||||
|
parts = line.split("->")
|
||||||
|
message = parts[0].strip()
|
||||||
|
suggestion = "->".join(parts[1:]).strip()
|
||||||
|
|
||||||
|
issues.append(Issue(
|
||||||
|
file=current_file,
|
||||||
|
line=line_num,
|
||||||
|
severity=severity,
|
||||||
|
category=category,
|
||||||
|
message=message,
|
||||||
|
suggestion=suggestion
|
||||||
|
))
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def _get_strictness_profile(self) -> StrictnessProfile:
|
||||||
|
return self.config.strictness_profiles.get_profile(
|
||||||
|
self.config.review.strictness
|
||||||
|
)
|
||||||
|
|
||||||
|
def _filter_issues_by_strictness(self, issues: list[Issue]) -> list[Issue]:
|
||||||
|
profile = self._get_strictness_profile()
|
||||||
|
|
||||||
|
severity_order = {
|
||||||
|
IssueSeverity.CRITICAL: 0,
|
||||||
|
IssueSeverity.WARNING: 1,
|
||||||
|
IssueSeverity.INFO: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
min_severity = profile.min_severity.lower()
|
||||||
|
min_level = 2
|
||||||
|
if min_severity == "critical":
|
||||||
|
min_level = 0
|
||||||
|
elif min_severity == "warning":
|
||||||
|
min_level = 1
|
||||||
|
|
||||||
|
filtered = []
|
||||||
|
for issue in issues:
|
||||||
|
level = severity_order.get(issue.severity, 2)
|
||||||
|
if level <= min_level:
|
||||||
|
if issue.category == IssueCategory.SECURITY and not profile.check_security:
|
||||||
|
continue
|
||||||
|
if issue.category == IssueCategory.BUG and not profile.check_bugs:
|
||||||
|
continue
|
||||||
|
if issue.category == IssueCategory.STYLE and not profile.check_style:
|
||||||
|
continue
|
||||||
|
if issue.category == IssueCategory.PERFORMANCE and not profile.check_performance:
|
||||||
|
continue
|
||||||
|
if issue.category == IssueCategory.DOCUMENTATION and not profile.check_documentation:
|
||||||
|
continue
|
||||||
|
filtered.append(issue)
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
def _aggregate_summary(self, issues: list[Issue], files: list[FileChange]) -> ReviewSummary:
|
||||||
|
summary = ReviewSummary()
|
||||||
|
summary.files_reviewed = len(files)
|
||||||
|
summary.lines_changed = sum(
|
||||||
|
sum(1 for line in f.diff.split("\n") if line.startswith("+") and not line.startswith("+++"))
|
||||||
|
for f in files
|
||||||
|
)
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
if issue.severity == IssueSeverity.CRITICAL:
|
||||||
|
summary.critical_count += 1
|
||||||
|
elif issue.severity == IssueSeverity.WARNING:
|
||||||
|
summary.warning_count += 1
|
||||||
|
else:
|
||||||
|
summary.info_count += 1
|
||||||
|
|
||||||
|
if issue.category.value not in summary.issues_by_category:
|
||||||
|
summary.issues_by_category[issue.category.value] = []
|
||||||
|
summary.issues_by_category[issue.category.value].append(issue.file)
|
||||||
|
|
||||||
|
if issue.file not in summary.issues_by_file:
|
||||||
|
summary.issues_by_file[issue.file] = []
|
||||||
|
summary.issues_by_file[issue.file].append(issue.line)
|
||||||
|
|
||||||
|
if summary.critical_count > 0:
|
||||||
|
summary.overall_assessment = "Critical issues found. Review recommended before committing."
|
||||||
|
elif summary.warning_count > 0:
|
||||||
|
summary.overall_assessment = "Warnings found. Consider addressing before committing."
|
||||||
|
elif summary.info_count > 0:
|
||||||
|
summary.overall_assessment = "Minor issues found. Ready for commit with optional fixes."
|
||||||
|
else:
|
||||||
|
summary.overall_assessment = "No issues found. Code is ready for commit."
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def review_staged_changes(
|
||||||
|
self,
|
||||||
|
files: list[FileChange] | None = None,
|
||||||
|
strictness: str | None = None,
|
||||||
|
language: str | None = None
|
||||||
|
) -> ReviewResult:
|
||||||
|
if files is None:
|
||||||
|
if self.repo is None:
|
||||||
|
self.repo = GitRepo(Path.cwd())
|
||||||
|
files = self.repo.get_all_staged_changes()
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
return ReviewResult(error="No staged changes found")
|
||||||
|
|
||||||
|
result = ReviewResult()
|
||||||
|
result.review_mode = strictness or self.config.review.strictness
|
||||||
|
|
||||||
|
if strictness is None:
|
||||||
|
strictness = self.config.review.strictness
|
||||||
|
|
||||||
|
all_issues = []
|
||||||
|
|
||||||
|
for file_change in files:
|
||||||
|
if not file_change.diff.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_language = language
|
||||||
|
if not file_language and self.repo is not None:
|
||||||
|
file_language = self.repo.get_file_language(file_change.filename)
|
||||||
|
|
||||||
|
prompt = ReviewPromptTemplates.get_prompt(
|
||||||
|
diff=file_change.diff,
|
||||||
|
strictness=strictness,
|
||||||
|
language=file_language or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.llm_provider.is_available():
|
||||||
|
response = self.llm_provider.generate(
|
||||||
|
prompt,
|
||||||
|
max_tokens=self.config.llm.max_tokens,
|
||||||
|
temperature=self.config.llm.temperature
|
||||||
|
)
|
||||||
|
result.model_used = response.model
|
||||||
|
result.tokens_used += response.tokens_used
|
||||||
|
|
||||||
|
file_result = self._parse_llm_response(response.text, [file_change])
|
||||||
|
all_issues.extend(file_result.issues)
|
||||||
|
else:
|
||||||
|
result.error = "LLM provider is not available"
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
result.error = f"Review failed: {e!s}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
filtered_issues = self._filter_issues_by_strictness(all_issues)
|
||||||
|
max_issues = self.config.review.max_issues_per_file
|
||||||
|
limited_issues = filtered_issues[:max_issues * len(files)]
|
||||||
|
result.issues = limited_issues
|
||||||
|
result.summary = self._aggregate_summary(limited_issues, files)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def review_commit(
|
||||||
|
self,
|
||||||
|
sha: str,
|
||||||
|
strictness: str | None = None
|
||||||
|
) -> ReviewResult:
|
||||||
|
if self.repo is None:
|
||||||
|
self.repo = GitRepo(Path.cwd())
|
||||||
|
|
||||||
|
commit_info = self.repo.get_commit_info(sha)
|
||||||
|
if commit_info is None:
|
||||||
|
return ReviewResult(error=f"Commit {sha} not found")
|
||||||
|
|
||||||
|
result = ReviewResult()
|
||||||
|
result.review_mode = strictness or self.config.review.strictness
|
||||||
|
|
||||||
|
if strictness is None:
|
||||||
|
strictness = self.config.review.strictness
|
||||||
|
|
||||||
|
all_issues = []
|
||||||
|
|
||||||
|
for file_change in commit_info.changes:
|
||||||
|
if not file_change.diff.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
prompt = ReviewPromptTemplates.get_commit_review_prompt(
|
||||||
|
diff=file_change.diff,
|
||||||
|
commit_message=commit_info.message,
|
||||||
|
strictness=strictness
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.llm_provider.is_available():
|
||||||
|
response = self.llm_provider.generate(
|
||||||
|
prompt,
|
||||||
|
max_tokens=self.config.llm.max_tokens,
|
||||||
|
temperature=self.config.llm.temperature
|
||||||
|
)
|
||||||
|
result.model_used = response.model
|
||||||
|
result.tokens_used += response.tokens_used
|
||||||
|
|
||||||
|
file_result = self._parse_llm_response(response.text, [file_change])
|
||||||
|
all_issues.extend(file_result.issues)
|
||||||
|
else:
|
||||||
|
result.error = "LLM provider is not available"
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
result.error = f"Review failed: {e!s}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
filtered_issues = self._filter_issues_by_strictness(all_issues)
|
||||||
|
result.issues = filtered_issues
|
||||||
|
result.summary = self._aggregate_summary(filtered_issues, commit_info.changes)
|
||||||
|
|
||||||
|
return result
|
||||||
3
local-ai-commit-reviewer/src/formatters/__init__.py
Normal file
3
local-ai-commit-reviewer/src/formatters/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .formatters import JSONFormatter, MarkdownFormatter, TerminalFormatter, get_formatter
|
||||||
|
|
||||||
|
__all__ = ["JSONFormatter", "MarkdownFormatter", "TerminalFormatter", "get_formatter"]
|
||||||
141
local-ai-commit-reviewer/src/formatters/formatters.py
Normal file
141
local-ai-commit-reviewer/src/formatters/formatters.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.style import Style
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from ..core import Issue, IssueCategory, IssueSeverity, ReviewResult
|
||||||
|
|
||||||
|
|
||||||
|
class BaseFormatter(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def format(self, result: ReviewResult) -> str:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalFormatter(BaseFormatter):
|
||||||
|
def __init__(self, theme: str = "auto", show_line_numbers: bool = True):
|
||||||
|
self.console = Console()
|
||||||
|
self.show_line_numbers = show_line_numbers
|
||||||
|
self.use_colors = theme != "dark" if theme == "auto" else theme == "dark"
|
||||||
|
|
||||||
|
def _get_severity_style(self, severity: IssueSeverity) -> Style:
|
||||||
|
styles = {
|
||||||
|
IssueSeverity.CRITICAL: Style(color="red", bold=True),
|
||||||
|
IssueSeverity.WARNING: Style(color="yellow"),
|
||||||
|
IssueSeverity.INFO: Style(color="blue"),
|
||||||
|
}
|
||||||
|
return styles.get(severity, Style())
|
||||||
|
|
||||||
|
def _get_category_icon(self, category: IssueCategory) -> str:
|
||||||
|
icons = {
|
||||||
|
IssueCategory.BUG: "[BUG]",
|
||||||
|
IssueCategory.SECURITY: "[SECURITY]",
|
||||||
|
IssueCategory.STYLE: "[STYLE]",
|
||||||
|
IssueCategory.PERFORMANCE: "[PERF]",
|
||||||
|
IssueCategory.DOCUMENTATION: "[DOC]",
|
||||||
|
}
|
||||||
|
return icons.get(category, "")
|
||||||
|
|
||||||
|
def _format_issue(self, issue: Issue) -> Text:
|
||||||
|
text = Text()
|
||||||
|
text.append(f"{issue.file}:{issue.line} ", style="dim")
|
||||||
|
text.append(f"[{issue.severity.value.upper()}] ", self._get_severity_style(issue.severity))
|
||||||
|
text.append(f"{self._get_category_icon(issue.category)} ")
|
||||||
|
text.append(issue.message)
|
||||||
|
|
||||||
|
if issue.suggestion:
|
||||||
|
text.append("\n Suggestion: ", style="dim")
|
||||||
|
text.append(issue.suggestion)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def format(self, result: ReviewResult) -> str:
|
||||||
|
output: list[Panel | Table | str] = []
|
||||||
|
|
||||||
|
if result.error:
|
||||||
|
output.append(Panel(
|
||||||
|
f"[red]Error: {result.error}[/red]",
|
||||||
|
title="Review Failed",
|
||||||
|
expand=False
|
||||||
|
))
|
||||||
|
return "\n".join(str(p) for p in output)
|
||||||
|
|
||||||
|
summary = result.summary
|
||||||
|
|
||||||
|
summary_panel = Panel(
|
||||||
|
f"[bold]Files Reviewed:[/bold] {summary.files_reviewed}\n"
|
||||||
|
f"[bold]Lines Changed:[/bold] {summary.lines_changed}\n\n"
|
||||||
|
f"[red]Critical:[/red] {summary.critical_count} "
|
||||||
|
f"[yellow]Warnings:[/yellow] {summary.warning_count} "
|
||||||
|
f"[blue]Info:[/blue] {summary.info_count}\n\n"
|
||||||
|
f"[bold]Assessment:[/bold] {summary.overall_assessment}",
|
||||||
|
title="Review Summary",
|
||||||
|
expand=False
|
||||||
|
)
|
||||||
|
output.append(summary_panel)
|
||||||
|
|
||||||
|
if result.issues:
|
||||||
|
issues_table = Table(title="Issues Found", show_header=True)
|
||||||
|
issues_table.add_column("File", style="dim")
|
||||||
|
issues_table.add_column("Line", justify="right", style="dim")
|
||||||
|
issues_table.add_column("Severity", width=10)
|
||||||
|
issues_table.add_column("Category", width=12)
|
||||||
|
issues_table.add_column("Message")
|
||||||
|
|
||||||
|
for issue in result.issues:
|
||||||
|
issues_table.add_row(
|
||||||
|
issue.file,
|
||||||
|
str(issue.line),
|
||||||
|
f"[{issue.severity.value.upper()}]",
|
||||||
|
f"[{issue.category.value.upper()}]",
|
||||||
|
issue.message,
|
||||||
|
style=self._get_severity_style(issue.severity)
|
||||||
|
)
|
||||||
|
|
||||||
|
output.append(issues_table)
|
||||||
|
|
||||||
|
suggestions_panel = Panel(
|
||||||
|
"\n".join(
|
||||||
|
f"[bold]{issue.file}:{issue.line}[/bold]\n"
|
||||||
|
f" {issue.message}\n"
|
||||||
|
+ (f" [green]→ {issue.suggestion}[/green]\n" if issue.suggestion else "")
|
||||||
|
for issue in result.issues if issue.suggestion
|
||||||
|
),
|
||||||
|
title="Suggestions",
|
||||||
|
expand=False
|
||||||
|
)
|
||||||
|
output.append(suggestions_panel)
|
||||||
|
|
||||||
|
model_info = Panel(
|
||||||
|
f"[bold]Model:[/bold] {result.model_used}\n"
|
||||||
|
f"[bold]Tokens Used:[/bold] {result.tokens_used}\n"
|
||||||
|
f"[bold]Mode:[/bold] {result.review_mode}",
|
||||||
|
title="Review Info",
|
||||||
|
expand=False
|
||||||
|
)
|
||||||
|
output.append(model_info)
|
||||||
|
|
||||||
|
return "\n".join(str(o) for o in output)
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(BaseFormatter):
|
||||||
|
def format(self, result: ReviewResult) -> str:
|
||||||
|
return result.to_json()
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownFormatter(BaseFormatter):
|
||||||
|
def format(self, result: ReviewResult) -> str:
|
||||||
|
return result.to_markdown()
|
||||||
|
|
||||||
|
|
||||||
|
def get_formatter(format_type: str = "terminal", **kwargs) -> BaseFormatter:
|
||||||
|
formatters: dict[str, type[BaseFormatter]] = {
|
||||||
|
"terminal": TerminalFormatter,
|
||||||
|
"json": JSONFormatter,
|
||||||
|
"markdown": MarkdownFormatter,
|
||||||
|
}
|
||||||
|
formatter_class = formatters.get(format_type, TerminalFormatter)
|
||||||
|
return formatter_class(**kwargs) # type: ignore[arg-type]
|
||||||
3
local-ai-commit-reviewer/src/git/__init__.py
Normal file
3
local-ai-commit-reviewer/src/git/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .git import FileChange, GitRepo, get_commit_context, get_staged_changes, install_hook
|
||||||
|
|
||||||
|
__all__ = ["FileChange", "GitRepo", "get_commit_context", "get_staged_changes", "install_hook"]
|
||||||
278
local-ai-commit-reviewer/src/git/git.py
Normal file
278
local-ai-commit-reviewer/src/git/git.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileChange:
|
||||||
|
filename: str
|
||||||
|
status: str
|
||||||
|
diff: str
|
||||||
|
old_content: str | None = None
|
||||||
|
new_content: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommitInfo:
|
||||||
|
sha: str
|
||||||
|
message: str
|
||||||
|
author: str
|
||||||
|
date: str
|
||||||
|
changes: list[FileChange]
|
||||||
|
|
||||||
|
|
||||||
|
class GitRepo:
|
||||||
|
def __init__(self, path: Path | None = None):
|
||||||
|
self.path = path or Path.cwd()
|
||||||
|
self.repo = self._get_repo()
|
||||||
|
|
||||||
|
def _get_repo(self) -> Path | None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--show-toplevel"],
|
||||||
|
cwd=self.path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return Path(result.stdout.strip())
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
return self.repo is not None and (self.repo / ".git").exists()
|
||||||
|
|
||||||
|
def get_staged_files(self) -> list[str]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "diff", "--cached", "--name-only"],
|
||||||
|
cwd=self.repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip().split("\n") if result.stdout.strip() else []
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_staged_diff(self, filename: str) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "diff", "--cached", "--", filename],
|
||||||
|
cwd=self.repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
return result.stdout if result.returncode == 0 else ""
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_all_staged_changes(self) -> list[FileChange]:
|
||||||
|
files = self.get_staged_files()
|
||||||
|
changes = []
|
||||||
|
for filename in files:
|
||||||
|
diff = self.get_staged_diff(filename)
|
||||||
|
status = self._get_file_status(filename)
|
||||||
|
changes.append(FileChange(
|
||||||
|
filename=filename,
|
||||||
|
status=status,
|
||||||
|
diff=diff
|
||||||
|
))
|
||||||
|
return changes
|
||||||
|
|
||||||
|
def _get_file_status(self, filename: str) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "diff", "--cached", "--name-status", "--", filename],
|
||||||
|
cwd=self.repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip().split()[0]
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
return "M"
|
||||||
|
|
||||||
|
def get_commit_info(self, sha: str) -> CommitInfo | None:
|
||||||
|
try:
|
||||||
|
message_result = subprocess.run(
|
||||||
|
["git", "log", "-1", "--format=%B", sha],
|
||||||
|
cwd=self.repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
author_result = subprocess.run(
|
||||||
|
["git", "log", "-1", "--format=%an|%ae|%ad", "--date=iso", sha],
|
||||||
|
cwd=self.repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if message_result.returncode == 0 and author_result.returncode == 0:
|
||||||
|
message = message_result.stdout.strip()
|
||||||
|
author_parts = author_result.stdout.strip().split("|")
|
||||||
|
author = author_parts[0] if author_parts else "Unknown"
|
||||||
|
date = author_parts[2] if len(author_parts) > 2 else "" # noqa: PLR2004
|
||||||
|
|
||||||
|
changes = self._get_commit_changes(sha)
|
||||||
|
|
||||||
|
return CommitInfo(
|
||||||
|
sha=sha,
|
||||||
|
message=message,
|
||||||
|
author=author,
|
||||||
|
date=date,
|
||||||
|
changes=changes
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_commit_changes(self, sha: str) -> list[FileChange]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "show", "--stat", sha],
|
||||||
|
cwd=self.repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
files = []
|
||||||
|
if result.returncode == 0:
|
||||||
|
for line in result.stdout.split("\n"):
|
||||||
|
if line.startswith(" ") and "|" in line:
|
||||||
|
filename = line.split("|")[0].strip()
|
||||||
|
diff = self._get_commit_file_diff(sha, filename)
|
||||||
|
files.append(FileChange(
|
||||||
|
filename=filename,
|
||||||
|
status="M",
|
||||||
|
diff=diff
|
||||||
|
))
|
||||||
|
return files
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _get_commit_file_diff(self, sha: str, filename: str) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "show", f"{sha} -- {filename}"],
|
||||||
|
cwd=self.repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
return result.stdout if result.returncode == 0 else ""
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_current_branch(self) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=self.repo,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else "unknown"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def get_file_language(self, filename: str) -> str:
|
||||||
|
ext_map = {
|
||||||
|
".py": "python",
|
||||||
|
".js": "javascript",
|
||||||
|
".ts": "typescript",
|
||||||
|
".go": "go",
|
||||||
|
".rs": "rust",
|
||||||
|
".java": "java",
|
||||||
|
".c": "c",
|
||||||
|
".cpp": "cpp",
|
||||||
|
".h": "c",
|
||||||
|
".hpp": "cpp",
|
||||||
|
".jsx": "javascript",
|
||||||
|
".tsx": "typescript",
|
||||||
|
}
|
||||||
|
ext = Path(filename).suffix.lower()
|
||||||
|
return ext_map.get(ext, "unknown")
|
||||||
|
|
||||||
|
def get_diff_stats(self, diff: str) -> tuple[int, int]:
|
||||||
|
additions = 0
|
||||||
|
deletions = 0
|
||||||
|
for line in diff.split("\n"):
|
||||||
|
if line.startswith("+") and not line.startswith("+++"):
|
||||||
|
additions += 1
|
||||||
|
elif line.startswith("-") and not line.startswith("---"):
|
||||||
|
deletions += 1
|
||||||
|
return additions, deletions
|
||||||
|
|
||||||
|
|
||||||
|
def get_staged_changes(path: Path | None = None) -> list[FileChange]:
|
||||||
|
repo = GitRepo(path)
|
||||||
|
return repo.get_all_staged_changes()
|
||||||
|
|
||||||
|
|
||||||
|
def get_commit_context(sha: str, path: Path | None = None) -> CommitInfo | None:
|
||||||
|
repo = GitRepo(path)
|
||||||
|
return repo.get_commit_info(sha)
|
||||||
|
|
||||||
|
|
||||||
|
def install_hook(repo_path: Path, hook_type: str = "pre-commit", content: str | None = None) -> bool:
|
||||||
|
hooks_dir = repo_path / ".git" / "hooks"
|
||||||
|
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
hook_path = hooks_dir / hook_type
|
||||||
|
|
||||||
|
if hook_path.exists():
|
||||||
|
backup_path = hooks_dir / f"{hook_type}.backup"
|
||||||
|
hook_path.rename(backup_path)
|
||||||
|
|
||||||
|
if content is None:
|
||||||
|
content = _get_default_hook_script()
|
||||||
|
|
||||||
|
try:
|
||||||
|
hook_path.write_text(content)
|
||||||
|
hook_path.chmod(0o755)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_hook_script() -> str:
|
||||||
|
return """#!/bin/bash
|
||||||
|
# Local AI Commit Reviewer - Pre-commit Hook
|
||||||
|
# This hook runs code review on staged changes before committing
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Check if running with --no-verify
|
||||||
|
if [ "$1" = "--no-verify" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the directory where this script is located
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Run the AI commit reviewer
|
||||||
|
cd "$SCRIPT_DIR/../.."
|
||||||
|
python -m aicr review --hook || exit 1
|
||||||
|
"""
|
||||||
3
local-ai-commit-reviewer/src/hooks/__init__.py
Normal file
3
local-ai-commit-reviewer/src/hooks/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .hooks import check_hook_installed, install_pre_commit_hook
|
||||||
|
|
||||||
|
__all__ = ["check_hook_installed", "install_pre_commit_hook"]
|
||||||
69
local-ai-commit-reviewer/src/hooks/hooks.py
Normal file
69
local-ai-commit-reviewer/src/hooks/hooks.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_hook_script() -> str:
|
||||||
|
return """#!/bin/bash
|
||||||
|
# Local AI Commit Reviewer - Pre-commit Hook
|
||||||
|
# Automatically reviews staged changes before committing
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Allow bypass with --no-verify
|
||||||
|
if [ "$1" = "--no-verify" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the directory where this script is located
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Change to repository root
|
||||||
|
cd "$SCRIPT_DIR/../.."
|
||||||
|
|
||||||
|
# Run the AI commit reviewer
|
||||||
|
python -m aicr review --hook --strictness balanced || exit 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def install_pre_commit_hook(
|
||||||
|
repo_path: Path,
|
||||||
|
content: str | None = None,
|
||||||
|
force: bool = False
|
||||||
|
) -> bool:
|
||||||
|
hooks_dir = repo_path / ".git" / "hooks"
|
||||||
|
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
hook_path = hooks_dir / "pre-commit"
|
||||||
|
|
||||||
|
if hook_path.exists() and not force:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if content is None:
|
||||||
|
content = get_hook_script()
|
||||||
|
|
||||||
|
try:
|
||||||
|
hook_path.write_text(content)
|
||||||
|
hook_path.chmod(0o755)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_hook_installed(repo_path: Path) -> bool:
|
||||||
|
hook_path = repo_path / ".git" / "hooks" / "pre-commit"
|
||||||
|
if not hook_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
content = hook_path.read_text()
|
||||||
|
return "aicr" in content or "local-ai-commit-reviewer" in content
|
||||||
|
|
||||||
|
|
||||||
|
def uninstall_hook(repo_path: Path) -> bool:
|
||||||
|
hook_path = repo_path / ".git" / "hooks" / "pre-commit"
|
||||||
|
if not hook_path.exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
hook_path.unlink()
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
4
local-ai-commit-reviewer/src/llm/__init__.py
Normal file
4
local-ai-commit-reviewer/src/llm/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .ollama import OllamaProvider
|
||||||
|
from .provider import LLMProvider
|
||||||
|
|
||||||
|
__all__ = ["LLMProvider", "OllamaProvider"]
|
||||||
143
local-ai-commit-reviewer/src/llm/ollama.py
Normal file
143
local-ai-commit-reviewer/src/llm/ollama.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import asyncio
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import ollama
|
||||||
|
|
||||||
|
from .provider import LLMProvider, LLMResponse, ModelInfo
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaProvider(LLMProvider):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
endpoint: str = "http://localhost:11434",
|
||||||
|
model: str = "codellama",
|
||||||
|
timeout: int = 120
|
||||||
|
):
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.model = model
|
||||||
|
self.timeout = timeout
|
||||||
|
self._client: ollama.Client | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> ollama.Client:
|
||||||
|
if self._client is None:
|
||||||
|
self._client = ollama.Client(host=self.endpoint)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
try:
|
||||||
|
self.health_check()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
try:
|
||||||
|
response = self.client.ps()
|
||||||
|
return response is not None
|
||||||
|
except Exception as e:
|
||||||
|
raise ConnectionError(f"Ollama health check failed: {e}") from None
|
||||||
|
|
||||||
|
def generate(self, prompt: str, **kwargs) -> LLMResponse:
|
||||||
|
try:
|
||||||
|
max_tokens = kwargs.get("max_tokens", 2048)
|
||||||
|
temperature = kwargs.get("temperature", 0.3)
|
||||||
|
|
||||||
|
response = self.client.chat(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "You are a helpful code review assistant. Provide concise, constructive feedback on code changes."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"num_predict": max_tokens,
|
||||||
|
"temperature": temperature,
|
||||||
|
},
|
||||||
|
stream=False
|
||||||
|
)
|
||||||
|
|
||||||
|
return LLMResponse(
|
||||||
|
text=response["message"]["content"],
|
||||||
|
model=self.model,
|
||||||
|
tokens_used=response.get("eval_count", 0),
|
||||||
|
finish_reason=response.get("done_reason", "stop")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Ollama generation failed: {e}") from None
|
||||||
|
|
||||||
|
async def agenerate(self, prompt: str, **kwargs) -> LLMResponse:
|
||||||
|
try:
|
||||||
|
max_tokens = kwargs.get("max_tokens", 2048)
|
||||||
|
temperature = kwargs.get("temperature", 0.3)
|
||||||
|
|
||||||
|
response = await asyncio.to_thread(
|
||||||
|
self.client.chat,
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "You are a helpful code review assistant. Provide concise, constructive feedback on code changes."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"num_predict": max_tokens,
|
||||||
|
"temperature": temperature,
|
||||||
|
},
|
||||||
|
stream=False
|
||||||
|
)
|
||||||
|
|
||||||
|
return LLMResponse(
|
||||||
|
text=response["message"]["content"],
|
||||||
|
model=self.model,
|
||||||
|
tokens_used=response.get("eval_count", 0),
|
||||||
|
finish_reason=response.get("done_reason", "stop")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Ollama async generation failed: {e}") from None
|
||||||
|
|
||||||
|
async def stream_generate(self, prompt: str, **kwargs) -> AsyncIterator[str]: # type: ignore[misc]
|
||||||
|
try:
|
||||||
|
max_tokens = kwargs.get("max_tokens", 2048)
|
||||||
|
temperature = kwargs.get("temperature", 0.3)
|
||||||
|
|
||||||
|
response = self.client.chat(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "You are a helpful code review assistant. Provide concise, constructive feedback on code changes."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"num_predict": max_tokens,
|
||||||
|
"temperature": temperature,
|
||||||
|
},
|
||||||
|
stream=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for chunk in response:
|
||||||
|
if "message" in chunk and "content" in chunk["message"]:
|
||||||
|
yield chunk["message"]["content"]
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Ollama streaming failed: {e}") from None
|
||||||
|
|
||||||
|
def list_models(self) -> list[ModelInfo]:
|
||||||
|
try:
|
||||||
|
response = self.client.ps()
|
||||||
|
models = []
|
||||||
|
if response and "models" in response:
|
||||||
|
for model in response["models"]:
|
||||||
|
models.append(ModelInfo(
|
||||||
|
name=model.get("name", "unknown"),
|
||||||
|
size=model.get("size", "unknown"),
|
||||||
|
modified=model.get("modified", datetime.now().isoformat()),
|
||||||
|
digest=model.get("digest", "")
|
||||||
|
))
|
||||||
|
return models
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def pull_model(self, model_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
for _ in self.client.pull(model_name, stream=True):
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
45
local-ai-commit-reviewer/src/llm/provider.py
Normal file
45
local-ai-commit-reviewer/src/llm/provider.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LLMResponse:
|
||||||
|
text: str
|
||||||
|
model: str
|
||||||
|
tokens_used: int
|
||||||
|
finish_reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelInfo:
|
||||||
|
name: str
|
||||||
|
size: str
|
||||||
|
modified: str
|
||||||
|
digest: str
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def generate(self, prompt: str, **kwargs) -> LLMResponse:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def agenerate(self, prompt: str, **kwargs) -> LLMResponse:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def stream_generate(self, prompt: str, **kwargs) -> AsyncIterator[str]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_models(self) -> list[ModelInfo]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
pass
|
||||||
133
local-ai-commit-reviewer/src/llm/templates.py
Normal file
133
local-ai-commit-reviewer/src/llm/templates.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
class ReviewPromptTemplates:
|
||||||
|
base_prompt: str = """You are an expert code reviewer analyzing staged changes in a Git repository.
|
||||||
|
|
||||||
|
Review the following code changes and provide detailed feedback on:
|
||||||
|
1. Potential bugs and security vulnerabilities
|
||||||
|
2. Code style and best practices violations
|
||||||
|
3. Performance concerns
|
||||||
|
4. Documentation issues
|
||||||
|
5. Suggestions for improvement
|
||||||
|
|
||||||
|
Respond in the following JSON format:
|
||||||
|
{{
|
||||||
|
"issues": [
|
||||||
|
{{
|
||||||
|
"file": "filename",
|
||||||
|
"line": line_number,
|
||||||
|
"severity": "critical|warning|info",
|
||||||
|
"category": "bug|security|style|performance|documentation",
|
||||||
|
"message": "description of the issue",
|
||||||
|
"suggestion": "suggested fix (if applicable)"
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"summary": {{
|
||||||
|
"critical_count": number,
|
||||||
|
"warning_count": number,
|
||||||
|
"info_count": number,
|
||||||
|
"overall_assessment": "brief summary"
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
Only include issues that match the strictness level: {strictness}
|
||||||
|
|
||||||
|
{strictness_settings}
|
||||||
|
|
||||||
|
Review the following diff:
|
||||||
|
```
|
||||||
|
{diff}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
permissive_settings: str = """Strictness: PERMISSIVE
|
||||||
|
- Only report critical security issues
|
||||||
|
- Only report definite bugs (not potential issues)
|
||||||
|
- Ignore style and formatting issues
|
||||||
|
- Ignore performance concerns
|
||||||
|
- Ignore documentation issues
|
||||||
|
"""
|
||||||
|
|
||||||
|
balanced_settings: str = """Strictness: BALANCED
|
||||||
|
- Report all security issues
|
||||||
|
- Report all definite bugs and potential bugs
|
||||||
|
- Report major style violations
|
||||||
|
- Ignore minor performance concerns
|
||||||
|
- Ignore documentation issues unless critical
|
||||||
|
"""
|
||||||
|
|
||||||
|
strict_settings: str = """Strictness: STRICT
|
||||||
|
- Report all security issues (even minor)
|
||||||
|
- Report all bugs (definite and potential)
|
||||||
|
- Report all style violations
|
||||||
|
- Report performance concerns
|
||||||
|
- Report documentation issues
|
||||||
|
- Suggest specific improvements
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_prompt(cls, diff: str, strictness: str = "balanced", language: str = "unknown") -> str:
|
||||||
|
settings_map = {
|
||||||
|
"permissive": cls.permissive_settings,
|
||||||
|
"balanced": cls.balanced_settings,
|
||||||
|
"strict": cls.strict_settings
|
||||||
|
}
|
||||||
|
|
||||||
|
settings = settings_map.get(strictness.lower(), cls.balanced_settings)
|
||||||
|
|
||||||
|
base = cls.base_prompt.format(
|
||||||
|
strictness=strictness.upper(),
|
||||||
|
strictness_settings=settings,
|
||||||
|
diff=diff
|
||||||
|
)
|
||||||
|
|
||||||
|
if language != "unknown":
|
||||||
|
base += f"\n\nNote: This code is in {language}. Apply {language}-specific best practices."
|
||||||
|
|
||||||
|
return base
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_commit_review_prompt(cls, diff: str, commit_message: str, strictness: str = "balanced") -> str:
|
||||||
|
prompt = f"""Review the following commit with message: "{commit_message}"
|
||||||
|
|
||||||
|
Analyze whether the changes align with the commit message and provide feedback.
|
||||||
|
|
||||||
|
"""
|
||||||
|
prompt += cls.get_prompt(diff, strictness)
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_security_review_prompt(cls, diff: str) -> str:
|
||||||
|
template = """You are a security expert reviewing code changes for vulnerabilities.
|
||||||
|
|
||||||
|
Focus specifically on:
|
||||||
|
1. Injection vulnerabilities (SQL, command, code injection)
|
||||||
|
2. Authentication and authorization issues
|
||||||
|
3. Sensitive data exposure
|
||||||
|
4. Cryptographic weaknesses
|
||||||
|
5. Path traversal and file inclusion
|
||||||
|
6. Dependency security issues
|
||||||
|
|
||||||
|
Provide findings in JSON format:
|
||||||
|
```
|
||||||
|
{{
|
||||||
|
"vulnerabilities": [
|
||||||
|
{{
|
||||||
|
"file": "filename",
|
||||||
|
"line": line_number,
|
||||||
|
"severity": "critical|high|medium|low",
|
||||||
|
"type": "vulnerability type",
|
||||||
|
"description": "detailed description",
|
||||||
|
"exploit_scenario": "how it could be exploited",
|
||||||
|
"fix": "recommended fix"
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"secure_patterns": ["list of good security practices observed"],
|
||||||
|
"concerns": ["list of potential security concerns"]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Review the following diff:
|
||||||
|
```
|
||||||
|
{diff}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
return template.format(diff=diff)
|
||||||
3
local-ai-commit-reviewer/src/utils/__init__.py
Normal file
3
local-ai-commit-reviewer/src/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .utils import get_file_language, sanitize_output, setup_logging
|
||||||
|
|
||||||
|
__all__ = ["get_file_language", "sanitize_output", "setup_logging"]
|
||||||
66
local-ai-commit-reviewer/src/utils/utils.py
Normal file
66
local-ai-commit-reviewer/src/utils/utils.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level: str = "info", log_file: str | None = None) -> logging.Logger:
|
||||||
|
logger = logging.getLogger("aicr")
|
||||||
|
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||||
|
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
stream_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
stream_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(stream_handler)
|
||||||
|
|
||||||
|
if log_file:
|
||||||
|
file_handler = logging.FileHandler(log_file)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_language(filename: str) -> str:
|
||||||
|
ext_map = {
|
||||||
|
".py": "python",
|
||||||
|
".js": "javascript",
|
||||||
|
".ts": "typescript",
|
||||||
|
".go": "go",
|
||||||
|
".rs": "rust",
|
||||||
|
".java": "java",
|
||||||
|
".c": "c",
|
||||||
|
".cpp": "cpp",
|
||||||
|
".h": "c",
|
||||||
|
".hpp": "cpp",
|
||||||
|
".jsx": "javascript",
|
||||||
|
".tsx": "typescript",
|
||||||
|
".rb": "ruby",
|
||||||
|
".php": "php",
|
||||||
|
".swift": "swift",
|
||||||
|
".kt": "kotlin",
|
||||||
|
".scala": "scala",
|
||||||
|
}
|
||||||
|
ext = Path(filename).suffix.lower()
|
||||||
|
return ext_map.get(ext, "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_output(text: str) -> str:
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_text(text: str, max_length: int = 2000, suffix: str = "...") -> str:
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return text
|
||||||
|
return text[:max_length - len(suffix)] + suffix
|
||||||
|
|
||||||
|
|
||||||
|
def format_file_size(size: float) -> str:
|
||||||
|
KB_SIZE = 1024
|
||||||
|
for unit in ["B", "KB", "MB", "GB"]:
|
||||||
|
if size < KB_SIZE:
|
||||||
|
return f"{size:.1f}{unit}"
|
||||||
|
size /= 1024
|
||||||
|
return f"{size:.1f}TB"
|
||||||
126
local-ai-commit-reviewer/tests/conftest.py
Normal file
126
local-ai-commit-reviewer/tests/conftest.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config import Config
|
||||||
|
from src.llm.provider import LLMProvider, LLMResponse, ModelInfo
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_git_repo():
|
||||||
|
"""Create a temporary Git repository for testing."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
repo_path = Path(tmpdir)
|
||||||
|
subprocess.run(["git", "init"], cwd=repo_path, capture_output=True, check=False)
|
||||||
|
subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, capture_output=True, check=False)
|
||||||
|
subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, capture_output=True, check=False)
|
||||||
|
yield repo_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_python_file(temp_git_repo):
|
||||||
|
"""Create a sample Python file in the temp repo."""
|
||||||
|
test_file = temp_git_repo / "test.py"
|
||||||
|
test_file.write_text('def hello():\n print("Hello, World!")\n return True\n')
|
||||||
|
subprocess.run(["git", "add", "test.py"], cwd=temp_git_repo, capture_output=True, check=False)
|
||||||
|
return test_file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_js_file(temp_git_repo):
|
||||||
|
"""Create a sample JavaScript file."""
|
||||||
|
test_file = temp_git_repo / "test.js"
|
||||||
|
test_file.write_text('function hello() {\n console.log("Hello, World!");\n}\n')
|
||||||
|
subprocess.run(["git", "add", "test.js"], cwd=temp_git_repo, capture_output=True, check=False)
|
||||||
|
return test_file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_diff():
|
||||||
|
"""Return a sample diff for testing."""
|
||||||
|
return """diff --git a/test.py b/test.py
|
||||||
|
--- a/test.py
|
||||||
|
+++ b/test.py
|
||||||
|
@@ -1,3 +1,4 @@
|
||||||
|
def hello():
|
||||||
|
+ print("hello")
|
||||||
|
return True
|
||||||
|
- return False
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config():
|
||||||
|
"""Return a default Config instance."""
|
||||||
|
return Config()
|
||||||
|
|
||||||
|
|
||||||
|
class MockLLMProvider(LLMProvider):
|
||||||
|
"""Mock LLM provider for testing."""
|
||||||
|
|
||||||
|
def __init__(self, available: bool = True, response_text: str | None = None):
|
||||||
|
self._available = available
|
||||||
|
self._response_text = response_text or '{"issues": [], "summary": {"critical_count": 0, "warning_count": 0, "info_count": 0, "overall_assessment": "No issues"}}'
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
def generate(self, _prompt: str, **_kwargs) -> LLMResponse:
|
||||||
|
return LLMResponse(
|
||||||
|
text=self._response_text,
|
||||||
|
model="mock-model",
|
||||||
|
tokens_used=50,
|
||||||
|
finish_reason="stop"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def agenerate(self, _prompt: str, **_kwargs) -> LLMResponse:
|
||||||
|
return self.generate(_prompt, **_kwargs)
|
||||||
|
|
||||||
|
def stream_generate(self, _prompt: str, **_kwargs):
|
||||||
|
yield "Mock"
|
||||||
|
|
||||||
|
def list_models(self) -> list[ModelInfo]:
|
||||||
|
return [
|
||||||
|
ModelInfo(name="mock-model", size="4GB", modified="2024-01-01", digest="abc123")
|
||||||
|
]
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_llm_provider():
|
||||||
|
"""Return a mock LLM provider."""
|
||||||
|
return MockLLMProvider(available=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_llm_unavailable():
|
||||||
|
"""Return a mock LLM provider that's not available."""
|
||||||
|
return MockLLMProvider(available=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_llm_with_issues():
|
||||||
|
"""Return a mock LLM provider that returns issues."""
|
||||||
|
response = '''{
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"file": "test.py",
|
||||||
|
"line": 2,
|
||||||
|
"severity": "warning",
|
||||||
|
"category": "style",
|
||||||
|
"message": "Missing docstring for function",
|
||||||
|
"suggestion": "Add a docstring above the function definition"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"critical_count": 0,
|
||||||
|
"warning_count": 1,
|
||||||
|
"info_count": 0,
|
||||||
|
"overall_assessment": "Minor style issues found"
|
||||||
|
}
|
||||||
|
}'''
|
||||||
|
return MockLLMProvider(available=True, response_text=response)
|
||||||
126
local-ai-commit-reviewer/tests/fixtures/sample_repo.py
vendored
Normal file
126
local-ai-commit-reviewer/tests/fixtures/sample_repo.py
vendored
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config import Config
|
||||||
|
from src.llm.provider import LLMProvider, LLMResponse, ModelInfo
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_git_repo():
|
||||||
|
"""Create a temporary Git repository for testing."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
repo_path = Path(tmpdir)
|
||||||
|
subprocess.run(["git", "init"], cwd=repo_path, capture_output=True, check=False)
|
||||||
|
subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, capture_output=True, check=False)
|
||||||
|
subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, capture_output=True, check=False)
|
||||||
|
yield repo_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_python_file(temp_git_repo):
|
||||||
|
"""Create a sample Python file in the temp repo."""
|
||||||
|
test_file = temp_git_repo / "test.py"
|
||||||
|
test_file.write_text('def hello():\n print("Hello, World!")\n return True\n')
|
||||||
|
subprocess.run(["git", "add", "test.py"], cwd=temp_git_repo, capture_output=True, check=False)
|
||||||
|
return test_file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_js_file(temp_git_repo):
|
||||||
|
"""Create a sample JavaScript file."""
|
||||||
|
test_file = temp_git_repo / "test.js"
|
||||||
|
test_file.write_text('function hello() {\n console.log("Hello, World!");\n}\n')
|
||||||
|
subprocess.run(["git", "add", "test.js"], cwd=temp_git_repo, capture_output=True, check=False)
|
||||||
|
return test_file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_diff():
|
||||||
|
"""Return a sample diff for testing."""
|
||||||
|
return """diff --git a/test.py b/test.py
|
||||||
|
--- a/test.py
|
||||||
|
+++ b/test.py
|
||||||
|
@@ -1,3 +1,4 @@
|
||||||
|
def hello():
|
||||||
|
+ print("hello")
|
||||||
|
return True
|
||||||
|
- return False
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config():
|
||||||
|
"""Return a default Config instance."""
|
||||||
|
return Config()
|
||||||
|
|
||||||
|
|
||||||
|
class MockLLMProvider(LLMProvider):
|
||||||
|
"""Mock LLM provider for testing."""
|
||||||
|
|
||||||
|
def __init__(self, available: bool = True, response_text: str | None = None):
|
||||||
|
self._available = available
|
||||||
|
self._response_text = response_text or '{"issues": [], "summary": {"critical_count": 0, "warning_count": 0, "info_count": 0, "overall_assessment": "No issues"}}'
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
def generate(self, _prompt: str, **_kwargs) -> LLMResponse:
|
||||||
|
return LLMResponse(
|
||||||
|
text=self._response_text,
|
||||||
|
model="mock-model",
|
||||||
|
tokens_used=50,
|
||||||
|
finish_reason="stop"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def agenerate(self, _prompt: str, **_kwargs) -> LLMResponse:
|
||||||
|
return self.generate(_prompt, **_kwargs)
|
||||||
|
|
||||||
|
def stream_generate(self, _prompt: str, **_kwargs):
|
||||||
|
yield "Mock"
|
||||||
|
|
||||||
|
def list_models(self) -> list[ModelInfo]:
|
||||||
|
return [
|
||||||
|
ModelInfo(name="mock-model", size="4GB", modified="2024-01-01", digest="abc123")
|
||||||
|
]
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_llm_provider():
|
||||||
|
"""Return a mock LLM provider."""
|
||||||
|
return MockLLMProvider(available=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_llm_unavailable():
|
||||||
|
"""Return a mock LLM provider that's not available."""
|
||||||
|
return MockLLMProvider(available=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_llm_with_issues():
|
||||||
|
"""Return a mock LLM provider that returns issues."""
|
||||||
|
response = '''{
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"file": "test.py",
|
||||||
|
"line": 2,
|
||||||
|
"severity": "warning",
|
||||||
|
"category": "style",
|
||||||
|
"message": "Missing docstring for function",
|
||||||
|
"suggestion": "Add a docstring above the function definition"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"critical_count": 0,
|
||||||
|
"warning_count": 1,
|
||||||
|
"info_count": 0,
|
||||||
|
"overall_assessment": "Minor style issues found"
|
||||||
|
}
|
||||||
|
}'''
|
||||||
|
return MockLLMProvider(available=True, response_text=response)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from fixtures.sample_repo import MockLLMProvider
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewWorkflow:
|
||||||
|
def test_review_with_no_staged_changes(self, temp_git_repo, mock_config):
|
||||||
|
from src.core.review_engine import ReviewEngine # noqa: PLC0415
|
||||||
|
|
||||||
|
engine = ReviewEngine(config=mock_config, llm_provider=MockLLMProvider())
|
||||||
|
engine.set_repo(temp_git_repo)
|
||||||
|
result = engine.review_staged_changes([])
|
||||||
|
assert result.error == "No staged changes found"
|
||||||
|
|
||||||
|
def test_review_with_staged_file(self, temp_git_repo, mock_config, request):
|
||||||
|
from src.core.review_engine import ReviewEngine # noqa: PLC0415
|
||||||
|
from src.git import get_staged_changes # noqa: PLC0415
|
||||||
|
|
||||||
|
request.getfixturevalue("sample_python_file")
|
||||||
|
changes = get_staged_changes(temp_git_repo)
|
||||||
|
|
||||||
|
engine = ReviewEngine(config=mock_config, llm_provider=MockLLMProvider())
|
||||||
|
engine.set_repo(temp_git_repo)
|
||||||
|
result = engine.review_staged_changes(changes)
|
||||||
|
|
||||||
|
assert result.review_mode == "balanced"
|
||||||
|
assert result.error is None or len(result.issues) >= 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestHookInstallation:
|
||||||
|
def test_install_hook(self, temp_git_repo):
|
||||||
|
from src.hooks import install_pre_commit_hook # noqa: PLC0415
|
||||||
|
|
||||||
|
result = install_pre_commit_hook(temp_git_repo)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
hook_path = temp_git_repo / ".git" / "hooks" / "pre-commit"
|
||||||
|
assert hook_path.exists()
|
||||||
|
|
||||||
|
content = hook_path.read_text()
|
||||||
|
assert "aicr" in content or "review" in content
|
||||||
|
|
||||||
|
def test_check_hook_installed(self, temp_git_repo):
|
||||||
|
from src.hooks import check_hook_installed, install_pre_commit_hook # noqa: PLC0415
|
||||||
|
|
||||||
|
assert check_hook_installed(temp_git_repo) is False
|
||||||
|
install_pre_commit_hook(temp_git_repo)
|
||||||
|
assert check_hook_installed(temp_git_repo) is True
|
||||||
51
local-ai-commit-reviewer/tests/unit/test_config.py
Normal file
51
local-ai-commit-reviewer/tests/unit/test_config.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
|
||||||
|
from src.config import Config, ConfigLoader
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig:
|
||||||
|
def test_default_config(self):
|
||||||
|
config = Config()
|
||||||
|
assert config.llm.endpoint == "http://localhost:11434"
|
||||||
|
assert config.llm.model == "codellama"
|
||||||
|
assert config.review.strictness == "balanced"
|
||||||
|
assert config.hooks.enabled is True
|
||||||
|
|
||||||
|
def test_config_from_dict(self):
|
||||||
|
data = {
|
||||||
|
"llm": {
|
||||||
|
"endpoint": "http://custom:9000",
|
||||||
|
"model": "custom-model"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"strictness": "strict"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config = Config(**data)
|
||||||
|
assert config.llm.endpoint == "http://custom:9000"
|
||||||
|
assert config.llm.model == "custom-model"
|
||||||
|
assert config.review.strictness == "strict"
|
||||||
|
|
||||||
|
def test_language_config(self):
|
||||||
|
config = Config()
|
||||||
|
py_config = config.languages.get_language_config("python")
|
||||||
|
assert py_config is not None
|
||||||
|
assert py_config.enabled is True
|
||||||
|
|
||||||
|
def test_strictness_profiles(self):
|
||||||
|
config = Config()
|
||||||
|
permissive = config.strictness_profiles.get_profile("permissive")
|
||||||
|
assert permissive.check_style is False
|
||||||
|
strict = config.strictness_profiles.get_profile("strict")
|
||||||
|
assert strict.check_performance is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigLoader:
|
||||||
|
def test_load_default_config(self):
|
||||||
|
loader = ConfigLoader()
|
||||||
|
config = loader.load()
|
||||||
|
assert isinstance(config, Config)
|
||||||
|
|
||||||
|
def test_find_config_files_nonexistent(self):
|
||||||
|
loader = ConfigLoader("/nonexistent/path.yaml")
|
||||||
|
path, _global_path = loader.find_config_files()
|
||||||
|
assert path is None
|
||||||
40
local-ai-commit-reviewer/tests/unit/test_git.py
Normal file
40
local-ai-commit-reviewer/tests/unit/test_git.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.git.git import FileChange, GitRepo
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitRepo:
|
||||||
|
def test_get_file_language(self):
|
||||||
|
repo = GitRepo(Path.cwd())
|
||||||
|
assert repo.get_file_language("test.py") == "python"
|
||||||
|
assert repo.get_file_language("test.js") == "javascript"
|
||||||
|
assert repo.get_file_language("test.go") == "go"
|
||||||
|
assert repo.get_file_language("test.rs") == "rust"
|
||||||
|
assert repo.get_file_language("test.unknown") == "unknown"
|
||||||
|
|
||||||
|
def test_get_diff_stats(self):
|
||||||
|
repo = GitRepo(Path.cwd())
|
||||||
|
diff = """diff --git a/test.py b/test.py
|
||||||
|
--- a/test.py
|
||||||
|
+++ b/test.py
|
||||||
|
@@ -1,3 +1,4 @@
|
||||||
|
def hello():
|
||||||
|
+ print("hello")
|
||||||
|
return True
|
||||||
|
- return False
|
||||||
|
"""
|
||||||
|
additions, deletions = repo.get_diff_stats(diff)
|
||||||
|
assert additions == 1
|
||||||
|
assert deletions == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileChange:
|
||||||
|
def test_file_change_creation(self):
|
||||||
|
change = FileChange(
|
||||||
|
filename="test.py",
|
||||||
|
status="M",
|
||||||
|
diff="diff content"
|
||||||
|
)
|
||||||
|
assert change.filename == "test.py"
|
||||||
|
assert change.status == "M"
|
||||||
|
assert change.diff == "diff content"
|
||||||
52
local-ai-commit-reviewer/tests/unit/test_llm.py
Normal file
52
local-ai-commit-reviewer/tests/unit/test_llm.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from src.llm.provider import LLMProvider, LLMResponse, ModelInfo
|
||||||
|
|
||||||
|
|
||||||
|
class MockLLMProvider(LLMProvider):
|
||||||
|
def __init__(self, available: bool = True):
|
||||||
|
self._available = available
|
||||||
|
self._models = []
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
def generate(self, _prompt: str, **_kwargs) -> LLMResponse:
|
||||||
|
return LLMResponse(
|
||||||
|
text="Mock review response",
|
||||||
|
model="mock-model",
|
||||||
|
tokens_used=100,
|
||||||
|
finish_reason="stop"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def agenerate(self, _prompt: str, **_kwargs) -> LLMResponse:
|
||||||
|
return self.generate(_prompt, **_kwargs)
|
||||||
|
|
||||||
|
def stream_generate(self, _prompt: str, **_kwargs):
|
||||||
|
yield "Mock"
|
||||||
|
|
||||||
|
def list_models(self) -> list[ModelInfo]:
|
||||||
|
return self._models
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
|
||||||
|
class TestLLMProvider:
|
||||||
|
def test_mock_provider_is_available(self):
|
||||||
|
provider = MockLLMProvider(available=True)
|
||||||
|
assert provider.is_available() is True
|
||||||
|
|
||||||
|
def test_mock_provider_not_available(self):
|
||||||
|
provider = MockLLMProvider(available=False)
|
||||||
|
assert provider.is_available() is False
|
||||||
|
|
||||||
|
def test_mock_generate(self):
|
||||||
|
provider = MockLLMProvider()
|
||||||
|
response = provider.generate("test prompt")
|
||||||
|
assert isinstance(response, LLMResponse)
|
||||||
|
assert response.text == "Mock review response"
|
||||||
|
assert response.model == "mock-model"
|
||||||
|
|
||||||
|
def test_mock_list_models(self):
|
||||||
|
provider = MockLLMProvider()
|
||||||
|
models = provider.list_models()
|
||||||
|
assert isinstance(models, list)
|
||||||
76
local-ai-commit-reviewer/tests/unit/test_review.py
Normal file
76
local-ai-commit-reviewer/tests/unit/test_review.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from src.core.review_engine import Issue, IssueCategory, IssueSeverity, ReviewResult, ReviewSummary
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue:
|
||||||
|
def test_issue_creation(self):
|
||||||
|
issue = Issue(
|
||||||
|
file="test.py",
|
||||||
|
line=10,
|
||||||
|
severity=IssueSeverity.WARNING,
|
||||||
|
category=IssueCategory.STYLE,
|
||||||
|
message="Missing docstring",
|
||||||
|
suggestion="Add a docstring"
|
||||||
|
)
|
||||||
|
assert issue.file == "test.py"
|
||||||
|
assert issue.line == 10 # noqa: PLR2004
|
||||||
|
assert issue.severity == IssueSeverity.WARNING
|
||||||
|
|
||||||
|
def test_issue_to_dict(self):
|
||||||
|
issue = Issue(
|
||||||
|
file="test.py",
|
||||||
|
line=10,
|
||||||
|
severity=IssueSeverity.CRITICAL,
|
||||||
|
category=IssueCategory.BUG,
|
||||||
|
message="Potential bug"
|
||||||
|
)
|
||||||
|
data = issue.to_dict()
|
||||||
|
assert data["file"] == "test.py"
|
||||||
|
assert data["severity"] == "critical"
|
||||||
|
assert data["category"] == "bug"
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewResult:
|
||||||
|
def test_review_result_no_issues(self):
|
||||||
|
result = ReviewResult()
|
||||||
|
assert result.has_issues() is False
|
||||||
|
assert result.has_critical_issues() is False
|
||||||
|
|
||||||
|
def test_review_result_with_issues(self):
|
||||||
|
result = ReviewResult()
|
||||||
|
result.issues = [
|
||||||
|
Issue(
|
||||||
|
file="test.py",
|
||||||
|
line=1,
|
||||||
|
severity=IssueSeverity.CRITICAL,
|
||||||
|
category=IssueCategory.SECURITY,
|
||||||
|
message="SQL injection"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert result.has_issues() is True
|
||||||
|
assert result.has_critical_issues() is True
|
||||||
|
|
||||||
|
def test_get_issues_by_severity(self):
|
||||||
|
result = ReviewResult()
|
||||||
|
result.issues = [
|
||||||
|
Issue(file="a.py", line=1, severity=IssueSeverity.CRITICAL, category=IssueCategory.BUG, message="Bug1"),
|
||||||
|
Issue(file="b.py", line=2, severity=IssueSeverity.WARNING, category=IssueCategory.STYLE, message="Style1"),
|
||||||
|
Issue(file="c.py", line=3, severity=IssueSeverity.INFO, category=IssueCategory.DOCUMENTATION, message="Doc1"),
|
||||||
|
]
|
||||||
|
critical = result.get_issues_by_severity(IssueSeverity.CRITICAL)
|
||||||
|
assert len(critical) == 1
|
||||||
|
assert critical[0].file == "a.py"
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewSummary:
|
||||||
|
def test_review_summary_aggregation(self):
|
||||||
|
summary = ReviewSummary()
|
||||||
|
summary.files_reviewed = 5
|
||||||
|
summary.lines_changed = 100
|
||||||
|
summary.critical_count = 2
|
||||||
|
summary.warning_count = 5
|
||||||
|
summary.info_count = 10
|
||||||
|
summary.overall_assessment = "Good"
|
||||||
|
|
||||||
|
data = summary.to_dict()
|
||||||
|
assert data["files_reviewed"] == 5 # noqa: PLR2004
|
||||||
|
assert data["critical_count"] == 2 # noqa: PLR2004
|
||||||
928
main.py
Normal file
928
main.py
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
"""
|
||||||
|
7000%AUTO - AI Automation System
|
||||||
|
Main Entry Point
|
||||||
|
|
||||||
|
This module initializes the FastAPI application, database, and orchestrator.
|
||||||
|
It handles graceful startup and shutdown of all system components.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Database initialization on startup
|
||||||
|
- FastAPI web server with uvicorn
|
||||||
|
- Orchestrator workflow running in background task
|
||||||
|
- Graceful shutdown handling
|
||||||
|
- Structured logging with configurable log level
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Set SDK log level BEFORE importing any SDK packages
|
||||||
|
# This must be done at module load time, before opencode_ai is imported
|
||||||
|
# Using setdefault allows users to override via environment variable for debugging
|
||||||
|
import os
|
||||||
|
os.environ.setdefault("OPENCODE_LOG", "warn")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import shutil
|
||||||
|
import structlog
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
# Import project modules
|
||||||
|
from config import settings
|
||||||
|
from database import init_db, close_db
|
||||||
|
from orchestrator import WorkflowOrchestrator
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Logging Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def configure_logging(log_level: str = "INFO") -> None:
|
||||||
|
"""
|
||||||
|
Configure structured logging with the specified log level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||||
|
"""
|
||||||
|
# Set root logger level
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(message)s",
|
||||||
|
level=getattr(logging, log_level.upper(), logging.INFO),
|
||||||
|
stream=sys.stdout,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Silence noisy third-party loggers
|
||||||
|
noisy_loggers = [
|
||||||
|
# SQLAlchemy
|
||||||
|
"sqlalchemy",
|
||||||
|
"sqlalchemy.engine",
|
||||||
|
"sqlalchemy.pool",
|
||||||
|
"sqlalchemy.dialects",
|
||||||
|
"sqlalchemy.orm",
|
||||||
|
"aiosqlite",
|
||||||
|
# HTTP clients
|
||||||
|
"httpx",
|
||||||
|
"httpx._client",
|
||||||
|
"httpcore",
|
||||||
|
"httpcore.http11",
|
||||||
|
"httpcore.http2",
|
||||||
|
"httpcore.connection",
|
||||||
|
"urllib3",
|
||||||
|
"hpack",
|
||||||
|
"h11",
|
||||||
|
"h2",
|
||||||
|
# OpenCode SDK (uses stainless framework)
|
||||||
|
"opencode",
|
||||||
|
"opencode_ai",
|
||||||
|
"opencode_ai._base_client",
|
||||||
|
"opencode_ai._client",
|
||||||
|
# Stainless SDK framework (base for OpenAI/OpenCode SDKs)
|
||||||
|
"stainless",
|
||||||
|
"stainless._base_client",
|
||||||
|
# Uvicorn
|
||||||
|
"uvicorn.access",
|
||||||
|
]
|
||||||
|
for logger_name in noisy_loggers:
|
||||||
|
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
# Suppress httpx debug logging via environment variable
|
||||||
|
os.environ.setdefault("HTTPX_LOG_LEVEL", "WARNING")
|
||||||
|
|
||||||
|
# Configure structlog
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.filter_by_level,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
structlog.processors.UnicodeDecoder(),
|
||||||
|
structlog.processors.JSONRenderer() if not settings.DEBUG else structlog.dev.ConsoleRenderer(),
|
||||||
|
],
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize logging
|
||||||
|
configure_logging(settings.LOG_LEVEL)
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Global State
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Global orchestrator instance
|
||||||
|
orchestrator: Optional[WorkflowOrchestrator] = None
|
||||||
|
|
||||||
|
# Background task reference
|
||||||
|
orchestrator_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
# OpenCode server process
|
||||||
|
opencode_process: Optional[subprocess.Popen] = None
|
||||||
|
opencode_server_url: Optional[str] = None
|
||||||
|
|
||||||
|
# Default OpenCode server port
|
||||||
|
OPENCODE_SERVER_PORT = 18080
|
||||||
|
|
||||||
|
# Shutdown event for graceful termination
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OpenCode Configuration Generation
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def generate_opencode_config() -> None:
|
||||||
|
"""
|
||||||
|
Generate opencode.json dynamically from environment variables.
|
||||||
|
|
||||||
|
This ensures all configuration values are properly set from environment
|
||||||
|
variables. If required variables are missing, exits with a clear error.
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
|
- OPENCODE_API_KEY: API key for the AI provider
|
||||||
|
- OPENCODE_API_BASE: API base URL
|
||||||
|
- OPENCODE_SDK: npm package (e.g. @ai-sdk/anthropic, @ai-sdk/openai)
|
||||||
|
- OPENCODE_MODEL: Model name to use
|
||||||
|
- OPENCODE_MAX_TOKENS: Maximum output tokens
|
||||||
|
"""
|
||||||
|
# Check for required environment variables
|
||||||
|
missing = settings.get_missing_opencode_settings()
|
||||||
|
if missing:
|
||||||
|
logger.error(
|
||||||
|
"Missing required OpenCode environment variables",
|
||||||
|
missing=missing,
|
||||||
|
hint="Set these environment variables before starting the application",
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
"Example configuration:",
|
||||||
|
example={
|
||||||
|
"OPENCODE_API_KEY": "your-api-key",
|
||||||
|
"OPENCODE_API_BASE": "https://api.minimax.io/anthropic/v1",
|
||||||
|
"OPENCODE_SDK": "@ai-sdk/anthropic",
|
||||||
|
"OPENCODE_MODEL": "MiniMax-M2.1",
|
||||||
|
"OPENCODE_MAX_TOKENS": "196608",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Extract provider name from SDK package (e.g. @ai-sdk/anthropic -> anthropic)
|
||||||
|
# This is used as the provider key in the config
|
||||||
|
sdk_parts = settings.OPENCODE_SDK.split("/")
|
||||||
|
provider_name = sdk_parts[-1] if sdk_parts else "custom"
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
provider_name: {
|
||||||
|
"npm": settings.OPENCODE_SDK,
|
||||||
|
"name": provider_name.title(),
|
||||||
|
"options": {
|
||||||
|
"baseURL": settings.OPENCODE_API_BASE,
|
||||||
|
"apiKey": "{env:OPENCODE_API_KEY}"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
settings.OPENCODE_MODEL: {
|
||||||
|
"name": settings.OPENCODE_MODEL,
|
||||||
|
"options": {
|
||||||
|
"max_tokens": settings.OPENCODE_MAX_TOKENS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model": f"{provider_name}/{settings.OPENCODE_MODEL}",
|
||||||
|
"agent": {
|
||||||
|
"ideator": {
|
||||||
|
"description": "Finds innovative project ideas from various sources",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/ideator.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": True,
|
||||||
|
"grep": True,
|
||||||
|
"glob": True,
|
||||||
|
"bash": True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"planner": {
|
||||||
|
"description": "Creates detailed implementation plans",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/planner.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": True,
|
||||||
|
"grep": True,
|
||||||
|
"glob": True,
|
||||||
|
"bash": True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"developer": {
|
||||||
|
"description": "Implements code based on plans",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/developer.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": True,
|
||||||
|
"write": True,
|
||||||
|
"edit": True,
|
||||||
|
"bash": True,
|
||||||
|
"grep": True,
|
||||||
|
"glob": True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tester": {
|
||||||
|
"description": "Tests and validates implementations",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/tester.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": True,
|
||||||
|
"bash": True,
|
||||||
|
"grep": True,
|
||||||
|
"glob": True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uploader": {
|
||||||
|
"description": "Uploads projects to Gitea",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/uploader.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": True,
|
||||||
|
"write": True,
|
||||||
|
"bash": True,
|
||||||
|
"grep": True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evangelist": {
|
||||||
|
"description": "Promotes projects on X/Twitter",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/evangelist.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": True,
|
||||||
|
"bash": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"search": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["python", "-m", "mcp_servers.search_mcp"],
|
||||||
|
"enabled": True
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["python", "-m", "mcp_servers.gitea_mcp"],
|
||||||
|
"enabled": True
|
||||||
|
},
|
||||||
|
"x_api": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["python", "-m", "mcp_servers.x_mcp"],
|
||||||
|
"enabled": True
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["python", "-m", "mcp_servers.database_mcp"],
|
||||||
|
"enabled": True
|
||||||
|
},
|
||||||
|
"devtest": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["python", "-m", "mcp_servers.devtest_mcp"],
|
||||||
|
"enabled": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write the config file
|
||||||
|
config_path = Path("opencode.json")
|
||||||
|
config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Generated opencode.json from environment variables",
|
||||||
|
sdk=settings.OPENCODE_SDK,
|
||||||
|
model=settings.OPENCODE_MODEL,
|
||||||
|
max_tokens=settings.OPENCODE_MAX_TOKENS,
|
||||||
|
base_url=settings.OPENCODE_API_BASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OpenCode Server Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def start_opencode_server() -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Start the OpenCode server as a subprocess.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The server URL if successful, None otherwise
|
||||||
|
"""
|
||||||
|
global opencode_process, opencode_server_url
|
||||||
|
|
||||||
|
# Check if OpenCode CLI is available
|
||||||
|
# Check multiple locations: npm global, user home, and PATH
|
||||||
|
possible_paths = [
|
||||||
|
"/usr/local/bin/opencode", # npm global bin (Docker)
|
||||||
|
"/usr/bin/opencode", # System bin
|
||||||
|
os.path.expanduser("~/.opencode/bin/opencode"), # User home (curl install)
|
||||||
|
]
|
||||||
|
|
||||||
|
opencode_path = None
|
||||||
|
for path in possible_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
|
opencode_path = path
|
||||||
|
break
|
||||||
|
|
||||||
|
if not opencode_path:
|
||||||
|
# Try to find in PATH
|
||||||
|
opencode_path = shutil.which("opencode")
|
||||||
|
if not opencode_path:
|
||||||
|
logger.warning(
|
||||||
|
"OpenCode CLI not found",
|
||||||
|
checked_paths=["~/.opencode/bin/opencode", "PATH"]
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Determine port to use
|
||||||
|
port = OPENCODE_SERVER_PORT
|
||||||
|
if settings.OPENCODE_SERVER_URL:
|
||||||
|
# Extract port from existing URL if configured
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
parsed = urlparse(settings.OPENCODE_SERVER_URL)
|
||||||
|
if parsed.port:
|
||||||
|
port = parsed.port
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
server_url = f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Starting OpenCode server",
|
||||||
|
opencode_path=opencode_path,
|
||||||
|
port=port,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start OpenCode server in serve mode
|
||||||
|
opencode_process = subprocess.Popen(
|
||||||
|
[
|
||||||
|
opencode_path,
|
||||||
|
"serve",
|
||||||
|
"--port", str(port),
|
||||||
|
"--hostname", "127.0.0.1",
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
cwd=os.getcwd(), # Run in project directory for opencode.json
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for server to be ready
|
||||||
|
ready = await wait_for_opencode_server(server_url, timeout=30)
|
||||||
|
|
||||||
|
if ready:
|
||||||
|
opencode_server_url = server_url
|
||||||
|
logger.info(
|
||||||
|
"OpenCode server started successfully",
|
||||||
|
url=server_url,
|
||||||
|
pid=opencode_process.pid,
|
||||||
|
)
|
||||||
|
return server_url
|
||||||
|
else:
|
||||||
|
logger.error("OpenCode server failed to start within timeout")
|
||||||
|
await stop_opencode_server()
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to start OpenCode server",
|
||||||
|
error=str(e),
|
||||||
|
error_type=type(e).__name__,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_opencode_server(url: str, timeout: int = 30) -> bool:
|
||||||
|
"""
|
||||||
|
Wait for the OpenCode server to be ready.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Server URL to check
|
||||||
|
timeout: Maximum seconds to wait
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if server is ready, False otherwise
|
||||||
|
"""
|
||||||
|
start_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
while (asyncio.get_event_loop().time() - start_time) < timeout:
|
||||||
|
try:
|
||||||
|
# Try to connect to the server
|
||||||
|
# OpenCode server might not have a /health endpoint,
|
||||||
|
# so we just try to connect
|
||||||
|
response = await client.get(f"{url}/")
|
||||||
|
# Any response means server is up
|
||||||
|
logger.debug("OpenCode server responded", status=response.status_code)
|
||||||
|
return True
|
||||||
|
except httpx.ConnectError:
|
||||||
|
# Server not yet ready
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
# Connection timeout, try again
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Waiting for OpenCode server: {e}")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# Check if process has died
|
||||||
|
if opencode_process and opencode_process.poll() is not None:
|
||||||
|
returncode = opencode_process.returncode
|
||||||
|
stderr = opencode_process.stderr.read().decode() if opencode_process.stderr else ""
|
||||||
|
logger.error(
|
||||||
|
"OpenCode server process died",
|
||||||
|
returncode=returncode,
|
||||||
|
stderr=stderr[:500],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_opencode_server() -> None:
|
||||||
|
"""
|
||||||
|
Stop the OpenCode server subprocess.
|
||||||
|
"""
|
||||||
|
global opencode_process, opencode_server_url
|
||||||
|
|
||||||
|
if opencode_process is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Stopping OpenCode server", pid=opencode_process.pid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try graceful termination first
|
||||||
|
opencode_process.terminate()
|
||||||
|
|
||||||
|
# Wait for process to terminate
|
||||||
|
try:
|
||||||
|
opencode_process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
# Force kill if needed
|
||||||
|
logger.warning("OpenCode server did not terminate gracefully, killing...")
|
||||||
|
opencode_process.kill()
|
||||||
|
opencode_process.wait(timeout=5)
|
||||||
|
|
||||||
|
logger.info("OpenCode server stopped")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping OpenCode server: {e}")
|
||||||
|
finally:
|
||||||
|
opencode_process = None
|
||||||
|
opencode_server_url = None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Orchestrator Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def run_orchestrator_loop() -> None:
|
||||||
|
"""
|
||||||
|
Run the orchestrator pipeline in a continuous loop.
|
||||||
|
|
||||||
|
The orchestrator will run the full pipeline and then wait for a configured
|
||||||
|
interval before starting the next run. This loop continues until shutdown
|
||||||
|
is requested.
|
||||||
|
"""
|
||||||
|
global orchestrator
|
||||||
|
|
||||||
|
orchestrator = WorkflowOrchestrator()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Orchestrator loop started",
|
||||||
|
auto_start=settings.AUTO_START,
|
||||||
|
max_concurrent_projects=settings.MAX_CONCURRENT_PROJECTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
logger.info("Starting orchestrator pipeline run")
|
||||||
|
|
||||||
|
# Run the full pipeline
|
||||||
|
result = await orchestrator.run_full_pipeline()
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
logger.info(
|
||||||
|
"Pipeline completed successfully",
|
||||||
|
project_id=result.get("project_id"),
|
||||||
|
github_url=result.get("github_url"),
|
||||||
|
x_post_url=result.get("x_post_url"),
|
||||||
|
iterations=result.get("dev_test_iterations"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Pipeline completed with errors",
|
||||||
|
project_id=result.get("project_id"),
|
||||||
|
error=result.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait before next run (or until shutdown)
|
||||||
|
# Use a reasonable interval between pipeline runs
|
||||||
|
pipeline_interval = 60 # seconds between pipeline runs
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
shutdown_event.wait(),
|
||||||
|
timeout=pipeline_interval
|
||||||
|
)
|
||||||
|
# If we get here, shutdown was requested
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Timeout means we should continue the loop
|
||||||
|
continue
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Orchestrator loop cancelled")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Orchestrator pipeline error",
|
||||||
|
error=str(e),
|
||||||
|
error_type=type(e).__name__,
|
||||||
|
)
|
||||||
|
# Wait before retrying after error
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(shutdown_event.wait(), timeout=30)
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info("Orchestrator loop stopped")
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_orchestrator() -> None:
|
||||||
|
"""
|
||||||
|
Stop the orchestrator gracefully.
|
||||||
|
"""
|
||||||
|
global orchestrator, orchestrator_task
|
||||||
|
|
||||||
|
logger.info("Stopping orchestrator...")
|
||||||
|
|
||||||
|
# Signal shutdown
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
# Stop the orchestrator if running
|
||||||
|
if orchestrator is not None:
|
||||||
|
await orchestrator.stop()
|
||||||
|
|
||||||
|
# Cancel and wait for background task
|
||||||
|
if orchestrator_task is not None and not orchestrator_task.done():
|
||||||
|
orchestrator_task.cancel()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(orchestrator_task, timeout=10.0)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("Orchestrator stopped")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Database Initialization
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def initialize_database() -> None:
|
||||||
|
"""
|
||||||
|
Initialize the database and create all tables.
|
||||||
|
"""
|
||||||
|
logger.info("Initializing database...")
|
||||||
|
|
||||||
|
# Ensure required directories exist
|
||||||
|
settings.ensure_directories()
|
||||||
|
|
||||||
|
# Initialize database tables
|
||||||
|
await init_db()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Database initialized successfully",
|
||||||
|
database_url=settings.DATABASE_URL.split("@")[-1] if "@" in settings.DATABASE_URL else "local",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown_database() -> None:
|
||||||
|
"""
|
||||||
|
Close database connections gracefully.
|
||||||
|
"""
|
||||||
|
logger.info("Closing database connections...")
|
||||||
|
await close_db()
|
||||||
|
logger.info("Database connections closed")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FastAPI Application Lifespan
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""
|
||||||
|
Application lifespan context manager.
|
||||||
|
|
||||||
|
Handles startup and shutdown events for the FastAPI application:
|
||||||
|
- Startup: Initialize database, start OpenCode server, start orchestrator (if AUTO_START)
|
||||||
|
- Shutdown: Stop orchestrator, stop OpenCode server, close database connections
|
||||||
|
"""
|
||||||
|
global orchestrator_task, opencode_server_url
|
||||||
|
|
||||||
|
# === STARTUP ===
|
||||||
|
logger.info(
|
||||||
|
"Starting 7000%AUTO application",
|
||||||
|
app_name=settings.APP_NAME,
|
||||||
|
debug=settings.DEBUG,
|
||||||
|
host=settings.HOST,
|
||||||
|
port=settings.PORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Initialize database
|
||||||
|
await initialize_database()
|
||||||
|
|
||||||
|
# Mount web dashboard AFTER database is initialized
|
||||||
|
try:
|
||||||
|
from web.app import app as dashboard_app
|
||||||
|
app.mount("/dashboard", dashboard_app)
|
||||||
|
logger.info("Web dashboard mounted at /dashboard")
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("Web dashboard not available, skipping mount")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to mount web dashboard: {e}")
|
||||||
|
|
||||||
|
# Generate opencode.json from environment variables
|
||||||
|
# This ensures all config values are properly set without {env:...} syntax issues
|
||||||
|
generate_opencode_config()
|
||||||
|
|
||||||
|
# Start OpenCode server
|
||||||
|
opencode_url = await start_opencode_server()
|
||||||
|
if opencode_url:
|
||||||
|
# Set the server URL for the orchestrator to use
|
||||||
|
# Update settings dynamically
|
||||||
|
settings.OPENCODE_SERVER_URL = opencode_url
|
||||||
|
logger.info(
|
||||||
|
"OpenCode server ready",
|
||||||
|
url=opencode_url,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"OpenCode server not available, agent operations may fail",
|
||||||
|
fallback="Will attempt to use OPENCODE_API directly if configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start orchestrator in background if AUTO_START is enabled
|
||||||
|
if settings.AUTO_START:
|
||||||
|
logger.info("AUTO_START enabled, starting orchestrator background task")
|
||||||
|
orchestrator_task = asyncio.create_task(
|
||||||
|
run_orchestrator_loop(),
|
||||||
|
name="orchestrator-loop"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("AUTO_START disabled, orchestrator will not start automatically")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Application startup complete",
|
||||||
|
auto_start=settings.AUTO_START,
|
||||||
|
gitea_configured=settings.is_gitea_configured,
|
||||||
|
x_configured=settings.is_x_configured,
|
||||||
|
opencode_configured=settings.is_opencode_configured,
|
||||||
|
opencode_available=opencode_url is not None,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# === SHUTDOWN ===
|
||||||
|
logger.info("Shutting down application...")
|
||||||
|
|
||||||
|
# Stop orchestrator
|
||||||
|
await stop_orchestrator()
|
||||||
|
|
||||||
|
# Stop OpenCode server
|
||||||
|
await stop_opencode_server()
|
||||||
|
|
||||||
|
# Close database connections
|
||||||
|
await shutdown_database()
|
||||||
|
|
||||||
|
logger.info("Application shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FastAPI Application
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
description="Autonomous AI System with 6 Orchestrated Agents: Ideator -> Planner -> Developer <-> Tester -> Uploader -> Evangelist",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/docs" if settings.DEBUG else None,
|
||||||
|
redoc_url="/redoc" if settings.DEBUG else None,
|
||||||
|
openapi_url="/openapi.json" if settings.DEBUG else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add CORS middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Core API Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Redirect to dashboard."""
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""
|
||||||
|
Health check endpoint for monitoring and load balancers.
|
||||||
|
"""
|
||||||
|
orchestrator_status = "running" if (orchestrator and orchestrator.is_running) else "idle"
|
||||||
|
if not settings.AUTO_START and orchestrator is None:
|
||||||
|
orchestrator_status = "disabled"
|
||||||
|
|
||||||
|
opencode_status = "running" if opencode_server_url else "unavailable"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"components": {
|
||||||
|
"database": "healthy",
|
||||||
|
"orchestrator": orchestrator_status,
|
||||||
|
"opencode_server": opencode_status,
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"auto_start": settings.AUTO_START,
|
||||||
|
"debug": settings.DEBUG,
|
||||||
|
"gitea_configured": settings.is_gitea_configured,
|
||||||
|
"x_configured": settings.is_x_configured,
|
||||||
|
"opencode_configured": settings.is_opencode_configured,
|
||||||
|
"opencode_url": opencode_server_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/status")
|
||||||
|
async def get_status():
|
||||||
|
"""
|
||||||
|
Get detailed system status.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"app_name": settings.APP_NAME,
|
||||||
|
"orchestrator": {
|
||||||
|
"running": orchestrator.is_running if orchestrator else False,
|
||||||
|
"auto_start": settings.AUTO_START,
|
||||||
|
},
|
||||||
|
"opencode": {
|
||||||
|
"available": opencode_server_url is not None,
|
||||||
|
"url": opencode_server_url,
|
||||||
|
"pid": opencode_process.pid if opencode_process else None,
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"host": settings.HOST,
|
||||||
|
"port": settings.PORT,
|
||||||
|
"debug": settings.DEBUG,
|
||||||
|
"log_level": settings.LOG_LEVEL,
|
||||||
|
"workspace_dir": str(settings.WORKSPACE_DIR),
|
||||||
|
"max_concurrent_projects": settings.MAX_CONCURRENT_PROJECTS,
|
||||||
|
},
|
||||||
|
"integrations": {
|
||||||
|
"gitea": settings.is_gitea_configured,
|
||||||
|
"x_twitter": settings.is_x_configured,
|
||||||
|
"minimax": settings.is_opencode_configured,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Error Handlers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
"""
|
||||||
|
Global exception handler for unhandled errors.
|
||||||
|
"""
|
||||||
|
logger.error(
|
||||||
|
"Unhandled exception",
|
||||||
|
path=request.url.path,
|
||||||
|
method=request.method,
|
||||||
|
error=str(exc),
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={
|
||||||
|
"detail": "Internal server error",
|
||||||
|
"error": str(exc) if settings.DEBUG else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Signal Handlers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def create_signal_handler():
|
||||||
|
"""
|
||||||
|
Create signal handlers for graceful shutdown.
|
||||||
|
"""
|
||||||
|
def handle_signal(signum, frame):
|
||||||
|
"""Handle shutdown signals."""
|
||||||
|
signal_name = signal.Signals(signum).name
|
||||||
|
logger.info(f"Received {signal_name}, initiating graceful shutdown...")
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
return handle_signal
|
||||||
|
|
||||||
|
|
||||||
|
def setup_signal_handlers():
|
||||||
|
"""
|
||||||
|
Set up signal handlers for SIGTERM and SIGINT.
|
||||||
|
"""
|
||||||
|
handler = create_signal_handler()
|
||||||
|
|
||||||
|
# Register signal handlers (Unix only)
|
||||||
|
if sys.platform != "win32":
|
||||||
|
signal.signal(signal.SIGTERM, handler)
|
||||||
|
signal.signal(signal.SIGINT, handler)
|
||||||
|
else:
|
||||||
|
# Windows: only SIGINT (Ctrl+C) is supported
|
||||||
|
signal.signal(signal.SIGINT, handler)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Mount Web Dashboard (mounted lazily in lifespan to avoid import issues)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Dashboard is mounted inside lifespan() after database initialization
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Entry Point
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Main entry point for running the application.
|
||||||
|
|
||||||
|
Configures and starts the uvicorn server with the FastAPI application.
|
||||||
|
"""
|
||||||
|
# Set up signal handlers
|
||||||
|
setup_signal_handlers()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Starting uvicorn server",
|
||||||
|
host=settings.HOST,
|
||||||
|
port=settings.PORT,
|
||||||
|
log_level=settings.LOG_LEVEL.lower(),
|
||||||
|
reload=settings.DEBUG,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host=settings.HOST,
|
||||||
|
port=settings.PORT,
|
||||||
|
reload=settings.DEBUG,
|
||||||
|
log_level=settings.LOG_LEVEL.lower(),
|
||||||
|
access_log=True,
|
||||||
|
# Production settings
|
||||||
|
workers=1, # Use 1 worker for orchestrator state consistency
|
||||||
|
loop="auto",
|
||||||
|
http="auto",
|
||||||
|
# Timeouts
|
||||||
|
timeout_keep_alive=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
12
mcp_servers/__init__.py
Normal file
12
mcp_servers/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
MCP Servers for 7000%AUTO
|
||||||
|
Provides external API access to AI agents via Model Context Protocol
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .search_mcp import mcp as search_mcp
|
||||||
|
from .x_mcp import mcp as x_mcp
|
||||||
|
from .database_mcp import mcp as database_mcp
|
||||||
|
from .gitea_mcp import mcp as gitea_mcp
|
||||||
|
from .devtest_mcp import mcp as devtest_mcp
|
||||||
|
|
||||||
|
__all__ = ['search_mcp', 'x_mcp', 'database_mcp', 'gitea_mcp', 'devtest_mcp']
|
||||||
340
mcp_servers/database_mcp.py
Normal file
340
mcp_servers/database_mcp.py
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
"""Database MCP Server for 7000%AUTO
|
||||||
|
Provides database operations for idea management
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
mcp = FastMCP("Database Server")
|
||||||
|
|
||||||
|
# Database initialization flag for MCP server process
|
||||||
|
_db_ready = False
|
||||||
|
|
||||||
|
|
||||||
|
async def _init_db_if_needed():
|
||||||
|
"""Initialize database if not already initialized. MCP servers run in separate processes."""
|
||||||
|
global _db_ready
|
||||||
|
if not _db_ready:
|
||||||
|
try:
|
||||||
|
from database.db import init_db
|
||||||
|
await init_db()
|
||||||
|
_db_ready = True
|
||||||
|
logger.info("Database initialized in MCP server")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize database in MCP server: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_previous_ideas(limit: int = 50) -> dict:
|
||||||
|
"""
|
||||||
|
Get list of previously generated ideas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of ideas to return (default 50)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with list of ideas
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database import get_db, Idea
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
async with get_db() as session:
|
||||||
|
query = select(Idea).order_by(Idea.created_at.desc()).limit(limit)
|
||||||
|
result = await session.execute(query)
|
||||||
|
ideas = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"ideas": [
|
||||||
|
{
|
||||||
|
"id": idea.id,
|
||||||
|
"title": idea.title,
|
||||||
|
"description": idea.description[:200],
|
||||||
|
"source": idea.source,
|
||||||
|
"used": idea.used
|
||||||
|
}
|
||||||
|
for idea in ideas
|
||||||
|
],
|
||||||
|
"count": len(ideas)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting previous ideas: {e}")
|
||||||
|
return {"success": False, "error": str(e), "ideas": []}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def check_idea_exists(title: str) -> dict:
|
||||||
|
"""
|
||||||
|
Check if a similar idea already exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Title to check for similarity
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with exists flag and similar ideas if found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database import get_db, Idea
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
title_lower = title.lower()
|
||||||
|
title_words = set(title_lower.split())
|
||||||
|
|
||||||
|
async with get_db() as session:
|
||||||
|
# Get all ideas for comparison
|
||||||
|
query = select(Idea)
|
||||||
|
result = await session.execute(query)
|
||||||
|
ideas = result.scalars().all()
|
||||||
|
|
||||||
|
similar = []
|
||||||
|
for idea in ideas:
|
||||||
|
idea_title_lower = idea.title.lower()
|
||||||
|
idea_words = set(idea_title_lower.split())
|
||||||
|
|
||||||
|
# Check exact match
|
||||||
|
if title_lower == idea_title_lower:
|
||||||
|
similar.append({
|
||||||
|
"id": idea.id,
|
||||||
|
"title": idea.title,
|
||||||
|
"match_type": "exact"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check partial match (title contains or is contained)
|
||||||
|
if title_lower in idea_title_lower or idea_title_lower in title_lower:
|
||||||
|
similar.append({
|
||||||
|
"id": idea.id,
|
||||||
|
"title": idea.title,
|
||||||
|
"match_type": "partial"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check word overlap (>50%)
|
||||||
|
overlap = len(title_words & idea_words)
|
||||||
|
total = len(title_words | idea_words)
|
||||||
|
if total > 0 and overlap / total > 0.5:
|
||||||
|
similar.append({
|
||||||
|
"id": idea.id,
|
||||||
|
"title": idea.title,
|
||||||
|
"match_type": "similar"
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"exists": len(similar) > 0,
|
||||||
|
"similar_ideas": similar[:5],
|
||||||
|
"count": len(similar)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking idea existence: {e}")
|
||||||
|
return {"success": False, "error": str(e), "exists": False}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def save_idea(title: str, description: str, source: str) -> dict:
|
||||||
|
"""
|
||||||
|
Save a new idea to the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Idea title
|
||||||
|
description: Idea description
|
||||||
|
source: Source of the idea (arxiv, reddit, x, hn, ph)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with saved idea details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database import create_idea
|
||||||
|
|
||||||
|
idea = await create_idea(
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
source=source
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"idea": {
|
||||||
|
"id": idea.id,
|
||||||
|
"title": idea.title,
|
||||||
|
"description": idea.description,
|
||||||
|
"source": idea.source,
|
||||||
|
"created_at": idea.created_at.isoformat() if idea.created_at else None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving idea: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_database_stats() -> dict:
|
||||||
|
"""
|
||||||
|
Get database statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with database stats
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database import get_stats
|
||||||
|
|
||||||
|
stats = await get_stats()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"stats": stats
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting database stats: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def submit_idea(
|
||||||
|
project_id: int,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
source: str,
|
||||||
|
tech_stack: list[str] = None,
|
||||||
|
target_audience: str = None,
|
||||||
|
key_features: list[str] = None,
|
||||||
|
complexity: str = None,
|
||||||
|
estimated_time: str = None,
|
||||||
|
inspiration: str = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Submit a generated project idea. Use this tool to finalize and save your idea.
|
||||||
|
The idea will be saved directly to the database for the given project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID to associate this idea with (required)
|
||||||
|
title: Short project name (required)
|
||||||
|
description: Detailed description of the project (required)
|
||||||
|
source: Source of inspiration - arxiv, reddit, x, hn, or ph (required)
|
||||||
|
tech_stack: List of technologies to use (e.g., ["python", "fastapi"])
|
||||||
|
target_audience: Who would use this project
|
||||||
|
key_features: List of key features
|
||||||
|
complexity: low, medium, or high
|
||||||
|
estimated_time: Estimated implementation time (e.g., "2-4 hours")
|
||||||
|
inspiration: Brief note on what inspired this idea
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import set_project_idea_json
|
||||||
|
|
||||||
|
# Build the complete idea dict
|
||||||
|
idea_data = {
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"source": source,
|
||||||
|
"tech_stack": tech_stack or [],
|
||||||
|
"target_audience": target_audience or "",
|
||||||
|
"key_features": key_features or [],
|
||||||
|
"complexity": complexity or "medium",
|
||||||
|
"estimated_time": estimated_time or "",
|
||||||
|
"inspiration": inspiration or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
success = await set_project_idea_json(project_id, idea_data)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Idea submitted for project {project_id}: {title}")
|
||||||
|
return {"success": True, "message": f"Idea '{title}' saved successfully"}
|
||||||
|
else:
|
||||||
|
logger.error(f"Project {project_id} not found")
|
||||||
|
return {"success": False, "error": f"Project {project_id} not found"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error submitting idea: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def submit_plan(
|
||||||
|
project_id: int,
|
||||||
|
project_name: str,
|
||||||
|
overview: str,
|
||||||
|
display_name: str = None,
|
||||||
|
tech_stack: dict = None,
|
||||||
|
file_structure: dict = None,
|
||||||
|
features: list[dict] = None,
|
||||||
|
implementation_steps: list[dict] = None,
|
||||||
|
testing_strategy: dict = None,
|
||||||
|
configuration: dict = None,
|
||||||
|
error_handling: dict = None,
|
||||||
|
readme_sections: list[str] = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Submit an implementation plan. Use this tool to finalize your project plan.
|
||||||
|
The plan will be saved directly to the database for the given project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID to associate this plan with (required)
|
||||||
|
project_name: kebab-case project name (required)
|
||||||
|
overview: 2-3 sentence summary of what will be built (required)
|
||||||
|
display_name: Human readable project name
|
||||||
|
tech_stack: Technology stack details with language, runtime, framework, key_dependencies
|
||||||
|
file_structure: File structure with root_files and directories
|
||||||
|
features: List of features with name, priority, description, implementation_notes
|
||||||
|
implementation_steps: Ordered list of implementation steps
|
||||||
|
testing_strategy: Testing approach with unit_tests, integration_tests, test_files, test_commands
|
||||||
|
configuration: Config details with env_variables and config_files
|
||||||
|
error_handling: Error handling strategies
|
||||||
|
readme_sections: List of README section titles
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import set_project_plan_json
|
||||||
|
|
||||||
|
# Build the complete plan dict
|
||||||
|
plan_data = {
|
||||||
|
"project_name": project_name,
|
||||||
|
"display_name": display_name or project_name.replace("-", " ").title(),
|
||||||
|
"overview": overview,
|
||||||
|
"tech_stack": tech_stack or {},
|
||||||
|
"file_structure": file_structure or {},
|
||||||
|
"features": features or [],
|
||||||
|
"implementation_steps": implementation_steps or [],
|
||||||
|
"testing_strategy": testing_strategy or {},
|
||||||
|
"configuration": configuration or {},
|
||||||
|
"error_handling": error_handling or {},
|
||||||
|
"readme_sections": readme_sections or []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
success = await set_project_plan_json(project_id, plan_data)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Plan submitted for project {project_id}: {project_name}")
|
||||||
|
return {"success": True, "message": f"Plan '{project_name}' saved successfully"}
|
||||||
|
else:
|
||||||
|
logger.error(f"Project {project_id} not found")
|
||||||
|
return {"success": False, "error": f"Project {project_id} not found"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error submitting plan: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run()
|
||||||
635
mcp_servers/devtest_mcp.py
Normal file
635
mcp_servers/devtest_mcp.py
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
"""Developer-Tester Communication MCP Server for 7000%AUTO
|
||||||
|
Provides structured communication between Developer and Tester agents via MCP tools.
|
||||||
|
|
||||||
|
This enables Developer and Tester to share:
|
||||||
|
- Test results (PASS/FAIL with detailed bug reports)
|
||||||
|
- Implementation status (completed/fixing with file changes)
|
||||||
|
- Project context (plan, current iteration)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
mcp = FastMCP("DevTest Communication Server")
|
||||||
|
|
||||||
|
# Database initialization flag for MCP server process
|
||||||
|
_db_ready = False
|
||||||
|
|
||||||
|
|
||||||
|
async def _init_db_if_needed():
|
||||||
|
"""Initialize database if not already initialized. MCP servers run in separate processes."""
|
||||||
|
global _db_ready
|
||||||
|
if not _db_ready:
|
||||||
|
try:
|
||||||
|
from database.db import init_db
|
||||||
|
await init_db()
|
||||||
|
_db_ready = True
|
||||||
|
logger.info("Database initialized in DevTest MCP server")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize database in DevTest MCP server: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def submit_test_result(
|
||||||
|
project_id: int,
|
||||||
|
status: str,
|
||||||
|
summary: str,
|
||||||
|
checks_performed: list[dict] = None,
|
||||||
|
bugs: list[dict] = None,
|
||||||
|
code_quality: dict = None,
|
||||||
|
ready_for_upload: bool = False
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Submit test results after testing the implementation. Use this tool to report test outcomes.
|
||||||
|
The Developer will read these results to fix any bugs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID being tested (required)
|
||||||
|
status: Test status - "PASS" or "FAIL" (required)
|
||||||
|
summary: Brief summary of test results (required)
|
||||||
|
checks_performed: List of checks with {check, result, details} format
|
||||||
|
bugs: List of bugs found with {id, severity, type, file, line, issue, error_message, suggestion} format
|
||||||
|
code_quality: Quality assessment with {error_handling, documentation, test_coverage} ratings
|
||||||
|
ready_for_upload: Whether the project is ready for upload (true only if PASS)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status
|
||||||
|
|
||||||
|
Example for PASS:
|
||||||
|
submit_test_result(
|
||||||
|
project_id=1,
|
||||||
|
status="PASS",
|
||||||
|
summary="All tests passed successfully",
|
||||||
|
checks_performed=[
|
||||||
|
{"check": "linting", "result": "pass", "details": "No issues found"},
|
||||||
|
{"check": "unit_tests", "result": "pass", "details": "15/15 tests passed"}
|
||||||
|
],
|
||||||
|
ready_for_upload=True
|
||||||
|
)
|
||||||
|
|
||||||
|
Example for FAIL:
|
||||||
|
submit_test_result(
|
||||||
|
project_id=1,
|
||||||
|
status="FAIL",
|
||||||
|
summary="Found 2 critical issues",
|
||||||
|
checks_performed=[
|
||||||
|
{"check": "linting", "result": "pass", "details": "No issues"},
|
||||||
|
{"check": "type_check", "result": "fail", "details": "3 type errors"}
|
||||||
|
],
|
||||||
|
bugs=[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"severity": "critical",
|
||||||
|
"type": "type_error",
|
||||||
|
"file": "src/main.py",
|
||||||
|
"line": 42,
|
||||||
|
"issue": "Missing return type annotation",
|
||||||
|
"error_message": "error: Function is missing return type annotation",
|
||||||
|
"suggestion": "Add -> str return type"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ready_for_upload=False
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import set_project_test_result_json
|
||||||
|
|
||||||
|
# Validate status
|
||||||
|
if status not in ("PASS", "FAIL"):
|
||||||
|
return {"success": False, "error": "status must be 'PASS' or 'FAIL'"}
|
||||||
|
|
||||||
|
# Build test result data
|
||||||
|
test_result_data = {
|
||||||
|
"status": status,
|
||||||
|
"summary": summary,
|
||||||
|
"checks_performed": checks_performed or [],
|
||||||
|
"bugs": bugs or [],
|
||||||
|
"code_quality": code_quality or {},
|
||||||
|
"ready_for_upload": ready_for_upload and status == "PASS",
|
||||||
|
"submitted_at": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
success = await set_project_test_result_json(project_id, test_result_data)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Test result submitted for project {project_id}: {status}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Test result '{status}' submitted successfully",
|
||||||
|
"bugs_count": len(bugs) if bugs else 0
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(f"Project {project_id} not found")
|
||||||
|
return {"success": False, "error": f"Project {project_id} not found"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error submitting test result: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_test_result(project_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get the latest test result for a project. Use this tool to see what the Tester found.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID to get test results for (required)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with test result data including status, bugs, and suggestions
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import get_project_test_result_json
|
||||||
|
|
||||||
|
test_result = await get_project_test_result_json(project_id)
|
||||||
|
|
||||||
|
if test_result:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"test_result": test_result
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"test_result": None,
|
||||||
|
"message": "No test result found for this project"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting test result: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def submit_implementation_status(
|
||||||
|
project_id: int,
|
||||||
|
status: str,
|
||||||
|
files_created: list[dict] = None,
|
||||||
|
files_modified: list[dict] = None,
|
||||||
|
dependencies_installed: list[str] = None,
|
||||||
|
commands_run: list[str] = None,
|
||||||
|
bugs_addressed: list[dict] = None,
|
||||||
|
notes: str = None,
|
||||||
|
ready_for_testing: bool = True
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Submit implementation status after coding or fixing bugs. Use this tool to inform the Tester.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID being worked on (required)
|
||||||
|
status: Status - "completed", "fixed", "in_progress", or "blocked" (required)
|
||||||
|
files_created: List of files created with {path, lines, purpose} format
|
||||||
|
files_modified: List of files modified with {path, changes} format
|
||||||
|
dependencies_installed: List of installed dependencies
|
||||||
|
commands_run: List of commands executed
|
||||||
|
bugs_addressed: List of bugs fixed with {original_issue, fix_applied, file, line} format
|
||||||
|
notes: Any important notes about the implementation
|
||||||
|
ready_for_testing: Whether the code is ready for testing (default: True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status
|
||||||
|
|
||||||
|
Example for new implementation:
|
||||||
|
submit_implementation_status(
|
||||||
|
project_id=1,
|
||||||
|
status="completed",
|
||||||
|
files_created=[
|
||||||
|
{"path": "src/main.py", "lines": 150, "purpose": "Main entry point"}
|
||||||
|
],
|
||||||
|
dependencies_installed=["fastapi", "uvicorn"],
|
||||||
|
ready_for_testing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
Example for bug fix:
|
||||||
|
submit_implementation_status(
|
||||||
|
project_id=1,
|
||||||
|
status="fixed",
|
||||||
|
bugs_addressed=[
|
||||||
|
{
|
||||||
|
"original_issue": "TypeError in parse_input()",
|
||||||
|
"fix_applied": "Added null check before processing",
|
||||||
|
"file": "src/parser.py",
|
||||||
|
"line": 42
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ready_for_testing=True
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import set_project_implementation_status_json
|
||||||
|
|
||||||
|
# Validate status
|
||||||
|
valid_statuses = ("completed", "fixed", "in_progress", "blocked")
|
||||||
|
if status not in valid_statuses:
|
||||||
|
return {"success": False, "error": f"status must be one of: {valid_statuses}"}
|
||||||
|
|
||||||
|
# Build implementation status data
|
||||||
|
implementation_data = {
|
||||||
|
"status": status,
|
||||||
|
"files_created": files_created or [],
|
||||||
|
"files_modified": files_modified or [],
|
||||||
|
"dependencies_installed": dependencies_installed or [],
|
||||||
|
"commands_run": commands_run or [],
|
||||||
|
"bugs_addressed": bugs_addressed or [],
|
||||||
|
"notes": notes or "",
|
||||||
|
"ready_for_testing": ready_for_testing,
|
||||||
|
"submitted_at": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
success = await set_project_implementation_status_json(project_id, implementation_data)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Implementation status submitted for project {project_id}: {status}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Implementation status '{status}' submitted successfully"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(f"Project {project_id} not found")
|
||||||
|
return {"success": False, "error": f"Project {project_id} not found"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error submitting implementation status: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_implementation_status(project_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get the latest implementation status for a project. Use this tool to see what the Developer did.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID to get implementation status for (required)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with implementation status data
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import get_project_implementation_status_json
|
||||||
|
|
||||||
|
impl_status = await get_project_implementation_status_json(project_id)
|
||||||
|
|
||||||
|
if impl_status:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"implementation_status": impl_status
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"implementation_status": None,
|
||||||
|
"message": "No implementation status found for this project"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting implementation status: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_project_context(project_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get full project context including idea, plan, and current dev-test state.
|
||||||
|
Use this tool to understand the complete project situation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID to get context for (required)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with complete project context
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import (
|
||||||
|
get_project_by_id,
|
||||||
|
get_project_idea_json,
|
||||||
|
get_project_plan_json,
|
||||||
|
get_project_test_result_json,
|
||||||
|
get_project_implementation_status_json
|
||||||
|
)
|
||||||
|
|
||||||
|
project = await get_project_by_id(project_id)
|
||||||
|
if not project:
|
||||||
|
return {"success": False, "error": f"Project {project_id} not found"}
|
||||||
|
|
||||||
|
idea = await get_project_idea_json(project_id)
|
||||||
|
plan = await get_project_plan_json(project_id)
|
||||||
|
test_result = await get_project_test_result_json(project_id)
|
||||||
|
impl_status = await get_project_implementation_status_json(project_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"project": {
|
||||||
|
"id": project.id,
|
||||||
|
"name": project.name,
|
||||||
|
"status": project.status,
|
||||||
|
"dev_test_iterations": project.dev_test_iterations,
|
||||||
|
"current_agent": project.current_agent,
|
||||||
|
},
|
||||||
|
"idea": idea,
|
||||||
|
"plan": plan,
|
||||||
|
"test_result": test_result,
|
||||||
|
"implementation_status": impl_status
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting project context: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def submit_ci_result(
|
||||||
|
project_id: int,
|
||||||
|
status: str,
|
||||||
|
repo_name: str,
|
||||||
|
gitea_url: str,
|
||||||
|
run_id: int = None,
|
||||||
|
run_url: str = None,
|
||||||
|
summary: str = None,
|
||||||
|
failed_jobs: list[dict] = None,
|
||||||
|
error_logs: str = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Submit CI/CD (Gitea Actions) result after checking workflow status.
|
||||||
|
Use this tool to report CI/CD status to Developer for fixes if needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID (required)
|
||||||
|
status: CI status - "PASS", "FAIL", or "PENDING" (required)
|
||||||
|
repo_name: Repository name (required)
|
||||||
|
gitea_url: Repository URL on Gitea (required)
|
||||||
|
run_id: Workflow run ID (if available)
|
||||||
|
run_url: URL to the workflow run (if available)
|
||||||
|
summary: Brief summary of CI result
|
||||||
|
failed_jobs: List of failed jobs with {name, conclusion, steps} format
|
||||||
|
error_logs: Relevant error logs or messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status
|
||||||
|
|
||||||
|
Example for PASS:
|
||||||
|
submit_ci_result(
|
||||||
|
project_id=1,
|
||||||
|
status="PASS",
|
||||||
|
repo_name="my-project",
|
||||||
|
gitea_url="https://gitea.example.com/user/my-project",
|
||||||
|
summary="All CI checks passed successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
Example for FAIL:
|
||||||
|
submit_ci_result(
|
||||||
|
project_id=1,
|
||||||
|
status="FAIL",
|
||||||
|
repo_name="my-project",
|
||||||
|
gitea_url="https://gitea.example.com/user/my-project",
|
||||||
|
run_id=123,
|
||||||
|
run_url="https://gitea.example.com/user/my-project/actions/runs/123",
|
||||||
|
summary="CI failed: test job failed",
|
||||||
|
failed_jobs=[{"name": "test", "conclusion": "failure", "steps": [...]}],
|
||||||
|
error_logs="Error: pytest failed with exit code 1"
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import set_project_ci_result_json
|
||||||
|
|
||||||
|
# Validate status
|
||||||
|
if status not in ("PASS", "FAIL", "PENDING"):
|
||||||
|
return {"success": False, "error": "status must be 'PASS', 'FAIL', or 'PENDING'"}
|
||||||
|
|
||||||
|
# Build CI result data
|
||||||
|
ci_result_data = {
|
||||||
|
"status": status,
|
||||||
|
"repo_name": repo_name,
|
||||||
|
"gitea_url": gitea_url,
|
||||||
|
"run_id": run_id,
|
||||||
|
"run_url": run_url,
|
||||||
|
"summary": summary or "",
|
||||||
|
"failed_jobs": failed_jobs or [],
|
||||||
|
"error_logs": error_logs or "",
|
||||||
|
"submitted_at": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
success = await set_project_ci_result_json(project_id, ci_result_data)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"CI result submitted for project {project_id}: {status}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"CI result '{status}' submitted successfully",
|
||||||
|
"needs_fix": status == "FAIL"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(f"Project {project_id} not found")
|
||||||
|
return {"success": False, "error": f"Project {project_id} not found"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error submitting CI result: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_ci_result(project_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get the latest CI/CD result for a project. Use this to see if CI passed or failed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID to get CI result for (required)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with CI result data including status, failed jobs, and error logs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import get_project_ci_result_json
|
||||||
|
|
||||||
|
ci_result = await get_project_ci_result_json(project_id)
|
||||||
|
|
||||||
|
if ci_result:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"ci_result": ci_result
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"ci_result": None,
|
||||||
|
"message": "No CI result found for this project"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting CI result: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def submit_upload_status(
|
||||||
|
project_id: int,
|
||||||
|
status: str,
|
||||||
|
repo_name: str,
|
||||||
|
gitea_url: str,
|
||||||
|
files_pushed: list[str] = None,
|
||||||
|
commit_sha: str = None,
|
||||||
|
message: str = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Submit upload status after pushing code to Gitea.
|
||||||
|
Use this to inform Tester that code has been uploaded and needs CI check.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID (required)
|
||||||
|
status: Upload status - "completed", "failed", or "in_progress" (required)
|
||||||
|
repo_name: Repository name (required)
|
||||||
|
gitea_url: Repository URL on Gitea (required)
|
||||||
|
files_pushed: List of files that were pushed
|
||||||
|
commit_sha: Commit SHA of the push
|
||||||
|
message: Any additional message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import set_project_upload_status_json
|
||||||
|
|
||||||
|
# Validate status
|
||||||
|
valid_statuses = ("completed", "failed", "in_progress")
|
||||||
|
if status not in valid_statuses:
|
||||||
|
return {"success": False, "error": f"status must be one of: {valid_statuses}"}
|
||||||
|
|
||||||
|
# Build upload status data
|
||||||
|
upload_status_data = {
|
||||||
|
"status": status,
|
||||||
|
"repo_name": repo_name,
|
||||||
|
"gitea_url": gitea_url,
|
||||||
|
"files_pushed": files_pushed or [],
|
||||||
|
"commit_sha": commit_sha or "",
|
||||||
|
"message": message or "",
|
||||||
|
"submitted_at": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
success = await set_project_upload_status_json(project_id, upload_status_data)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Upload status submitted for project {project_id}: {status}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Upload status '{status}' submitted successfully"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(f"Project {project_id} not found")
|
||||||
|
return {"success": False, "error": f"Project {project_id} not found"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error submitting upload status: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_upload_status(project_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get the latest upload status for a project.
|
||||||
|
Use this to see what the Uploader did and get the Gitea repository URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID to get upload status for (required)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with upload status data including repo URL
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import get_project_upload_status_json
|
||||||
|
|
||||||
|
upload_status = await get_project_upload_status_json(project_id)
|
||||||
|
|
||||||
|
if upload_status:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"upload_status": upload_status
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"upload_status": None,
|
||||||
|
"message": "No upload status found for this project"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting upload status: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def clear_devtest_state(project_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Clear test result and implementation status for a new dev-test iteration.
|
||||||
|
Use this at the start of each iteration to reset state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID to clear state for (required)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import clear_project_devtest_state
|
||||||
|
|
||||||
|
success = await clear_project_devtest_state(project_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"DevTest state cleared for project {project_id}")
|
||||||
|
return {"success": True, "message": "DevTest state cleared for new iteration"}
|
||||||
|
else:
|
||||||
|
return {"success": False, "error": f"Project {project_id} not found"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clearing devtest state: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def clear_ci_state(project_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Clear CI result and upload status for a new CI iteration.
|
||||||
|
Use this at the start of each Uploader-Tester-Developer CI loop iteration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project ID to clear CI state for (required)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _init_db_if_needed()
|
||||||
|
from database.db import clear_project_ci_state
|
||||||
|
|
||||||
|
success = await clear_project_ci_state(project_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"CI state cleared for project {project_id}")
|
||||||
|
return {"success": True, "message": "CI state cleared for new iteration"}
|
||||||
|
else:
|
||||||
|
return {"success": False, "error": f"Project {project_id} not found"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clearing CI state: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run()
|
||||||
619
mcp_servers/gitea_mcp.py
Normal file
619
mcp_servers/gitea_mcp.py
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
"""
|
||||||
|
Gitea MCP Server for 7000%AUTO
|
||||||
|
Provides Gitea repository management functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
mcp = FastMCP("Gitea Server")
|
||||||
|
|
||||||
|
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
||||||
|
GITEA_URL = os.getenv("GITEA_URL", "https://7000pct.gitea.bloupla.net")
|
||||||
|
GITEA_USERNAME = os.getenv("GITEA_USERNAME", "")
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_base_url() -> str:
|
||||||
|
"""Get the Gitea API base URL"""
|
||||||
|
return f"{GITEA_URL.rstrip('/')}/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_headers() -> Dict[str, str]:
|
||||||
|
"""Get authentication headers for Gitea API"""
|
||||||
|
return {
|
||||||
|
"Authorization": f"token {GITEA_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_gitea_username() -> str:
|
||||||
|
"""Get Gitea username from env or fetch from API"""
|
||||||
|
if GITEA_USERNAME:
|
||||||
|
return GITEA_USERNAME
|
||||||
|
|
||||||
|
if not GITEA_TOKEN:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=get_api_base_url(),
|
||||||
|
headers=get_auth_headers(),
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
response = await client.get("/user")
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json().get("login", "")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get Gitea username: {e}")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def create_repo(name: str, description: str, private: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Create a new Gitea repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Repository name (kebab-case recommended)
|
||||||
|
description: Repository description
|
||||||
|
private: Whether the repo should be private (default False)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with repository URL and details
|
||||||
|
"""
|
||||||
|
if not GITEA_TOKEN:
|
||||||
|
return {"success": False, "error": "Gitea token not configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=get_api_base_url(),
|
||||||
|
headers=get_auth_headers(),
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/user/repos",
|
||||||
|
json={
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"private": private,
|
||||||
|
"auto_init": True,
|
||||||
|
"default_branch": "main",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
repo_data = response.json()
|
||||||
|
logger.info(f"Created repository: {repo_data.get('html_url')}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"repo": {
|
||||||
|
"name": repo_data.get("name"),
|
||||||
|
"full_name": repo_data.get("full_name"),
|
||||||
|
"url": repo_data.get("html_url"),
|
||||||
|
"clone_url": repo_data.get("clone_url"),
|
||||||
|
"description": repo_data.get("description"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_msg = response.json().get("message", response.text)
|
||||||
|
logger.error(f"Gitea API error: {error_msg}")
|
||||||
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating repo: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def push_files(repo: str, files: dict, message: str, branch: str = "main") -> dict:
|
||||||
|
"""
|
||||||
|
Push multiple files to a Gitea repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: Repository name (username/repo or just repo name)
|
||||||
|
files: Dictionary of {path: content} for files to push
|
||||||
|
message: Commit message
|
||||||
|
branch: Target branch (default "main")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with commit details
|
||||||
|
"""
|
||||||
|
if not GITEA_TOKEN:
|
||||||
|
return {"success": False, "error": "Gitea token not configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine owner and repo name
|
||||||
|
if "/" in repo:
|
||||||
|
owner, repo_name = repo.split("/", 1)
|
||||||
|
else:
|
||||||
|
owner = await get_gitea_username()
|
||||||
|
repo_name = repo
|
||||||
|
|
||||||
|
if not owner:
|
||||||
|
return {"success": False, "error": "Could not determine repository owner"}
|
||||||
|
|
||||||
|
pushed_files = []
|
||||||
|
last_commit = None
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=get_api_base_url(),
|
||||||
|
headers=get_auth_headers(),
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
for file_path, content in files.items():
|
||||||
|
# Check if file exists to determine if we need to update or create
|
||||||
|
check_response = await client.get(f"/repos/{owner}/{repo_name}/contents/{file_path}")
|
||||||
|
|
||||||
|
file_data = {
|
||||||
|
"content": base64.b64encode(content.encode()).decode(),
|
||||||
|
"message": message,
|
||||||
|
"branch": branch,
|
||||||
|
}
|
||||||
|
|
||||||
|
if check_response.status_code == 200:
|
||||||
|
# File exists, need to include SHA for update
|
||||||
|
existing = check_response.json()
|
||||||
|
file_data["sha"] = existing.get("sha")
|
||||||
|
response = await client.put(
|
||||||
|
f"/repos/{owner}/{repo_name}/contents/{file_path}",
|
||||||
|
json=file_data
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# File doesn't exist, create it
|
||||||
|
response = await client.post(
|
||||||
|
f"/repos/{owner}/{repo_name}/contents/{file_path}",
|
||||||
|
json=file_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
result = response.json()
|
||||||
|
last_commit = result.get("commit", {})
|
||||||
|
pushed_files.append(file_path)
|
||||||
|
else:
|
||||||
|
error_msg = response.json().get("message", response.text)
|
||||||
|
logger.error(f"Failed to push {file_path}: {error_msg}")
|
||||||
|
|
||||||
|
if pushed_files:
|
||||||
|
logger.info(f"Pushed {len(pushed_files)} files to {owner}/{repo_name}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"commit": {
|
||||||
|
"sha": last_commit.get("sha", "") if last_commit else "",
|
||||||
|
"message": message,
|
||||||
|
"url": f"{GITEA_URL}/{owner}/{repo_name}/commit/{last_commit.get('sha', '')}" if last_commit else ""
|
||||||
|
},
|
||||||
|
"files_pushed": pushed_files
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {"success": False, "error": "No files were pushed"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error pushing files: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def create_release(
|
||||||
|
repo: str,
|
||||||
|
tag: str,
|
||||||
|
name: str,
|
||||||
|
body: str,
|
||||||
|
draft: bool = False,
|
||||||
|
prerelease: bool = False
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Create a release on Gitea.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: Repository name
|
||||||
|
tag: Tag name (e.g., "v1.0.0")
|
||||||
|
name: Release name
|
||||||
|
body: Release notes/body
|
||||||
|
draft: Whether this is a draft release
|
||||||
|
prerelease: Whether this is a prerelease
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with release URL
|
||||||
|
"""
|
||||||
|
if not GITEA_TOKEN:
|
||||||
|
return {"success": False, "error": "Gitea token not configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine owner and repo name
|
||||||
|
if "/" in repo:
|
||||||
|
owner, repo_name = repo.split("/", 1)
|
||||||
|
else:
|
||||||
|
owner = await get_gitea_username()
|
||||||
|
repo_name = repo
|
||||||
|
|
||||||
|
if not owner:
|
||||||
|
return {"success": False, "error": "Could not determine repository owner"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=get_api_base_url(),
|
||||||
|
headers=get_auth_headers(),
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"/repos/{owner}/{repo_name}/releases",
|
||||||
|
json={
|
||||||
|
"tag_name": tag,
|
||||||
|
"name": name,
|
||||||
|
"body": body,
|
||||||
|
"draft": draft,
|
||||||
|
"prerelease": prerelease,
|
||||||
|
"target_commitish": "main",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
release_data = response.json()
|
||||||
|
logger.info(f"Created release {tag} for {owner}/{repo_name}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"release": {
|
||||||
|
"tag": tag,
|
||||||
|
"name": name,
|
||||||
|
"url": release_data.get("html_url", f"{GITEA_URL}/{owner}/{repo_name}/releases/tag/{tag}"),
|
||||||
|
"id": release_data.get("id"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_msg = response.json().get("message", response.text)
|
||||||
|
logger.error(f"Gitea API error: {error_msg}")
|
||||||
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating release: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def setup_actions(repo: str, workflow_content: str, workflow_name: str = "ci.yml") -> dict:
|
||||||
|
"""
|
||||||
|
Set up Gitea Actions workflow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: Repository name
|
||||||
|
workflow_content: YAML content for the workflow
|
||||||
|
workflow_name: Workflow file name (default "ci.yml")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with workflow path
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
workflow_path = f".gitea/workflows/{workflow_name}"
|
||||||
|
|
||||||
|
result = await push_files(
|
||||||
|
repo=repo,
|
||||||
|
files={workflow_path: workflow_content},
|
||||||
|
message=f"Add Gitea Actions workflow: {workflow_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"workflow": {
|
||||||
|
"path": workflow_path,
|
||||||
|
"name": workflow_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting up actions: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_workflow_runs(repo: str, status: str = None, branch: str = None, limit: int = 10) -> dict:
|
||||||
|
"""
|
||||||
|
Get workflow runs (Gitea Actions) for a repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: Repository name (username/repo or just repo name)
|
||||||
|
status: Filter by status (queued, in_progress, success, failure, cancelled, skipped, timedout)
|
||||||
|
branch: Filter by branch name
|
||||||
|
limit: Maximum number of runs to return (default 10, max 100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with workflow runs list
|
||||||
|
"""
|
||||||
|
if not GITEA_TOKEN:
|
||||||
|
return {"success": False, "error": "Gitea token not configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine owner and repo name
|
||||||
|
if "/" in repo:
|
||||||
|
owner, repo_name = repo.split("/", 1)
|
||||||
|
else:
|
||||||
|
owner = await get_gitea_username()
|
||||||
|
repo_name = repo
|
||||||
|
|
||||||
|
if not owner:
|
||||||
|
return {"success": False, "error": "Could not determine repository owner"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=get_api_base_url(),
|
||||||
|
headers=get_auth_headers(),
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
params = {"per_page": min(limit, 100)}
|
||||||
|
if status:
|
||||||
|
params["status"] = status
|
||||||
|
if branch:
|
||||||
|
params["branch"] = branch
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
f"/repos/{owner}/{repo_name}/actions/runs",
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
runs = data.get("workflow_runs", data) if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
# Simplify the runs data
|
||||||
|
simplified_runs = []
|
||||||
|
for run in (runs if isinstance(runs, list) else []):
|
||||||
|
simplified_runs.append({
|
||||||
|
"id": run.get("id"),
|
||||||
|
"name": run.get("display_title") or run.get("name"),
|
||||||
|
"status": run.get("status"),
|
||||||
|
"conclusion": run.get("conclusion"),
|
||||||
|
"branch": run.get("head_branch"),
|
||||||
|
"commit_sha": run.get("head_sha", "")[:7],
|
||||||
|
"started_at": run.get("run_started_at"),
|
||||||
|
"url": f"{GITEA_URL}/{owner}/{repo_name}/actions/runs/{run.get('id')}"
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"repo": f"{owner}/{repo_name}",
|
||||||
|
"runs": simplified_runs,
|
||||||
|
"total": len(simplified_runs)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_msg = response.json().get("message", response.text)
|
||||||
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting workflow runs: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_latest_workflow_status(repo: str, branch: str = "main") -> dict:
|
||||||
|
"""
|
||||||
|
Get the status of the latest workflow run for a repository.
|
||||||
|
Use this to check if CI/CD passed or failed after uploading code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: Repository name (username/repo or just repo name)
|
||||||
|
branch: Branch to check (default "main")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with latest run status (passed/failed/pending/none)
|
||||||
|
"""
|
||||||
|
if not GITEA_TOKEN:
|
||||||
|
return {"success": False, "error": "Gitea token not configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine owner and repo name
|
||||||
|
if "/" in repo:
|
||||||
|
owner, repo_name = repo.split("/", 1)
|
||||||
|
else:
|
||||||
|
owner = await get_gitea_username()
|
||||||
|
repo_name = repo
|
||||||
|
|
||||||
|
if not owner:
|
||||||
|
return {"success": False, "error": "Could not determine repository owner"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=get_api_base_url(),
|
||||||
|
headers=get_auth_headers(),
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"/repos/{owner}/{repo_name}/actions/runs",
|
||||||
|
params={"branch": branch, "per_page": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
runs = data.get("workflow_runs", data) if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
if not runs or (isinstance(runs, list) and len(runs) == 0):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status": "none",
|
||||||
|
"message": "No workflow runs found",
|
||||||
|
"repo": f"{owner}/{repo_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
latest_run = runs[0] if isinstance(runs, list) else runs
|
||||||
|
run_status = latest_run.get("status", "unknown")
|
||||||
|
conclusion = latest_run.get("conclusion")
|
||||||
|
|
||||||
|
# Determine overall status
|
||||||
|
if run_status in ("queued", "in_progress", "waiting"):
|
||||||
|
overall_status = "pending"
|
||||||
|
elif conclusion == "success":
|
||||||
|
overall_status = "passed"
|
||||||
|
elif conclusion in ("failure", "timedout", "action_required"):
|
||||||
|
overall_status = "failed"
|
||||||
|
elif conclusion in ("cancelled", "skipped"):
|
||||||
|
overall_status = "cancelled"
|
||||||
|
else:
|
||||||
|
overall_status = "unknown"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status": overall_status,
|
||||||
|
"run_status": run_status,
|
||||||
|
"conclusion": conclusion,
|
||||||
|
"run_id": latest_run.get("id"),
|
||||||
|
"run_name": latest_run.get("display_title") or latest_run.get("name"),
|
||||||
|
"branch": latest_run.get("head_branch"),
|
||||||
|
"commit_sha": latest_run.get("head_sha", "")[:7],
|
||||||
|
"url": f"{GITEA_URL}/{owner}/{repo_name}/actions/runs/{latest_run.get('id')}",
|
||||||
|
"repo": f"{owner}/{repo_name}"
|
||||||
|
}
|
||||||
|
elif response.status_code == 404:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status": "none",
|
||||||
|
"message": "Actions not enabled or no runs found",
|
||||||
|
"repo": f"{owner}/{repo_name}"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_msg = response.json().get("message", response.text)
|
||||||
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting latest workflow status: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_workflow_run_jobs(repo: str, run_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Get jobs and their status for a specific workflow run.
|
||||||
|
Use this to see which specific jobs failed in a CI/CD run.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: Repository name (username/repo or just repo name)
|
||||||
|
run_id: Workflow run ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with job details including status and log URLs
|
||||||
|
"""
|
||||||
|
if not GITEA_TOKEN:
|
||||||
|
return {"success": False, "error": "Gitea token not configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine owner and repo name
|
||||||
|
if "/" in repo:
|
||||||
|
owner, repo_name = repo.split("/", 1)
|
||||||
|
else:
|
||||||
|
owner = await get_gitea_username()
|
||||||
|
repo_name = repo
|
||||||
|
|
||||||
|
if not owner:
|
||||||
|
return {"success": False, "error": "Could not determine repository owner"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=get_api_base_url(),
|
||||||
|
headers=get_auth_headers(),
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"/repos/{owner}/{repo_name}/actions/runs/{run_id}/jobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
jobs = data.get("jobs", data) if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
simplified_jobs = []
|
||||||
|
for job in (jobs if isinstance(jobs, list) else []):
|
||||||
|
simplified_jobs.append({
|
||||||
|
"id": job.get("id"),
|
||||||
|
"name": job.get("name"),
|
||||||
|
"status": job.get("status"),
|
||||||
|
"conclusion": job.get("conclusion"),
|
||||||
|
"started_at": job.get("started_at"),
|
||||||
|
"completed_at": job.get("completed_at"),
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"name": step.get("name"),
|
||||||
|
"status": step.get("status"),
|
||||||
|
"conclusion": step.get("conclusion")
|
||||||
|
}
|
||||||
|
for step in job.get("steps", [])
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"run_id": run_id,
|
||||||
|
"repo": f"{owner}/{repo_name}",
|
||||||
|
"jobs": simplified_jobs
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_msg = response.json().get("message", response.text)
|
||||||
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting workflow run jobs: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_repo_info(repo: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get repository information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: Repository name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with repository details
|
||||||
|
"""
|
||||||
|
if not GITEA_TOKEN:
|
||||||
|
return {"success": False, "error": "Gitea token not configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine owner and repo name
|
||||||
|
if "/" in repo:
|
||||||
|
owner, repo_name = repo.split("/", 1)
|
||||||
|
else:
|
||||||
|
owner = await get_gitea_username()
|
||||||
|
repo_name = repo
|
||||||
|
|
||||||
|
if not owner:
|
||||||
|
return {"success": False, "error": "Could not determine repository owner"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=get_api_base_url(),
|
||||||
|
headers=get_auth_headers(),
|
||||||
|
timeout=30.0,
|
||||||
|
) as client:
|
||||||
|
response = await client.get(f"/repos/{owner}/{repo_name}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
repo_data = response.json()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"repo": {
|
||||||
|
"name": repo_data.get("name"),
|
||||||
|
"full_name": repo_data.get("full_name"),
|
||||||
|
"url": repo_data.get("html_url"),
|
||||||
|
"description": repo_data.get("description"),
|
||||||
|
"stars": repo_data.get("stars_count", 0),
|
||||||
|
"forks": repo_data.get("forks_count", 0),
|
||||||
|
"default_branch": repo_data.get("default_branch"),
|
||||||
|
"language": repo_data.get("language"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_msg = response.json().get("message", response.text)
|
||||||
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run()
|
||||||
207
mcp_servers/search_mcp.py
Normal file
207
mcp_servers/search_mcp.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"""
|
||||||
|
Search MCP Server for 7000%AUTO
|
||||||
|
Provides search functionality across arXiv, Reddit, Hacker News, Product Hunt
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
mcp = FastMCP("Search Server")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_arxiv(query: str, max_results: int = 5) -> dict:
|
||||||
|
"""
|
||||||
|
Search arXiv papers for the given query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
max_results: Maximum number of results to return (default 5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with papers list containing title, summary, authors, link, published date
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = "http://export.arxiv.org/api/query"
|
||||||
|
params = {
|
||||||
|
"search_query": f"all:{query}",
|
||||||
|
"start": 0,
|
||||||
|
"max_results": max_results,
|
||||||
|
"sortBy": "submittedDate",
|
||||||
|
"sortOrder": "descending"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.get(url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Parse XML response
|
||||||
|
root = ET.fromstring(response.text)
|
||||||
|
ns = {"atom": "http://www.w3.org/2005/Atom"}
|
||||||
|
|
||||||
|
papers = []
|
||||||
|
for entry in root.findall("atom:entry", ns):
|
||||||
|
title = entry.find("atom:title", ns)
|
||||||
|
summary = entry.find("atom:summary", ns)
|
||||||
|
published = entry.find("atom:published", ns)
|
||||||
|
link = entry.find("atom:id", ns)
|
||||||
|
|
||||||
|
authors = []
|
||||||
|
for author in entry.findall("atom:author", ns):
|
||||||
|
name = author.find("atom:name", ns)
|
||||||
|
if name is not None:
|
||||||
|
authors.append(name.text)
|
||||||
|
|
||||||
|
papers.append({
|
||||||
|
"title": title.text.strip() if title is not None else "",
|
||||||
|
"summary": summary.text.strip()[:500] if summary is not None else "",
|
||||||
|
"authors": authors[:3],
|
||||||
|
"link": link.text if link is not None else "",
|
||||||
|
"published": published.text if published is not None else ""
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"success": True, "papers": papers, "count": len(papers)}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"arXiv search failed: {e}")
|
||||||
|
return {"success": False, "error": str(e), "papers": []}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_reddit(subreddit: str, query: str, limit: int = 10) -> dict:
|
||||||
|
"""
|
||||||
|
Search Reddit posts in a specific subreddit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subreddit: Subreddit name (e.g., "programming")
|
||||||
|
query: Search query string
|
||||||
|
limit: Maximum number of results (default 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with posts list containing title, score, url, comments count
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = f"https://www.reddit.com/r/{subreddit}/search.json"
|
||||||
|
params = {
|
||||||
|
"q": query,
|
||||||
|
"restrict_sr": "on",
|
||||||
|
"sort": "relevance",
|
||||||
|
"t": "month",
|
||||||
|
"limit": limit
|
||||||
|
}
|
||||||
|
headers = {"User-Agent": "7000AUTO/1.0"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.get(url, params=params, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
posts = []
|
||||||
|
for child in data.get("data", {}).get("children", []):
|
||||||
|
post = child.get("data", {})
|
||||||
|
posts.append({
|
||||||
|
"title": post.get("title", ""),
|
||||||
|
"score": post.get("score", 0),
|
||||||
|
"url": f"https://reddit.com{post.get('permalink', '')}",
|
||||||
|
"comments": post.get("num_comments", 0),
|
||||||
|
"created_utc": post.get("created_utc", 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"success": True, "posts": posts, "count": len(posts)}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Reddit search failed: {e}")
|
||||||
|
return {"success": False, "error": str(e), "posts": []}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_hackernews(query: str, limit: int = 10) -> dict:
|
||||||
|
"""
|
||||||
|
Search Hacker News via Algolia API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
limit: Maximum number of results (default 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with stories list containing title, points, url, comments count
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = "https://hn.algolia.com/api/v1/search"
|
||||||
|
params = {
|
||||||
|
"query": query,
|
||||||
|
"tags": "story",
|
||||||
|
"hitsPerPage": limit
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.get(url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
stories = []
|
||||||
|
for hit in data.get("hits", []):
|
||||||
|
stories.append({
|
||||||
|
"title": hit.get("title", ""),
|
||||||
|
"points": hit.get("points", 0),
|
||||||
|
"url": hit.get("url", f"https://news.ycombinator.com/item?id={hit.get('objectID', '')}"),
|
||||||
|
"comments": hit.get("num_comments", 0),
|
||||||
|
"author": hit.get("author", ""),
|
||||||
|
"created_at": hit.get("created_at", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"success": True, "stories": stories, "count": len(stories)}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Hacker News search failed: {e}")
|
||||||
|
return {"success": False, "error": str(e), "stories": []}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_producthunt(days: int = 7) -> dict:
|
||||||
|
"""
|
||||||
|
Get recent Product Hunt posts via RSS feed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days to look back (default 7)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with products list containing title, tagline, url
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Product Hunt doesn't have a free API, use RSS feed
|
||||||
|
url = "https://www.producthunt.com/feed"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Parse RSS XML
|
||||||
|
root = ET.fromstring(response.text)
|
||||||
|
|
||||||
|
products = []
|
||||||
|
for item in root.findall(".//item")[:20]:
|
||||||
|
title = item.find("title")
|
||||||
|
link = item.find("link")
|
||||||
|
description = item.find("description")
|
||||||
|
|
||||||
|
products.append({
|
||||||
|
"title": title.text if title is not None else "",
|
||||||
|
"tagline": description.text[:200] if description is not None and description.text else "",
|
||||||
|
"url": link.text if link is not None else ""
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"success": True, "products": products, "count": len(products)}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Product Hunt search failed: {e}")
|
||||||
|
return {"success": False, "error": str(e), "products": []}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run()
|
||||||
176
mcp_servers/x_mcp.py
Normal file
176
mcp_servers/x_mcp.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
X/Twitter MCP Server for 7000%AUTO
|
||||||
|
Provides Twitter posting and search functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import tweepy
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
mcp = FastMCP("X API Server")
|
||||||
|
|
||||||
|
# Twitter API credentials from environment
|
||||||
|
API_KEY = os.getenv("X_API_KEY", "")
|
||||||
|
API_SECRET = os.getenv("X_API_SECRET", "")
|
||||||
|
ACCESS_TOKEN = os.getenv("X_ACCESS_TOKEN", "")
|
||||||
|
ACCESS_TOKEN_SECRET = os.getenv("X_ACCESS_TOKEN_SECRET", "")
|
||||||
|
BEARER_TOKEN = os.getenv("X_BEARER_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> Optional[tweepy.Client]:
|
||||||
|
"""Get authenticated Twitter client"""
|
||||||
|
if not all([API_KEY, API_SECRET, ACCESS_TOKEN, ACCESS_TOKEN_SECRET]):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return tweepy.Client(
|
||||||
|
consumer_key=API_KEY,
|
||||||
|
consumer_secret=API_SECRET,
|
||||||
|
access_token=ACCESS_TOKEN,
|
||||||
|
access_token_secret=ACCESS_TOKEN_SECRET,
|
||||||
|
bearer_token=BEARER_TOKEN if BEARER_TOKEN else None,
|
||||||
|
wait_on_rate_limit=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def post_tweet(text: str) -> dict:
|
||||||
|
"""
|
||||||
|
Post a tweet to X/Twitter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Tweet text (max 280 characters)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with tweet URL and status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if len(text) > 280:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Tweet exceeds 280 characters (got {len(text)})"
|
||||||
|
}
|
||||||
|
|
||||||
|
client = get_client()
|
||||||
|
if not client:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Twitter API credentials not configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.create_tweet(text=text)
|
||||||
|
tweet_id = response.data["id"]
|
||||||
|
|
||||||
|
# Get username for URL construction
|
||||||
|
me = client.get_me()
|
||||||
|
username = me.data.username if me.data else "user"
|
||||||
|
|
||||||
|
tweet_url = f"https://twitter.com/{username}/status/{tweet_id}"
|
||||||
|
|
||||||
|
logger.info(f"Posted tweet: {tweet_url}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"tweet_id": tweet_id,
|
||||||
|
"url": tweet_url,
|
||||||
|
"text": text,
|
||||||
|
"character_count": len(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
except tweepy.TweepyException as e:
|
||||||
|
logger.error(f"Twitter API error: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error posting tweet: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_tweets(query: str, max_results: int = 10) -> dict:
|
||||||
|
"""
|
||||||
|
Search recent tweets on X/Twitter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
max_results: Maximum number of results (default 10, max 100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with list of tweets
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
if not client:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Twitter API credentials not configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
max_results = min(max_results, 100)
|
||||||
|
|
||||||
|
response = client.search_recent_tweets(
|
||||||
|
query=query,
|
||||||
|
max_results=max_results,
|
||||||
|
tweet_fields=["created_at", "public_metrics", "author_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
tweets = []
|
||||||
|
if response.data:
|
||||||
|
for tweet in response.data:
|
||||||
|
tweets.append({
|
||||||
|
"id": tweet.id,
|
||||||
|
"text": tweet.text,
|
||||||
|
"created_at": tweet.created_at.isoformat() if tweet.created_at else "",
|
||||||
|
"metrics": tweet.public_metrics if hasattr(tweet, "public_metrics") else {}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"tweets": tweets,
|
||||||
|
"count": len(tweets)
|
||||||
|
}
|
||||||
|
|
||||||
|
except tweepy.TweepyException as e:
|
||||||
|
logger.error(f"Twitter search error: {e}")
|
||||||
|
return {"success": False, "error": str(e), "tweets": []}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error searching tweets: {e}")
|
||||||
|
return {"success": False, "error": str(e), "tweets": []}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_rate_limit_status() -> dict:
|
||||||
|
"""
|
||||||
|
Get current rate limit status for the Twitter API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with rate limit information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
if not client:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Twitter API credentials not configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Basic check by getting user info
|
||||||
|
me = client.get_me()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"authenticated": True,
|
||||||
|
"username": me.data.username if me.data else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except tweepy.TweepyException as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run()
|
||||||
137
opencode.json
Normal file
137
opencode.json
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"anthropic": {
|
||||||
|
"npm": "@ai-sdk/anthropic",
|
||||||
|
"name": "Anthropic",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "https://api.minimax.io/anthropic/v1",
|
||||||
|
"apiKey": "{env:OPENCODE_API_KEY}"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"MiniMax-M2.1": {
|
||||||
|
"name": "MiniMax-M2.1",
|
||||||
|
"options": {
|
||||||
|
"max_tokens": 196608
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model": "anthropic/MiniMax-M2.1",
|
||||||
|
"agent": {
|
||||||
|
"ideator": {
|
||||||
|
"description": "Finds innovative project ideas from various sources",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/ideator.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": true,
|
||||||
|
"grep": true,
|
||||||
|
"glob": true,
|
||||||
|
"bash": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"planner": {
|
||||||
|
"description": "Creates detailed implementation plans",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/planner.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": true,
|
||||||
|
"grep": true,
|
||||||
|
"glob": true,
|
||||||
|
"bash": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"developer": {
|
||||||
|
"description": "Implements code based on plans",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/developer.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": true,
|
||||||
|
"write": true,
|
||||||
|
"edit": true,
|
||||||
|
"bash": true,
|
||||||
|
"grep": true,
|
||||||
|
"glob": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tester": {
|
||||||
|
"description": "Tests and validates implementations",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/tester.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": true,
|
||||||
|
"bash": true,
|
||||||
|
"grep": true,
|
||||||
|
"glob": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uploader": {
|
||||||
|
"description": "Uploads projects to Gitea",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/uploader.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": true,
|
||||||
|
"write": true,
|
||||||
|
"bash": true,
|
||||||
|
"grep": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evangelist": {
|
||||||
|
"description": "Promotes projects on X/Twitter",
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:.opencode/agent/evangelist.md}",
|
||||||
|
"tools": {
|
||||||
|
"read": true,
|
||||||
|
"bash": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"search": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"mcp_servers.search_mcp"
|
||||||
|
],
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"mcp_servers.gitea_mcp"
|
||||||
|
],
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"x_api": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"mcp_servers.x_mcp"
|
||||||
|
],
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"mcp_servers.database_mcp"
|
||||||
|
],
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"devtest": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"mcp_servers.devtest_mcp"
|
||||||
|
],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
orchestrator/__init__.py
Normal file
14
orchestrator/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
Orchestrator Module for 7000%AUTO
|
||||||
|
Manages AI agent workflow and project state
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .state import ProjectState, StateManager, AgentType
|
||||||
|
from .opencode_client import OpenCodeClient, OpenCodeError
|
||||||
|
from .workflow import WorkflowOrchestrator, WorkflowEvent, WorkflowEventType
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'ProjectState', 'StateManager', 'AgentType',
|
||||||
|
'OpenCodeClient', 'OpenCodeError',
|
||||||
|
'WorkflowOrchestrator', 'WorkflowEvent', 'WorkflowEventType'
|
||||||
|
]
|
||||||
2799
orchestrator/opencode_client.py
Normal file
2799
orchestrator/opencode_client.py
Normal file
File diff suppressed because it is too large
Load Diff
131
orchestrator/state.py
Normal file
131
orchestrator/state.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
Project State Management for 7000%AUTO
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, Dict, List, Any
|
||||||
|
|
||||||
|
|
||||||
|
class AgentType(str, Enum):
|
||||||
|
"""Agent types in the workflow"""
|
||||||
|
IDEATOR = "ideator"
|
||||||
|
PLANNER = "planner"
|
||||||
|
DEVELOPER = "developer"
|
||||||
|
TESTER = "tester"
|
||||||
|
UPLOADER = "uploader"
|
||||||
|
EVANGELIST = "evangelist"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProjectState:
|
||||||
|
"""State of a project in the workflow"""
|
||||||
|
project_id: int
|
||||||
|
current_agent: Optional[str] = None
|
||||||
|
status: str = "ideation"
|
||||||
|
idea: Optional[Dict[str, Any]] = None
|
||||||
|
plan: Optional[Dict[str, Any]] = None
|
||||||
|
dev_test_iterations: int = 0
|
||||||
|
github_url: Optional[str] = None
|
||||||
|
x_post_url: Optional[str] = None
|
||||||
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
errors: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"current_agent": self.current_agent,
|
||||||
|
"status": self.status,
|
||||||
|
"idea": self.idea,
|
||||||
|
"plan": self.plan,
|
||||||
|
"dev_test_iterations": self.dev_test_iterations,
|
||||||
|
"github_url": self.github_url,
|
||||||
|
"x_post_url": self.x_post_url,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
"errors": self.errors
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StateManager:
|
||||||
|
"""Manages project states"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._states: Dict[int, ProjectState] = {}
|
||||||
|
self._active_project_id: Optional[int] = None
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def get_state(self, project_id: int) -> Optional[ProjectState]:
|
||||||
|
"""Get state for a project"""
|
||||||
|
async with self._lock:
|
||||||
|
return self._states.get(project_id)
|
||||||
|
|
||||||
|
async def create_state(self, project_id: int) -> ProjectState:
|
||||||
|
"""Create new project state"""
|
||||||
|
async with self._lock:
|
||||||
|
state = ProjectState(project_id=project_id)
|
||||||
|
self._states[project_id] = state
|
||||||
|
return state
|
||||||
|
|
||||||
|
async def update_state(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
current_agent: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
idea: Optional[Dict] = None,
|
||||||
|
plan: Optional[Dict] = None,
|
||||||
|
dev_test_iterations: Optional[int] = None,
|
||||||
|
github_url: Optional[str] = None,
|
||||||
|
x_post_url: Optional[str] = None,
|
||||||
|
error: Optional[str] = None
|
||||||
|
) -> Optional[ProjectState]:
|
||||||
|
"""Update project state"""
|
||||||
|
async with self._lock:
|
||||||
|
state = self._states.get(project_id)
|
||||||
|
if not state:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if current_agent is not None:
|
||||||
|
state.current_agent = current_agent
|
||||||
|
if status is not None:
|
||||||
|
state.status = status
|
||||||
|
if idea is not None:
|
||||||
|
state.idea = idea
|
||||||
|
if plan is not None:
|
||||||
|
state.plan = plan
|
||||||
|
if dev_test_iterations is not None:
|
||||||
|
state.dev_test_iterations = dev_test_iterations
|
||||||
|
if github_url is not None:
|
||||||
|
state.github_url = github_url
|
||||||
|
if x_post_url is not None:
|
||||||
|
state.x_post_url = x_post_url
|
||||||
|
if error is not None:
|
||||||
|
state.errors.append(error)
|
||||||
|
|
||||||
|
state.updated_at = datetime.utcnow()
|
||||||
|
return state
|
||||||
|
|
||||||
|
async def get_active_project_id(self) -> Optional[int]:
|
||||||
|
"""Get currently active project ID"""
|
||||||
|
async with self._lock:
|
||||||
|
return self._active_project_id
|
||||||
|
|
||||||
|
async def set_active_project(self, project_id: Optional[int]):
|
||||||
|
"""Set active project"""
|
||||||
|
async with self._lock:
|
||||||
|
self._active_project_id = project_id
|
||||||
|
|
||||||
|
async def get_active_state(self) -> Optional[ProjectState]:
|
||||||
|
"""Get state of active project"""
|
||||||
|
project_id = await self.get_active_project_id()
|
||||||
|
if project_id is None:
|
||||||
|
return None
|
||||||
|
return await self.get_state(project_id)
|
||||||
|
|
||||||
|
async def get_all_states(self) -> List[ProjectState]:
|
||||||
|
"""Get all project states"""
|
||||||
|
async with self._lock:
|
||||||
|
return list(self._states.values())
|
||||||
1059
orchestrator/workflow.py
Normal file
1059
orchestrator/workflow.py
Normal file
File diff suppressed because it is too large
Load Diff
82
pyproject.toml
Normal file
82
pyproject.toml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "auto-readme-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A CLI tool that automatically generates comprehensive README.md files by analyzing project structure"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "Auto README Team", email = "team@autoreadme.dev"}
|
||||||
|
]
|
||||||
|
keywords = ["cli", "documentation", "readme", "generator", "markdown"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Environment :: Console",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"click>=8.0.0",
|
||||||
|
"tree-sitter>=0.23.0",
|
||||||
|
"jinja2>=3.1.0",
|
||||||
|
"tomli>=2.0.0; python_version<'3.11'",
|
||||||
|
"requests>=2.31.0",
|
||||||
|
"rich>=13.0.0",
|
||||||
|
"pyyaml>=6.0.0",
|
||||||
|
"gitpython>=3.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
|
"black>=23.0.0",
|
||||||
|
"isort>=5.12.0",
|
||||||
|
"flake8>=6.0.0",
|
||||||
|
"pre-commit>=3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
auto-readme = "auto_readme.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = "-v --tb=short"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src/auto_readme"]
|
||||||
|
omit = ["*/tests/*", "*/__pycache__/*"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = ["pragma: no cover", "def __repr__", "raise NotImplementedError"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ["py39", "py310", "py311", "py312"]
|
||||||
|
include = "\\.pyi?$"
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
line_length = 100
|
||||||
|
skip = [".venv", "venv"]
|
||||||
|
|
||||||
|
[tool.flake8]
|
||||||
|
max-line-length = 100
|
||||||
|
exclude = [".venv", "venv", "build", "dist"]
|
||||||
|
per-file-ignores = ["__init__.py: F401"]
|
||||||
15
railway.json
Normal file
15
railway.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://railway.app/railway.schema.json",
|
||||||
|
"build": {
|
||||||
|
"builder": "DOCKERFILE",
|
||||||
|
"dockerfilePath": "Dockerfile"
|
||||||
|
},
|
||||||
|
"deploy": {
|
||||||
|
"numReplicas": 1,
|
||||||
|
"startCommand": "sh -c 'uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}'",
|
||||||
|
"healthcheckPath": "/health",
|
||||||
|
"healthcheckTimeout": 30,
|
||||||
|
"restartPolicyType": "ON_FAILURE",
|
||||||
|
"restartPolicyMaxRetries": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
35
requirements.txt
Normal file
35
requirements.txt
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 7000%AUTO - AI Autonomous Development System
|
||||||
|
# Core dependencies
|
||||||
|
|
||||||
|
# OpenCode SDK
|
||||||
|
opencode-ai>=0.1.0a36
|
||||||
|
|
||||||
|
# Web Framework
|
||||||
|
fastapi>=0.109.0
|
||||||
|
uvicorn[standard]>=0.27.0
|
||||||
|
sse-starlette>=1.8.0
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy[asyncio]>=2.0.0
|
||||||
|
aiosqlite>=0.19.0
|
||||||
|
|
||||||
|
# HTTP Client
|
||||||
|
httpx>=0.26.0
|
||||||
|
|
||||||
|
# Data Validation
|
||||||
|
pydantic>=2.5.0
|
||||||
|
pydantic-settings>=2.1.0
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
|
||||||
|
# MCP Server
|
||||||
|
mcp>=1.0.0
|
||||||
|
|
||||||
|
# External APIs
|
||||||
|
tweepy>=4.14.0
|
||||||
|
# PyGithub removed - using Gitea API via httpx
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
aiofiles>=23.2.0
|
||||||
|
structlog>=23.2.0
|
||||||
14
setup.cfg
Normal file
14
setup.cfg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[metadata]
|
||||||
|
max-line-length = 100
|
||||||
|
exclude = test.*?$, *.pyc, *.pyo, __pycache__, .mypy_cache, .tox, .nox, dist, build
|
||||||
|
|
||||||
|
[flake8]
|
||||||
|
max-line-length = 100
|
||||||
|
ignore = E203, E501, W503
|
||||||
|
per-file-ignores = __init__.py:F401
|
||||||
|
|
||||||
|
[mypy]
|
||||||
|
python_version = 3.9
|
||||||
|
warn_return_any = True
|
||||||
|
warn_unused_configs = True
|
||||||
|
disallow_untyped_defs = True
|
||||||
7
src/auto_readme/__init__.py
Normal file
7
src/auto_readme/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Auto README Generator CLI.
|
||||||
|
|
||||||
|
A CLI tool that automatically generates comprehensive README.md files
|
||||||
|
by analyzing project structure, dependencies, code patterns, and imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
56
src/auto_readme/analyzers/__init__.py
Normal file
56
src/auto_readme/analyzers/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Code analyzers for extracting functions, classes, and imports from source code."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from ..utils.path_utils import PathUtils
|
||||||
|
|
||||||
|
|
||||||
|
class CodeAnalyzer(Protocol):
|
||||||
|
"""Protocol for code analyzers."""
|
||||||
|
|
||||||
|
def can_analyze(self, path: Path) -> bool: ...
|
||||||
|
def analyze(self, path: Path) -> dict: ...
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAnalyzer(ABC):
|
||||||
|
"""Abstract base class for code analyzers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def can_analyze(self, path: Path) -> bool:
|
||||||
|
"""Check if this analyzer can handle the file."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def analyze(self, path: Path) -> dict:
|
||||||
|
"""Analyze the file and return extracted information."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _get_file_content(self, path: Path) -> str | None:
|
||||||
|
"""Safely read file content."""
|
||||||
|
try:
|
||||||
|
if PathUtils.get_file_size(path) > 1024 * 1024:
|
||||||
|
return None
|
||||||
|
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
return f.read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Imports placed here to avoid circular imports
|
||||||
|
from .python_analyzer import PythonAnalyzer # noqa: E402
|
||||||
|
from .javascript_analyzer import JavaScriptAnalyzer # noqa: E402
|
||||||
|
from .go_analyzer import GoAnalyzer # noqa: E402
|
||||||
|
from .rust_analyzer import RustAnalyzer # noqa: E402
|
||||||
|
from .analyzer_factory import CodeAnalyzerFactory # noqa: E402
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CodeAnalyzer",
|
||||||
|
"BaseAnalyzer",
|
||||||
|
"PythonAnalyzer",
|
||||||
|
"JavaScriptAnalyzer",
|
||||||
|
"GoAnalyzer",
|
||||||
|
"RustAnalyzer",
|
||||||
|
"CodeAnalyzerFactory",
|
||||||
|
]
|
||||||
38
src/auto_readme/analyzers/analyzer_factory.py
Normal file
38
src/auto_readme/analyzers/analyzer_factory.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Code analyzer factory for routing to correct analyzer."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .python_analyzer import PythonAnalyzer
|
||||||
|
from .javascript_analyzer import JavaScriptAnalyzer
|
||||||
|
from .go_analyzer import GoAnalyzer
|
||||||
|
from .rust_analyzer import RustAnalyzer
|
||||||
|
|
||||||
|
|
||||||
|
class CodeAnalyzerFactory:
|
||||||
|
"""Factory for creating appropriate code analyzers."""
|
||||||
|
|
||||||
|
ANALYZERS = [
|
||||||
|
PythonAnalyzer(),
|
||||||
|
JavaScriptAnalyzer(),
|
||||||
|
GoAnalyzer(),
|
||||||
|
RustAnalyzer(),
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_analyzer(cls, path: Path) -> Optional[Any]:
|
||||||
|
"""Get the appropriate analyzer for a file."""
|
||||||
|
for analyzer in cls.ANALYZERS:
|
||||||
|
if analyzer.can_analyze(path):
|
||||||
|
return analyzer
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all_analyzers(cls) -> list[Any]:
|
||||||
|
"""Get all available analyzers."""
|
||||||
|
return cls.ANALYZERS.copy()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def can_analyze(cls, path: Path) -> bool:
|
||||||
|
"""Check if any analyzer can handle the file."""
|
||||||
|
return cls.get_analyzer(path) is not None
|
||||||
152
src/auto_readme/analyzers/go_analyzer.py
Normal file
152
src/auto_readme/analyzers/go_analyzer.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""Go code analyzer using tree-sitter."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from tree_sitter import Language, Node, Parser
|
||||||
|
|
||||||
|
from tree_sitter_go import language as go_language
|
||||||
|
|
||||||
|
from . import BaseAnalyzer
|
||||||
|
from ..models import Function, Class, ImportStatement
|
||||||
|
|
||||||
|
|
||||||
|
class GoAnalyzer(BaseAnalyzer):
|
||||||
|
"""Analyzer for Go source files."""
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS = {".go"}
|
||||||
|
|
||||||
|
def can_analyze(self, path: Path) -> bool:
|
||||||
|
"""Check if this analyzer can handle the file."""
|
||||||
|
return path.suffix.lower() in self.SUPPORTED_EXTENSIONS
|
||||||
|
|
||||||
|
def analyze(self, path: Path) -> dict:
|
||||||
|
"""Analyze a Go file and extract functions, structs, and imports."""
|
||||||
|
content = self._get_file_content(path)
|
||||||
|
if not content:
|
||||||
|
return {"functions": [], "classes": [], "imports": []}
|
||||||
|
|
||||||
|
content_bytes = content.encode("utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
lang = Language(go_language())
|
||||||
|
parser = Parser(language=lang)
|
||||||
|
tree = parser.parse(content_bytes)
|
||||||
|
except Exception:
|
||||||
|
return {"functions": [], "classes": [], "imports": []}
|
||||||
|
|
||||||
|
functions = self._extract_functions(tree.root_node, content, content_bytes)
|
||||||
|
classes = self._extract_structs(tree.root_node, content_bytes)
|
||||||
|
imports = self._extract_imports(tree.root_node, content_bytes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"functions": functions,
|
||||||
|
"classes": classes,
|
||||||
|
"imports": imports,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_functions(self, node: Node, content: str, content_bytes: bytes) -> list[Function]:
|
||||||
|
"""Extract function definitions from the AST."""
|
||||||
|
functions = []
|
||||||
|
|
||||||
|
if node.type == "function_declaration":
|
||||||
|
func = self._parse_function(node, content, content_bytes)
|
||||||
|
if func:
|
||||||
|
functions.append(func)
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
funcs = self._extract_functions(child, content, content_bytes)
|
||||||
|
functions.extend(funcs)
|
||||||
|
|
||||||
|
return functions
|
||||||
|
|
||||||
|
def _extract_structs(self, node: Node, content_bytes: bytes) -> list[Class]:
|
||||||
|
"""Extract struct definitions from the AST."""
|
||||||
|
structs = []
|
||||||
|
|
||||||
|
if node.type == "type_spec":
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "type_identifier":
|
||||||
|
name = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
struct = Class(
|
||||||
|
name=name,
|
||||||
|
line_number=node.start_point[0] + 1,
|
||||||
|
)
|
||||||
|
structs.append(struct)
|
||||||
|
break
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
structs.extend(self._extract_structs(child, content_bytes))
|
||||||
|
|
||||||
|
return structs
|
||||||
|
|
||||||
|
def _extract_imports(self, node: Node, content_bytes: bytes) -> list[ImportStatement]:
|
||||||
|
"""Extract import statements from the AST."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type == "import_declaration":
|
||||||
|
imp = self._parse_import(node, content_bytes)
|
||||||
|
if imp:
|
||||||
|
imports.append(imp)
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content_bytes))
|
||||||
|
|
||||||
|
return imports
|
||||||
|
|
||||||
|
def _parse_function(self, node: Node, content: str, content_bytes: bytes) -> Optional[Function]:
|
||||||
|
"""Parse a function definition node."""
|
||||||
|
name = None
|
||||||
|
parameters = []
|
||||||
|
return_type = None
|
||||||
|
line_number = node.start_point[0] + 1
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "identifier":
|
||||||
|
name = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "parameter_list":
|
||||||
|
parameters = self._parse_parameters(child, content_bytes)
|
||||||
|
elif child.type == "type_identifier" or child.type == "qualified_type":
|
||||||
|
return_type = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "pointer_type":
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "type_identifier":
|
||||||
|
return_type = "*" + content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8")
|
||||||
|
break
|
||||||
|
|
||||||
|
return Function(
|
||||||
|
name=name or "unknown",
|
||||||
|
parameters=parameters,
|
||||||
|
return_type=return_type,
|
||||||
|
line_number=line_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_import(self, node: Node, content_bytes: bytes) -> Optional[ImportStatement]:
|
||||||
|
"""Parse an import declaration node."""
|
||||||
|
line_number = node.start_point[0] + 1
|
||||||
|
module = None
|
||||||
|
alias = None
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "import_spec":
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "string":
|
||||||
|
module = content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8").strip('"')
|
||||||
|
elif grandchild.type == "identifier":
|
||||||
|
alias = content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8")
|
||||||
|
|
||||||
|
return ImportStatement(
|
||||||
|
module=module or "",
|
||||||
|
alias=alias,
|
||||||
|
line_number=line_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_parameters(self, node: Node, content_bytes: bytes) -> list[str]:
|
||||||
|
"""Parse function parameters."""
|
||||||
|
params = []
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "parameter_declaration":
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "identifier":
|
||||||
|
params.append(content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8"))
|
||||||
|
break
|
||||||
|
return params
|
||||||
207
src/auto_readme/analyzers/javascript_analyzer.py
Normal file
207
src/auto_readme/analyzers/javascript_analyzer.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"""JavaScript/TypeScript code analyzer using tree-sitter."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from tree_sitter import Language, Node, Parser
|
||||||
|
|
||||||
|
from tree_sitter_javascript import language as javascript_language
|
||||||
|
|
||||||
|
from . import BaseAnalyzer
|
||||||
|
from ..models import Function, Class, ImportStatement
|
||||||
|
|
||||||
|
|
||||||
|
class JavaScriptAnalyzer(BaseAnalyzer):
|
||||||
|
"""Analyzer for JavaScript and TypeScript source files."""
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
|
||||||
|
|
||||||
|
def can_analyze(self, path: Path) -> bool:
|
||||||
|
"""Check if this analyzer can handle the file."""
|
||||||
|
return path.suffix.lower() in self.SUPPORTED_EXTENSIONS
|
||||||
|
|
||||||
|
def analyze(self, path: Path) -> dict:
|
||||||
|
"""Analyze a JavaScript/TypeScript file and extract functions, classes, and imports."""
|
||||||
|
content = self._get_file_content(path)
|
||||||
|
if not content:
|
||||||
|
return {"functions": [], "classes": [], "imports": []}
|
||||||
|
|
||||||
|
content_bytes = content.encode("utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
lang = Language(javascript_language())
|
||||||
|
parser = Parser(language=lang)
|
||||||
|
tree = parser.parse(content_bytes)
|
||||||
|
except Exception:
|
||||||
|
return {"functions": [], "classes": [], "imports": []}
|
||||||
|
|
||||||
|
functions = self._extract_functions(tree.root_node, content, content_bytes)
|
||||||
|
classes = self._extract_classes(tree.root_node, content, content_bytes)
|
||||||
|
imports = self._extract_imports(tree.root_node, content_bytes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"functions": functions,
|
||||||
|
"classes": classes,
|
||||||
|
"imports": imports,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_functions(self, node: Node, content: str, content_bytes: bytes) -> list[Function]:
|
||||||
|
"""Extract function definitions from the AST."""
|
||||||
|
functions = []
|
||||||
|
|
||||||
|
if node.type in ("function_declaration", "method_definition", "generator_function_declaration"):
|
||||||
|
func = self._parse_function(node, content, content_bytes)
|
||||||
|
if func:
|
||||||
|
functions.append(func)
|
||||||
|
elif node.type == "arrow_function":
|
||||||
|
parent_func = self._find_parent_function(node, content, content_bytes)
|
||||||
|
if parent_func:
|
||||||
|
functions.append(parent_func)
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
funcs = self._extract_functions(child, content, content_bytes)
|
||||||
|
functions.extend(funcs)
|
||||||
|
|
||||||
|
return functions
|
||||||
|
|
||||||
|
def _extract_classes(self, node: Node, content: str, content_bytes: bytes) -> list[Class]:
|
||||||
|
"""Extract class definitions from the AST."""
|
||||||
|
classes = []
|
||||||
|
|
||||||
|
if node.type == "class_declaration":
|
||||||
|
cls = self._parse_class(node, content, content_bytes)
|
||||||
|
if cls:
|
||||||
|
classes.append(cls)
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
classes.extend(self._extract_classes(child, content, content_bytes))
|
||||||
|
|
||||||
|
return classes
|
||||||
|
|
||||||
|
def _extract_imports(self, node: Node, content_bytes: bytes) -> list[ImportStatement]:
|
||||||
|
"""Extract import statements from the AST."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type == "import_statement":
|
||||||
|
imp = self._parse_import(node, content_bytes)
|
||||||
|
if imp:
|
||||||
|
imports.append(imp)
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content_bytes))
|
||||||
|
|
||||||
|
return imports
|
||||||
|
|
||||||
|
def _parse_function(self, node: Node, content: str, content_bytes: bytes) -> Optional[Function]:
|
||||||
|
"""Parse a function definition node."""
|
||||||
|
name = "anonymous"
|
||||||
|
parameters = []
|
||||||
|
docstring = None
|
||||||
|
line_number = node.start_point[0] + 1
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "identifier":
|
||||||
|
name = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "formal_parameters":
|
||||||
|
parameters = self._parse_parameters(child, content_bytes)
|
||||||
|
elif child.type == "property_signature":
|
||||||
|
name = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "method_definition":
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "property_identifier":
|
||||||
|
name = content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8")
|
||||||
|
elif grandchild.type == "formal_parameters":
|
||||||
|
parameters = self._parse_parameters(grandchild, content_bytes)
|
||||||
|
|
||||||
|
return Function(
|
||||||
|
name=name,
|
||||||
|
parameters=parameters,
|
||||||
|
docstring=docstring,
|
||||||
|
line_number=line_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _find_parent_function(self, node: Node, content: str, content_bytes: bytes) -> Optional[Function]:
|
||||||
|
"""Find parent function for arrow functions."""
|
||||||
|
parent = node.parent
|
||||||
|
while parent:
|
||||||
|
if parent.type == "variable_declarator":
|
||||||
|
for child in parent.children:
|
||||||
|
if child.type == "identifier":
|
||||||
|
return Function(
|
||||||
|
name=content_bytes[child.start_byte : child.end_byte].decode("utf-8"),
|
||||||
|
parameters=[],
|
||||||
|
line_number=node.start_point[0] + 1,
|
||||||
|
)
|
||||||
|
if parent.type == "pair":
|
||||||
|
for child in parent.children:
|
||||||
|
if child.type == "property_identifier":
|
||||||
|
return Function(
|
||||||
|
name=content_bytes[child.start_byte : child.end_byte].decode("utf-8"),
|
||||||
|
parameters=[],
|
||||||
|
line_number=node.start_point[0] + 1,
|
||||||
|
)
|
||||||
|
parent = parent.parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_class(self, node: Node, content: str, content_bytes: bytes) -> Optional[Class]:
|
||||||
|
"""Parse a class definition node."""
|
||||||
|
name = None
|
||||||
|
base_classes = []
|
||||||
|
methods = []
|
||||||
|
docstring = None
|
||||||
|
line_number = node.start_point[0] + 1
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "class_heritage":
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "identifier":
|
||||||
|
base_classes.append(
|
||||||
|
content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8")
|
||||||
|
)
|
||||||
|
elif child.type == "class_body":
|
||||||
|
methods = self._extract_functions(child, content, content_bytes)
|
||||||
|
|
||||||
|
return Class(
|
||||||
|
name=name or "Unknown",
|
||||||
|
base_classes=base_classes,
|
||||||
|
methods=methods,
|
||||||
|
docstring=docstring,
|
||||||
|
line_number=line_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_import(self, node: Node, content_bytes: bytes) -> Optional[ImportStatement]:
|
||||||
|
"""Parse an import statement node."""
|
||||||
|
line_number = node.start_point[0] + 1
|
||||||
|
module = None
|
||||||
|
items = []
|
||||||
|
alias = None
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "import_clause":
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "identifier":
|
||||||
|
items.append(content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8"))
|
||||||
|
elif grandchild.type == "namespace_import":
|
||||||
|
alias = content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "string":
|
||||||
|
module = content_bytes[child.start_byte : child.end_byte].decode("utf-8").strip('"').strip("'")
|
||||||
|
|
||||||
|
return ImportStatement(
|
||||||
|
module=module or "",
|
||||||
|
items=items,
|
||||||
|
alias=alias,
|
||||||
|
line_number=line_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_parameters(self, node: Node, content_bytes: bytes) -> list[str]:
|
||||||
|
"""Parse function parameters."""
|
||||||
|
params = []
|
||||||
|
for child in node.children:
|
||||||
|
if child.type in ("identifier", "rest_parameter", "optional_parameter"):
|
||||||
|
param = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
params.append(param)
|
||||||
|
elif child.type in ("typed_parameter", "parameter"):
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "identifier":
|
||||||
|
params.append(content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8"))
|
||||||
|
break
|
||||||
|
return params
|
||||||
208
src/auto_readme/analyzers/python_analyzer.py
Normal file
208
src/auto_readme/analyzers/python_analyzer.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""Python code analyzer using tree-sitter."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from tree_sitter import Language, Node, Parser
|
||||||
|
|
||||||
|
from tree_sitter_python import language as python_language
|
||||||
|
|
||||||
|
from . import BaseAnalyzer
|
||||||
|
from ..models import Function, Class, ImportStatement
|
||||||
|
|
||||||
|
|
||||||
|
class PythonAnalyzer(BaseAnalyzer):
|
||||||
|
"""Analyzer for Python source files."""
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS = {".py", ".pyi"}
|
||||||
|
|
||||||
|
def can_analyze(self, path: Path) -> bool:
|
||||||
|
"""Check if this analyzer can handle the file."""
|
||||||
|
return path.suffix.lower() in self.SUPPORTED_EXTENSIONS
|
||||||
|
|
||||||
|
def analyze(self, path: Path) -> dict:
|
||||||
|
"""Analyze a Python file and extract functions, classes, and imports."""
|
||||||
|
content = self._get_file_content(path)
|
||||||
|
if not content:
|
||||||
|
return {"functions": [], "classes": [], "imports": []}
|
||||||
|
|
||||||
|
content_bytes = content.encode("utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
lang = Language(python_language())
|
||||||
|
parser = Parser(language=lang)
|
||||||
|
tree = parser.parse(content_bytes)
|
||||||
|
except Exception:
|
||||||
|
return {"functions": [], "classes": [], "imports": []}
|
||||||
|
|
||||||
|
functions = self._extract_functions(tree.root_node, content, content_bytes)
|
||||||
|
classes = self._extract_classes(tree.root_node, content, content_bytes)
|
||||||
|
imports = self._extract_imports(tree.root_node, content_bytes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"functions": functions,
|
||||||
|
"classes": classes,
|
||||||
|
"imports": imports,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_functions(self, node: Node, content: str, content_bytes: bytes) -> list[Function]:
|
||||||
|
"""Extract function definitions from the AST."""
|
||||||
|
functions = []
|
||||||
|
|
||||||
|
if node.type == "function_definition":
|
||||||
|
func = self._parse_function(node, content, content_bytes)
|
||||||
|
if func:
|
||||||
|
functions.append(func)
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
funcs = self._extract_functions(child, content, content_bytes)
|
||||||
|
functions.extend(funcs)
|
||||||
|
|
||||||
|
return functions
|
||||||
|
|
||||||
|
def _extract_classes(self, node: Node, content: str, content_bytes: bytes) -> list[Class]:
|
||||||
|
"""Extract class definitions from the AST."""
|
||||||
|
classes = []
|
||||||
|
|
||||||
|
if node.type == "class_definition":
|
||||||
|
cls = self._parse_class(node, content, content_bytes)
|
||||||
|
if cls:
|
||||||
|
classes.append(cls)
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
classes.extend(self._extract_classes(child, content, content_bytes))
|
||||||
|
|
||||||
|
return classes
|
||||||
|
|
||||||
|
def _extract_imports(self, node: Node, content_bytes: bytes) -> list[ImportStatement]:
|
||||||
|
"""Extract import statements from the AST."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type in ("import_statement", "import_from_statement"):
|
||||||
|
imp = self._parse_import(node, content_bytes)
|
||||||
|
if imp:
|
||||||
|
imports.append(imp)
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content_bytes))
|
||||||
|
|
||||||
|
return imports
|
||||||
|
|
||||||
|
def _parse_function(self, node: Node, content: str, content_bytes: bytes) -> Optional[Function]:
|
||||||
|
"""Parse a function definition node."""
|
||||||
|
name = None
|
||||||
|
parameters = []
|
||||||
|
docstring = None
|
||||||
|
line_number = node.start_point[0] + 1
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "identifier":
|
||||||
|
name = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "parameters":
|
||||||
|
parameters = self._parse_parameters(child, content_bytes)
|
||||||
|
elif child.type == "block":
|
||||||
|
docstring = self._parse_docstring(child, content_bytes)
|
||||||
|
|
||||||
|
return Function(
|
||||||
|
name=name or "unknown",
|
||||||
|
parameters=parameters,
|
||||||
|
docstring=docstring,
|
||||||
|
line_number=line_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_class(self, node: Node, content: str, content_bytes: bytes) -> Optional[Class]:
|
||||||
|
"""Parse a class definition node."""
|
||||||
|
name = None
|
||||||
|
base_classes = []
|
||||||
|
docstring = None
|
||||||
|
methods = []
|
||||||
|
line_number = node.start_point[0] + 1
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "identifier":
|
||||||
|
name = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "argument_list":
|
||||||
|
base_classes = self._parse_base_classes(child, content_bytes)
|
||||||
|
elif child.type == "block":
|
||||||
|
docstring = self._parse_docstring(child, content_bytes)
|
||||||
|
methods = self._extract_functions(child, content, content_bytes)
|
||||||
|
|
||||||
|
return Class(
|
||||||
|
name=name or "Unknown",
|
||||||
|
base_classes=base_classes,
|
||||||
|
docstring=docstring,
|
||||||
|
methods=methods,
|
||||||
|
line_number=line_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_import(self, node: Node, content_bytes: bytes) -> Optional[ImportStatement]:
|
||||||
|
"""Parse an import statement node."""
|
||||||
|
line_number = node.start_point[0] + 1
|
||||||
|
|
||||||
|
if node.type == "import_statement":
|
||||||
|
module = content_bytes[node.start_byte : node.end_byte].decode("utf-8")
|
||||||
|
return ImportStatement(
|
||||||
|
module=module,
|
||||||
|
line_number=line_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
if node.type == "import_from_statement":
|
||||||
|
module = None
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "module":
|
||||||
|
module = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "dotted_name":
|
||||||
|
module = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "import_as_names" or child.type == "import_as_name":
|
||||||
|
name = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
items.append(name)
|
||||||
|
elif child.type == "wildcard_import":
|
||||||
|
return ImportStatement(
|
||||||
|
module=module or "",
|
||||||
|
line_number=line_number,
|
||||||
|
is_from=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ImportStatement(
|
||||||
|
module=module or "",
|
||||||
|
items=items,
|
||||||
|
line_number=line_number,
|
||||||
|
is_from=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_parameters(self, node: Node, content_bytes: bytes) -> list[str]:
|
||||||
|
"""Parse function parameters."""
|
||||||
|
params = []
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "identifier":
|
||||||
|
param = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
params.append(param)
|
||||||
|
elif child.type in ("default_parameter", "typed_parameter"):
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "identifier":
|
||||||
|
param = content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8")
|
||||||
|
params.append(param)
|
||||||
|
break
|
||||||
|
return params
|
||||||
|
|
||||||
|
def _parse_base_classes(self, node: Node, content_bytes: bytes) -> list[str]:
|
||||||
|
"""Parse class base classes."""
|
||||||
|
bases = []
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "attribute" or child.type == "identifier":
|
||||||
|
name = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
bases.append(name)
|
||||||
|
return bases
|
||||||
|
|
||||||
|
def _parse_docstring(self, node: Node, content_bytes: bytes) -> Optional[str]:
|
||||||
|
"""Parse a docstring from a block."""
|
||||||
|
if node.children and node.children[0].type == "expression_statement":
|
||||||
|
expr = node.children[0]
|
||||||
|
if expr.children and expr.children[0].type == "string":
|
||||||
|
string_content = content_bytes[expr.children[0].start_byte : expr.children[0].end_byte].decode("utf-8")
|
||||||
|
if string_content.startswith('"""') or string_content.startswith("'''"):
|
||||||
|
return string_content.strip('"""').strip("'''").strip()
|
||||||
|
return None
|
||||||
187
src/auto_readme/analyzers/rust_analyzer.py
Normal file
187
src/auto_readme/analyzers/rust_analyzer.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""Rust code analyzer using tree-sitter."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from tree_sitter import Language, Node, Parser
|
||||||
|
|
||||||
|
from tree_sitter_rust import language as rust_language
|
||||||
|
|
||||||
|
from . import BaseAnalyzer
|
||||||
|
from ..models import Function, Class, ImportStatement
|
||||||
|
|
||||||
|
|
||||||
|
class RustAnalyzer(BaseAnalyzer):
|
||||||
|
"""Analyzer for Rust source files."""
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS = {".rs"}
|
||||||
|
|
||||||
|
def can_analyze(self, path: Path) -> bool:
|
||||||
|
"""Check if this analyzer can handle the file."""
|
||||||
|
return path.suffix.lower() in self.SUPPORTED_EXTENSIONS
|
||||||
|
|
||||||
|
def analyze(self, path: Path) -> dict:
|
||||||
|
"""Analyze a Rust file and extract functions, structs, and imports."""
|
||||||
|
content = self._get_file_content(path)
|
||||||
|
if not content:
|
||||||
|
return {"functions": [], "classes": [], "imports": []}
|
||||||
|
|
||||||
|
content_bytes = content.encode("utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
lang = Language(rust_language())
|
||||||
|
parser = Parser(language=lang)
|
||||||
|
tree = parser.parse(content_bytes)
|
||||||
|
except Exception:
|
||||||
|
return {"functions": [], "classes": [], "imports": []}
|
||||||
|
|
||||||
|
functions = self._extract_functions(tree.root_node, content, content_bytes)
|
||||||
|
classes = self._extract_structs(tree.root_node, content_bytes)
|
||||||
|
imports = self._extract_imports(tree.root_node, content_bytes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"functions": functions,
|
||||||
|
"classes": classes,
|
||||||
|
"imports": imports,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_functions(self, node: Node, content: str, content_bytes: bytes) -> list[Function]:
|
||||||
|
"""Extract function definitions from the AST."""
|
||||||
|
functions = []
|
||||||
|
|
||||||
|
if node.type in ("function_item", "function_signature"):
|
||||||
|
func = self._parse_function(node, content, content_bytes)
|
||||||
|
if func:
|
||||||
|
functions.append(func)
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
funcs = self._extract_functions(child, content, content_bytes)
|
||||||
|
functions.extend(funcs)
|
||||||
|
|
||||||
|
return functions
|
||||||
|
|
||||||
|
def _extract_structs(self, node: Node, content_bytes: bytes) -> list[Class]:
|
||||||
|
"""Extract struct/enum definitions from the AST."""
|
||||||
|
structs = []
|
||||||
|
|
||||||
|
if node.type == "struct_item":
|
||||||
|
name = None
|
||||||
|
fields = []
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "identifier":
|
||||||
|
name = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "field_declaration_list":
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "field_identifier":
|
||||||
|
fields.append(
|
||||||
|
content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8")
|
||||||
|
)
|
||||||
|
if name:
|
||||||
|
structs.append(
|
||||||
|
Class(
|
||||||
|
name=name,
|
||||||
|
attributes=fields,
|
||||||
|
line_number=node.start_point[0] + 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif node.type == "enum_item":
|
||||||
|
name = None
|
||||||
|
variants = []
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "identifier":
|
||||||
|
name = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "enum_variant_list":
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "identifier":
|
||||||
|
variants.append(
|
||||||
|
content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8")
|
||||||
|
)
|
||||||
|
if name:
|
||||||
|
structs.append(
|
||||||
|
Class(
|
||||||
|
name=name,
|
||||||
|
attributes=variants,
|
||||||
|
line_number=node.start_point[0] + 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
structs.extend(self._extract_structs(child, content_bytes))
|
||||||
|
|
||||||
|
return structs
|
||||||
|
|
||||||
|
def _extract_imports(self, node: Node, content_bytes: bytes) -> list[ImportStatement]:
|
||||||
|
"""Extract use statements from the AST."""
|
||||||
|
imports = []
|
||||||
|
|
||||||
|
if node.type == "use_declaration":
|
||||||
|
imp = self._parse_import(node, content_bytes)
|
||||||
|
if imp:
|
||||||
|
imports.append(imp)
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
imports.extend(self._extract_imports(child, content_bytes))
|
||||||
|
|
||||||
|
return imports
|
||||||
|
|
||||||
|
def _parse_function(self, node: Node, content: str, content_bytes: bytes) -> Optional[Function]:
|
||||||
|
"""Parse a function definition node."""
|
||||||
|
name = None
|
||||||
|
parameters = []
|
||||||
|
return_type = None
|
||||||
|
visibility = "private"
|
||||||
|
line_number = node.start_point[0] + 1
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "visibility_modifier":
|
||||||
|
visibility = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "identifier":
|
||||||
|
name = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "parameters":
|
||||||
|
parameters = self._parse_parameters(child, content_bytes)
|
||||||
|
elif child.type == "return_type":
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type in ("type_identifier", "qualified_type"):
|
||||||
|
return_type = content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8")
|
||||||
|
|
||||||
|
return Function(
|
||||||
|
name=name or "unknown",
|
||||||
|
parameters=parameters,
|
||||||
|
return_type=return_type,
|
||||||
|
line_number=line_number,
|
||||||
|
visibility=visibility if visibility != "pub(crate)" else "public",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_import(self, node: Node, content_bytes: bytes) -> Optional[ImportStatement]:
|
||||||
|
"""Parse a use declaration node."""
|
||||||
|
line_number = node.start_point[0] + 1
|
||||||
|
module = None
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "use_path":
|
||||||
|
module = content_bytes[child.start_byte : child.end_byte].decode("utf-8")
|
||||||
|
elif child.type == "use_as_path":
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "use_path":
|
||||||
|
module = content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8")
|
||||||
|
break
|
||||||
|
|
||||||
|
return ImportStatement(
|
||||||
|
module=module or "",
|
||||||
|
line_number=line_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_parameters(self, node: Node, content_bytes: bytes) -> list[str]:
|
||||||
|
"""Parse function parameters."""
|
||||||
|
params = []
|
||||||
|
for child in node.children:
|
||||||
|
if child.type == "parameter":
|
||||||
|
for grandchild in child.children:
|
||||||
|
if grandchild.type == "identifier":
|
||||||
|
params.append(content_bytes[grandchild.start_byte : grandchild.end_byte].decode("utf-8"))
|
||||||
|
break
|
||||||
|
elif grandchild.type == "pattern":
|
||||||
|
for ggchild in grandchild.children:
|
||||||
|
if ggchild.type == "identifier":
|
||||||
|
params.append(content_bytes[ggchild.start_byte : ggchild.end_byte].decode("utf-8"))
|
||||||
|
break
|
||||||
|
return params
|
||||||
364
src/auto_readme/cli.py
Normal file
364
src/auto_readme/cli.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
"""Main CLI interface for the Auto README Generator."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
|
||||||
|
from auto_readme import __version__
|
||||||
|
from auto_readme.models import Project, ProjectType, ProjectConfig, FileType
|
||||||
|
from auto_readme.parsers import DependencyParserFactory
|
||||||
|
from auto_readme.analyzers import CodeAnalyzerFactory
|
||||||
|
from auto_readme.utils import scan_project, get_git_info
|
||||||
|
from auto_readme.templates import TemplateRenderer
|
||||||
|
from auto_readme.config import ConfigLoader
|
||||||
|
from auto_readme.interactive import run_wizard
|
||||||
|
from auto_readme.github import GitHubActionsGenerator
|
||||||
|
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.version_option(version=__version__, prog_name="auto-readme")
|
||||||
|
@click.option(
|
||||||
|
"--verbose",
|
||||||
|
"-v",
|
||||||
|
is_flag=True,
|
||||||
|
help="Enable verbose output",
|
||||||
|
)
|
||||||
|
def main(verbose: bool):
|
||||||
|
"""Auto README Generator - Automatically generate comprehensive README files."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.option(
|
||||||
|
"--input",
|
||||||
|
"-i",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
help="Input directory to analyze (default: current directory)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=click.Path(dir_okay=False, path_type=Path),
|
||||||
|
default=Path("README.md"),
|
||||||
|
help="Output file path for the generated README",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--interactive",
|
||||||
|
"-I",
|
||||||
|
is_flag=True,
|
||||||
|
help="Run in interactive mode to customize the README",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--template",
|
||||||
|
"-t",
|
||||||
|
type=click.Choice(["base", "minimal", "detailed"]),
|
||||||
|
default="base",
|
||||||
|
help="Template to use for generation",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--config",
|
||||||
|
"-c",
|
||||||
|
type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path),
|
||||||
|
help="Path to configuration file",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--github-actions",
|
||||||
|
is_flag=True,
|
||||||
|
help="Generate GitHub Actions workflow for auto-updating README",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--force",
|
||||||
|
"-f",
|
||||||
|
is_flag=True,
|
||||||
|
help="Force overwrite existing README",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--dry-run",
|
||||||
|
is_flag=True,
|
||||||
|
help="Generate README but don't write to file",
|
||||||
|
)
|
||||||
|
def generate(
|
||||||
|
input: Path,
|
||||||
|
output: Path,
|
||||||
|
interactive: bool,
|
||||||
|
template: str,
|
||||||
|
config: Optional[Path],
|
||||||
|
github_actions: bool,
|
||||||
|
force: bool,
|
||||||
|
dry_run: bool,
|
||||||
|
):
|
||||||
|
"""Generate a README.md file for your project."""
|
||||||
|
try:
|
||||||
|
if output.exists() and not force and not dry_run:
|
||||||
|
if not click.confirm(f"File {output} already exists. Overwrite?"):
|
||||||
|
click.echo("Aborted.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
project = analyze_project(input)
|
||||||
|
|
||||||
|
if config:
|
||||||
|
project_config = ConfigLoader.load(config)
|
||||||
|
if project_config.project_name:
|
||||||
|
if not project.config:
|
||||||
|
project.config = ProjectConfig(name=project_config.project_name)
|
||||||
|
else:
|
||||||
|
project.config.name = project_config.project_name
|
||||||
|
if project_config.description:
|
||||||
|
project.config.description = project_config.description
|
||||||
|
|
||||||
|
if interactive:
|
||||||
|
project = run_wizard(project)
|
||||||
|
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
readme_content = renderer.render(project, template_name=template)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
click.echo(readme_content)
|
||||||
|
else:
|
||||||
|
output.write_text(readme_content)
|
||||||
|
click.echo(f"Successfully generated README.md at {output}")
|
||||||
|
|
||||||
|
if github_actions:
|
||||||
|
if GitHubActionsGenerator.can_generate(project):
|
||||||
|
workflow_path = GitHubActionsGenerator.save_workflow(project, input)
|
||||||
|
click.echo(f"Generated workflow at {workflow_path}")
|
||||||
|
else:
|
||||||
|
click.echo("GitHub Actions workflow not generated: Not a GitHub repository or missing owner info.")
|
||||||
|
elif not dry_run and click.get_current_context().params.get("interactive", False) is False:
|
||||||
|
pass # Skip prompt in non-interactive mode
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(Panel(f"Error: {e}", style="red"))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.option(
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
type=click.Path(dir_okay=False, path_type=Path),
|
||||||
|
default=Path(".readmerc"),
|
||||||
|
help="Output file path for the configuration template",
|
||||||
|
)
|
||||||
|
def init_config(output: Path):
|
||||||
|
"""Generate a template configuration file."""
|
||||||
|
from auto_readme.config import ConfigValidator
|
||||||
|
|
||||||
|
template = ConfigValidator.generate_template()
|
||||||
|
output.write_text(template)
|
||||||
|
click.echo(f"Generated configuration template at {output}")
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.option(
|
||||||
|
"--input",
|
||||||
|
"-i",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
help="Directory to preview README for",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--template",
|
||||||
|
"-t",
|
||||||
|
type=click.Choice(["base", "minimal", "detailed"]),
|
||||||
|
default="base",
|
||||||
|
help="Template to preview",
|
||||||
|
)
|
||||||
|
def preview(input: Path, template: str):
|
||||||
|
"""Preview the generated README without writing to file."""
|
||||||
|
try:
|
||||||
|
project = analyze_project(input)
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
readme_content = renderer.render(project, template_name=template)
|
||||||
|
click.echo(readme_content)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(Panel(f"Error: {e}", style="red"))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.argument(
|
||||||
|
"path",
|
||||||
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
||||||
|
default=Path("."),
|
||||||
|
)
|
||||||
|
def analyze(path: Path):
|
||||||
|
"""Analyze a project and display information."""
|
||||||
|
try:
|
||||||
|
project = analyze_project(path)
|
||||||
|
|
||||||
|
info = [
|
||||||
|
f"Project: {project.config.name if project.config else 'Unknown'}",
|
||||||
|
f"Type: {project.project_type.value}",
|
||||||
|
f"Files: {len(project.files)}",
|
||||||
|
f"Source files: {len(project.source_files())}",
|
||||||
|
f"Test files: {len(project.test_files())}",
|
||||||
|
f"Dependencies: {len(project.dependencies)}",
|
||||||
|
f"Dev dependencies: {len(project.dev_dependencies)}",
|
||||||
|
f"Classes: {len(project.all_classes())}",
|
||||||
|
f"Functions: {len(project.all_functions())}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if project.git_info and project.git_info.is_repo:
|
||||||
|
info.append(f"Git repository: {project.git_info.remote_url}")
|
||||||
|
|
||||||
|
click.echo("\n".join(info))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(Panel(f"Error: {e}", style="red"))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_project(path: Path) -> Project:
|
||||||
|
"""Analyze a project and return a Project object."""
|
||||||
|
scan_result = scan_project(path)
|
||||||
|
|
||||||
|
project_type = scan_result.project_type
|
||||||
|
if project_type == ProjectType.UNKNOWN:
|
||||||
|
project_type = detect_project_type(path)
|
||||||
|
|
||||||
|
git_info = get_git_info(path)
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
root_path=scan_result.root_path,
|
||||||
|
project_type=project_type,
|
||||||
|
git_info=git_info,
|
||||||
|
files=scan_result.files,
|
||||||
|
)
|
||||||
|
|
||||||
|
project.config = parse_project_config(path, project_type)
|
||||||
|
|
||||||
|
dependencies = parse_dependencies(path, project_type)
|
||||||
|
project.dependencies = [d for d in dependencies if not d.is_dev]
|
||||||
|
project.dev_dependencies = [d for d in dependencies if d.is_dev]
|
||||||
|
|
||||||
|
project = analyze_code(path, project)
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def detect_project_type(path: Path) -> ProjectType:
|
||||||
|
"""Detect the project type based on marker files."""
|
||||||
|
markers = {
|
||||||
|
"pyproject.toml": ProjectType.PYTHON,
|
||||||
|
"setup.py": ProjectType.PYTHON,
|
||||||
|
"requirements.txt": ProjectType.PYTHON,
|
||||||
|
"package.json": ProjectType.JAVASCRIPT,
|
||||||
|
"go.mod": ProjectType.GO,
|
||||||
|
"Cargo.toml": ProjectType.RUST,
|
||||||
|
}
|
||||||
|
|
||||||
|
for marker, project_type in markers.items():
|
||||||
|
if (path / marker).exists():
|
||||||
|
return project_type
|
||||||
|
|
||||||
|
return ProjectType.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def parse_project_config(path: Path, project_type: ProjectType) -> Optional[ProjectConfig]:
|
||||||
|
"""Parse project configuration from marker files."""
|
||||||
|
config = ProjectConfig(name=path.name)
|
||||||
|
|
||||||
|
if project_type == ProjectType.PYTHON:
|
||||||
|
pyproject = path / "pyproject.toml"
|
||||||
|
if pyproject.exists():
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
with open(pyproject, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
if "project" in data:
|
||||||
|
project_data = data["project"]
|
||||||
|
config.name = project_data.get("name", config.name)
|
||||||
|
config.version = project_data.get("version")
|
||||||
|
config.description = project_data.get("description")
|
||||||
|
config.license = project_data.get("license")
|
||||||
|
config.homepage = project_data.get("urls", {}).get("Homepage")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif project_type == ProjectType.JAVASCRIPT:
|
||||||
|
package_json = path / "package.json"
|
||||||
|
if package_json.exists():
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
with open(package_json) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
config.name = data.get("name", config.name)
|
||||||
|
config.version = data.get("version")
|
||||||
|
config.description = data.get("description")
|
||||||
|
config.license = data.get("license")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif project_type == ProjectType.GO:
|
||||||
|
go_mod = path / "go.mod"
|
||||||
|
if go_mod.exists():
|
||||||
|
try:
|
||||||
|
content = go_mod.read_text()
|
||||||
|
for line in content.splitlines():
|
||||||
|
if line.startswith("module "):
|
||||||
|
config.name = line.replace("module ", "").strip()
|
||||||
|
elif line.startswith("go "):
|
||||||
|
config.version = line.replace("go ", "").strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif project_type == ProjectType.RUST:
|
||||||
|
cargo_toml = path / "Cargo.toml"
|
||||||
|
if cargo_toml.exists():
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
with open(cargo_toml, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
if "package" in data:
|
||||||
|
pkg = data["package"]
|
||||||
|
config.name = pkg.get("name", config.name)
|
||||||
|
config.version = pkg.get("version")
|
||||||
|
config.description = pkg.get("description")
|
||||||
|
config.license = pkg.get("license")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dependencies(path: Path, project_type: ProjectType) -> list:
|
||||||
|
"""Parse project dependencies."""
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
for parser in DependencyParserFactory.get_all_parsers():
|
||||||
|
for dep_file in path.rglob("*"):
|
||||||
|
if parser.can_parse(dep_file):
|
||||||
|
deps = parser.parse(dep_file)
|
||||||
|
dependencies.extend(deps)
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_code(path: Path, project: Project) -> Project:
|
||||||
|
"""Analyze source code to extract functions, classes, and imports."""
|
||||||
|
for source_file in project.files:
|
||||||
|
if source_file.file_type != FileType.SOURCE:
|
||||||
|
continue
|
||||||
|
|
||||||
|
analyzer = CodeAnalyzerFactory.get_analyzer(Path(path / source_file.path))
|
||||||
|
if analyzer:
|
||||||
|
full_path = path / source_file.path
|
||||||
|
analysis = analyzer.analyze(full_path)
|
||||||
|
source_file.functions = analysis.get("functions", [])
|
||||||
|
source_file.classes = analysis.get("classes", [])
|
||||||
|
source_file.imports = analysis.get("imports", [])
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
183
src/auto_readme/config/__init__.py
Normal file
183
src/auto_readme/config/__init__.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""Configuration file support for the Auto README Generator."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
import yaml
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 11):
|
||||||
|
import tomllib
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import tomli as tomllib
|
||||||
|
except ImportError:
|
||||||
|
tomllib = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReadmeConfig:
|
||||||
|
"""Configuration for README generation."""
|
||||||
|
|
||||||
|
project_name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
template: str = "base"
|
||||||
|
interactive: bool = False
|
||||||
|
sections: dict = field(default_factory=dict)
|
||||||
|
output_filename: str = "README.md"
|
||||||
|
custom_fields: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigLoader:
|
||||||
|
"""Loads and validates configuration files."""
|
||||||
|
|
||||||
|
CONFIG_FILES = [".readmerc", ".readmerc.yaml", ".readmerc.yml", "readme.config.yaml"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_config(cls, directory: Path) -> Optional[Path]:
|
||||||
|
"""Find a configuration file in the directory."""
|
||||||
|
for config_name in cls.CONFIG_FILES:
|
||||||
|
config_path = directory / config_name
|
||||||
|
if config_path.exists():
|
||||||
|
return config_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path) -> ReadmeConfig:
|
||||||
|
"""Load configuration from a file."""
|
||||||
|
if not path.exists():
|
||||||
|
return ReadmeConfig()
|
||||||
|
|
||||||
|
config = ReadmeConfig()
|
||||||
|
|
||||||
|
if path.suffix in (".yaml", ".yml"):
|
||||||
|
config = cls._load_yaml(path, config)
|
||||||
|
elif path.suffix == ".toml":
|
||||||
|
config = cls._load_toml(path, config)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _load_yaml(cls, path: Path, config: ReadmeConfig) -> ReadmeConfig:
|
||||||
|
"""Load configuration from YAML file."""
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise ValueError(f"Invalid YAML in config file: {e}")
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("Config file must contain a dictionary")
|
||||||
|
|
||||||
|
if "project_name" in data:
|
||||||
|
config.project_name = data["project_name"]
|
||||||
|
if "description" in data:
|
||||||
|
config.description = data["description"]
|
||||||
|
if "template" in data:
|
||||||
|
config.template = data["template"]
|
||||||
|
if "interactive" in data:
|
||||||
|
config.interactive = data["interactive"]
|
||||||
|
if "filename" in data:
|
||||||
|
config.output_filename = data["filename"]
|
||||||
|
if "sections" in data:
|
||||||
|
config.sections = data["sections"]
|
||||||
|
if "custom_fields" in data:
|
||||||
|
config.custom_fields = data["custom_fields"]
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _load_toml(cls, path: Path, config: ReadmeConfig) -> ReadmeConfig:
|
||||||
|
"""Load configuration from TOML file."""
|
||||||
|
if tomllib is None:
|
||||||
|
raise ValueError("TOML support requires Python 3.11+ or tomli package")
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Invalid TOML in config file: {e}")
|
||||||
|
|
||||||
|
if "auto-readme" in data:
|
||||||
|
auto_readme = data["auto-readme"]
|
||||||
|
if "filename" in auto_readme:
|
||||||
|
config.output_filename = auto_readme["filename"]
|
||||||
|
if "sections" in auto_readme:
|
||||||
|
config.sections = auto_readme["sections"]
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigValidator:
|
||||||
|
"""Validates configuration files."""
|
||||||
|
|
||||||
|
VALID_TEMPLATES = ["base", "minimal", "detailed"]
|
||||||
|
VALID_SECTIONS = [
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"badges",
|
||||||
|
"table_of_contents",
|
||||||
|
"overview",
|
||||||
|
"installation",
|
||||||
|
"usage",
|
||||||
|
"features",
|
||||||
|
"api",
|
||||||
|
"contributing",
|
||||||
|
"license",
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls, config: ReadmeConfig) -> list[str]:
|
||||||
|
"""Validate a configuration and return any errors."""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if config.template and config.template not in cls.VALID_TEMPLATES:
|
||||||
|
errors.append(f"Invalid template: {config.template}. Valid options: {cls.VALID_TEMPLATES}")
|
||||||
|
|
||||||
|
if config.sections:
|
||||||
|
if "order" in config.sections:
|
||||||
|
for section in config.sections["order"]:
|
||||||
|
if section not in cls.VALID_SECTIONS:
|
||||||
|
errors.append(f"Invalid section in order: {section}")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_template(cls) -> str:
|
||||||
|
"""Generate a template configuration file."""
|
||||||
|
template = """# Auto README Configuration File
|
||||||
|
# https://github.com/yourusername/yourrepo
|
||||||
|
|
||||||
|
# Project metadata
|
||||||
|
project_name: "My Project"
|
||||||
|
description: "A brief description of your project"
|
||||||
|
|
||||||
|
# Template to use
|
||||||
|
template: "base"
|
||||||
|
|
||||||
|
# Interactive mode
|
||||||
|
interactive: false
|
||||||
|
|
||||||
|
# Output filename
|
||||||
|
filename: "README.md"
|
||||||
|
|
||||||
|
# Section configuration
|
||||||
|
sections:
|
||||||
|
order:
|
||||||
|
- title
|
||||||
|
- description
|
||||||
|
- overview
|
||||||
|
- installation
|
||||||
|
- usage
|
||||||
|
- features
|
||||||
|
- api
|
||||||
|
- contributing
|
||||||
|
- license
|
||||||
|
optional:
|
||||||
|
Contributing: false
|
||||||
|
|
||||||
|
# Custom fields to include
|
||||||
|
custom_fields:
|
||||||
|
author: "Your Name"
|
||||||
|
email: "your.email@example.com"
|
||||||
|
"""
|
||||||
|
return template
|
||||||
128
src/auto_readme/github/__init__.py
Normal file
128
src/auto_readme/github/__init__.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""GitHub Actions integration for README auto-update workflows."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..models import Project
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubActionsGenerator:
|
||||||
|
"""Generates GitHub Actions workflows for README updates."""
|
||||||
|
|
||||||
|
WORKFLOW_TEMPLATE = """name: Auto Update README
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
paths:
|
||||||
|
- '**.py'
|
||||||
|
- '**.js'
|
||||||
|
- '**.ts'
|
||||||
|
- '**.go'
|
||||||
|
- '**.rs'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'go.mod'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'requirements.txt'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
force:
|
||||||
|
description: 'Force README regeneration'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-readme:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.repository_owner != '{{ owner }}' || github.event_name == 'workflow_dispatch'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install auto-readme
|
||||||
|
run: |
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install auto-readme-cli
|
||||||
|
|
||||||
|
- name: Generate README
|
||||||
|
run: |
|
||||||
|
auto-readme generate --input . --output README.md --force
|
||||||
|
|
||||||
|
- name: Commit and push changes
|
||||||
|
if: github.event_name != 'workflow_dispatch' || github.event.inputs.force == 'true'
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add README.md
|
||||||
|
git diff --quiet README.md || git commit -m "docs: Auto-update README"
|
||||||
|
git push
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def can_generate(cls, project: Project) -> bool:
|
||||||
|
"""Check if GitHub Actions integration can be generated."""
|
||||||
|
return (
|
||||||
|
project.git_info is not None
|
||||||
|
and project.git_info.is_repo
|
||||||
|
and project.git_info.owner is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_workflow(cls, project: Project, output_path: Optional[Path] = None) -> str:
|
||||||
|
"""Generate the GitHub Actions workflow content."""
|
||||||
|
owner = project.git_info.owner if project.git_info else "owner"
|
||||||
|
workflow_content = cls.WORKFLOW_TEMPLATE.replace("{{ owner }}", owner)
|
||||||
|
return workflow_content
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def save_workflow(cls, project: Project, output_dir: Path) -> Path:
|
||||||
|
"""Save the GitHub Actions workflow to a file."""
|
||||||
|
workflow_content = cls.generate_workflow(project)
|
||||||
|
workflow_path = output_dir / ".github" / "workflows" / "readme-update.yml"
|
||||||
|
workflow_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
workflow_path.write_text(workflow_content)
|
||||||
|
return workflow_path
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubRepoDetector:
|
||||||
|
"""Detects GitHub repository information."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_github_repo(remote_url: Optional[str]) -> bool:
|
||||||
|
"""Check if the remote URL is a GitHub repository."""
|
||||||
|
if not remote_url:
|
||||||
|
return False
|
||||||
|
return "github.com" in remote_url.lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_repo_info(remote_url: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Extract owner and repo name from remote URL."""
|
||||||
|
if not remote_url:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
patterns = [
|
||||||
|
r"github\.com[:/]([^/]+)/([^/]+)\.git",
|
||||||
|
r"github\.com/([^/]+)/([^/]+)",
|
||||||
|
r"git@github\.com:([^/]+)/([^/]+)\.git",
|
||||||
|
r"git@github\.com:([^/]+)/([^/]+)",
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, remote_url)
|
||||||
|
if match:
|
||||||
|
owner, repo = match.groups()
|
||||||
|
repo = repo.replace(".git", "")
|
||||||
|
return owner, repo
|
||||||
|
|
||||||
|
return None, None
|
||||||
136
src/auto_readme/interactive/__init__.py
Normal file
136
src/auto_readme/interactive/__init__.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""Interactive wizard for customizing README content."""
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from ..models import Project
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectInfoWizard:
|
||||||
|
"""Wizard for collecting project information."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def run(project: Project) -> Project:
|
||||||
|
"""Run the project info wizard."""
|
||||||
|
if not project.config:
|
||||||
|
project.config.name = project.root_path.name
|
||||||
|
|
||||||
|
project.config.name = click.prompt(
|
||||||
|
"Project name",
|
||||||
|
default=project.config.name if project.config else project.root_path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
project.config.description = click.prompt(
|
||||||
|
"Project description",
|
||||||
|
default=project.config.description or "A project generated by auto-readme",
|
||||||
|
)
|
||||||
|
|
||||||
|
project.config.author = click.prompt(
|
||||||
|
"Author (optional)",
|
||||||
|
default=project.config.author or "",
|
||||||
|
show_default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
project.config.email = click.prompt(
|
||||||
|
"Email (optional)",
|
||||||
|
default=project.config.email or "",
|
||||||
|
show_default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
project.config.license = click.prompt(
|
||||||
|
"License (optional)",
|
||||||
|
default=project.config.license or "MIT",
|
||||||
|
show_default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
class SectionWizard:
|
||||||
|
"""Wizard for customizing README sections."""
|
||||||
|
|
||||||
|
SECTION_OPTIONS = [
|
||||||
|
("overview", "Project Overview"),
|
||||||
|
("installation", "Installation"),
|
||||||
|
("usage", "Usage"),
|
||||||
|
("features", "Features"),
|
||||||
|
("api", "API Reference"),
|
||||||
|
("contributing", "Contributing"),
|
||||||
|
("license", "License"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def run() -> dict:
|
||||||
|
"""Run the section wizard."""
|
||||||
|
click.echo("\n=== Section Configuration ===")
|
||||||
|
click.echo("Select which sections to include in your README:\n")
|
||||||
|
|
||||||
|
sections = {}
|
||||||
|
for section_key, section_name in SectionWizard.SECTION_OPTIONS:
|
||||||
|
include = click.confirm(f"Include {section_name}?", default=True)
|
||||||
|
sections[section_key] = include
|
||||||
|
|
||||||
|
click.echo("\nSection order (default order used). You can reorder by editing the config file.")
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
class ContentWizard:
|
||||||
|
"""Wizard for manual overrides of auto-detected values."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def run(project: Project) -> Project:
|
||||||
|
"""Run the content wizard."""
|
||||||
|
click.echo("\n=== Content Customization ===")
|
||||||
|
click.echo("You can override any auto-detected values below. Leave blank to keep auto-detected values.\n")
|
||||||
|
|
||||||
|
project.installation_steps = ContentWizard._edit_list(
|
||||||
|
"installation steps",
|
||||||
|
project.installation_steps or ["pip install -e ."],
|
||||||
|
)
|
||||||
|
|
||||||
|
project.usage_examples = ContentWizard._edit_list(
|
||||||
|
"usage examples",
|
||||||
|
project.usage_examples or ["# See the docs"],
|
||||||
|
)
|
||||||
|
|
||||||
|
project.features = ContentWizard._edit_list(
|
||||||
|
"features",
|
||||||
|
project.features or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _edit_list(prompt_name: str, current_list: list[str]) -> list[str]:
|
||||||
|
"""Edit a list of strings."""
|
||||||
|
click.echo(f"\nCurrent {prompt_name}:")
|
||||||
|
for i, item in enumerate(current_list, 1):
|
||||||
|
click.echo(f" {i}. {item}")
|
||||||
|
|
||||||
|
add_new = click.confirm(f"Add new {prompt_name}?", default=False)
|
||||||
|
if not add_new:
|
||||||
|
return current_list
|
||||||
|
|
||||||
|
new_list = current_list[:]
|
||||||
|
while True:
|
||||||
|
new_item = click.prompt(f" Enter {prompt_name} (or leave blank to finish)", default="", show_default=False)
|
||||||
|
if not new_item:
|
||||||
|
break
|
||||||
|
new_list.append(new_item)
|
||||||
|
|
||||||
|
return new_list if new_list else current_list
|
||||||
|
|
||||||
|
|
||||||
|
def run_wizard(project: Project) -> Project:
|
||||||
|
"""Run the complete interactive wizard."""
|
||||||
|
click.echo("=== Auto README Generator - Interactive Mode ===\n")
|
||||||
|
|
||||||
|
project = ProjectInfoWizard.run(project)
|
||||||
|
|
||||||
|
project.custom_sections["section_config"] = SectionWizard.run()
|
||||||
|
|
||||||
|
project = ContentWizard.run(project)
|
||||||
|
|
||||||
|
click.echo("\n=== Wizard Complete ===")
|
||||||
|
click.echo("Your README will be generated with the provided information.")
|
||||||
|
|
||||||
|
return project
|
||||||
185
src/auto_readme/models/__init__.py
Normal file
185
src/auto_readme/models/__init__.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""Data models for the Auto README Generator."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectType(Enum):
|
||||||
|
"""Enumeration of supported project types."""
|
||||||
|
|
||||||
|
PYTHON = "python"
|
||||||
|
JAVASCRIPT = "javascript"
|
||||||
|
TYPESCRIPT = "typescript"
|
||||||
|
GO = "go"
|
||||||
|
RUST = "rust"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class FileType(Enum):
|
||||||
|
"""Enumeration of file types."""
|
||||||
|
|
||||||
|
SOURCE = "source"
|
||||||
|
TEST = "test"
|
||||||
|
CONFIG = "config"
|
||||||
|
DOCUMENTATION = "documentation"
|
||||||
|
RESOURCE = "resource"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Dependency:
|
||||||
|
"""Represents a project dependency."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
version: Optional[str] = None
|
||||||
|
is_dev: bool = False
|
||||||
|
is_optional: bool = False
|
||||||
|
source_file: Optional[Path] = None
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.name)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if isinstance(other, Dependency):
|
||||||
|
return self.name == other.name
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportStatement:
|
||||||
|
"""Represents an import statement in source code."""
|
||||||
|
|
||||||
|
module: str
|
||||||
|
alias: Optional[str] = None
|
||||||
|
items: list[str] = field(default_factory=list)
|
||||||
|
line_number: int = 0
|
||||||
|
is_from: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Function:
|
||||||
|
"""Represents a function extracted from source code."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
docstring: Optional[str] = None
|
||||||
|
parameters: list[str] = field(default_factory=list)
|
||||||
|
return_type: Optional[str] = None
|
||||||
|
line_number: int = 0
|
||||||
|
file_path: Optional[Path] = None
|
||||||
|
visibility: str = "public"
|
||||||
|
|
||||||
|
def signature(self) -> str:
|
||||||
|
"""Generate a function signature string."""
|
||||||
|
params = ", ".join(self.parameters)
|
||||||
|
return f"{self.name}({params})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Class:
|
||||||
|
"""Represents a class extracted from source code."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
docstring: Optional[str] = None
|
||||||
|
base_classes: list[str] = field(default_factory=list)
|
||||||
|
methods: list[Function] = field(default_factory=list)
|
||||||
|
attributes: list[str] = field(default_factory=list)
|
||||||
|
line_number: int = 0
|
||||||
|
file_path: Optional[Path] = None
|
||||||
|
visibility: str = "public"
|
||||||
|
|
||||||
|
def public_methods(self) -> list[Function]:
|
||||||
|
"""Return only public methods."""
|
||||||
|
return [m for m in self.methods if m.visibility == "public"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SourceFile:
|
||||||
|
"""Represents a source file in the project."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
file_type: FileType
|
||||||
|
language: Optional[ProjectType] = None
|
||||||
|
functions: list[Function] = field(default_factory=list)
|
||||||
|
classes: list[Class] = field(default_factory=list)
|
||||||
|
imports: list[ImportStatement] = field(default_factory=list)
|
||||||
|
raw_content: Optional[str] = None
|
||||||
|
line_count: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extension(self) -> str:
|
||||||
|
"""Get the file extension."""
|
||||||
|
return self.path.suffix.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProjectConfig:
|
||||||
|
"""Represents project configuration metadata."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
version: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
author: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
license: Optional[str] = None
|
||||||
|
homepage: Optional[str] = None
|
||||||
|
repository: Optional[str] = None
|
||||||
|
python_requires: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GitInfo:
|
||||||
|
"""Represents git repository information."""
|
||||||
|
|
||||||
|
remote_url: Optional[str] = None
|
||||||
|
branch: Optional[str] = None
|
||||||
|
commit_sha: Optional[str] = None
|
||||||
|
is_repo: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Project:
|
||||||
|
"""Represents a complete project analysis result."""
|
||||||
|
|
||||||
|
root_path: Path
|
||||||
|
project_type: ProjectType
|
||||||
|
config: Optional[ProjectConfig] = None
|
||||||
|
git_info: Optional[GitInfo] = None
|
||||||
|
files: list[SourceFile] = field(default_factory=list)
|
||||||
|
dependencies: list[Dependency] = field(default_factory=list)
|
||||||
|
dev_dependencies: list[Dependency] = field(default_factory=list)
|
||||||
|
features: list[str] = field(default_factory=list)
|
||||||
|
installation_steps: list[str] = field(default_factory=list)
|
||||||
|
usage_examples: list[str] = field(default_factory=list)
|
||||||
|
generated_at: datetime = field(default_factory=datetime.now)
|
||||||
|
custom_sections: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def source_files(self) -> list[SourceFile]:
|
||||||
|
"""Return only source files (not tests or config)."""
|
||||||
|
return [f for f in self.files if f.file_type == FileType.SOURCE]
|
||||||
|
|
||||||
|
def test_files(self) -> list[SourceFile]:
|
||||||
|
"""Return only test files."""
|
||||||
|
return [f for f in self.files if f.file_type == FileType.TEST]
|
||||||
|
|
||||||
|
def all_functions(self) -> list[Function]:
|
||||||
|
"""Return all functions from all source files."""
|
||||||
|
return [f for source in self.source_files() for f in source.functions]
|
||||||
|
|
||||||
|
def all_classes(self) -> list[Class]:
|
||||||
|
"""Return all classes from all source files."""
|
||||||
|
return [c for source in self.source_files() for c in source.classes]
|
||||||
|
|
||||||
|
def total_line_count(self) -> int:
|
||||||
|
"""Calculate total line count of source files."""
|
||||||
|
return sum(f.line_count for f in self.files)
|
||||||
|
|
||||||
|
def language_count(self) -> dict[ProjectType, int]:
|
||||||
|
"""Count files by programming language."""
|
||||||
|
counts: dict[ProjectType, int] = {}
|
||||||
|
for f in self.files:
|
||||||
|
if f.language:
|
||||||
|
counts[f.language] = counts.get(f.language, 0) + 1
|
||||||
|
return counts
|
||||||
57
src/auto_readme/parsers/__init__.py
Normal file
57
src/auto_readme/parsers/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Dependency parsers for the Auto README Generator."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Protocol
|
||||||
|
|
||||||
|
from ..models import Dependency
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyParser(Protocol):
|
||||||
|
"""Protocol for dependency parsers."""
|
||||||
|
|
||||||
|
def can_parse(self, path: Path) -> bool: ...
|
||||||
|
def parse(self, path: Path) -> list[Dependency]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class BaseParser(ABC):
|
||||||
|
"""Abstract base class for dependency parsers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def can_parse(self, path: Path) -> bool:
|
||||||
|
"""Check if this parser can handle the given file."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse dependencies from the file."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _normalize_version(self, version: Optional[str]) -> Optional[str]:
|
||||||
|
"""Normalize version string."""
|
||||||
|
if version is None:
|
||||||
|
return None
|
||||||
|
version = version.strip()
|
||||||
|
if version.startswith("^") or version.startswith("~"):
|
||||||
|
version = version[1:]
|
||||||
|
if version.startswith(">="):
|
||||||
|
version = version[2:]
|
||||||
|
return version if version else None
|
||||||
|
|
||||||
|
|
||||||
|
# Imports placed here to avoid circular imports
|
||||||
|
from .python_parser import PythonDependencyParser # noqa: E402
|
||||||
|
from .javascript_parser import JavaScriptDependencyParser # noqa: E402
|
||||||
|
from .go_parser import GoDependencyParser # noqa: E402
|
||||||
|
from .rust_parser import RustDependencyParser # noqa: E402
|
||||||
|
from .parser_factory import DependencyParserFactory # noqa: E402
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DependencyParser",
|
||||||
|
"BaseParser",
|
||||||
|
"PythonDependencyParser",
|
||||||
|
"JavaScriptDependencyParser",
|
||||||
|
"GoDependencyParser",
|
||||||
|
"RustDependencyParser",
|
||||||
|
"DependencyParserFactory",
|
||||||
|
]
|
||||||
92
src/auto_readme/parsers/go_parser.py
Normal file
92
src/auto_readme/parsers/go_parser.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Go dependency parser for go.mod."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from . import BaseParser, Dependency
|
||||||
|
|
||||||
|
|
||||||
|
class GoDependencyParser(BaseParser):
|
||||||
|
"""Parser for Go go.mod files."""
|
||||||
|
|
||||||
|
def can_parse(self, path: Path) -> bool:
|
||||||
|
"""Check if the file is a go.mod."""
|
||||||
|
return path.name.lower() == "go.mod"
|
||||||
|
|
||||||
|
def parse(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse dependencies from go.mod."""
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
lines = content.splitlines()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
in_require_block = False
|
||||||
|
current_block_indent = 0
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if stripped.startswith("require ("):
|
||||||
|
in_require_block = True
|
||||||
|
current_block_indent = len(line) - len(line.lstrip())
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stripped.startswith(")"):
|
||||||
|
in_require_block = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_require_block or stripped.startswith("require "):
|
||||||
|
if not in_require_block:
|
||||||
|
if "require (" in line:
|
||||||
|
continue
|
||||||
|
match = self._parse_single_require(line)
|
||||||
|
if match:
|
||||||
|
dependencies.append(match)
|
||||||
|
else:
|
||||||
|
current_indent = len(line) - len(line.lstrip())
|
||||||
|
if current_indent < current_block_indent:
|
||||||
|
in_require_block = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stripped and not stripped.startswith("//"):
|
||||||
|
match = self._parse_require_block_line(line)
|
||||||
|
if match:
|
||||||
|
dependencies.append(match)
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def _parse_single_require(self, line: str) -> Optional[Dependency]:
|
||||||
|
"""Parse a single require statement."""
|
||||||
|
match = re.match(r"require\s+([^\s]+)\s+(.+)", line)
|
||||||
|
if match:
|
||||||
|
name = match.group(1)
|
||||||
|
version = match.group(2).strip()
|
||||||
|
return Dependency(
|
||||||
|
name=name,
|
||||||
|
version=self._normalize_version(version),
|
||||||
|
is_dev=False,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_require_block_line(self, line: str) -> Optional[Dependency]:
|
||||||
|
"""Parse a line within a require block."""
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("//"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
match = re.match(r"([^\s]+)\s+v?(.+)", stripped)
|
||||||
|
if match:
|
||||||
|
name = match.group(1)
|
||||||
|
version = match.group(2).strip()
|
||||||
|
return Dependency(
|
||||||
|
name=name,
|
||||||
|
version=self._normalize_version(version),
|
||||||
|
is_dev=False,
|
||||||
|
)
|
||||||
|
return None
|
||||||
56
src/auto_readme/parsers/javascript_parser.py
Normal file
56
src/auto_readme/parsers/javascript_parser.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""JavaScript dependency parser for package.json."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import BaseParser, Dependency
|
||||||
|
|
||||||
|
|
||||||
|
class JavaScriptDependencyParser(BaseParser):
|
||||||
|
"""Parser for JavaScript/TypeScript package.json files."""
|
||||||
|
|
||||||
|
def can_parse(self, path: Path) -> bool:
|
||||||
|
"""Check if the file is a package.json."""
|
||||||
|
return path.name.lower() == "package.json"
|
||||||
|
|
||||||
|
def parse(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse dependencies from package.json."""
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
self._parse_dependencies_section(data, "dependencies", dependencies, path)
|
||||||
|
self._parse_dependencies_section(data, "devDependencies", dependencies, path, is_dev=True)
|
||||||
|
self._parse_dependencies_section(data, "optionalDependencies", dependencies, path, is_optional=True)
|
||||||
|
self._parse_dependencies_section(data, "peerDependencies", dependencies, path)
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def _parse_dependencies_section(
|
||||||
|
self,
|
||||||
|
data: dict,
|
||||||
|
section: str,
|
||||||
|
dependencies: list,
|
||||||
|
source_file: Path,
|
||||||
|
is_dev: bool = False,
|
||||||
|
is_optional: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Parse a specific dependencies section."""
|
||||||
|
if section in data:
|
||||||
|
for name, version in data[section].items():
|
||||||
|
if isinstance(version, str):
|
||||||
|
dependencies.append(
|
||||||
|
Dependency(
|
||||||
|
name=name,
|
||||||
|
version=self._normalize_version(version),
|
||||||
|
is_dev=is_dev,
|
||||||
|
is_optional=is_optional,
|
||||||
|
source_file=source_file,
|
||||||
|
)
|
||||||
|
)
|
||||||
39
src/auto_readme/parsers/parser_factory.py
Normal file
39
src/auto_readme/parsers/parser_factory.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Dependency parser factory for routing to correct parser."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from . import DependencyParser
|
||||||
|
from .python_parser import PythonDependencyParser
|
||||||
|
from .javascript_parser import JavaScriptDependencyParser
|
||||||
|
from .go_parser import GoDependencyParser
|
||||||
|
from .rust_parser import RustDependencyParser
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyParserFactory:
|
||||||
|
"""Factory for creating appropriate dependency parsers."""
|
||||||
|
|
||||||
|
PARSERS = [
|
||||||
|
PythonDependencyParser(),
|
||||||
|
JavaScriptDependencyParser(),
|
||||||
|
GoDependencyParser(),
|
||||||
|
RustDependencyParser(),
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_parser(cls, path: Path) -> Optional[DependencyParser]:
|
||||||
|
"""Get the appropriate parser for a file."""
|
||||||
|
for parser in cls.PARSERS:
|
||||||
|
if parser.can_parse(path):
|
||||||
|
return parser
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all_parsers(cls) -> list[DependencyParser]:
|
||||||
|
"""Get all available parsers."""
|
||||||
|
return cls.PARSERS.copy()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def can_parse(cls, path: Path) -> bool:
|
||||||
|
"""Check if any parser can handle the file."""
|
||||||
|
return cls.get_parser(path) is not None
|
||||||
223
src/auto_readme/parsers/python_parser.py
Normal file
223
src/auto_readme/parsers/python_parser.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""Python dependency parser for requirements.txt and pyproject.toml."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from . import BaseParser, Dependency
|
||||||
|
|
||||||
|
|
||||||
|
class PythonDependencyParser(BaseParser):
|
||||||
|
"""Parser for Python dependency files."""
|
||||||
|
|
||||||
|
SUPPORTED_FILES = ["requirements.txt", "pyproject.toml", "setup.py", "setup.cfg", "Pipfile"]
|
||||||
|
|
||||||
|
def can_parse(self, path: Path) -> bool:
|
||||||
|
"""Check if the file is a Python dependency file."""
|
||||||
|
return path.name.lower() in self.SUPPORTED_FILES
|
||||||
|
|
||||||
|
def parse(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse dependencies from Python files."""
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
if path.name.lower() == "requirements.txt":
|
||||||
|
return self._parse_requirements_txt(path)
|
||||||
|
elif path.name.lower() in ["pyproject.toml", "setup.cfg"]:
|
||||||
|
return self._parse_toml(path)
|
||||||
|
elif path.name.lower() == "pipfile":
|
||||||
|
return self._parse_pipfile(path)
|
||||||
|
elif path.name.lower() in ["setup.py", "setup.cfg"]:
|
||||||
|
return self._parse_setup_file(path)
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _parse_requirements_txt(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse requirements.txt file."""
|
||||||
|
dependencies = []
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
for line in content.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_dev = any(x in line for x in ["-e", "--dev", "[dev]"])
|
||||||
|
version = None
|
||||||
|
name = line
|
||||||
|
|
||||||
|
if ">=" in line:
|
||||||
|
parts = line.split(">=")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = ">=" + parts[1].strip()
|
||||||
|
elif "<=" in line:
|
||||||
|
parts = line.split("<=")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = "<=" + parts[1].strip()
|
||||||
|
elif ">" in line:
|
||||||
|
parts = line.split(">")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = ">" + parts[1].strip()
|
||||||
|
elif "<" in line:
|
||||||
|
parts = line.split("<")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = "<" + parts[1].strip()
|
||||||
|
elif "==" in line:
|
||||||
|
parts = line.split("==")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = parts[1].strip()
|
||||||
|
elif "~=" in line:
|
||||||
|
parts = line.split("~=")
|
||||||
|
name = parts[0].strip()
|
||||||
|
version = "~=" + parts[1].strip()
|
||||||
|
|
||||||
|
name = self._clean_package_name(name)
|
||||||
|
if name:
|
||||||
|
dependencies.append(
|
||||||
|
Dependency(
|
||||||
|
name=name,
|
||||||
|
version=version,
|
||||||
|
is_dev=is_dev,
|
||||||
|
source_file=path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def _parse_toml(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse pyproject.toml or setup.cfg file."""
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if path.name.endswith(".toml"):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
else:
|
||||||
|
import configparser
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(path)
|
||||||
|
data = {s: dict(config[s]) for s in config.sections()}
|
||||||
|
except Exception:
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
if "project" in data:
|
||||||
|
project_data = data["project"]
|
||||||
|
if "dependencies" in project_data:
|
||||||
|
deps = project_data["dependencies"]
|
||||||
|
if isinstance(deps, list):
|
||||||
|
for dep in deps:
|
||||||
|
parsed = self._parse_pep508_string(dep)
|
||||||
|
if parsed:
|
||||||
|
dependencies.append(parsed)
|
||||||
|
elif isinstance(deps, str):
|
||||||
|
for dep in deps.split("\n"):
|
||||||
|
parsed = self._parse_pep508_string(dep.strip())
|
||||||
|
if parsed:
|
||||||
|
dependencies.append(parsed)
|
||||||
|
|
||||||
|
if "optional-dependencies" in project_data:
|
||||||
|
for group, optional_deps in project_data["optional-dependencies"].items():
|
||||||
|
if isinstance(optional_deps, list):
|
||||||
|
for dep in optional_deps:
|
||||||
|
parsed = self._parse_pep508_string(dep)
|
||||||
|
if parsed:
|
||||||
|
parsed.is_dev = True
|
||||||
|
parsed.is_optional = True
|
||||||
|
dependencies.append(parsed)
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def _parse_pep508_string(self, dep_string: str) -> Optional[Dependency]:
|
||||||
|
"""Parse a PEP 508 dependency specification string."""
|
||||||
|
dep_string = dep_string.strip()
|
||||||
|
if not dep_string or dep_string.startswith("#"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_dev = False
|
||||||
|
is_optional = False
|
||||||
|
|
||||||
|
if "[dev]" in dep_string:
|
||||||
|
is_dev = True
|
||||||
|
dep_string = dep_string.replace("[dev]", "")
|
||||||
|
elif "[tests]" in dep_string:
|
||||||
|
is_dev = True
|
||||||
|
dep_string = dep_string.replace("[tests]", "")
|
||||||
|
|
||||||
|
match = re.match(r"([a-zA-Z0-9_-]+)(.*)", dep_string)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = match.group(1).strip()
|
||||||
|
version_part = match.group(2).strip()
|
||||||
|
|
||||||
|
version = self._normalize_version(version_part)
|
||||||
|
|
||||||
|
return Dependency(
|
||||||
|
name=name,
|
||||||
|
version=version,
|
||||||
|
is_dev=is_dev,
|
||||||
|
is_optional=is_optional,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_pipfile(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse Pipfile."""
|
||||||
|
dependencies = []
|
||||||
|
try:
|
||||||
|
import tomli
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomli.load(f)
|
||||||
|
except Exception:
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
for section in ["packages", "dev-packages"]:
|
||||||
|
if section in data:
|
||||||
|
for name, value in data[section].items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
version = value
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
version = value.get("version", None)
|
||||||
|
else:
|
||||||
|
version = None
|
||||||
|
|
||||||
|
dependencies.append(
|
||||||
|
Dependency(
|
||||||
|
name=name,
|
||||||
|
version=self._normalize_version(version),
|
||||||
|
is_dev="dev" in section,
|
||||||
|
source_file=path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def _parse_setup_file(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse setup.py or setup.cfg file."""
|
||||||
|
dependencies = []
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
install_requires_match = re.search(
|
||||||
|
r"install_requires\s*=\s*\[([^\]]+)\]", content, re.DOTALL
|
||||||
|
)
|
||||||
|
if install_requires_match:
|
||||||
|
deps_str = install_requires_match.group(1)
|
||||||
|
for dep in deps_str.split(","):
|
||||||
|
dep = dep.strip().strip("'\"")
|
||||||
|
if dep:
|
||||||
|
parsed = self._parse_pep508_string(dep)
|
||||||
|
if parsed:
|
||||||
|
dependencies.append(parsed)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def _clean_package_name(self, name: str) -> str:
|
||||||
|
"""Clean a package name."""
|
||||||
|
name = name.strip()
|
||||||
|
name = re.sub(r"^\.+", "", name)
|
||||||
|
name = re.sub(r"[<>=!~].*$", "", name)
|
||||||
|
name = name.strip()
|
||||||
|
return name if name else None
|
||||||
64
src/auto_readme/parsers/rust_parser.py
Normal file
64
src/auto_readme/parsers/rust_parser.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Rust dependency parser for Cargo.toml."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from . import BaseParser, Dependency
|
||||||
|
|
||||||
|
|
||||||
|
class RustDependencyParser(BaseParser):
|
||||||
|
"""Parser for Rust Cargo.toml files."""
|
||||||
|
|
||||||
|
def can_parse(self, path: Path) -> bool:
|
||||||
|
"""Check if the file is a Cargo.toml."""
|
||||||
|
return path.name.lower() == "cargo.toml"
|
||||||
|
|
||||||
|
def parse(self, path: Path) -> list[Dependency]:
|
||||||
|
"""Parse dependencies from Cargo.toml."""
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
except (tomllib.TOMLDecodeError, OSError):
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
self._parse_dependencies_section(data, "dependencies", dependencies, path)
|
||||||
|
self._parse_dependencies_section(data, "dev-dependencies", dependencies, path, is_dev=True)
|
||||||
|
self._parse_dependencies_section(data, "build-dependencies", dependencies, path, is_dev=True)
|
||||||
|
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def _parse_dependencies_section(
|
||||||
|
self,
|
||||||
|
data: dict,
|
||||||
|
section: str,
|
||||||
|
dependencies: list,
|
||||||
|
source_file: Path,
|
||||||
|
is_dev: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Parse a specific dependencies section."""
|
||||||
|
if section in data:
|
||||||
|
section_data = data[section]
|
||||||
|
if isinstance(section_data, dict):
|
||||||
|
for name, value in section_data.items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
version = value
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
version = value.get("version", None)
|
||||||
|
features = value.get("features", [])
|
||||||
|
if features:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
version = None
|
||||||
|
|
||||||
|
dependencies.append(
|
||||||
|
Dependency(
|
||||||
|
name=name,
|
||||||
|
version=self._normalize_version(version),
|
||||||
|
is_dev=is_dev,
|
||||||
|
source_file=source_file,
|
||||||
|
)
|
||||||
|
)
|
||||||
312
src/auto_readme/templates/__init__.py
Normal file
312
src/auto_readme/templates/__init__.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
"""Template system for README generation."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from jinja2 import Environment, FileSystemLoader, BaseLoader
|
||||||
|
|
||||||
|
from ..models import Project
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateRenderer:
|
||||||
|
"""Renders README templates with project context."""
|
||||||
|
|
||||||
|
BUILTIN_TEMPLATES = {
|
||||||
|
"base": """# {{ title }}
|
||||||
|
|
||||||
|
{% if description %}
|
||||||
|
{{ description }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if badges %}
|
||||||
|
{{ badges }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if table_of_contents %}
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
{% if installation_steps %}- [Installation](#installation){% endif %}
|
||||||
|
{% if usage_examples %}- [Usage](#usage){% endif %}
|
||||||
|
{% if features %}- [Features](#features){% endif %}
|
||||||
|
{% if api_functions %}- [API Reference](#api-reference){% endif %}
|
||||||
|
{% if contributing_guidelines %}- [Contributing](#contributing){% endif %}
|
||||||
|
{% if license_info %}- [License](#license){% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
{{ project_overview }}
|
||||||
|
|
||||||
|
{% if tech_stack %}
|
||||||
|
## Supported Languages
|
||||||
|
|
||||||
|
This project uses:
|
||||||
|
{% for tech in tech_stack %}
|
||||||
|
- **{{ tech }}**
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if installation_steps %}
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
{{ installation_steps|join('\\n') }}
|
||||||
|
```
|
||||||
|
|
||||||
|
{% if dependencies %}
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
{% for dep in dependencies %}
|
||||||
|
- `{{ dep.name }}`{% if dep.version %} v{{ dep.version }}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if usage_examples %}
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
{{ usage_examples[0] }}
|
||||||
|
```
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if features %}
|
||||||
|
## Features
|
||||||
|
|
||||||
|
{% for feature in features %}
|
||||||
|
- {{ feature }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if api_functions %}
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
{% for func in api_functions %}
|
||||||
|
### `{{ func.name }}`
|
||||||
|
|
||||||
|
{% if func.docstring %}
|
||||||
|
{{ func.docstring }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
**Parameters:** `{{ func.parameters|join(', ') }}`
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if contributing_guidelines %}
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
{{ contributing_guidelines }}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if license_info %}
|
||||||
|
## License
|
||||||
|
|
||||||
|
{{ license_info }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated by Auto README Generator on {{ generated_at }}*
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, custom_template_dir: Optional[Path] = None):
|
||||||
|
"""Initialize the template renderer."""
|
||||||
|
self.env = Environment(
|
||||||
|
loader=FileSystemLoader(str(custom_template_dir)) if custom_template_dir else BaseLoader(),
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
)
|
||||||
|
self.custom_template_dir = custom_template_dir
|
||||||
|
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
template_name: str = "base",
|
||||||
|
**extra_context,
|
||||||
|
) -> str:
|
||||||
|
"""Render a README template with project context."""
|
||||||
|
context = self._build_context(project, **extra_context)
|
||||||
|
|
||||||
|
if template_name in self.BUILTIN_TEMPLATES:
|
||||||
|
template = self.env.from_string(self.BUILTIN_TEMPLATES[template_name])
|
||||||
|
elif self.custom_template_dir:
|
||||||
|
try:
|
||||||
|
template = self.env.get_template(template_name)
|
||||||
|
except Exception:
|
||||||
|
template = self.env.from_string(self.BUILTIN_TEMPLATES["base"])
|
||||||
|
else:
|
||||||
|
template = self.env.from_string(self.BUILTIN_TEMPLATES["base"])
|
||||||
|
|
||||||
|
return template.render(**context)
|
||||||
|
|
||||||
|
def _build_context(self, project: Project, **extra_context) -> dict:
|
||||||
|
"""Build the template context from project data."""
|
||||||
|
context = {
|
||||||
|
"title": project.config.name if project.config else project.root_path.name,
|
||||||
|
"description": project.config.description if project.config else None,
|
||||||
|
"project_overview": self._generate_overview(project),
|
||||||
|
"badges": None,
|
||||||
|
"tech_stack": self._get_tech_stack(project),
|
||||||
|
"installation_steps": project.installation_steps or self._generate_installation_steps(project),
|
||||||
|
"usage_examples": project.usage_examples or self._generate_usage_examples(project),
|
||||||
|
"features": project.features or self._detect_features(project),
|
||||||
|
"api_functions": project.all_functions()[:20],
|
||||||
|
"contributing_guidelines": self._generate_contributing_guidelines(project),
|
||||||
|
"license_info": project.config.license if project.config else None,
|
||||||
|
"generated_at": project.generated_at.strftime("%Y-%m-%d"),
|
||||||
|
"table_of_contents": True,
|
||||||
|
"dependencies": project.dependencies,
|
||||||
|
}
|
||||||
|
|
||||||
|
context.update(extra_context)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def _generate_overview(self, project: Project) -> str:
|
||||||
|
"""Generate a project overview."""
|
||||||
|
lines = [
|
||||||
|
f"A project of type **{project.project_type.value}** located at `{project.root_path}`.",
|
||||||
|
]
|
||||||
|
lines.append(f"Contains {len(project.files)} files.")
|
||||||
|
|
||||||
|
if project.config and project.config.description:
|
||||||
|
lines.insert(0, project.config.description)
|
||||||
|
|
||||||
|
return " ".join(lines)
|
||||||
|
|
||||||
|
def _get_tech_stack(self, project: Project) -> list[str]:
|
||||||
|
"""Extract technology stack from dependencies."""
|
||||||
|
tech = [project.project_type.value.title()]
|
||||||
|
|
||||||
|
dep_names = {dep.name.lower() for dep in project.dependencies}
|
||||||
|
|
||||||
|
framework_map = {
|
||||||
|
"fastapi": "FastAPI",
|
||||||
|
"flask": "Flask",
|
||||||
|
"django": "Django",
|
||||||
|
"click": "Click",
|
||||||
|
"express": "Express.js",
|
||||||
|
"react": "React",
|
||||||
|
"vue": "Vue.js",
|
||||||
|
"angular": "Angular",
|
||||||
|
"gin": "Gin",
|
||||||
|
"echo": "Echo",
|
||||||
|
"actix-web": "Actix Web",
|
||||||
|
"rocket": "Rocket",
|
||||||
|
}
|
||||||
|
|
||||||
|
for dep, framework in framework_map.items():
|
||||||
|
if dep in dep_names:
|
||||||
|
tech.append(framework)
|
||||||
|
|
||||||
|
return list(dict.fromkeys(tech))
|
||||||
|
|
||||||
|
def _generate_installation_steps(self, project: Project) -> list[str]:
|
||||||
|
"""Generate installation steps based on project type."""
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
if project.project_type.value == "python":
|
||||||
|
steps = [
|
||||||
|
"pip install -r requirements.txt",
|
||||||
|
"pip install -e .",
|
||||||
|
]
|
||||||
|
elif project.project_type.value in ("javascript", "typescript"):
|
||||||
|
steps = ["npm install", "npm run build"]
|
||||||
|
elif project.project_type.value == "go":
|
||||||
|
steps = ["go mod download", "go build"]
|
||||||
|
elif project.project_type.value == "rust":
|
||||||
|
steps = ["cargo build --release"]
|
||||||
|
|
||||||
|
if project.git_info and project.git_info.is_repo:
|
||||||
|
steps.insert(0, f"git clone {project.git_info.remote_url or 'https://github.com/user/repo.git'}")
|
||||||
|
|
||||||
|
return steps
|
||||||
|
|
||||||
|
def _generate_usage_examples(self, project: Project) -> list[str]:
|
||||||
|
"""Generate usage examples based on project type."""
|
||||||
|
if project.project_type.value == "python":
|
||||||
|
return ["from project_name import main", "main()"]
|
||||||
|
elif project.project_type.value in ("javascript", "typescript"):
|
||||||
|
return ["import { main } from 'project-name'", "main()"]
|
||||||
|
elif project.project_type.value == "go":
|
||||||
|
return ["go run main.go"]
|
||||||
|
elif project.project_type.value == "rust":
|
||||||
|
return ["cargo run --release"]
|
||||||
|
return ["# Check the docs for usage information"]
|
||||||
|
|
||||||
|
def _detect_features(self, project: Project) -> list[str]:
|
||||||
|
"""Detect project features from structure."""
|
||||||
|
features = []
|
||||||
|
|
||||||
|
if any(f.file_type.name == "TEST" for f in project.files):
|
||||||
|
features.append("Test suite included")
|
||||||
|
|
||||||
|
if project.git_info and project.git_info.is_repo:
|
||||||
|
features.append("Git repository")
|
||||||
|
|
||||||
|
if project.dependencies:
|
||||||
|
features.append(f"Uses {len(project.dependencies)} dependencies")
|
||||||
|
|
||||||
|
if project.all_classes():
|
||||||
|
features.append(f"Contains {len(project.all_classes())} classes")
|
||||||
|
|
||||||
|
if project.all_functions():
|
||||||
|
features.append(f"Contains {len(project.all_functions())} functions")
|
||||||
|
|
||||||
|
return features if features else ["Auto-generated documentation"]
|
||||||
|
|
||||||
|
def _generate_contributing_guidelines(self, project: Project) -> str:
|
||||||
|
"""Generate contributing guidelines."""
|
||||||
|
guidelines = [
|
||||||
|
"1. Fork the repository",
|
||||||
|
"2. Create a feature branch (`git checkout -b feature/amazing-feature`)",
|
||||||
|
"3. Commit your changes (`git commit -m 'Add some amazing feature'`)",
|
||||||
|
"4. Push to the branch (`git push origin feature/amazing-feature`)",
|
||||||
|
"5. Open a Pull Request",
|
||||||
|
]
|
||||||
|
|
||||||
|
if project.project_type.value == "python":
|
||||||
|
guidelines.extend([
|
||||||
|
"",
|
||||||
|
"For Python development:",
|
||||||
|
"- Run tests with `pytest`",
|
||||||
|
"- Format code with `black` and `isort`",
|
||||||
|
"- Check types with `mypy`",
|
||||||
|
])
|
||||||
|
elif project.project_type.value in ("javascript", "typescript"):
|
||||||
|
guidelines.extend([
|
||||||
|
"",
|
||||||
|
"For JavaScript/TypeScript development:",
|
||||||
|
"- Run tests with `npm test`",
|
||||||
|
"- Format code with `npm run format`",
|
||||||
|
"- Lint with `npm run lint`",
|
||||||
|
])
|
||||||
|
|
||||||
|
return "\n".join(guidelines)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateManager:
|
||||||
|
"""Manages template operations."""
|
||||||
|
|
||||||
|
def __init__(self, template_dir: Optional[Path] = None):
|
||||||
|
"""Initialize the template manager."""
|
||||||
|
self.template_dir = template_dir
|
||||||
|
self.renderer = TemplateRenderer(custom_template_dir=template_dir)
|
||||||
|
|
||||||
|
def list_templates(self) -> list[str]:
|
||||||
|
"""List available templates."""
|
||||||
|
return list(self.renderer.BUILTIN_TEMPLATES.keys())
|
||||||
|
|
||||||
|
def get_template_path(self, name: str) -> Optional[Path]:
|
||||||
|
"""Get the path to a template."""
|
||||||
|
if name in self.renderer.BUILTIN_TEMPLATES:
|
||||||
|
return None
|
||||||
|
if self.template_dir:
|
||||||
|
return self.template_dir / name
|
||||||
|
return None
|
||||||
14
src/auto_readme/utils/__init__.py
Normal file
14
src/auto_readme/utils/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Utility modules for the Auto README Generator."""
|
||||||
|
|
||||||
|
from .file_scanner import FileScanner, scan_project
|
||||||
|
from .path_utils import PathUtils, normalize_path
|
||||||
|
from .git_utils import GitUtils, get_git_info
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FileScanner",
|
||||||
|
"scan_project",
|
||||||
|
"PathUtils",
|
||||||
|
"normalize_path",
|
||||||
|
"GitUtils",
|
||||||
|
"get_git_info",
|
||||||
|
]
|
||||||
132
src/auto_readme/utils/file_scanner.py
Normal file
132
src/auto_readme/utils/file_scanner.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""File scanning utilities for the Auto README Generator."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from ..models import ProjectType, FileType, SourceFile
|
||||||
|
from .path_utils import PathUtils
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanResult:
|
||||||
|
"""Result of a file scan operation."""
|
||||||
|
|
||||||
|
files: list[SourceFile]
|
||||||
|
project_type: ProjectType
|
||||||
|
root_path: Path
|
||||||
|
error_messages: list[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileScanner:
|
||||||
|
"""Scanner for discovering and categorizing project files."""
|
||||||
|
|
||||||
|
PROJECT_MARKERS = {
|
||||||
|
ProjectType.PYTHON: ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"],
|
||||||
|
ProjectType.JAVASCRIPT: ["package.json"],
|
||||||
|
ProjectType.TYPESCRIPT: ["package.json", "tsconfig.json"],
|
||||||
|
ProjectType.GO: ["go.mod"],
|
||||||
|
ProjectType.RUST: ["Cargo.toml"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, root_path: Path, exclude_hidden: bool = True):
|
||||||
|
self.root_path = PathUtils.normalize_path(root_path)
|
||||||
|
self.exclude_hidden = exclude_hidden
|
||||||
|
self.errors: list[str] = []
|
||||||
|
|
||||||
|
def scan(self) -> list[Path]:
|
||||||
|
"""Scan the directory and return all relevant file paths."""
|
||||||
|
files = []
|
||||||
|
for path in self.root_path.rglob("*"):
|
||||||
|
if path.is_file():
|
||||||
|
if self.exclude_hidden and PathUtils.is_hidden(path):
|
||||||
|
continue
|
||||||
|
if PathUtils.is_ignored_file(path):
|
||||||
|
continue
|
||||||
|
files.append(path)
|
||||||
|
return sorted(files)
|
||||||
|
|
||||||
|
def categorize_file(self, file_path: Path) -> FileType:
|
||||||
|
"""Determine the type of a file."""
|
||||||
|
if PathUtils.is_test_file(file_path):
|
||||||
|
return FileType.TEST
|
||||||
|
elif PathUtils.is_config_file(file_path):
|
||||||
|
return FileType.CONFIG
|
||||||
|
elif PathUtils.is_documentation_file(file_path):
|
||||||
|
return FileType.DOCUMENTATION
|
||||||
|
elif PathUtils.is_source_file(file_path):
|
||||||
|
return FileType.SOURCE
|
||||||
|
return FileType.UNKNOWN
|
||||||
|
|
||||||
|
def detect_language(self, file_path: Path) -> Optional[ProjectType]:
|
||||||
|
"""Detect the programming language of a file based on extension."""
|
||||||
|
ext = file_path.suffix.lower()
|
||||||
|
|
||||||
|
language_map = {
|
||||||
|
".py": ProjectType.PYTHON,
|
||||||
|
".js": ProjectType.JAVASCRIPT,
|
||||||
|
".ts": ProjectType.TYPESCRIPT,
|
||||||
|
".jsx": ProjectType.JAVASCRIPT,
|
||||||
|
".tsx": ProjectType.TYPESCRIPT,
|
||||||
|
".go": ProjectType.GO,
|
||||||
|
".rs": ProjectType.RUST,
|
||||||
|
".java": ProjectType.UNKNOWN,
|
||||||
|
".c": ProjectType.UNKNOWN,
|
||||||
|
".cpp": ProjectType.UNKNOWN,
|
||||||
|
".rb": ProjectType.UNKNOWN,
|
||||||
|
".php": ProjectType.UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
return language_map.get(ext)
|
||||||
|
|
||||||
|
def detect_project_type(self) -> ProjectType:
|
||||||
|
"""Detect the project type based on marker files."""
|
||||||
|
for project_type, markers in self.PROJECT_MARKERS.items():
|
||||||
|
for marker in markers:
|
||||||
|
if (self.root_path / marker).exists():
|
||||||
|
return project_type
|
||||||
|
return ProjectType.UNKNOWN
|
||||||
|
|
||||||
|
def create_source_file(self, file_path: Path) -> Optional[SourceFile]:
|
||||||
|
"""Create a SourceFile object from a path."""
|
||||||
|
try:
|
||||||
|
file_type = self.categorize_file(file_path)
|
||||||
|
language = self.detect_language(file_path)
|
||||||
|
relative_path = PathUtils.get_relative_path(file_path, self.root_path)
|
||||||
|
line_count = PathUtils.count_lines(file_path)
|
||||||
|
|
||||||
|
return SourceFile(
|
||||||
|
path=relative_path,
|
||||||
|
file_type=file_type,
|
||||||
|
language=language,
|
||||||
|
line_count=line_count,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.errors.append(f"Error processing {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def scan_and_create(self) -> list[SourceFile]:
|
||||||
|
"""Scan files and create SourceFile objects."""
|
||||||
|
source_files = []
|
||||||
|
file_paths = self.scan()
|
||||||
|
|
||||||
|
for file_path in file_paths:
|
||||||
|
source_file = self.create_source_file(file_path)
|
||||||
|
if source_file:
|
||||||
|
source_files.append(source_file)
|
||||||
|
|
||||||
|
return source_files
|
||||||
|
|
||||||
|
|
||||||
|
def scan_project(root_path: Path) -> ScanResult:
|
||||||
|
"""Scan a project and return all discovered files and project type."""
|
||||||
|
scanner = FileScanner(root_path)
|
||||||
|
files = scanner.scan_and_create()
|
||||||
|
project_type = scanner.detect_project_type()
|
||||||
|
|
||||||
|
return ScanResult(
|
||||||
|
files=files,
|
||||||
|
project_type=project_type,
|
||||||
|
root_path=scanner.root_path,
|
||||||
|
error_messages=scanner.errors,
|
||||||
|
)
|
||||||
186
src/auto_readme/utils/git_utils.py
Normal file
186
src/auto_readme/utils/git_utils.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""Git utilities for the Auto README Generator."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..models import GitInfo
|
||||||
|
|
||||||
|
|
||||||
|
class GitUtils:
|
||||||
|
"""Utility class for git operations."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_git_repo(cls, path: Path) -> bool:
|
||||||
|
"""Check if the path is a git repository."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--git-dir"],
|
||||||
|
cwd=path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_remote_url(cls, path: Path) -> Optional[str]:
|
||||||
|
"""Get the remote URL for the origin remote."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "remote", "get-url", "origin"],
|
||||||
|
cwd=path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_current_branch(cls, path: Path) -> Optional[str]:
|
||||||
|
"""Get the current branch name."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_commit_sha(cls, path: Path, short: bool = True) -> Optional[str]:
|
||||||
|
"""Get the current commit SHA."""
|
||||||
|
try:
|
||||||
|
flag = "--short" if short else ""
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", flag, "HEAD"],
|
||||||
|
cwd=path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_repo_info(cls, remote_url: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Extract owner and repo name from remote URL."""
|
||||||
|
if not remote_url:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
patterns = [
|
||||||
|
r"github\.com[:/]([^/]+)/([^/]+)\.git",
|
||||||
|
r"github\.com/([^/]+)/([^/]+)",
|
||||||
|
r"git@github\.com:([^/]+)/([^/]+)\.git",
|
||||||
|
r"git@github\.com:([^/]+)/([^/]+)",
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, remote_url)
|
||||||
|
if match:
|
||||||
|
owner, repo = match.groups()
|
||||||
|
repo = repo.replace(".git", "")
|
||||||
|
return owner, repo
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_git_info(cls, path: Path) -> GitInfo:
|
||||||
|
"""Get complete git information for a repository."""
|
||||||
|
is_repo = cls.is_git_repo(path)
|
||||||
|
if not is_repo:
|
||||||
|
return GitInfo()
|
||||||
|
|
||||||
|
remote_url = cls.get_remote_url(path)
|
||||||
|
branch = cls.get_current_branch(path)
|
||||||
|
commit_sha = cls.get_commit_sha(path)
|
||||||
|
|
||||||
|
owner, repo_name = cls.get_repo_info(remote_url)
|
||||||
|
|
||||||
|
return GitInfo(
|
||||||
|
remote_url=remote_url,
|
||||||
|
branch=branch,
|
||||||
|
commit_sha=commit_sha,
|
||||||
|
is_repo=True,
|
||||||
|
repo_name=repo_name,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_last_commit_message(cls, path: Path) -> Optional[str]:
|
||||||
|
"""Get the last commit message."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "log", "-1", "--format=%s"],
|
||||||
|
cwd=path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_commit_count(cls, path: Path) -> Optional[int]:
|
||||||
|
"""Get the total number of commits."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-list", "--count", "HEAD"],
|
||||||
|
cwd=path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return int(result.stdout.strip())
|
||||||
|
except (subprocess.SubprocessError, FileNotFoundError, ValueError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_contributors(cls, path: Path) -> list[str]:
|
||||||
|
"""Get list of contributors."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "log", "--format=%an", "--reverse"],
|
||||||
|
cwd=path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
contributors = []
|
||||||
|
seen = set()
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if line and line not in seen:
|
||||||
|
seen.add(line)
|
||||||
|
contributors.append(line)
|
||||||
|
return contributors
|
||||||
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_info(path: Path) -> GitInfo:
|
||||||
|
"""Get complete git information for a repository."""
|
||||||
|
return GitUtils.get_git_info(path)
|
||||||
190
src/auto_readme/utils/path_utils.py
Normal file
190
src/auto_readme/utils/path_utils.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""Path utilities for the Auto README Generator."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class PathUtils:
|
||||||
|
"""Utility class for path operations."""
|
||||||
|
|
||||||
|
IGNORED_DIRS = {
|
||||||
|
"__pycache__",
|
||||||
|
".git",
|
||||||
|
".svn",
|
||||||
|
".hg",
|
||||||
|
"__MACOSX",
|
||||||
|
".DS_Store",
|
||||||
|
"node_modules",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
"ENV",
|
||||||
|
"env",
|
||||||
|
".tox",
|
||||||
|
".nox",
|
||||||
|
"build",
|
||||||
|
"dist",
|
||||||
|
"target",
|
||||||
|
".idea",
|
||||||
|
".vscode",
|
||||||
|
}
|
||||||
|
|
||||||
|
IGNORED_FILES = {
|
||||||
|
".DS_Store",
|
||||||
|
".gitignore",
|
||||||
|
".gitattributes",
|
||||||
|
".gitmodules",
|
||||||
|
}
|
||||||
|
|
||||||
|
SOURCE_EXTENSIONS = {
|
||||||
|
".py",
|
||||||
|
".js",
|
||||||
|
".ts",
|
||||||
|
".jsx",
|
||||||
|
".tsx",
|
||||||
|
".go",
|
||||||
|
".rs",
|
||||||
|
".java",
|
||||||
|
".c",
|
||||||
|
".cpp",
|
||||||
|
".h",
|
||||||
|
".hpp",
|
||||||
|
".rb",
|
||||||
|
".php",
|
||||||
|
".swift",
|
||||||
|
".kt",
|
||||||
|
".scala",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG_EXTENSIONS = {".json", ".yaml", ".yml", ".toml", ".cfg", ".ini", ".conf"}
|
||||||
|
|
||||||
|
DOCUMENTATION_EXTENSIONS = {".md", ".rst", ".txt", ".adoc"}
|
||||||
|
|
||||||
|
TEST_PATTERNS = [
|
||||||
|
re.compile(r"^test_"),
|
||||||
|
re.compile(r"_test\.py$"),
|
||||||
|
re.compile(r"^tests?\/"),
|
||||||
|
re.compile(r"spec\."),
|
||||||
|
re.compile(r"\.test\."),
|
||||||
|
re.compile(r"\.tests\."),
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize_path(cls, path: str | Path) -> Path:
|
||||||
|
"""Normalize a path to absolute form."""
|
||||||
|
return Path(path).resolve()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_ignored_dir(cls, path: Path) -> bool:
|
||||||
|
"""Check if a directory should be ignored."""
|
||||||
|
return path.name in cls.IGNORED_DIRS or path.name.startswith(".")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_ignored_file(cls, path: Path) -> bool:
|
||||||
|
"""Check if a file should be ignored."""
|
||||||
|
return path.name in cls.IGNORED_FILES
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_source_file(cls, path: Path) -> bool:
|
||||||
|
"""Check if a file is a source code file."""
|
||||||
|
return path.suffix in cls.SOURCE_EXTENSIONS
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_config_file(cls, path: Path) -> bool:
|
||||||
|
"""Check if a file is a configuration file."""
|
||||||
|
return path.suffix in cls.CONFIG_EXTENSIONS
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_documentation_file(cls, path: Path) -> bool:
|
||||||
|
"""Check if a file is a documentation file."""
|
||||||
|
return path.suffix in cls.DOCUMENTATION_EXTENSIONS
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_test_file(cls, path: Path) -> bool:
|
||||||
|
"""Check if a file is a test file."""
|
||||||
|
for pattern in cls.TEST_PATTERNS:
|
||||||
|
if pattern.search(str(path)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_relative_path(cls, path: Path, base: Path) -> Path:
|
||||||
|
"""Get relative path from base directory."""
|
||||||
|
try:
|
||||||
|
return path.relative_to(base)
|
||||||
|
except ValueError:
|
||||||
|
return path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_hidden(cls, path: Path) -> bool:
|
||||||
|
"""Check if a path is hidden (starts with dot)."""
|
||||||
|
return path.name.startswith(".")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def detect_project_root(cls, path: Path) -> Optional[Path]:
|
||||||
|
"""Detect project root by looking for marker files."""
|
||||||
|
markers = [
|
||||||
|
"pyproject.toml",
|
||||||
|
"package.json",
|
||||||
|
"go.mod",
|
||||||
|
"Cargo.toml",
|
||||||
|
"setup.py",
|
||||||
|
"setup.cfg",
|
||||||
|
"requirements.txt",
|
||||||
|
"Pipfile",
|
||||||
|
"pom.xml",
|
||||||
|
"build.gradle",
|
||||||
|
]
|
||||||
|
|
||||||
|
current = cls.normalize_path(path)
|
||||||
|
for _ in range(50):
|
||||||
|
for marker in markers:
|
||||||
|
if (current / marker).exists():
|
||||||
|
return current
|
||||||
|
if current.parent == current:
|
||||||
|
break
|
||||||
|
current = current.parent
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_file_empty(cls, path: Path) -> bool:
|
||||||
|
"""Check if a file is empty."""
|
||||||
|
try:
|
||||||
|
return os.path.getsize(path) == 0
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_file_size(cls, path: Path) -> int:
|
||||||
|
"""Get file size in bytes."""
|
||||||
|
try:
|
||||||
|
return os.path.getsize(path)
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def count_lines(cls, path: Path) -> int:
|
||||||
|
"""Count lines in a file."""
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
return sum(1 for _ in f)
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def safe_read(cls, path: Path, max_size: int = 1024 * 1024) -> Optional[str]:
|
||||||
|
"""Safely read file contents with size limit."""
|
||||||
|
try:
|
||||||
|
if cls.get_file_size(path) > max_size:
|
||||||
|
return None
|
||||||
|
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
return f.read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path(path: str | Path) -> Path:
|
||||||
|
"""Normalize a path to absolute form."""
|
||||||
|
return PathUtils.normalize_path(path)
|
||||||
203
tests/conftest.py
Normal file
203
tests/conftest.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""Test configuration and fixtures."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_python_project(tmp_path: Path) -> Path:
|
||||||
|
"""Create a Python project structure for testing."""
|
||||||
|
src_dir = tmp_path / "src"
|
||||||
|
src_dir.mkdir()
|
||||||
|
|
||||||
|
(src_dir / "__init__.py").write_text('"""Package init."""')
|
||||||
|
|
||||||
|
(src_dir / "main.py").write_text('''"""Main module."""
|
||||||
|
|
||||||
|
def hello():
|
||||||
|
"""Say hello."""
|
||||||
|
print("Hello, World!")
|
||||||
|
|
||||||
|
class Calculator:
|
||||||
|
"""A simple calculator."""
|
||||||
|
|
||||||
|
def add(self, a: int, b: int) -> int:
|
||||||
|
"""Add two numbers."""
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
def multiply(self, a: int, b: int) -> int:
|
||||||
|
"""Multiply two numbers."""
|
||||||
|
return a * b
|
||||||
|
''')
|
||||||
|
|
||||||
|
(tmp_path / "requirements.txt").write_text('''requests>=2.31.0
|
||||||
|
click>=8.0.0
|
||||||
|
pytest>=7.0.0
|
||||||
|
''')
|
||||||
|
|
||||||
|
(tmp_path / "pyproject.toml").write_text('''[build-system]
|
||||||
|
requires = ["setuptools"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "test-project"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A test project"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"requests>=2.31.0",
|
||||||
|
"click>=8.0.0",
|
||||||
|
]
|
||||||
|
''')
|
||||||
|
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_javascript_project(tmp_path: Path) -> Path:
|
||||||
|
"""Create a JavaScript project structure for testing."""
|
||||||
|
(tmp_path / "package.json").write_text('''{
|
||||||
|
"name": "test-js-project",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A test JavaScript project",
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.0",
|
||||||
|
"lodash": "^4.17.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
|
||||||
|
(tmp_path / "index.js").write_text('''const express = require('express');
|
||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
|
function hello() {
|
||||||
|
return 'Hello, World!';
|
||||||
|
}
|
||||||
|
|
||||||
|
class Calculator {
|
||||||
|
add(a, b) {
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { hello, Calculator };
|
||||||
|
''')
|
||||||
|
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_go_project(tmp_path: Path) -> Path:
|
||||||
|
"""Create a Go project structure for testing."""
|
||||||
|
(tmp_path / "go.mod").write_text('''module test-go-project
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.9.0
|
||||||
|
github.com/stretchr/testify v1.8.0
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
(tmp_path / "main.go").write_text('''package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func hello() string {
|
||||||
|
return "Hello, World!"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Calculator struct{}
|
||||||
|
|
||||||
|
func (c *Calculator) Add(a, b int) int {
|
||||||
|
return a + b
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println(hello())
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_rust_project(tmp_path: Path) -> Path:
|
||||||
|
"""Create a Rust project structure for testing."""
|
||||||
|
src_dir = tmp_path / "src"
|
||||||
|
src_dir.mkdir()
|
||||||
|
|
||||||
|
(tmp_path / "Cargo.toml").write_text('''[package]
|
||||||
|
name = "test-rust-project"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
tokio = { version = "1.0", features = "full" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assertions = "0.1"
|
||||||
|
''')
|
||||||
|
|
||||||
|
(src_dir / "main.rs").write_text('''fn hello() -> String {
|
||||||
|
"Hello, World!".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Calculator;
|
||||||
|
|
||||||
|
impl Calculator {
|
||||||
|
pub fn add(a: i32, b: i32) -> i32 {
|
||||||
|
a + b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("{}", hello());
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_mixed_project(tmp_path: Path) -> Path:
|
||||||
|
"""Create a project with multiple languages for testing."""
|
||||||
|
python_part = tmp_path / "python_part"
|
||||||
|
python_part.mkdir()
|
||||||
|
js_part = tmp_path / "js_part"
|
||||||
|
js_part.mkdir()
|
||||||
|
|
||||||
|
src_dir = python_part / "src"
|
||||||
|
src_dir.mkdir()
|
||||||
|
(src_dir / "__init__.py").write_text('"""Package init."""')
|
||||||
|
(src_dir / "main.py").write_text('''"""Main module."""
|
||||||
|
def hello():
|
||||||
|
print("Hello")
|
||||||
|
''')
|
||||||
|
(python_part / "pyproject.toml").write_text('''[project]
|
||||||
|
name = "test-project"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A test project"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = ["requests>=2.31.0"]
|
||||||
|
''')
|
||||||
|
|
||||||
|
(js_part / "package.json").write_text('''{
|
||||||
|
"name": "test-js-project",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A test JavaScript project"
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
(js_part / "index.js").write_text('''function hello() {
|
||||||
|
return 'Hello';
|
||||||
|
}
|
||||||
|
module.exports = { hello };
|
||||||
|
''')
|
||||||
|
|
||||||
|
return tmp_path
|
||||||
75
tests/fixtures/README_EXAMPLE.md
vendored
Normal file
75
tests/fixtures/README_EXAMPLE.md
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Example Generated README
|
||||||
|
|
||||||
|
This is an example of a README file that can be generated by Auto README Generator CLI.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A Python project located at `/example/project` containing multiple files.
|
||||||
|
|
||||||
|
## Supported Languages
|
||||||
|
|
||||||
|
This project uses:
|
||||||
|
- **Python**
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `requests` v2.31.0
|
||||||
|
- `click` v8.0.0
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from project_name import main
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Test suite included
|
||||||
|
- Uses 2 dependencies
|
||||||
|
- Contains 1 classes
|
||||||
|
- Contains 3 functions
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### `hello()`
|
||||||
|
|
||||||
|
Say hello.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
### `add(a, b)`
|
||||||
|
|
||||||
|
Add two numbers.
|
||||||
|
|
||||||
|
**Parameters:** `a, b`
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
For Python development:
|
||||||
|
- Run tests with `pytest`
|
||||||
|
- Format code with `black` and `isort`
|
||||||
|
- Check types with `mypy`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated by Auto README Generator on 2024-01-15*
|
||||||
255
tests/test_analyzers.py
Normal file
255
tests/test_analyzers.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""Tests for code analyzers."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
from src.auto_readme.analyzers import (
|
||||||
|
PythonAnalyzer,
|
||||||
|
JavaScriptAnalyzer,
|
||||||
|
GoAnalyzer,
|
||||||
|
RustAnalyzer,
|
||||||
|
CodeAnalyzerFactory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPythonAnalyzer:
|
||||||
|
"""Tests for PythonAnalyzer."""
|
||||||
|
|
||||||
|
def test_can_analyze_py_file(self):
|
||||||
|
"""Test that analyzer recognizes Python files."""
|
||||||
|
analyzer = PythonAnalyzer()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="w") as f:
|
||||||
|
f.write("def hello():\n pass")
|
||||||
|
f.flush()
|
||||||
|
assert analyzer.can_analyze(Path(f.name))
|
||||||
|
Path(f.name).unlink()
|
||||||
|
|
||||||
|
def test_can_analyze_pyi_file(self):
|
||||||
|
"""Test that analyzer recognizes .pyi stub files."""
|
||||||
|
analyzer = PythonAnalyzer()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pyi", delete=False, mode="w") as f:
|
||||||
|
f.write("def hello() -> None: ...")
|
||||||
|
f.flush()
|
||||||
|
assert analyzer.can_analyze(Path(f.name))
|
||||||
|
Path(f.name).unlink()
|
||||||
|
|
||||||
|
def test_analyze_simple_function(self):
|
||||||
|
"""Test analyzing a simple function."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="w") as f:
|
||||||
|
f.write('''"""Module docstring."""
|
||||||
|
|
||||||
|
def hello():
|
||||||
|
"""Say hello."""
|
||||||
|
print("Hello, World!")
|
||||||
|
|
||||||
|
class Calculator:
|
||||||
|
"""A calculator class."""
|
||||||
|
|
||||||
|
def add(self, a, b):
|
||||||
|
"""Add two numbers."""
|
||||||
|
return a + b
|
||||||
|
''')
|
||||||
|
f.flush()
|
||||||
|
file_path = Path(f.name)
|
||||||
|
analyzer = PythonAnalyzer()
|
||||||
|
result = analyzer.analyze(file_path)
|
||||||
|
|
||||||
|
assert len(result["functions"]) == 2 # hello and add
|
||||||
|
assert len(result["classes"]) == 1
|
||||||
|
|
||||||
|
hello_func = next((f for f in result["functions"] if f.name == "hello"), None)
|
||||||
|
assert hello_func is not None
|
||||||
|
assert hello_func.docstring == "Say hello."
|
||||||
|
assert hello_func.parameters == []
|
||||||
|
|
||||||
|
calc_class = result["classes"][0]
|
||||||
|
assert calc_class.name == "Calculator"
|
||||||
|
assert calc_class.docstring == "A calculator class."
|
||||||
|
|
||||||
|
Path(f.name).unlink()
|
||||||
|
|
||||||
|
def test_analyze_with_parameters(self):
|
||||||
|
"""Test analyzing functions with parameters."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="w") as f:
|
||||||
|
f.write('''def greet(name, greeting="Hello"):
|
||||||
|
"""Greet someone."""
|
||||||
|
return f"{greeting}, {name}!"
|
||||||
|
|
||||||
|
def add_numbers(a: int, b: int) -> int:
|
||||||
|
"""Add two integers."""
|
||||||
|
return a + b
|
||||||
|
''')
|
||||||
|
f.flush()
|
||||||
|
file_path = Path(f.name)
|
||||||
|
analyzer = PythonAnalyzer()
|
||||||
|
result = analyzer.analyze(file_path)
|
||||||
|
|
||||||
|
greet_func = next((f for f in result["functions"] if f.name == "greet"), None)
|
||||||
|
assert greet_func is not None
|
||||||
|
assert "name" in greet_func.parameters
|
||||||
|
assert "greeting" in greet_func.parameters
|
||||||
|
|
||||||
|
Path(f.name).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
class TestJavaScriptAnalyzer:
|
||||||
|
"""Tests for JavaScriptAnalyzer."""
|
||||||
|
|
||||||
|
def test_can_analyze_js_file(self):
|
||||||
|
"""Test that analyzer recognizes JavaScript files."""
|
||||||
|
analyzer = JavaScriptAnalyzer()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".js", delete=False, mode="w") as f:
|
||||||
|
f.write("function hello() { return 'hello'; }")
|
||||||
|
f.flush()
|
||||||
|
assert analyzer.can_analyze(Path(f.name))
|
||||||
|
Path(f.name).unlink()
|
||||||
|
|
||||||
|
def test_can_analyze_ts_file(self):
|
||||||
|
"""Test that analyzer recognizes TypeScript files."""
|
||||||
|
analyzer = JavaScriptAnalyzer()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".ts", delete=False, mode="w") as f:
|
||||||
|
f.write("const hello = (): string => 'hello';")
|
||||||
|
f.flush()
|
||||||
|
assert analyzer.can_analyze(Path(f.name))
|
||||||
|
Path(f.name).unlink()
|
||||||
|
|
||||||
|
def test_analyze_simple_function(self):
|
||||||
|
"""Test analyzing a simple JavaScript function."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".js", delete=False, mode="w") as f:
|
||||||
|
f.write('''function hello(name) {
|
||||||
|
return "Hello, " + name + "!";
|
||||||
|
}
|
||||||
|
|
||||||
|
class Calculator {
|
||||||
|
add(a, b) {
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { hello, Calculator };
|
||||||
|
''')
|
||||||
|
f.flush()
|
||||||
|
file_path = Path(f.name)
|
||||||
|
analyzer = JavaScriptAnalyzer()
|
||||||
|
result = analyzer.analyze(file_path)
|
||||||
|
|
||||||
|
assert len(result["functions"]) >= 1
|
||||||
|
|
||||||
|
hello_func = next((f for f in result["functions"] if f.name == "hello"), None)
|
||||||
|
assert hello_func is not None
|
||||||
|
|
||||||
|
Path(f.name).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGoAnalyzer:
|
||||||
|
"""Tests for GoAnalyzer."""
|
||||||
|
|
||||||
|
def test_can_analyze_go_file(self):
|
||||||
|
"""Test that analyzer recognizes Go files."""
|
||||||
|
analyzer = GoAnalyzer()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".go", delete=False, mode="w") as f:
|
||||||
|
f.write("package main\n\nfunc hello() string { return 'hello' }")
|
||||||
|
f.flush()
|
||||||
|
assert analyzer.can_analyze(Path(f.name))
|
||||||
|
Path(f.name).unlink()
|
||||||
|
|
||||||
|
def test_analyze_simple_function(self):
|
||||||
|
"""Test analyzing a simple Go function."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".go", delete=False, mode="w") as f:
|
||||||
|
f.write('''package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func hello(name string) string {
|
||||||
|
return "Hello, " + name
|
||||||
|
}
|
||||||
|
|
||||||
|
type Calculator struct{}
|
||||||
|
|
||||||
|
func (c *Calculator) Add(a, b int) int {
|
||||||
|
return a + b
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
f.flush()
|
||||||
|
file_path = Path(f.name)
|
||||||
|
analyzer = GoAnalyzer()
|
||||||
|
result = analyzer.analyze(file_path)
|
||||||
|
|
||||||
|
hello_func = next((f for f in result["functions"] if f.name == "hello"), None)
|
||||||
|
assert hello_func is not None
|
||||||
|
assert hello_func.return_type is not None or "string" in str(hello_func.return_type)
|
||||||
|
|
||||||
|
Path(f.name).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRustAnalyzer:
|
||||||
|
"""Tests for RustAnalyzer."""
|
||||||
|
|
||||||
|
def test_can_analyze_rs_file(self):
|
||||||
|
"""Test that analyzer recognizes Rust files."""
|
||||||
|
analyzer = RustAnalyzer()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".rs", delete=False, mode="w") as f:
|
||||||
|
f.write("fn hello() -> String { String::from('hello') }")
|
||||||
|
f.flush()
|
||||||
|
assert analyzer.can_analyze(Path(f.name))
|
||||||
|
Path(f.name).unlink()
|
||||||
|
|
||||||
|
def test_analyze_simple_function(self):
|
||||||
|
"""Test analyzing a simple Rust function."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".rs", delete=False, mode="w") as f:
|
||||||
|
f.write('''fn hello(name: &str) -> String {
|
||||||
|
format!("Hello, {}", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Calculator;
|
||||||
|
|
||||||
|
impl Calculator {
|
||||||
|
pub fn add(a: i32, b: i32) -> i32 {
|
||||||
|
a + b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
f.flush()
|
||||||
|
file_path = Path(f.name)
|
||||||
|
analyzer = RustAnalyzer()
|
||||||
|
result = analyzer.analyze(file_path)
|
||||||
|
|
||||||
|
hello_func = next((f for f in result["functions"] if f.name == "hello"), None)
|
||||||
|
assert hello_func is not None
|
||||||
|
assert "name" in hello_func.parameters
|
||||||
|
assert hello_func.visibility == "private"
|
||||||
|
|
||||||
|
Path(f.name).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCodeAnalyzerFactory:
|
||||||
|
"""Tests for CodeAnalyzerFactory."""
|
||||||
|
|
||||||
|
def test_get_analyzer_python(self):
|
||||||
|
"""Test getting analyzer for Python file."""
|
||||||
|
analyzer = CodeAnalyzerFactory.get_analyzer(Path("main.py"))
|
||||||
|
assert isinstance(analyzer, PythonAnalyzer)
|
||||||
|
|
||||||
|
def test_get_analyzer_js(self):
|
||||||
|
"""Test getting analyzer for JavaScript file."""
|
||||||
|
analyzer = CodeAnalyzerFactory.get_analyzer(Path("index.js"))
|
||||||
|
assert isinstance(analyzer, JavaScriptAnalyzer)
|
||||||
|
|
||||||
|
def test_get_analyzer_go(self):
|
||||||
|
"""Test getting analyzer for Go file."""
|
||||||
|
analyzer = CodeAnalyzerFactory.get_analyzer(Path("main.go"))
|
||||||
|
assert isinstance(analyzer, GoAnalyzer)
|
||||||
|
|
||||||
|
def test_get_analyzer_rust(self):
|
||||||
|
"""Test getting analyzer for Rust file."""
|
||||||
|
analyzer = CodeAnalyzerFactory.get_analyzer(Path("main.rs"))
|
||||||
|
assert isinstance(analyzer, RustAnalyzer)
|
||||||
|
|
||||||
|
def test_can_analyze(self):
|
||||||
|
"""Test can_analyze returns correct results."""
|
||||||
|
assert CodeAnalyzerFactory.can_analyze(Path("main.py"))
|
||||||
|
assert CodeAnalyzerFactory.can_analyze(Path("index.js"))
|
||||||
|
assert CodeAnalyzerFactory.can_analyze(Path("main.go"))
|
||||||
|
assert CodeAnalyzerFactory.can_analyze(Path("main.rs"))
|
||||||
|
assert not CodeAnalyzerFactory.can_analyze(Path("random.txt"))
|
||||||
134
tests/test_cli.py
Normal file
134
tests/test_cli.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Tests for CLI commands."""
|
||||||
|
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from src.auto_readme.cli import generate, preview, analyze, init_config
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateCommand:
|
||||||
|
"""Tests for the generate command."""
|
||||||
|
|
||||||
|
def test_generate_basic_python(self, create_python_project, tmp_path):
|
||||||
|
"""Test basic README generation for Python project."""
|
||||||
|
from src.auto_readme.cli import generate
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(generate, ["--input", str(create_python_project), "--output", str(tmp_path / "README.md")])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
readme_content = (tmp_path / "README.md").read_text()
|
||||||
|
assert "# test-project" in readme_content
|
||||||
|
|
||||||
|
def test_generate_dry_run(self, create_python_project):
|
||||||
|
"""Test README generation with dry-run option."""
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(generate, ["--input", str(create_python_project), "--dry-run"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "# test-project" in result.output
|
||||||
|
|
||||||
|
def test_generate_force_overwrite(self, create_python_project, tmp_path):
|
||||||
|
"""Test forced README overwrite."""
|
||||||
|
readme_file = tmp_path / "README.md"
|
||||||
|
readme_file.write_text("# Existing README")
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(generate, ["--input", str(create_python_project), "--output", str(readme_file), "--force"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert readme_file.read_text() != "# Existing README"
|
||||||
|
|
||||||
|
def test_generate_with_template(self, create_python_project):
|
||||||
|
"""Test README generation with specific template."""
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(generate, ["--input", str(create_python_project), "--template", "base", "--dry-run"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreviewCommand:
|
||||||
|
"""Tests for the preview command."""
|
||||||
|
|
||||||
|
def test_preview_python_project(self, create_python_project):
|
||||||
|
"""Test previewing README for Python project."""
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(preview, ["--input", str(create_python_project)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "# test-project" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyzeCommand:
|
||||||
|
"""Tests for the analyze command."""
|
||||||
|
|
||||||
|
def test_analyze_python_project(self, create_python_project):
|
||||||
|
"""Test analyzing Python project."""
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(analyze, [str(create_python_project)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "test-project" in result.output
|
||||||
|
assert "Type: python" in result.output
|
||||||
|
|
||||||
|
def test_analyze_js_project(self, create_javascript_project):
|
||||||
|
"""Test analyzing JavaScript project."""
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(analyze, [str(create_javascript_project)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Type: javascript" in result.output
|
||||||
|
|
||||||
|
def test_analyze_go_project(self, create_go_project):
|
||||||
|
"""Test analyzing Go project."""
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(analyze, [str(create_go_project)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Type: go" in result.output
|
||||||
|
|
||||||
|
def test_analyze_rust_project(self, create_rust_project):
|
||||||
|
"""Test analyzing Rust project."""
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(analyze, [str(create_rust_project)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Type: rust" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestInitConfigCommand:
|
||||||
|
"""Tests for the init-config command."""
|
||||||
|
|
||||||
|
def test_init_config(self, tmp_path):
|
||||||
|
"""Test generating configuration template."""
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
result = runner.invoke(init_config, ["--output", str(tmp_path / ".readmerc")])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
config_content = (tmp_path / ".readmerc").read_text()
|
||||||
|
assert "project_name:" in config_content
|
||||||
|
assert "description:" in config_content
|
||||||
|
|
||||||
|
def test_init_config_default_path(self, tmp_path):
|
||||||
|
"""Test generating configuration at default path."""
|
||||||
|
import os
|
||||||
|
original_dir = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(init_config, ["--output", ".readmerc"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert (tmp_path / ".readmerc").exists()
|
||||||
|
finally:
|
||||||
|
os.chdir(original_dir)
|
||||||
132
tests/test_config.py
Normal file
132
tests/test_config.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""Tests for configuration loading."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.auto_readme.config import ConfigLoader, ConfigValidator, ReadmeConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigLoader:
|
||||||
|
"""Tests for ConfigLoader."""
|
||||||
|
|
||||||
|
def test_find_config_yaml(self, tmp_path):
|
||||||
|
"""Test finding YAML configuration file."""
|
||||||
|
(tmp_path / ".readmerc.yaml").write_text("project_name: test")
|
||||||
|
|
||||||
|
config_path = ConfigLoader.find_config(tmp_path)
|
||||||
|
assert config_path is not None
|
||||||
|
assert config_path.name == ".readmerc.yaml"
|
||||||
|
|
||||||
|
def test_find_config_yml(self, tmp_path):
|
||||||
|
"""Test finding YML configuration file."""
|
||||||
|
(tmp_path / ".readmerc.yml").write_text("project_name: test")
|
||||||
|
|
||||||
|
config_path = ConfigLoader.find_config(tmp_path)
|
||||||
|
assert config_path is not None
|
||||||
|
|
||||||
|
def test_find_config_toml(self, tmp_path):
|
||||||
|
"""Test finding TOML configuration file."""
|
||||||
|
(tmp_path / ".readmerc").write_text("project_name = 'test'")
|
||||||
|
|
||||||
|
config_path = ConfigLoader.find_config(tmp_path)
|
||||||
|
assert config_path is not None
|
||||||
|
|
||||||
|
def test_load_yaml_config(self, tmp_path):
|
||||||
|
"""Test loading YAML configuration file."""
|
||||||
|
config_file = tmp_path / ".readmerc.yaml"
|
||||||
|
config_file.write_text("""
|
||||||
|
project_name: "My Test Project"
|
||||||
|
description: "A test description"
|
||||||
|
template: "minimal"
|
||||||
|
interactive: true
|
||||||
|
|
||||||
|
sections:
|
||||||
|
order:
|
||||||
|
- title
|
||||||
|
- description
|
||||||
|
- overview
|
||||||
|
|
||||||
|
custom_fields:
|
||||||
|
author: "Test Author"
|
||||||
|
email: "test@example.com"
|
||||||
|
""")
|
||||||
|
|
||||||
|
config = ConfigLoader.load(config_file)
|
||||||
|
|
||||||
|
assert config.project_name == "My Test Project"
|
||||||
|
assert config.description == "A test description"
|
||||||
|
assert config.template == "minimal"
|
||||||
|
assert config.interactive is True
|
||||||
|
assert "author" in config.custom_fields
|
||||||
|
|
||||||
|
def test_load_toml_config(self, tmp_path):
|
||||||
|
"""Test loading TOML configuration file."""
|
||||||
|
config_file = tmp_path / "pyproject.toml"
|
||||||
|
config_file.write_text("""
|
||||||
|
[tool.auto-readme]
|
||||||
|
filename = "README.md"
|
||||||
|
sections = ["title", "description", "overview"]
|
||||||
|
""")
|
||||||
|
|
||||||
|
config = ConfigLoader.load(config_file)
|
||||||
|
|
||||||
|
assert config.output_filename == "README.md"
|
||||||
|
|
||||||
|
def test_load_nonexistent_file(self):
|
||||||
|
"""Test loading nonexistent configuration file."""
|
||||||
|
config = ConfigLoader.load(Path("/nonexistent/config.yaml"))
|
||||||
|
assert config.project_name is None
|
||||||
|
|
||||||
|
def test_load_invalid_yaml(self, tmp_path):
|
||||||
|
"""Test loading invalid YAML raises error."""
|
||||||
|
config_file = tmp_path / ".readmerc.yaml"
|
||||||
|
config_file.write_text("invalid: yaml: content: [")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ConfigLoader.load(config_file)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigValidator:
|
||||||
|
"""Tests for ConfigValidator."""
|
||||||
|
|
||||||
|
def test_validate_valid_config(self):
|
||||||
|
"""Test validating a valid configuration."""
|
||||||
|
config = ReadmeConfig(
|
||||||
|
project_name="test",
|
||||||
|
template="base",
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = ConfigValidator.validate(config)
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
def test_validate_invalid_template(self):
|
||||||
|
"""Test validating invalid template name."""
|
||||||
|
config = ReadmeConfig(
|
||||||
|
project_name="test",
|
||||||
|
template="nonexistent",
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = ConfigValidator.validate(config)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "Invalid template" in errors[0]
|
||||||
|
|
||||||
|
def test_validate_invalid_section(self):
|
||||||
|
"""Test validating invalid section name."""
|
||||||
|
config = ReadmeConfig(
|
||||||
|
project_name="test",
|
||||||
|
sections={"order": ["invalid_section"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = ConfigValidator.validate(config)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "Invalid section" in errors[0]
|
||||||
|
|
||||||
|
def test_generate_template(self):
|
||||||
|
"""Test generating configuration template."""
|
||||||
|
template = ConfigValidator.generate_template()
|
||||||
|
|
||||||
|
assert "project_name:" in template
|
||||||
|
assert "description:" in template
|
||||||
|
assert "template:" in template
|
||||||
|
assert "interactive:" in template
|
||||||
242
tests/test_parsers.py
Normal file
242
tests/test_parsers.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
"""Tests for dependency parsers."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
from src.auto_readme.parsers import (
|
||||||
|
PythonDependencyParser,
|
||||||
|
JavaScriptDependencyParser,
|
||||||
|
GoDependencyParser,
|
||||||
|
RustDependencyParser,
|
||||||
|
DependencyParserFactory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPythonDependencyParser:
|
||||||
|
"""Tests for PythonDependencyParser."""
|
||||||
|
|
||||||
|
def test_can_parse_requirements_txt(self):
|
||||||
|
"""Test that parser recognizes requirements.txt."""
|
||||||
|
parser = PythonDependencyParser()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
req_file = Path(tmp_dir) / "requirements.txt"
|
||||||
|
req_file.write_text("requests>=2.31.0\n")
|
||||||
|
assert parser.can_parse(req_file)
|
||||||
|
|
||||||
|
def test_can_parse_pyproject_toml(self):
|
||||||
|
"""Test that parser recognizes pyproject.toml."""
|
||||||
|
parser = PythonDependencyParser()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
pyproject_file = Path(tmp_dir) / "pyproject.toml"
|
||||||
|
pyproject_file.write_text('[project]\ndependencies = ["requests>=2.0"]')
|
||||||
|
assert parser.can_parse(pyproject_file)
|
||||||
|
|
||||||
|
def test_parse_requirements_txt(self):
|
||||||
|
"""Test parsing requirements.txt file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
req_file = Path(tmp_dir) / "requirements.txt"
|
||||||
|
req_file.write_text("""
|
||||||
|
requests>=2.31.0
|
||||||
|
click>=8.0.0
|
||||||
|
pytest==7.0.0
|
||||||
|
-e git+https://github.com/user/repo.git#egg=package
|
||||||
|
# This is a comment
|
||||||
|
numpy~=1.24.0
|
||||||
|
""")
|
||||||
|
|
||||||
|
parser = PythonDependencyParser()
|
||||||
|
deps = parser.parse(req_file)
|
||||||
|
|
||||||
|
assert len(deps) == 5
|
||||||
|
names = {d.name for d in deps}
|
||||||
|
assert "requests" in names
|
||||||
|
assert "click" in names
|
||||||
|
assert "pytest" in names
|
||||||
|
assert "numpy" in names
|
||||||
|
|
||||||
|
def test_parse_pyproject_toml(self):
|
||||||
|
"""Test parsing pyproject.toml file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
pyproject_file = Path(tmp_dir) / "pyproject.toml"
|
||||||
|
pyproject_file.write_text("""
|
||||||
|
[project]
|
||||||
|
name = "test-project"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"requests>=2.31.0",
|
||||||
|
"click>=8.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=7.0.0", "black>=23.0.0"]
|
||||||
|
""")
|
||||||
|
|
||||||
|
parser = PythonDependencyParser()
|
||||||
|
deps = parser.parse(pyproject_file)
|
||||||
|
|
||||||
|
assert len(deps) == 4
|
||||||
|
|
||||||
|
|
||||||
|
class TestJavaScriptDependencyParser:
|
||||||
|
"""Tests for JavaScriptDependencyParser."""
|
||||||
|
|
||||||
|
def test_can_parse_package_json(self):
|
||||||
|
"""Test that parser recognizes package.json."""
|
||||||
|
parser = JavaScriptDependencyParser()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
package_file = Path(tmp_dir) / "package.json"
|
||||||
|
package_file.write_text('{"name": "test"}')
|
||||||
|
assert parser.can_parse(package_file)
|
||||||
|
|
||||||
|
def test_parse_package_json(self):
|
||||||
|
"""Test parsing package.json file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
package_file = Path(tmp_dir) / "package.json"
|
||||||
|
package_file.write_text("""
|
||||||
|
{
|
||||||
|
"name": "test-project",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.0",
|
||||||
|
"lodash": "~4.17.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"bcrypt": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
parser = JavaScriptDependencyParser()
|
||||||
|
deps = parser.parse(package_file)
|
||||||
|
|
||||||
|
assert len(deps) == 4
|
||||||
|
|
||||||
|
express = next((d for d in deps if d.name == "express"), None)
|
||||||
|
assert express is not None
|
||||||
|
assert express.version == "4.18.0"
|
||||||
|
assert not express.is_dev
|
||||||
|
|
||||||
|
jest = next((d for d in deps if d.name == "jest"), None)
|
||||||
|
assert jest is not None
|
||||||
|
assert jest.is_dev
|
||||||
|
|
||||||
|
bcrypt = next((d for d in deps if d.name == "bcrypt"), None)
|
||||||
|
assert bcrypt is not None
|
||||||
|
assert bcrypt.is_optional
|
||||||
|
|
||||||
|
|
||||||
|
class TestGoDependencyParser:
|
||||||
|
"""Tests for GoDependencyParser."""
|
||||||
|
|
||||||
|
def test_can_parse_go_mod(self):
|
||||||
|
"""Test that parser recognizes go.mod."""
|
||||||
|
parser = GoDependencyParser()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
go_mod_file = Path(tmp_dir) / "go.mod"
|
||||||
|
go_mod_file.write_text("module test\n")
|
||||||
|
assert parser.can_parse(go_mod_file)
|
||||||
|
|
||||||
|
def test_parse_go_mod(self):
|
||||||
|
"""Test parsing go.mod file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
go_mod_file = Path(tmp_dir) / "go.mod"
|
||||||
|
go_mod_file.write_text("""
|
||||||
|
module test-go-project
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.9.0
|
||||||
|
github.com/stretchr/testify v1.8.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require github.com/user/repo v1.0.0
|
||||||
|
""")
|
||||||
|
|
||||||
|
parser = GoDependencyParser()
|
||||||
|
deps = parser.parse(go_mod_file)
|
||||||
|
|
||||||
|
assert len(deps) == 3
|
||||||
|
|
||||||
|
gin = next((d for d in deps if d.name == "github.com/gin-gonic/gin"), None)
|
||||||
|
assert gin is not None
|
||||||
|
assert gin.version is not None and "1.9.0" in gin.version
|
||||||
|
|
||||||
|
|
||||||
|
class TestRustDependencyParser:
|
||||||
|
"""Tests for RustDependencyParser."""
|
||||||
|
|
||||||
|
def test_can_parse_cargo_toml(self):
|
||||||
|
"""Test that parser recognizes Cargo.toml."""
|
||||||
|
parser = RustDependencyParser()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
cargo_file = Path(tmp_dir) / "Cargo.toml"
|
||||||
|
cargo_file.write_text('[package]\nname = "test"')
|
||||||
|
assert parser.can_parse(cargo_file)
|
||||||
|
|
||||||
|
def test_parse_cargo_toml(self):
|
||||||
|
"""Test parsing Cargo.toml file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
cargo_file = Path(tmp_dir) / "Cargo.toml"
|
||||||
|
cargo_file.write_text("""
|
||||||
|
[package]
|
||||||
|
name = "test-project"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assertions = "0.1"
|
||||||
|
""")
|
||||||
|
|
||||||
|
parser = RustDependencyParser()
|
||||||
|
deps = parser.parse(cargo_file)
|
||||||
|
|
||||||
|
assert len(deps) == 3
|
||||||
|
|
||||||
|
serde = next((d for d in deps if d.name == "serde"), None)
|
||||||
|
assert serde is not None
|
||||||
|
assert serde.version is not None and "1.0" in serde.version
|
||||||
|
assert not serde.is_dev
|
||||||
|
|
||||||
|
assertions = next((d for d in deps if d.name == "assertions"), None)
|
||||||
|
assert assertions is not None
|
||||||
|
assert assertions.is_dev
|
||||||
|
|
||||||
|
|
||||||
|
class TestDependencyParserFactory:
|
||||||
|
"""Tests for DependencyParserFactory."""
|
||||||
|
|
||||||
|
def test_get_parser_python(self):
|
||||||
|
"""Test getting parser for Python file."""
|
||||||
|
parser = DependencyParserFactory.get_parser(Path("requirements.txt"))
|
||||||
|
assert isinstance(parser, PythonDependencyParser)
|
||||||
|
|
||||||
|
def test_get_parser_js(self):
|
||||||
|
"""Test getting parser for JavaScript file."""
|
||||||
|
parser = DependencyParserFactory.get_parser(Path("package.json"))
|
||||||
|
assert isinstance(parser, JavaScriptDependencyParser)
|
||||||
|
|
||||||
|
def test_get_parser_go(self):
|
||||||
|
"""Test getting parser for Go file."""
|
||||||
|
parser = DependencyParserFactory.get_parser(Path("go.mod"))
|
||||||
|
assert isinstance(parser, GoDependencyParser)
|
||||||
|
|
||||||
|
def test_get_parser_rust(self):
|
||||||
|
"""Test getting parser for Rust file."""
|
||||||
|
parser = DependencyParserFactory.get_parser(Path("Cargo.toml"))
|
||||||
|
assert isinstance(parser, RustDependencyParser)
|
||||||
|
|
||||||
|
def test_can_parse(self):
|
||||||
|
"""Test can_parse returns correct results."""
|
||||||
|
assert DependencyParserFactory.can_parse(Path("requirements.txt"))
|
||||||
|
assert DependencyParserFactory.can_parse(Path("package.json"))
|
||||||
|
assert DependencyParserFactory.can_parse(Path("go.mod"))
|
||||||
|
assert DependencyParserFactory.can_parse(Path("Cargo.toml"))
|
||||||
|
assert not DependencyParserFactory.can_parse(Path("random.txt"))
|
||||||
197
tests/test_templates.py
Normal file
197
tests/test_templates.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""Tests for template rendering."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
from src.auto_readme.models import Project, ProjectConfig, ProjectType
|
||||||
|
from src.auto_readme.templates import TemplateRenderer, TemplateManager
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateRenderer:
|
||||||
|
"""Tests for TemplateRenderer."""
|
||||||
|
|
||||||
|
def test_render_base_template(self):
|
||||||
|
"""Test rendering the base template."""
|
||||||
|
project = Project(
|
||||||
|
root_path=Path("/test"),
|
||||||
|
project_type=ProjectType.PYTHON,
|
||||||
|
config=ProjectConfig(
|
||||||
|
name="test-project",
|
||||||
|
description="A test project",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
content = renderer.render(project, "base")
|
||||||
|
|
||||||
|
assert "# test-project" in content
|
||||||
|
assert "A test project" in content
|
||||||
|
assert "## Overview" in content
|
||||||
|
|
||||||
|
def test_render_minimal_template(self):
|
||||||
|
"""Test rendering the minimal template."""
|
||||||
|
project = Project(
|
||||||
|
root_path=Path("/test"),
|
||||||
|
project_type=ProjectType.PYTHON,
|
||||||
|
)
|
||||||
|
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
content = renderer.render(project, "base")
|
||||||
|
|
||||||
|
assert "Generated by Auto README Generator" in content
|
||||||
|
|
||||||
|
def test_tech_stack_detection(self):
|
||||||
|
"""Test technology stack detection."""
|
||||||
|
from src.auto_readme.models import Dependency
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
root_path=Path("/test"),
|
||||||
|
project_type=ProjectType.PYTHON,
|
||||||
|
dependencies=[
|
||||||
|
Dependency(name="fastapi"),
|
||||||
|
Dependency(name="flask"),
|
||||||
|
Dependency(name="requests"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
context = renderer._build_context(project)
|
||||||
|
|
||||||
|
assert "FastAPI" in context["tech_stack"]
|
||||||
|
assert "Flask" in context["tech_stack"]
|
||||||
|
|
||||||
|
def test_installation_steps_generation(self):
|
||||||
|
"""Test automatic installation steps generation."""
|
||||||
|
project = Project(
|
||||||
|
root_path=Path("/test"),
|
||||||
|
project_type=ProjectType.PYTHON,
|
||||||
|
)
|
||||||
|
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
context = renderer._build_context(project)
|
||||||
|
|
||||||
|
assert "pip install" in context["installation_steps"][0]
|
||||||
|
|
||||||
|
def test_go_installation_steps(self):
|
||||||
|
"""Test Go installation steps."""
|
||||||
|
project = Project(
|
||||||
|
root_path=Path("/test"),
|
||||||
|
project_type=ProjectType.GO,
|
||||||
|
)
|
||||||
|
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
context = renderer._build_context(project)
|
||||||
|
|
||||||
|
assert "go mod download" in context["installation_steps"][0]
|
||||||
|
|
||||||
|
def test_rust_installation_steps(self):
|
||||||
|
"""Test Rust installation steps."""
|
||||||
|
project = Project(
|
||||||
|
root_path=Path("/test"),
|
||||||
|
project_type=ProjectType.RUST,
|
||||||
|
)
|
||||||
|
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
context = renderer._build_context(project)
|
||||||
|
|
||||||
|
assert "cargo build" in context["installation_steps"][0]
|
||||||
|
|
||||||
|
def test_feature_detection(self):
|
||||||
|
"""Test automatic feature detection."""
|
||||||
|
from src.auto_readme.models import Function, Class, SourceFile, FileType
|
||||||
|
|
||||||
|
functions = [
|
||||||
|
Function(name="test_func", line_number=1),
|
||||||
|
]
|
||||||
|
classes = [
|
||||||
|
Class(name="TestClass", line_number=1),
|
||||||
|
]
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
root_path=Path("/test"),
|
||||||
|
project_type=ProjectType.PYTHON,
|
||||||
|
files=[
|
||||||
|
SourceFile(
|
||||||
|
path=Path("test.py"),
|
||||||
|
file_type=FileType.SOURCE,
|
||||||
|
functions=functions,
|
||||||
|
classes=classes,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
context = renderer._build_context(project)
|
||||||
|
|
||||||
|
assert "Contains 1 classes" in context["features"]
|
||||||
|
assert "Contains 1 functions" in context["features"]
|
||||||
|
|
||||||
|
def test_custom_context_override(self):
|
||||||
|
"""Test custom context can override auto-detected values."""
|
||||||
|
project = Project(
|
||||||
|
root_path=Path("/test"),
|
||||||
|
project_type=ProjectType.PYTHON,
|
||||||
|
)
|
||||||
|
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
content = renderer.render(
|
||||||
|
project,
|
||||||
|
"base",
|
||||||
|
title="Custom Title",
|
||||||
|
description="Custom description",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "# Custom Title" in content
|
||||||
|
assert "Custom description" in content
|
||||||
|
|
||||||
|
def test_contributing_guidelines(self):
|
||||||
|
"""Test contributing guidelines generation."""
|
||||||
|
project = Project(
|
||||||
|
root_path=Path("/test"),
|
||||||
|
project_type=ProjectType.PYTHON,
|
||||||
|
)
|
||||||
|
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
context = renderer._build_context(project)
|
||||||
|
|
||||||
|
assert "Fork the repository" in context["contributing_guidelines"]
|
||||||
|
assert "git checkout -b" in context["contributing_guidelines"]
|
||||||
|
assert "pytest" in context["contributing_guidelines"]
|
||||||
|
|
||||||
|
def test_javascript_contributing_guidelines(self):
|
||||||
|
"""Test JavaScript contributing guidelines."""
|
||||||
|
project = Project(
|
||||||
|
root_path=Path("/test"),
|
||||||
|
project_type=ProjectType.JAVASCRIPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
renderer = TemplateRenderer()
|
||||||
|
context = renderer._build_context(project)
|
||||||
|
|
||||||
|
assert "npm test" in context["contributing_guidelines"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateManager:
|
||||||
|
"""Tests for TemplateManager."""
|
||||||
|
|
||||||
|
def test_list_templates(self):
|
||||||
|
"""Test listing available templates."""
|
||||||
|
manager = TemplateManager()
|
||||||
|
templates = manager.list_templates()
|
||||||
|
|
||||||
|
assert "base" in templates
|
||||||
|
|
||||||
|
def test_get_template_path_builtin(self):
|
||||||
|
"""Test getting path for built-in template."""
|
||||||
|
manager = TemplateManager()
|
||||||
|
path = manager.get_template_path("base")
|
||||||
|
|
||||||
|
assert path is None
|
||||||
|
|
||||||
|
def test_get_template_path_custom(self):
|
||||||
|
"""Test getting path for custom template."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
manager = TemplateManager(Path(tmp_dir))
|
||||||
|
path = manager.get_template_path("custom.md.j2")
|
||||||
|
assert path is not None
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user