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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion helpers/devcontainer_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,4 @@ def save_devcontainer(new_devcontainer):
return result.data[0] if result.data else None
except Exception as e:
logging.error(f"Error saving devcontainer to Supabase: {str(e)}")
raise
raise
114 changes: 114 additions & 0 deletions helpers/docker_compose_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import json
import logging
from urllib.parse import urlparse

try:
import yaml
except ImportError:
yaml = None


def _repo_slug(repo_url):
parsed = urlparse(repo_url)
repo_name = parsed.path.rstrip("/").split("/")[-1] or "workspace"
return "".join(char.lower() if char.isalnum() else "-" for char in repo_name).strip("-") or "workspace"


def _service_name(devcontainer_config, repo_url):
raw_name = devcontainer_config.get("name") or _repo_slug(repo_url)
name = "".join(char.lower() if char.isalnum() else "-" for char in raw_name).strip("-")
return name or "devcontainer"


def _load_devcontainer_config(devcontainer_json):
try:
return json.loads(devcontainer_json)
except (TypeError, json.JSONDecodeError) as e:
logging.warning(f"Unable to parse devcontainer.json for compose generation: {e}")
return {}


def _normalize_compose_env(container_env):
if not isinstance(container_env, dict):
return None

environment = {}
for key, value in container_env.items():
if isinstance(value, str) and value.startswith("${localEnv:") and value.endswith("}"):
environment[key] = "${" + value[len("${localEnv:"):-1] + "}"
else:
environment[key] = value
return environment or None


def _normalize_compose_ports(forward_ports):
if not isinstance(forward_ports, list):
return None

ports = []
for port in forward_ports:
if isinstance(port, int):
ports.append(f"{port}:{port}")
elif isinstance(port, str) and port.strip():
cleaned_port = port.strip()
ports.append(cleaned_port if ":" in cleaned_port else f"{cleaned_port}:{cleaned_port}")
return ports or None


def _dump_yaml(data, indent=0):
lines = []
prefix = " " * indent
for key, value in data.items():
if isinstance(value, dict):
lines.append(f"{prefix}{key}:")
lines.extend(_dump_yaml(value, indent + 2))
elif isinstance(value, list):
lines.append(f"{prefix}{key}:")
for item in value:
lines.append(f"{prefix} - {json.dumps(item)}")
else:
lines.append(f"{prefix}{key}: {json.dumps(value)}")
return lines


def generate_docker_compose_yml(devcontainer_json, repo_url):
devcontainer_config = _load_devcontainer_config(devcontainer_json)
workspace_name = _repo_slug(repo_url)
service = {
"image": devcontainer_config.get("image", "mcr.microsoft.com/devcontainers/base:ubuntu"),
"volumes": [f".:/workspaces/{workspace_name}:cached"],
"working_dir": f"/workspaces/{workspace_name}",
"command": "sleep infinity",
}

if isinstance(devcontainer_config.get("build"), dict):
service.pop("image", None)
service["build"] = {
"context": devcontainer_config["build"].get("context", "."),
}
dockerfile = devcontainer_config["build"].get("dockerfile") or devcontainer_config["build"].get("dockerFile")
if dockerfile:
service["build"]["dockerfile"] = dockerfile
elif devcontainer_config.get("dockerFile") or devcontainer_config.get("dockerfile"):
service.pop("image", None)
service["build"] = {
"context": devcontainer_config.get("context", "."),
"dockerfile": devcontainer_config.get("dockerFile") or devcontainer_config.get("dockerfile"),
}

ports = _normalize_compose_ports(devcontainer_config.get("forwardPorts"))
if ports:
service["ports"] = ports

environment = _normalize_compose_env(devcontainer_config.get("containerEnv"))
if environment:
service["environment"] = environment

