repro-cli 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- repro/cli.py +128 -0
- repro/detector/__init__.py +0 -0
- repro/detector/detector.py +51 -0
- repro/docker/__init__.py +0 -0
- repro/docker/runner.py +182 -0
- repro/github/__init__.py +0 -0
- repro/github/issue.py +59 -0
- repro_cli-0.1.0.dist-info/METADATA +8 -0
- repro_cli-0.1.0.dist-info/RECORD +13 -0
- repro_cli-0.1.0.dist-info/WHEEL +5 -0
- repro_cli-0.1.0.dist-info/entry_points.txt +2 -0
- repro_cli-0.1.0.dist-info/top_level.txt +1 -0
repro/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
repro/cli.py
ADDED
|
@@ -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
|
repro/docker/__init__.py
ADDED
|
File without changes
|
repro/docker/runner.py
ADDED
|
@@ -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)
|
repro/github/__init__.py
ADDED
|
File without changes
|
repro/github/issue.py
ADDED
|
@@ -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,13 @@
|
|
|
1
|
+
repro/__init__.py,sha256=Pru0BlFBASFCFo7McHdohtKkUtgMPDwbGfyUZlE2_Vw,21
|
|
2
|
+
repro/cli.py,sha256=k556YmPHX92kbkYd9YG5g2DBL7FnAHP9zieHaDRd4-Q,4114
|
|
3
|
+
repro/detector/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
repro/detector/detector.py,sha256=E0uT6Tuvh3uKyiqgHRV45bf5MAA2lRtaG3bEtAZkPNI,2792
|
|
5
|
+
repro/docker/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
repro/docker/runner.py,sha256=2nhX0GXBBN03fpZkqNCMapSxxmUYT7hV906-t5J6Uyw,6618
|
|
7
|
+
repro/github/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
repro/github/issue.py,sha256=YJ7J1Z0Wxjjj9K0PtJDlLyjb6M76O4DZxoMDFn2rQ0U,1949
|
|
9
|
+
repro_cli-0.1.0.dist-info/METADATA,sha256=arXr6-4PNubr1lxowPX7D9D-PjgWwvYUovhvaq43MAs,233
|
|
10
|
+
repro_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
repro_cli-0.1.0.dist-info/entry_points.txt,sha256=GwthLe66hkOKckt0ZARt1BdAg3vUFFIo9-NVwr86CF0,41
|
|
12
|
+
repro_cli-0.1.0.dist-info/top_level.txt,sha256=q-Y3CvzXh31Fj1Lbb5v0mrPMVTv6AErZXkqAxqEw7Ig,6
|
|
13
|
+
repro_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
repro
|