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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ AutoRepro is a developer tools project that transforms issue descriptions into c

The current MVP scope includes three core commands:
- **scan**: Detect languages/frameworks from file pointers (✅ implemented)
- **init**: devcontainer (coming soon)
- **init**: Create a developer container (✅ implemented)
- **plan**: Derive an execution plan from issue description (coming soon)

The project targets multilingualism (initially Python/JS/Go) and emphasizes simplicity, transparency, and automated testing. The ultimate goal is to produce tests that automatically fail and open Draft PRs containing them, improving contribution quality and speeding up maintenance on GitHub.
Expand Down Expand Up @@ -42,6 +42,9 @@ autorepro --help

# Scan current directory for language/framework indicators
autorepro scan

# Create a devcontainer.json file
autorepro init
```

### Scan Command
Expand Down Expand Up @@ -79,6 +82,41 @@ No known languages detected.
- Source-file globs (e.g., `*.py`, `*.go`) may cause false positives in sparse repos; prefer root indicators (config/lockfiles).
- Future direction: weight/score reasons and down-rank raw source globs in favor of strong root indicators (tracked on the roadmap).

### Init Command

Creates a devcontainer.json file with default configuration (Python 3.11, Node 20, Go 1.22). The command is idempotent and provides atomic file writes.

```bash
# Create default devcontainer (first time)
$ autorepro init
Wrote devcontainer to /path/to/.devcontainer/devcontainer.json

# Run again - idempotent behavior (exit code 0)
$ autorepro init
devcontainer.json already exists at /path/to/.devcontainer/devcontainer.json.
Use --force to overwrite or --out <path> to write elsewhere.

# Force overwrite existing file
$ autorepro init --force
Overwrote devcontainer at /path/to/.devcontainer/devcontainer.json