compose = {
"services": {
_service_name(devcontainer_config, repo_url): service
}
}
if yaml:
return yaml.safe_dump(compose, sort_keys=False)
return "\n".join(_dump_yaml(compose)) + "\n"
17 changes: 16 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from helpers.openai_helpers import setup_azure_openai, setup_instructor
from helpers.github_helpers import fetch_repo_context, check_url_exists
from helpers.devcontainer_helpers import generate_devcontainer_json, validate_devcontainer_json
from helpers.docker_compose_helpers import generate_docker_compose_yml
from helpers.token_helpers import count_tokens, truncate_to_token_limit
from models import DevContainer
from schemas import DevContainerModel
Expand Down Expand Up @@ -127,6 +128,7 @@ async def post(repo_url: str, regenerate: bool = False):
generated = True
source = "generated" if url is None else "repository"

docker_compose_yml = generate_docker_compose_yml(devcontainer_json, repo_url)

if not exists or regenerate:
logging.info("Saving to database...")
Expand Down Expand Up @@ -185,6 +187,19 @@ async def post(repo_url: str, regenerate: bool = False):
cls="button-group"
),
cls="code-container relative"
),
Article("Docker Compose generated"),
Pre(
Code(docker_compose_yml, id="docker-compose-code", cls="overflow-auto"),
Div(
Button(
Img(cls="w-4 h-4", src="assets/icons/copy-icon.svg", alt="Copy"),
cls="icon-button copy-button",
title="Copy docker-compose.yml to clipboard",
),
cls="button-group"
),
cls="code-container relative"
)
)
except Exception as e:
Expand All @@ -207,4 +222,4 @@ async def get(fname:str, ext:str):

if __name__ == "__main__":
logging.info("Starting FastHTML app...")
serve()
serve()
72 changes: 72 additions & 0 deletions tests/test_docker_compose_generation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import unittest

import helpers.docker_compose_helpers as docker_compose_helpers


class DockerComposeGenerationTest(unittest.TestCase):
def setUp(self):
self.original_yaml = docker_compose_helpers.yaml
docker_compose_helpers.yaml = None

def tearDown(self):
docker_compose_helpers.yaml = self.original_yaml

def test_generates_compose_from_image_devcontainer(self):
compose_yml = docker_compose_helpers.generate_docker_compose_yml(
"""
{
"name": "Python API",
"image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm",
"forwardPorts": [8000, "5173"],
"containerEnv": {
"API_KEY": "${localEnv:API_KEY}",
"ENVIRONMENT": "development"
}
}
""",
"https://github.com/example/python-api",
)

self.assertIn("python-api:", compose_yml)
self.assertIn('image: "mcr.microsoft.com/devcontainers/python:3.12-bookworm"', compose_yml)
self.assertIn(".:/workspaces/python-api:cached", compose_yml)
self.assertIn('working_dir: "/workspaces/python-api"', compose_yml)
self.assertIn("8000:8000", compose_yml)
self.assertIn("5173:5173", compose_yml)
self.assertIn('API_KEY: "${API_KEY}"', compose_yml)
self.assertIn('ENVIRONMENT: "development"', compose_yml)

def test_generates_compose_build_from_dockerfile_devcontainer(self):
compose_yml = docker_compose_helpers.generate_docker_compose_yml(
"""
{
"name": "Dockerfile App",
"build": {
"context": ".devcontainer",
"dockerfile": "Dockerfile"
}
}
""",
"https://github.com/example/dockerfile-app",
)

self.assertIn("dockerfile-app:", compose_yml)
self.assertNotIn("image:", compose_yml)
self.assertIn("build:", compose_yml)
self.assertIn('context: ".devcontainer"', compose_yml)
self.assertIn('dockerfile: "Dockerfile"', compose_yml)

def test_invalid_devcontainer_json_still_produces_usable_compose(self):
with self.assertLogs(level="WARNING"):
compose_yml = docker_compose_helpers.generate_docker_compose_yml(
"{ not json }",
"https://github.com/example/fallback-app",
)

self.assertIn("fallback-app:", compose_yml)
self.assertIn('image: "mcr.microsoft.com/devcontainers/base:ubuntu"', compose_yml)
self.assertIn(".:/workspaces/fallback-app:cached", compose_yml)


if __name__ == "__main__":
unittest.main()