Fix CI/CD: Add Gitea Actions workflow and fix linting issues

This commit is contained in:
Developer
2026-02-05 09:02:49 +00:00
commit d8325c4be2
111 changed files with 19657 additions and 0 deletions

94
.env.example Normal file
View 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
View 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
View 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/

View 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

View 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!**

View 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)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,252 @@
# Auto README Generator CLI
[![PyPI Version](https://img.shields.io/pypi/v/auto-readme-cli.svg)](https://pypi.org/project/auto-readme-cli/)
[![Python Versions](https://img.shields.io/pypi/pyversions/auto-readme-cli.svg)](https://pypi.org/project/auto-readme-cli/)
[![License](https://img.shields.io/pypi/l/auto-readme-cli.svg)](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
View 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
View 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
View 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
View 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)

View 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
View 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

View 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

View 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.

View 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.

View 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

View 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

View 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

View 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

View 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

View 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",
]

View File

@@ -0,0 +1,3 @@
from .cli import cli, main
__all__ = ["cli", "main"]

View 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()

View File

@@ -0,0 +1,3 @@
from .config import Config, ConfigLoader, Languages, StrictnessProfile, get_config
__all__ = ["Config", "ConfigLoader", "Languages", "StrictnessProfile", "get_config"]

View 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()

View File

@@ -0,0 +1,3 @@
from .review_engine import Issue, IssueCategory, IssueSeverity, ReviewEngine, ReviewResult
__all__ = ["Issue", "IssueCategory", "IssueSeverity", "ReviewEngine", "ReviewResult"]

View 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

View File

@@ -0,0 +1,3 @@
from .formatters import JSONFormatter, MarkdownFormatter, TerminalFormatter, get_formatter
__all__ = ["JSONFormatter", "MarkdownFormatter", "TerminalFormatter", "get_formatter"]

View 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]

View 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"]

View 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
"""

View File

@@ -0,0 +1,3 @@
from .hooks import check_hook_installed, install_pre_commit_hook
__all__ = ["check_hook_installed", "install_pre_commit_hook"]

View 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

View File

@@ -0,0 +1,4 @@
from .ollama import OllamaProvider
from .provider import LLMProvider
__all__ = ["LLMProvider", "OllamaProvider"]

View 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

View 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

View 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)

View File

@@ -0,0 +1,3 @@
from .utils import get_file_language, sanitize_output, setup_logging
__all__ = ["get_file_language", "sanitize_output", "setup_logging"]

View 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"

View 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)

View 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)

View File

@@ -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

View 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

View 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"

View 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)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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'
]

File diff suppressed because it is too large Load Diff

131
orchestrator/state.py Normal file
View 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

File diff suppressed because it is too large Load Diff

82
pyproject.toml Normal file
View 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
View 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
View 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
View 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

View 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"

View 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",
]

View 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

View 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

View 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

View 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

View 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
View 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()

View 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

View 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

View 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

View 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

View 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",
]

View 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

View 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,
)
)

View 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

View 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

View 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,
)
)

View 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

View 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",
]

View 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,
)

View 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)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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