repro-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- repro_cli-0.1.0/PKG-INFO +8 -0
- repro_cli-0.1.0/README.md +0 -0
- repro_cli-0.1.0/pyproject.toml +20 -0
- repro_cli-0.1.0/repro/__init__.py +1 -0
- repro_cli-0.1.0/repro/cli.py +128 -0
- repro_cli-0.1.0/repro/detector/__init__.py +0 -0
- repro_cli-0.1.0/repro/detector/detector.py +51 -0
- repro_cli-0.1.0/repro/docker/__init__.py +0 -0
- repro_cli-0.1.0/repro/docker/runner.py +182 -0
- repro_cli-0.1.0/repro/github/__init__.py +0 -0
- repro_cli-0.1.0/repro/github/issue.py +59 -0
- repro_cli-0.1.0/repro_cli.egg-info/PKG-INFO +8 -0
- repro_cli-0.1.0/repro_cli.egg-info/SOURCES.txt +18 -0
- repro_cli-0.1.0/repro_cli.egg-info/dependency_links.txt +1 -0
- repro_cli-0.1.0/repro_cli.egg-info/entry_points.txt +2 -0
- repro_cli-0.1.0/repro_cli.egg-info/top_level.txt +1 -0
- repro_cli-0.1.0/setup.cfg +4 -0
- repro_cli-0.1.0/tests/test_port_parsing.py +34 -0
- repro_cli-0.1.0/tests/test_repo_validation.py +46 -0
- repro_cli-0.1.0/tests/test_runtime_detection.py +41 -0
repro_cli-0.1.0/PKG-INFO
ADDED
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name="repro-cli"
|
|
7
|
+
version="0.1.0"
|
|
8
|
+
description = "Disposable Docker environments for any GitHub issue"
|
|
9
|
+
readme="README.md"
|
|
10
|
+
requires-python=">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Sukhdev Thukral"}
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
repro = "repro.cli:main"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
packages = ["repro", "repro.github", "repro.detector", "repro.docker"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from repro import __version__
|
|
6
|
+
from repro.github.issue import parse_issue
|
|
7
|
+
from repro.detector.detector import detect_runtime, universal
|
|
8
|
+
from repro.docker.runner import (
|
|
9
|
+
run_sandbox, DockerNotRunningError, DockerNotFoundError,
|
|
10
|
+
parse_port_spec, InvalidPortError, InvalidRepoError
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="repro",
|
|
16
|
+
description="Disposable Docker environments for GitHub issues.",
|
|
17
|
+
epilog=(
|
|
18
|
+
"Examples:\n"
|
|
19
|
+
" repro https://github.com/org/repo/issues/42\n"
|
|
20
|
+
" repro https://github.com/org/repo/issues/42 -p 3000\n"
|
|
21
|
+
" repro https://github.com/org/private-repo/issues/7 --token ghp_xxx\n"
|
|
22
|
+
),
|
|
23
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("issue_url", nargs="?", help="GitHub issue URL")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"-p", "--port",
|
|
28
|
+
action="append",
|
|
29
|
+
default=[],
|
|
30
|
+
metavar="PORT",
|
|
31
|
+
help="Forward a port from the sandbox to your machine. "
|
|
32
|
+
"Use PORT for same host/container port, or HOST:CONTAINER to map them."
|
|
33
|
+
"Can be passed multiple times.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"-t", "--token",
|
|
37
|
+
default=None,
|
|
38
|
+
metavar="TOKEN",
|
|
39
|
+
help="GitHub personal access token, for private repos. "
|
|
40
|
+
"Falls back to the REPRO_GITHUB_TOKEN env var if not passed.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"-v", "--version",
|
|
44
|
+
action="version",
|
|
45
|
+
version=f"repro {__version__}",
|
|
46
|
+
)
|
|
47
|
+
return parser
|
|
48
|
+
|
|
49
|
+
def main():
|
|
50
|
+
|
|
51
|
+
parser = build_parser()
|
|
52
|
+
args = parser.parse_args()
|
|
53
|
+
|
|
54
|
+
if not args.issue_url:
|
|
55
|
+
parser.print_help()
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if "github.com" not in args.issue_url or "/issues/" not in args.issue_url:
|
|
59
|
+
print(f'ERROR: Expected a GitHub issue URL, got "{args.issue_url}"')
|
|
60
|
+
print("Usage: python repro.py <issue-url> [-p PORT ...] [--token TOKEN]")
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
ports = [parse_port_spec(p) for p in args.port]
|
|
66
|
+
except InvalidPortError as e:
|
|
67
|
+
print(f"error: {e}")
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
token = args.token or os.environ.get("REPRO_GITHUB_TOKEN")
|
|
71
|
+
|
|
72
|
+
run(args.issue_url, ports, token)
|
|
73
|
+
|
|
74
|
+
def run(issue_url: str, ports:list, token: str = None):
|
|
75
|
+
|
|
76
|
+
# Parsing the issue
|
|
77
|
+
print("Fetching issue info...")
|
|
78
|
+
try:
|
|
79
|
+
issue = parse_issue(issue_url, token)
|
|
80
|
+
except ValueError as e:
|
|
81
|
+
print(f"error: {e}")
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
print(f"ISSUE #{issue['number']}: {issue['title']}")
|
|
85
|
+
print(f"REPO: {issue['owner']}/{issue['repo']}\n")
|
|
86
|
+
if token:
|
|
87
|
+
print(" Using authenticated access (private repo support enabled)")
|
|
88
|
+
print()
|
|
89
|
+
|
|
90
|
+
# Detecting the runtime
|
|
91
|
+
print("Detecting runtime...")
|
|
92
|
+
runtime = detect_runtime(issue["owner"], issue["repo"], token)
|
|
93
|
+
if runtime is None:
|
|
94
|
+
print("Could not detect runtime, using universal base image")
|
|
95
|
+
runtime = universal()
|
|
96
|
+
|
|
97
|
+
print(f"Detected: {runtime['name']} (image: {runtime['image']})\n")
|
|
98
|
+
|
|
99
|
+
if ports:
|
|
100
|
+
mapping = ", ".join(f"{h}->{c}" for h, c in ports)
|
|
101
|
+
print(f"Forwarding ports: {mapping}\n")
|
|
102
|
+
|
|
103
|
+
print(" Spinning up sandbox...")
|
|
104
|
+
try:
|
|
105
|
+
run_sandbox(issue, runtime, ports, token)
|
|
106
|
+
except DockerNotFoundError as e:
|
|
107
|
+
print(f"\n❌ {e}")
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
except DockerNotRunningError as e:
|
|
110
|
+
print(f"\n❌ {e}")
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
except InvalidRepoError as e:
|
|
113
|
+
print(f"\n❌ {e}")
|
|
114
|
+
sys.exit(1)
|
|
115
|
+
|
|
116
|
+
# def print_usage():
|
|
117
|
+
# print("""repro - disposable Docker environments for GitHub issues
|
|
118
|
+
|
|
119
|
+
# Usage:
|
|
120
|
+
# python repro.py <issue-url> Open a sandbox for a GitHub issue
|
|
121
|
+
# python repro.py --version Print version
|
|
122
|
+
# python repro.py --help Show this help
|
|
123
|
+
|
|
124
|
+
# Examples:
|
|
125
|
+
# python repro.py https://github.com/org/repo/issues/42
|
|
126
|
+
# python repro.py https://github.com/golang/go/issues/1234
|
|
127
|
+
# """
|
|
128
|
+
# )
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import urllib.request
|
|
2
|
+
import urllib.error
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
KNOWN_FILES = [
|
|
6
|
+
("package.json", {"name": "Node.js", "image": "node:20-alpine", "install": "npm install"}),
|
|
7
|
+
("yarn.lock", {"name": "Node.js (yarn)", "image": "node:20-alpine", "install": "yarn install"}),
|
|
8
|
+
("pnpm-lock.yaml", {"name": "Node.js (pnpm)", "image": "node:20-alpine", "install": "npm i -g pnpm && pnpm install"}),
|
|
9
|
+
("requirements.txt",{"name": "Python", "image": "python:3.12-slim", "install": "pip install -r requirements.txt"}),
|
|
10
|
+
("pyproject.toml", {"name": "Python", "image": "python:3.12-slim", "install": "pip install -e ."}),
|
|
11
|
+
("Pipfile", {"name": "Python (pipenv)", "image": "python:3.12-slim", "install": "pip install pipenv && pipenv install"}),
|
|
12
|
+
("go.mod", {"name": "Go", "image": "golang:1.21-alpine", "install": "go mod download"}),
|
|
13
|
+
("Cargo.toml", {"name": "Rust", "image": "rust:1.75-slim", "install": "cargo fetch"}),
|
|
14
|
+
("Gemfile", {"name": "Ruby", "image": "ruby:3.3-slim", "install": "bundle install"}),
|
|
15
|
+
("composer.json", {"name": "PHP", "image": "php:8.3-cli", "install": "composer install"}),
|
|
16
|
+
("pom.xml", {"name": "Java (Maven)", "image": "maven:3.9-eclipse-temurin-21", "install": "mvn dependency:resolve"}),
|
|
17
|
+
("build.gradle", {"name": "Java (Gradle)", "image": "gradle:8-jdk21", "install": "gradle dependencies"}),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
def universal() -> dict:
|
|
21
|
+
return {"name":"universal", "image": "ubuntu:22.04", "install": ""}
|
|
22
|
+
|
|
23
|
+
def detect_runtime(owner: str, repo: str, token: str = None) -> dict | None:
|
|
24
|
+
api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/"
|
|
25
|
+
headers={"User-Agent": "repro/0.1.0", "Accept": "applications/vnd.github+json"}
|
|
26
|
+
if token:
|
|
27
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
28
|
+
try:
|
|
29
|
+
req = urllib.request.Request(
|
|
30
|
+
api_url,
|
|
31
|
+
headers=headers
|
|
32
|
+
)
|
|
33
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
34
|
+
contents = json.loads(resp.read())
|
|
35
|
+
except Exception:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
present = {f["name"] for f in contents}
|
|
39
|
+
|
|
40
|
+
if ".devcontainer" in present or "devcontainer.json" in present:
|
|
41
|
+
return {
|
|
42
|
+
"name" : "devcontainer",
|
|
43
|
+
"image" : "mcr.microsoft.com/devcontainers/universal:latest",
|
|
44
|
+
"install": "",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for filename, runtime in KNOWN_FILES:
|
|
48
|
+
if filename in present:
|
|
49
|
+
return runtime
|
|
50
|
+
|
|
51
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
|
|
7
|
+
class DockerNotFoundError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
class DockerNotRunningError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class InvalidPortError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
class InvalidRepoError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
20
|
+
|
|
21
|
+
def check_docker_available():
|
|
22
|
+
"""Verify Docker CLI exists and the daemon is reachable before trying anythg"""
|
|
23
|
+
if shutil.which("docker") is None:
|
|
24
|
+
raise DockerNotFoundError(
|
|
25
|
+
"Docker is not installed or not on your PATH.\n"
|
|
26
|
+
" Install it from: https://docs.docker.com/get-docker/"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
result = subprocess.run(["docker", "info"], capture_output=True, text=True)
|
|
30
|
+
if result.returncode != 0:
|
|
31
|
+
raise DockerNotRunningError(
|
|
32
|
+
"Docker is installed but the daemon isnt running.\n"
|
|
33
|
+
" Start Docker Desktop and wait for the icon to go steady, then try again."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def validate_repo_identifiers(owner: str, repo:str):
|
|
37
|
+
""" Guard against shell injection -> owner/repo gets interpolated into a shell command, so anything outside GitHub's allowed charst is rejected
|
|
38
|
+
before it even reaches da subprocess"""
|
|
39
|
+
for label, value in (("owner", owner), ("repo", repo)):
|
|
40
|
+
if not SAFE_NAME.match(value):
|
|
41
|
+
raise InvalidRepoError(f'invalid {label} "{value}" - contains disallowed characters')
|
|
42
|
+
if value in (".", ".."):
|
|
43
|
+
raise InvalidRepoError(f'invalid {label} "{value}"')
|
|
44
|
+
if value.startswith("."):
|
|
45
|
+
raise InvalidRepoError(f'invalid {label} "{value}" - cannot start with a dot')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_port_spec(spec: str) -> tuple[int, int]:
|
|
49
|
+
""" Parse a --port value into (host_port, container_port).
|
|
50
|
+
Accepts "3000" (same both sides) or "8080:3000" (host:container).
|
|
51
|
+
"""
|
|
52
|
+
parts = spec.split(":")
|
|
53
|
+
|
|
54
|
+
if len(parts) == 1:
|
|
55
|
+
host, container = parts[0], parts[0]
|
|
56
|
+
elif len(parts) == 2:
|
|
57
|
+
host, container = parts
|
|
58
|
+
else:
|
|
59
|
+
raise InvalidPortError(f'invalid port spec "{spec}" - use PORT or HOST:CONTAINER')
|
|
60
|
+
|
|
61
|
+
for label, value in (("host", host), ("container", container)):
|
|
62
|
+
if not value.isdigit():
|
|
63
|
+
raise InvalidPortError(f'invalid {label} port "{value}" in "{spec}" - must be a number')
|
|
64
|
+
port_num = int(value)
|
|
65
|
+
if not(1 <= port_num <= 65535):
|
|
66
|
+
raise InvalidPortError(f'{label} port {port_num} out of range (1-65535)')
|
|
67
|
+
|
|
68
|
+
return int(host), int(container)
|
|
69
|
+
|
|
70
|
+
def run_sandbox(issue: dict, runtime: dict, ports: list =None, token: str = None):
|
|
71
|
+
check_docker_available()
|
|
72
|
+
ports = ports or []
|
|
73
|
+
|
|
74
|
+
owner = issue["owner"]
|
|
75
|
+
repo = issue["repo"]
|
|
76
|
+
number = issue["number"]
|
|
77
|
+
|
|
78
|
+
validate_repo_identifiers(owner, repo)
|
|
79
|
+
|
|
80
|
+
if not isinstance(number, int):
|
|
81
|
+
raise InvalidRepoError("issue number must be an integer")
|
|
82
|
+
|
|
83
|
+
clone_url_public = f"https://github.com/{owner}/{repo}.git"
|
|
84
|
+
clone_url_auth = f"https://{token}@github.com/{owner}/{repo}.git" if token else clone_url_public
|
|
85
|
+
|
|
86
|
+
work_dir = f"/sandbox/{repo}"
|
|
87
|
+
container_name = f"repro-{owner}-{repo}-{number}"
|
|
88
|
+
|
|
89
|
+
startup_script = build_startup_script(clone_url_auth, clone_url_public, work_dir, runtime["install"], number, ports, has_token=bool(token))
|
|
90
|
+
|
|
91
|
+
args = [
|
|
92
|
+
"docker", "run",
|
|
93
|
+
"--rm",
|
|
94
|
+
"-i",
|
|
95
|
+
"-t",
|
|
96
|
+
"--name", container_name,
|
|
97
|
+
"--hostname", f"sandbox-issue-{number}",
|
|
98
|
+
"-e", f"ISSUE_NUMBER={number}",
|
|
99
|
+
"-e", f"REPO={owner}/{repo}",
|
|
100
|
+
"-e", "TERM=xterm-256color",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
for host_port, container_port in ports:
|
|
104
|
+
args += ["-p", f"127.0.0.1:{host_port}:{container_port}"]
|
|
105
|
+
|
|
106
|
+
env_file_path = None
|
|
107
|
+
if token:
|
|
108
|
+
#writing the toekn to a temp file passed via --env-fle instead of -e/embedded-in-command
|
|
109
|
+
fd, env_file_path = tempfile.mkstemp(prefix="repro-", suffix=".env")
|
|
110
|
+
try:
|
|
111
|
+
os.chmod(env_file_path, 0o600)
|
|
112
|
+
with os.fdopen(fd, "w") as f:
|
|
113
|
+
f.write(f"GIT_TOKEN={token}\n")
|
|
114
|
+
except Exception:
|
|
115
|
+
if os.path.exists(env_file_path):
|
|
116
|
+
os.remove(env_file_path)
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
args += [runtime["image"], "sh", "-c", startup_script]
|
|
120
|
+
|
|
121
|
+
print(f"Starting sandbox for {owner}/{repo} issue #{number}")
|
|
122
|
+
print(" (container will be deleted automatically when you exit)\n")
|
|
123
|
+
|
|
124
|
+
env = os.environ.copy()
|
|
125
|
+
env["DOCKER_CLI_HINTS"] = "false"
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
result = subprocess.run(args, env=env)
|
|
129
|
+
except KeyboardInterrupt:
|
|
130
|
+
print("Interrupted - cleaning up the sandbox...")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
if result.returncode not in (0,130):
|
|
134
|
+
print(f"Sandbox exited with code {result.returncode} - check the output above.")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
print("\n Sandbox destroyed. Back to reality.")
|
|
138
|
+
|
|
139
|
+
def build_startup_script(clone_url_auth, clone_url_public, work_dir, install_cmd, issue_number, ports) -> str:
|
|
140
|
+
install_step = (
|
|
141
|
+
f'echo "Installing dependencies....." && {install_cmd}'
|
|
142
|
+
if install_cmd
|
|
143
|
+
else 'echo "No install step, dropping into shell... "'
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
host_and_path = clone_url_public[len("https://"):]
|
|
147
|
+
clone_cmd = (
|
|
148
|
+
f'if [ -n "$GIT_TOEKN" ]; then '
|
|
149
|
+
f'CLONE_URL="https://${{GIT_TOKEN}}@{host_and_path}";'
|
|
150
|
+
f'else CLONE_URL="{clone_url_public}"; fi && '
|
|
151
|
+
f'git clone --depth=1 "$CLONE_URL" {work_dir} 2>&1 | tail -5'
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
lines = [
|
|
155
|
+
"set -e",
|
|
156
|
+
"which git > /dev/null 2>&1 || (apk add --no-cache git curl)",
|
|
157
|
+
'echo ""',
|
|
158
|
+
'echo "=================================="',
|
|
159
|
+
f'echo " DevSandbox - Issue #{issue_number}"',
|
|
160
|
+
'echo "=================================="',
|
|
161
|
+
'echo ""',
|
|
162
|
+
f'echo "Cloning repo..."',
|
|
163
|
+
clone_cmd,
|
|
164
|
+
f"git clone --depth=1 {clone_url_auth} {work_dir} 2>&1 | tail -5",
|
|
165
|
+
f"cd {work_dir} && git remote set-url origin {clone_url_public} && unset GIT_TOKEN",
|
|
166
|
+
install_step,
|
|
167
|
+
'echo ""',
|
|
168
|
+
f'echo "Ready! You are in: {work_dir}"',
|
|
169
|
+
f'echo "Working on issue #{issue_number}"',
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
if ports:
|
|
173
|
+
lines.append('echo ""')
|
|
174
|
+
for host_port, container_port in ports:
|
|
175
|
+
lines.append(f'echo "Port {container_port} forwarded -> http://localhost:{host_port}"')
|
|
176
|
+
lines.append('echo " Start your app inside this shell (e.g. npm start / node server.js) to use it."')
|
|
177
|
+
|
|
178
|
+
lines.append('echo ""')
|
|
179
|
+
lines.append(f"cd {work_dir} && exec sh")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
return " && ".join(lines)
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import urllib.request
|
|
3
|
+
import urllib.error
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ISSUE_PATTERN = re.compile(r"github\.com/([^/]+)/([^/]+)/issues/(\d+)")
|
|
8
|
+
|
|
9
|
+
def parse_issue(url: str, token: str = None) -> dict:
|
|
10
|
+
match = ISSUE_PATTERN.search(url)
|
|
11
|
+
if not match:
|
|
12
|
+
raise ValueError(f"Invalid Github issue URL: {url}")
|
|
13
|
+
|
|
14
|
+
owner, repo, number = match.group(1), match.group(2), int(match.group(3))
|
|
15
|
+
|
|
16
|
+
issue = {
|
|
17
|
+
"owner": owner,
|
|
18
|
+
"repo": repo,
|
|
19
|
+
"number": number,
|
|
20
|
+
"title": "(could not fetch title)",
|
|
21
|
+
"body" : "",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# trying github api, for public repos without auth
|
|
25
|
+
|
|
26
|
+
api_url = f"https://api.github.com/repos/{owner}/{repo}/issues/{number}"
|
|
27
|
+
headers = {"User-Agent": "repro/0.1.0", "Accept": "applications/vnd.github+json"}
|
|
28
|
+
|
|
29
|
+
if token:
|
|
30
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
req = urllib.request.Request(
|
|
34
|
+
api_url,
|
|
35
|
+
headers=headers,
|
|
36
|
+
)
|
|
37
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
38
|
+
data = json.loads(resp.read())
|
|
39
|
+
issue["title"] = data.get("title", issue["title"])
|
|
40
|
+
issue["body"] = data.get("body", "")
|
|
41
|
+
|
|
42
|
+
except urllib.error.HTTPError as e:
|
|
43
|
+
if e.code == 404:
|
|
44
|
+
if token:
|
|
45
|
+
issue["title"] = "(not found - check the URL, or the token lacks access)"
|
|
46
|
+
else:
|
|
47
|
+
issue["title"] = "(not found - if this is a private repo, pass --token)"
|
|
48
|
+
elif e.code == 401:
|
|
49
|
+
issue["title"] = "(bad or expired token - check --token)"
|
|
50
|
+
elif e.code == 403:
|
|
51
|
+
issue["title"] = "(GitHub API rate limit hit - continuing anyway)"
|
|
52
|
+
else:
|
|
53
|
+
issue["title"] = f"(GitHub API returned {e.code})"
|
|
54
|
+
except urllib.error.URLError:
|
|
55
|
+
issue["title"] = "(no internet connection - continuing anyway)"
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
return issue
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
repro/__init__.py
|
|
4
|
+
repro/cli.py
|
|
5
|
+
repro/detector/__init__.py
|
|
6
|
+
repro/detector/detector.py
|
|
7
|
+
repro/docker/__init__.py
|
|
8
|
+
repro/docker/runner.py
|
|
9
|
+
repro/github/__init__.py
|
|
10
|
+
repro/github/issue.py
|
|
11
|
+
repro_cli.egg-info/PKG-INFO
|
|
12
|
+
repro_cli.egg-info/SOURCES.txt
|
|
13
|
+
repro_cli.egg-info/dependency_links.txt
|
|
14
|
+
repro_cli.egg-info/entry_points.txt
|
|
15
|
+
repro_cli.egg-info/top_level.txt
|
|
16
|
+
tests/test_port_parsing.py
|
|
17
|
+
tests/test_repo_validation.py
|
|
18
|
+
tests/test_runtime_detection.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
repro
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from repro.docker.runner import parse_port_spec, InvalidPortError
|
|
3
|
+
|
|
4
|
+
def test_single_port_maps_to_itself():
|
|
5
|
+
assert parse_port_spec("3000") == (3000, 3000)
|
|
6
|
+
|
|
7
|
+
def test_host_container_mapping():
|
|
8
|
+
assert parse_port_spec("8080:3000") == (8080, 3000)
|
|
9
|
+
|
|
10
|
+
def test_rejects_non_numeric_port():
|
|
11
|
+
with pytest.raises(InvalidPortError):
|
|
12
|
+
parse_port_spec("abc")
|
|
13
|
+
|
|
14
|
+
def test_rejects_port_out_of_range_high():
|
|
15
|
+
with pytest.raises(InvalidPortError):
|
|
16
|
+
parse_port_spec("99999")
|
|
17
|
+
|
|
18
|
+
def test_rejects_port_zero():
|
|
19
|
+
with pytest.raises(InvalidPortError):
|
|
20
|
+
parse_port_spec("0")
|
|
21
|
+
|
|
22
|
+
def test_rejects_malformed_spec_too_many_colons():
|
|
23
|
+
with pytest.raises(InvalidPortError):
|
|
24
|
+
parse_port_spec("1:2:3")
|
|
25
|
+
|
|
26
|
+
def test_rejects_empty_string():
|
|
27
|
+
with pytest.raises(InvalidPortError):
|
|
28
|
+
parse_port_spec("")
|
|
29
|
+
|
|
30
|
+
def test_accepts_boundary_port_1():
|
|
31
|
+
assert parse_port_spec("1") == (1, 1)
|
|
32
|
+
|
|
33
|
+
def test_accepts_boundary_port_65535():
|
|
34
|
+
assert parse_port_spec("65535") == (65535, 65535)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from repro.docker.runner import validate_repo_identifiers, InvalidRepoError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_accepts_normal_owner_repo():
|
|
6
|
+
validate_repo_identifiers("epressjs", "express")
|
|
7
|
+
|
|
8
|
+
def test_accpets_hyphens_and_underscores():
|
|
9
|
+
validate_repo_identifiers("my-org", "my_repo.name")
|
|
10
|
+
|
|
11
|
+
def test_rejects_shell_injection_semicolon():
|
|
12
|
+
with pytest.raises(InvalidRepoError):
|
|
13
|
+
validate_repo_identifiers("owner; rm -rf /", "repo")
|
|
14
|
+
|
|
15
|
+
def test_rejects_shell_injection_backtick():
|
|
16
|
+
with pytest.raises(InvalidRepoError):
|
|
17
|
+
validate_repo_identifiers("owner`whoami`", "repo")
|
|
18
|
+
|
|
19
|
+
def test_rejects_shell_injection_dollar():
|
|
20
|
+
with pytest.raises(InvalidRepoError):
|
|
21
|
+
validate_repo_identifiers("owner$(whoami)", "repo")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_rejects_path_traversal_dotdot():
|
|
25
|
+
with pytest.raises(InvalidRepoError):
|
|
26
|
+
validate_repo_identifiers("owner", "..")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_rejects_single_dot():
|
|
30
|
+
with pytest.raises(InvalidRepoError):
|
|
31
|
+
validate_repo_identifiers("owner", ".")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_rejects_leading_dot():
|
|
35
|
+
with pytest.raises(InvalidRepoError):
|
|
36
|
+
validate_repo_identifiers("owner", ".hidden")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_rejects_slash_in_repo_name():
|
|
40
|
+
with pytest.raises(InvalidRepoError):
|
|
41
|
+
validate_repo_identifiers("owner", "repo/../../etc")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_rejects_space():
|
|
45
|
+
with pytest.raises(InvalidRepoError):
|
|
46
|
+
validate_repo_identifiers("owner name", "repo")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from unittest.mock import patch, MagicMock
|
|
2
|
+
import json
|
|
3
|
+
from repro.detector.detector import detect_runtime, universal
|
|
4
|
+
|
|
5
|
+
def _mock_response(file_list):
|
|
6
|
+
body = json.dumps([{"name":name} for name in file_list]).encode()
|
|
7
|
+
mock_resp = MagicMock()
|
|
8
|
+
mock_resp.read.return_value = body
|
|
9
|
+
mock_resp.__enter__.return_value = mock_resp
|
|
10
|
+
return mock_resp
|
|
11
|
+
|
|
12
|
+
def test_detects_node_from_package_json():
|
|
13
|
+
with patch("urllib.request.urlopen", return_value=_mock_response(["package.json", "README.md"])):
|
|
14
|
+
runtime = detect_runtime("owner", "repo")
|
|
15
|
+
assert runtime["name"] == "Node.js"
|
|
16
|
+
assert runtime["image"] == "node:20-alpine"
|
|
17
|
+
|
|
18
|
+
def test_detecs_python_from_requirements_txt():
|
|
19
|
+
with patch("urllib.request.urlopen", return_value=_mock_response(["requirements.txt"])):
|
|
20
|
+
runtime = detect_runtime("owner", "repo")
|
|
21
|
+
assert runtime["name"] == "Python"
|
|
22
|
+
|
|
23
|
+
def test_detects_go_from_go_mod():
|
|
24
|
+
with patch("urllib.request.urlopen", return_value=_mock_response(["go.mod","main.go"])):
|
|
25
|
+
runtime = detect_runtime("owner", "repo")
|
|
26
|
+
assert runtime["name"] =="Go"
|
|
27
|
+
|
|
28
|
+
def test_devcontainer_takes_priority_over_package_json():
|
|
29
|
+
with patch("urllib.request.urlopen", return_value=_mock_response([".devcontainer", "package.json"])):
|
|
30
|
+
runtime = detect_runtime("owner", "repo")
|
|
31
|
+
assert runtime["name"] == "devcontainer"
|
|
32
|
+
|
|
33
|
+
def test_returns_none_on_network_error():
|
|
34
|
+
with patch("urllib.request.urlopen", side_effect=Exception("network down")):
|
|
35
|
+
runtime = detect_runtime("owner", "repo")
|
|
36
|
+
assert runtime is None
|
|
37
|
+
|
|
38
|
+
def test_universal_fallback_has_correct_shape():
|
|
39
|
+
fallback = universal()
|
|
40
|
+
assert fallback["name"] == "universal"
|
|
41
|
+
assert fallback["image"] == "ubuntu:22.04"
|