# Custom output location
$ autorepro init --out dev/devcontainer.json
Wrote devcontainer to /path/to/dev/devcontainer.json
```

**Status:** `init` is implemented with idempotent behavior and proper exit codes.

**Init Behavior:**
- **Idempotent**: Won't overwrite existing files without `--force` flag (returns exit code 0)
- **Atomic writes**: Uses temporary file + rename for safe file creation
- **Directory creation**: Automatically creates parent directories as needed
- **Exit codes**: 0=success/exists, 1=I/O errors, 2=misuse (e.g., --out points to directory)

**Options:**
- `--force`: Overwrite existing devcontainer.json file
- `--out PATH`: Custom output path (default: .devcontainer/devcontainer.json)

## Development

### Running Tests
Expand Down
71 changes: 70 additions & 1 deletion autorepro/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@

import argparse
import sys
from pathlib import Path

from autorepro import __version__
from autorepro.detect import detect_languages
from autorepro.env import (
DevcontainerExistsError,
DevcontainerMisuseError,
default_devcontainer,
write_devcontainer,
)


def create_parser():
Expand All @@ -20,7 +27,7 @@ def create_parser():

MVP commands:
scan Detect languages/frameworks from file pointers
init Create a developer container (coming soon)
init Create a developer container
plan Derive execution plan from issue description (coming soon)

For more information, visit: https://github.com/ali90h/AutoRepro
Expand All @@ -39,6 +46,22 @@ def create_parser():
description="Scan the current directory for language/framework indicators",
)

# init subcommand
init_parser = subparsers.add_parser(
"init",
help="Create a developer container",
description="Create a devcontainer.json file with default configuration",
)
init_parser.add_argument(
"--force",
action="store_true",
help="Overwrite existing devcontainer.json file",
)
init_parser.add_argument(
"--out",
help="Custom output path (default: .devcontainer/devcontainer.json)",
)

return parser


Expand All @@ -62,6 +85,50 @@ def cmd_scan() -> int:
return 0


def cmd_init(force: bool = False, out: str | None = None) -> int:
"""Handle the init command."""
# Get default devcontainer configuration
config = default_devcontainer()

try:
# Determine output path to check if file exists before writing
if out is None:
output_path = Path(".devcontainer") / "devcontainer.json"
file_existed = output_path.exists()
else:
try:
output_path = Path(out).resolve()
file_existed = output_path.exists()
except (OSError, ValueError):
# Let env.py handle path validation errors
file_existed = False

# Write devcontainer with specified options
result_path = write_devcontainer(config, force=force, out=out)

if force and file_existed:
print(f"Overwrote devcontainer at {result_path}")
else:
print(f"Wrote devcontainer to {result_path}")
return 0

except DevcontainerExistsError as e:
# Idempotent success (exit 0) with exact wording
print(f"devcontainer.json already exists at {e.path}.")
print("Use --force to overwrite or --out <path> to write elsewhere.")
return 0

except DevcontainerMisuseError as e:
# Misuse errors (e.g., --out points to directory) - exit 2
print(f"Error: {e.message}")
return 2

except (OSError, PermissionError) as e:
# I/O and permission errors - exit 1
print(f"Error: {e}")
return 1


def main() -> int:
parser = create_parser()
try:
Expand All @@ -72,6 +139,8 @@ def main() -> int:

if args.command == "scan":
return cmd_scan()
elif args.command == "init":
return cmd_init(force=args.force, out=args.out)

parser.print_help()
return 0
Expand Down
128 changes: 128 additions & 0 deletions autorepro/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Environment and devcontainer management for AutoRepro."""

import json
import os
from pathlib import Path


class DevcontainerExistsError(Exception):
"""Raised when devcontainer file already exists and force=False."""

def __init__(self, path: Path):
self.path = path
super().__init__(f"File already exists: {path}")


class DevcontainerMisuseError(Exception):
"""Raised when arguments are invalid (e.g., output path is a directory)."""

def __init__(self, message: str):
self.message = message
super().__init__(message)


def default_devcontainer() -> dict:
"""Return the default devcontainer configuration."""
return {
"name": "autorepro-dev",
"features": {
"ghcr.io/devcontainers/features/python:1": {"version": "3.11"},
"ghcr.io/devcontainers/features/node:1": {"version": "20"},
"ghcr.io/devcontainers/features/go:1": {"version": "1.22"},
},
"postCreateCommand": (
"python -m venv .venv && source .venv/bin/activate && pip install -e ."
),
}


def write_devcontainer(content: dict, force: bool = False, out: str | None = None) -> Path:
"""
Write devcontainer configuration to file with atomic and idempotent behavior.

Args:
content: Devcontainer configuration dictionary
force: If True, overwrite existing file
out: Custom output path (default: .devcontainer/devcontainer.json)

Returns:
Path: The path where the file was written

Raises:
DevcontainerExistsError: File exists and force=False
DevcontainerMisuseError: Invalid arguments (e.g., out points to directory)
OSError: I/O or permission errors
"""
# Determine output path
if out is None:
output_path = Path(".devcontainer") / "devcontainer.json"
else:
output_path = Path(out)

# Validate output path
try:
# Normalize path and check if it's valid
output_path = output_path.resolve()
except (OSError, ValueError) as e:
raise DevcontainerMisuseError(f"Invalid output path '{out}': {e}") from e

# Check if output path is a directory (handle permission errors separately)
try:
if output_path.exists() and output_path.is_dir():
raise DevcontainerMisuseError(f"Output path is a directory: {output_path}")
except (OSError, PermissionError) as e:
if not isinstance(e, DevcontainerMisuseError):
raise OSError(f"Permission denied: {output_path.parent}") from e
raise

# Check if parent directory can be created
try:
parent_dir = output_path.parent
if not parent_dir.exists():
parent_dir.mkdir(parents=True, exist_ok=True)
except (OSError, PermissionError) as e:
raise OSError(f"Cannot create parent directory: {parent_dir}") from e

# Check if file exists and handle idempotent behavior
try:
file_exists = output_path.exists()
except (OSError, PermissionError) as e:
raise OSError(f"Permission denied: {output_path.parent}") from e

if file_exists:
if not force:
raise DevcontainerExistsError(output_path)

# Check write permissions on existing file
if not os.access(output_path, os.W_OK):
raise PermissionError(f"Permission denied: {output_path}")
else:
# Check write permissions on parent directory for new file
if not os.access(output_path.parent, os.W_OK):
raise PermissionError(f"Permission denied: {output_path.parent}")

# Write file atomically
try:
# Create content with proper formatting
json_content = json.dumps(content, indent=2, sort_keys=True) + "\n"

# Write to temporary file first, then move (atomic operation)
temp_path = output_path.with_suffix(output_path.suffix + ".tmp")

try:
with open(temp_path, "w", encoding="utf-8") as f:
f.write(json_content)

# Atomic move
temp_path.rename(output_path)

return output_path

except Exception:
# Clean up temp file if it exists
if temp_path.exists():
temp_path.unlink()
raise

except (OSError, PermissionError) as e:
raise OSError(f"Failed to write file: {e}") from e
37 changes: 37 additions & 0 deletions report.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,43 @@ Detected: node, python

---

### Issue #4 Implementation Planning (August 16, 2025)
**Status**: 📋 Planned
**Issue**: [T-002 — `init`: idempotent + `--force` / `--out`](https://github.com/ali90h/AutoRepro/issues/4)
**Objective**: Implement `init` command to create default devcontainer.json with idempotent behavior

#### Analysis Summary:
**Problem**: Create an `init` command that generates standardized devcontainer configurations with Python 3.11, Node 20, and Go 1.22 features, supporting force overwrite and custom output paths.

**Key Requirements**:
- Idempotent behavior (don't overwrite without `--force`)
- Support `--force` flag for overwriting existing files
- Support `--out PATH` for custom output locations
- Create `.devcontainer/devcontainer.json` by default
- Proper exit codes (0=success, 1=exists, 2=args, 3=permissions)
- Comprehensive test coverage with unit and integration tests

#### Implementation Strategy:
**Phase 1**: Core command structure with subparsers
**Phase 2**: Embedded devcontainer JSON template
**Phase 3**: Error handling and edge cases
**Phase 4**: Comprehensive testing (unit + integration)

#### Technical Decisions:
- **Template Storage**: Embed JSON in code for minimal surface area
- **Path Handling**: Use `pathlib.Path` for cross-platform compatibility
- **Default Location**: `.devcontainer/devcontainer.json` (standard convention)
- **Testing**: pytest with tmp_path fixtures and subprocess integration tests

#### Scope & Risk Assessment:
**Files Modified**: `autorepro/cli.py`, `tests/test_cli.py` only
**Estimated Effort**: 4-6 hours (2-3 dev, 1-2 test, 30min validation)
**Risk Level**: Low (minimal surface area, well-defined requirements)

**Next Steps**: Await approval before implementation to ensure alignment with project priorities and approach.

---

*This report is updated after each major development milestone.*

---
Expand Down
Loading