replico 0.2.0__tar.gz → 0.2.3__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.
- {replico-0.2.0 → replico-0.2.3}/.github/workflows/ci.yml +3 -2
- {replico-0.2.0 → replico-0.2.3}/.gitignore +3 -0
- {replico-0.2.0 → replico-0.2.3}/CHANGELOG.md +50 -0
- {replico-0.2.0 → replico-0.2.3}/PKG-INFO +2 -2
- {replico-0.2.0 → replico-0.2.3}/README.md +1 -1
- replico-0.2.3/logs.zip +5 -0
- {replico-0.2.0 → replico-0.2.3}/pyproject.toml +2 -1
- {replico-0.2.0 → replico-0.2.3}/src/replico/__init__.py +1 -1
- {replico-0.2.0 → replico-0.2.3}/src/replico/analysis/whyfail_adapter.py +76 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/cli.py +14 -2
- {replico-0.2.0 → replico-0.2.3}/src/replico/environments/python.py +27 -2
- {replico-0.2.0 → replico-0.2.3}/src/replico/flows.py +58 -21
- {replico-0.2.0 → replico-0.2.3}/tests/test_cli.py +22 -1
- replico-0.2.0/.freebuff/project-id +0 -1
- {replico-0.2.0 → replico-0.2.3}/LICENSE +0 -0
- {replico-0.2.0 → replico-0.2.3}/SECURITY.md +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/__main__.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/analysis/__init__.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/analysis/classifier.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/analysis/logs.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/cmds.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/config.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/environments/__init__.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/environments/base.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/environments/fingerprint.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/environments/node.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/errors.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/execution/__init__.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/execution/docker.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/execution/runner.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/github/__init__.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/github/client.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/github/refs.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/gitrepo.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/models.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/pipeline.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/security/__init__.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/security/guard.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/security/redaction.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/storage/__init__.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/storage/store.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/ui.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/util.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/workflow/__init__.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/workflow/detector.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/workflow/matcher.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/src/replico/workflow/parser.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/conftest.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_classifier.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_config.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_exit_codes.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_fingerprint.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_github_client.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_gitrepo.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_integration_reproduce.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_integration_whyfail.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_logs.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_refs.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_runner.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_security_guard.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_security_redact.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_store.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_whyfail_adapter.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_workflow_detector.py +0 -0
- {replico-0.2.0 → replico-0.2.3}/tests/test_workflow_parser.py +0 -0
|
@@ -28,9 +28,10 @@ jobs:
|
|
|
28
28
|
- name: Test (offline suite, mocked GitHub)
|
|
29
29
|
run: python -m pytest -q
|
|
30
30
|
env:
|
|
31
|
-
# Never reach out to the network during tests.
|
|
31
|
+
# Never reach out to the network during tests. GitHub Actions env
|
|
32
|
+
# names are case-insensitive, so a lowercase `no_proxy` twin of
|
|
33
|
+
# `NO_PROXY` would be rejected as a duplicate key.
|
|
32
34
|
NO_PROXY: "*"
|
|
33
|
-
no_proxy: "*"
|
|
34
35
|
|
|
35
36
|
quality:
|
|
36
37
|
name: lint + types
|
|
@@ -4,6 +4,56 @@ All notable changes to Replico are documented here. Format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic
|
|
5
5
|
versioning (see [pyproject.toml](pyproject.toml)).
|
|
6
6
|
|
|
7
|
+
## [0.2.3] - 2026-09-05
|
|
8
|
+
|
|
9
|
+
Cross-platform fixes found by running Replico's own CI (the v0.2.1 CI
|
|
10
|
+
matrix shook out three real bugs that only appear on Linux runners).
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Python `'3'` requests now match any Python 3.x** — a workflow with
|
|
15
|
+
`python-version: '3'` (or `'3.x'`) reported `matches_request=False` for
|
|
16
|
+
every local interpreter, which made `_pick_isolation` wrongly force
|
|
17
|
+
Docker when Docker was available (e.g. on Ubuntu runners) and skip the
|
|
18
|
+
local venv + WhyFail diagnosis. Requests are now matched like
|
|
19
|
+
setup-python: a bare major matches any same-major version.
|
|
20
|
+
- **`replico reproduce` no longer targets `$GITHUB_RUN_ID`** — when run
|
|
21
|
+
inside GitHub Actions (where `GITHUB_RUN_ID` is set), a bare
|
|
22
|
+
`replico reproduce` silently reproduced the ambient CI run instead of
|
|
23
|
+
reporting "missing run". Only the explicit `replico run` command reads
|
|
24
|
+
the environment fallback now.
|
|
25
|
+
- **Regression tests** for both, plus the `rep` alias test from 0.2.2
|
|
26
|
+
upstreamed into the CLI suite.
|
|
27
|
+
|
|
28
|
+
## [0.2.2] - 2026-09-05
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`rep` console-script alias** — `rep` is now installed as a real
|
|
33
|
+
binary next to `replico` and accepts exactly the same commands
|
|
34
|
+
(`rep <run-url>`, `rep diagnose`, `rep rerun`, …). Zero behavior
|
|
35
|
+
differences; only a shorter command name.
|
|
36
|
+
|
|
37
|
+
## [0.2.1] - 2026-09-05
|
|
38
|
+
|
|
39
|
+
Bug fixes discovered by dogfooding Replico end to end and by running
|
|
40
|
+
Replico's own CI.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **`replico rerun` verdict** — the previously reproduced failure was
|
|
45
|
+
misreported as "a different failure now occurs locally" when CI logs
|
|
46
|
+
were not accessible (no token) and the workflow had no pytest tests.
|
|
47
|
+
Rerun now compares the fresh WhyFail diagnosis against the saved one
|
|
48
|
+
(exception type + message or file/function location) and reports
|
|
49
|
+
`REPRODUCED` with the matching failure signature (`KeyError at
|
|
50
|
+
failure.py in load`) when they agree.
|
|
51
|
+
- **Replico's own CI workflow** — GitHub Actions rejects duplicate env
|
|
52
|
+
names case-insensitively, so `NO_PROXY` + `no_proxy` made the whole
|
|
53
|
+
workflow fail validation with zero jobs started. Removed the lowercase
|
|
54
|
+
duplicate. This is the first real self-dogfooding fix: our CI failed,
|
|
55
|
+
and the failure was found by inspecting the failed run.
|
|
56
|
+
|
|
7
57
|
## [0.2.0] - 2026-09-04
|
|
8
58
|
|
|
9
59
|
WhyFail integration: reproduced Python failures are now diagnosed locally
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: replico
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Turn GitHub Actions CI failures into locally reproducible failures.
|
|
5
5
|
Author: Samarth Chugh (Sam3360)
|
|
6
6
|
License: MIT License
|
|
@@ -101,7 +101,7 @@ Developer pushes code → CI fails → Developer reads logs
|
|
|
101
101
|
```text
|
|
102
102
|
GitHub Actions fails
|
|
103
103
|
↓
|
|
104
|
-
replico <run-url> # or: replico run <run-id
|
|
104
|
+
replico <run-url> # or: replico run <run-id>; `rep` works too
|
|
105
105
|
↓
|
|
106
106
|
Analyze the failed workflow
|
|
107
107
|
↓
|
replico-0.2.3/logs.zip
ADDED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "replico"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Turn GitHub Actions CI failures into locally reproducible failures."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -57,6 +57,7 @@ dev = [
|
|
|
57
57
|
|
|
58
58
|
[project.scripts]
|
|
59
59
|
replico = "replico.cli:main"
|
|
60
|
+
rep = "replico.cli:rep_main"
|
|
60
61
|
|
|
61
62
|
[tool.hatch.build.targets.wheel]
|
|
62
63
|
packages = ["src/replico"]
|
|
@@ -146,6 +146,82 @@ def primary_confidence(diagnostics: list[DiagnosisEntry]) -> str | None:
|
|
|
146
146
|
return cause.get("confidence")
|
|
147
147
|
|
|
148
148
|
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Failure signatures (comparing diagnoses across runs)
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _normalized_diagnostic(raw: Any) -> dict[str, Any] | None:
|
|
155
|
+
"""Accept either a CLI entry ``{"context", "diagnostic"}`` or a bare
|
|
156
|
+
diagnostic dict and return the inner diagnostic (or None)."""
|
|
157
|
+
if isinstance(raw, dict) and isinstance(raw.get("diagnostic"), dict):
|
|
158
|
+
return raw["diagnostic"]
|
|
159
|
+
return raw if isinstance(raw, dict) else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def failure_signatures(diagnostics: list[Any]) -> list[dict[str, Any]]:
|
|
163
|
+
"""Compact identity of each diagnosed failure: exception type plus
|
|
164
|
+
message and user-code location. Used to recognize when a re-run hits
|
|
165
|
+
the *same* failure as a previous diagnosis."""
|
|
166
|
+
signatures: list[dict[str, Any]] = []
|
|
167
|
+
for raw in diagnostics or []:
|
|
168
|
+
diagnostic = _normalized_diagnostic(raw)
|
|
169
|
+
if diagnostic is None:
|
|
170
|
+
continue
|
|
171
|
+
exception_type = diagnostic.get("exception_type")
|
|
172
|
+
if not exception_type:
|
|
173
|
+
continue
|
|
174
|
+
location = diagnostic.get("location") or {}
|
|
175
|
+
signatures.append(
|
|
176
|
+
{
|
|
177
|
+
"exception_type": str(exception_type),
|
|
178
|
+
"message": diagnostic.get("message"),
|
|
179
|
+
"filename": location.get("filename"),
|
|
180
|
+
"function": location.get("function"),
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
return signatures
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def matching_signatures(
|
|
187
|
+
fresh: list[dict[str, Any]], saved: list[dict[str, Any]]
|
|
188
|
+
) -> list[dict[str, Any]]:
|
|
189
|
+
"""Signatures from ``fresh`` that also occur in ``saved``.
|
|
190
|
+
|
|
191
|
+
Two signatures describe the same failure when the exception type matches
|
|
192
|
+
and either the message or the file+function location agrees — tolerant
|
|
193
|
+
of small line shifts, but refusing to conflate different failures.
|
|
194
|
+
"""
|
|
195
|
+
matches: list[dict[str, Any]] = []
|
|
196
|
+
for sig in fresh:
|
|
197
|
+
for other in saved:
|
|
198
|
+
if sig.get("exception_type") != other.get("exception_type"):
|
|
199
|
+
continue
|
|
200
|
+
same_message = sig.get("message") is not None and sig.get("message") == other.get(
|
|
201
|
+
"message"
|
|
202
|
+
)
|
|
203
|
+
same_place = (
|
|
204
|
+
sig.get("filename") is not None
|
|
205
|
+
and sig.get("filename") == other.get("filename")
|
|
206
|
+
and sig.get("function") == other.get("function")
|
|
207
|
+
)
|
|
208
|
+
if same_message or same_place:
|
|
209
|
+
matches.append(sig)
|
|
210
|
+
break
|
|
211
|
+
return matches
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def signature_label(sig: dict[str, Any]) -> str:
|
|
215
|
+
"""Human-readable one-liner for a failure signature."""
|
|
216
|
+
label = str(sig.get("exception_type") or "unknown exception")
|
|
217
|
+
filename = sig.get("filename")
|
|
218
|
+
if filename:
|
|
219
|
+
label += f" at {filename}"
|
|
220
|
+
if sig.get("function"):
|
|
221
|
+
label += f" in {sig['function']}"
|
|
222
|
+
return label
|
|
223
|
+
|
|
224
|
+
|
|
149
225
|
# ---------------------------------------------------------------------------
|
|
150
226
|
# Availability
|
|
151
227
|
# ---------------------------------------------------------------------------
|
|
@@ -304,8 +304,11 @@ def _run(argv: list[str]) -> int:
|
|
|
304
304
|
outcome = rerun(ctx, options)
|
|
305
305
|
else:
|
|
306
306
|
target = getattr(args, "target", None)
|
|
307
|
-
if target is None:
|
|
308
|
-
# `replico run`
|
|
307
|
+
if target is None and command == "run":
|
|
308
|
+
# Only the explicit `replico run` command reads the run id
|
|
309
|
+
# from the environment (for reproducing a CI job's own run
|
|
310
|
+
# from inside a workflow step). A bare `replico reproduce`
|
|
311
|
+
# must never silently target `$GITHUB_RUN_ID`.
|
|
309
312
|
target = _env_run_id()
|
|
310
313
|
if target is None:
|
|
311
314
|
raise InvalidInputError(
|
|
@@ -383,5 +386,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
383
386
|
return EXIT_INTERNAL
|
|
384
387
|
|
|
385
388
|
|
|
389
|
+
def rep_main(argv: list[str] | None = None) -> int:
|
|
390
|
+
"""Entry point for the ``rep`` console-script alias of ``replico``.
|
|
391
|
+
|
|
392
|
+
Identical to :func:`main`; the two are separate entry points only so
|
|
393
|
+
that both ``replico`` and ``rep`` can be installed as console scripts.
|
|
394
|
+
"""
|
|
395
|
+
return main(argv)
|
|
396
|
+
|
|
397
|
+
|
|
386
398
|
if __name__ == "__main__":
|
|
387
399
|
raise SystemExit(main())
|
|
@@ -39,6 +39,31 @@ def parse_python_request(text: str) -> str | None:
|
|
|
39
39
|
return f"{major}.{minor}" if minor else major
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _matches_request(version: tuple[int, int, int], requested: str | None) -> bool:
|
|
43
|
+
"""Whether an installed version satisfies a CI python-version request.
|
|
44
|
+
|
|
45
|
+
* ``'3'``/``'3.x'`` matches any Python 3.x (GitHub's setup-python semantics).
|
|
46
|
+
* ``'3.12'`` matches exactly (major, minor).
|
|
47
|
+
* ``'>=3.12'`` etc. is treated as a loose floor (best effort).
|
|
48
|
+
"""
|
|
49
|
+
if requested is None:
|
|
50
|
+
return False
|
|
51
|
+
text = requested.strip().lower()
|
|
52
|
+
op_match = re.match(r"^(>=|~=|==)?\s*(\d+)(?:\.(\d+))?", text)
|
|
53
|
+
if not op_match:
|
|
54
|
+
return False
|
|
55
|
+
op = op_match.group(1) or "=="
|
|
56
|
+
major = int(op_match.group(2))
|
|
57
|
+
minor = op_match.group(3)
|
|
58
|
+
if minor is None or minor.lower() in ("x", "*"):
|
|
59
|
+
# Major-only request: any same-major satisfies it.
|
|
60
|
+
return version[0] == major
|
|
61
|
+
minor_i = int(minor)
|
|
62
|
+
if op == ">=":
|
|
63
|
+
return (version[0], version[1]) >= (major, minor_i)
|
|
64
|
+
return (version[0], version[1]) == (major, minor_i)
|
|
65
|
+
|
|
66
|
+
|
|
42
67
|
def probe_python_version(executable: str) -> tuple[int, int, int] | None:
|
|
43
68
|
"""Return the version of a python executable, or None on any failure."""
|
|
44
69
|
try:
|
|
@@ -95,7 +120,7 @@ def find_python(request: str | None) -> PythonRuntime:
|
|
|
95
120
|
version = probe_python_version(path)
|
|
96
121
|
if version is None:
|
|
97
122
|
continue
|
|
98
|
-
match =
|
|
123
|
+
match = _matches_request(version, requested)
|
|
99
124
|
if runtime is None or match:
|
|
100
125
|
runtime = PythonRuntime(
|
|
101
126
|
executable=path, version=version, how=f"found {name} on PATH", matches_request=match
|
|
@@ -118,7 +143,7 @@ def find_python(request: str | None) -> PythonRuntime:
|
|
|
118
143
|
executable=f"{py_launcher} {flag}",
|
|
119
144
|
version=version,
|
|
120
145
|
how=f"py launcher {flag}",
|
|
121
|
-
matches_request=
|
|
146
|
+
matches_request=_matches_request(version, requested),
|
|
122
147
|
)
|
|
123
148
|
if candidate.matches_request:
|
|
124
149
|
return candidate
|
|
@@ -16,10 +16,13 @@ from replico.analysis import classifier as clsf
|
|
|
16
16
|
from replico.analysis.whyfail_adapter import (
|
|
17
17
|
WhyFailResult,
|
|
18
18
|
describe_reason,
|
|
19
|
+
failure_signatures,
|
|
19
20
|
find_python_invocation,
|
|
21
|
+
matching_signatures,
|
|
20
22
|
render_diagnosis,
|
|
21
23
|
run_diagnosis,
|
|
22
24
|
should_diagnose,
|
|
25
|
+
signature_label,
|
|
23
26
|
whyfail_available,
|
|
24
27
|
)
|
|
25
28
|
from replico.config import Config
|
|
@@ -444,6 +447,36 @@ def rerun(ctx: AppContext, options: ReproOptions) -> Outcome:
|
|
|
444
447
|
ci_category = (payload.get("classification") or {}).get("category")
|
|
445
448
|
now_failed = not result.ok
|
|
446
449
|
local_tests = local_evidence.failing_tests
|
|
450
|
+
|
|
451
|
+
# -- diagnosis (WhyFail) ---------------------------------------------------
|
|
452
|
+
# Run before the verdict: for Python failures, comparing the fresh
|
|
453
|
+
# diagnosis against the saved one is the strongest evidence of whether
|
|
454
|
+
# the *same* failure still reproduces (test names and CI log categories
|
|
455
|
+
# are unavailable when CI logs were not accessible).
|
|
456
|
+
whyfail_result: WhyFailResult | None = None
|
|
457
|
+
whyfail_reason = ""
|
|
458
|
+
if now_failed or options.diagnose is True:
|
|
459
|
+
plan["target_entries"] = [target]
|
|
460
|
+
plan["cwd"] = _cwd_for(ctx, target)
|
|
461
|
+
whyfail_result, whyfail_reason = attempt_local_diagnosis(
|
|
462
|
+
ctx,
|
|
463
|
+
ecosystems=["python"] if plan.get("venv") else [],
|
|
464
|
+
plan=plan,
|
|
465
|
+
local_failed=now_failed,
|
|
466
|
+
flag=options.diagnose,
|
|
467
|
+
)
|
|
468
|
+
else:
|
|
469
|
+
whyfail_reason = "no_local_failure"
|
|
470
|
+
|
|
471
|
+
fresh_signatures: list[dict] = []
|
|
472
|
+
saved_signatures: list[dict] = []
|
|
473
|
+
same_diagnosis: list[dict] = []
|
|
474
|
+
if whyfail_result is not None and whyfail_result.diagnosed:
|
|
475
|
+
fresh_signatures = failure_signatures(whyfail_result.diagnostics)
|
|
476
|
+
saved_doc = ctx.store.read_whyfail() or {}
|
|
477
|
+
saved_signatures = failure_signatures(saved_doc.get("diagnostics") or [])
|
|
478
|
+
same_diagnosis = matching_signatures(fresh_signatures, saved_signatures)
|
|
479
|
+
|
|
447
480
|
matched = sorted(set(ci_tests) & set(local_tests))
|
|
448
481
|
|
|
449
482
|
if not now_failed:
|
|
@@ -460,7 +493,14 @@ def rerun(ctx: AppContext, options: ReproOptions) -> Outcome:
|
|
|
460
493
|
same_category = bool(ci_category) and (
|
|
461
494
|
clsf.classify(local_evidence).category == ci_category
|
|
462
495
|
)
|
|
463
|
-
if
|
|
496
|
+
if same_diagnosis:
|
|
497
|
+
verdict = Verdict.REPRODUCED
|
|
498
|
+
headline = "The reproduced failure still exists locally."
|
|
499
|
+
reasons = [
|
|
500
|
+
"the same diagnosed failure still occurs locally: "
|
|
501
|
+
+ ", ".join(signature_label(s) for s in same_diagnosis)
|
|
502
|
+
]
|
|
503
|
+
elif not ci_tests and same_category:
|
|
464
504
|
verdict = Verdict.REPRODUCED
|
|
465
505
|
headline = "A failure matching CI still occurs locally."
|
|
466
506
|
reasons = [f"failure category {ci_category} still reproduces"]
|
|
@@ -468,6 +508,14 @@ def rerun(ctx: AppContext, options: ReproOptions) -> Outcome:
|
|
|
468
508
|
verdict = Verdict.PARTIALLY_REPRODUCED
|
|
469
509
|
headline = "A different failure now occurs locally."
|
|
470
510
|
reasons = ["the previously reproduced failure appears to be gone"]
|
|
511
|
+
if fresh_signatures and saved_signatures:
|
|
512
|
+
reasons.append(
|
|
513
|
+
"previously diagnosed: "
|
|
514
|
+
+ ", ".join(signature_label(s) for s in saved_signatures)
|
|
515
|
+
)
|
|
516
|
+
reasons.append(
|
|
517
|
+
"now diagnosed: " + ", ".join(signature_label(s) for s in fresh_signatures)
|
|
518
|
+
)
|
|
471
519
|
if local_tests:
|
|
472
520
|
reasons.append("still failing locally: " + ", ".join(local_tests))
|
|
473
521
|
if ci_tests:
|
|
@@ -477,26 +525,11 @@ def rerun(ctx: AppContext, options: ReproOptions) -> Outcome:
|
|
|
477
525
|
ui.section("Local result")
|
|
478
526
|
_render_local_result(ui, result, local_evidence)
|
|
479
527
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
plan["cwd"] = _cwd_for(ctx, target)
|
|
486
|
-
whyfail_result, whyfail_reason = attempt_local_diagnosis(
|
|
487
|
-
ctx,
|
|
488
|
-
ecosystems=["python"] if plan.get("venv") else [],
|
|
489
|
-
plan=plan,
|
|
490
|
-
local_failed=now_failed,
|
|
491
|
-
flag=options.diagnose,
|
|
492
|
-
)
|
|
493
|
-
if whyfail_result is not None:
|
|
494
|
-
ui.section("WhyFail diagnosis")
|
|
495
|
-
render_diagnosis(ui, whyfail_result)
|
|
496
|
-
elif options.diagnose is True:
|
|
497
|
-
ui.warn(f"WhyFail diagnosis skipped: {describe_reason(whyfail_reason)}")
|
|
498
|
-
else:
|
|
499
|
-
whyfail_reason = "no_local_failure"
|
|
528
|
+
if whyfail_result is not None:
|
|
529
|
+
ui.section("WhyFail diagnosis")
|
|
530
|
+
render_diagnosis(ui, whyfail_result)
|
|
531
|
+
elif options.diagnose is True:
|
|
532
|
+
ui.warn(f"WhyFail diagnosis skipped: {describe_reason(whyfail_reason)}")
|
|
500
533
|
|
|
501
534
|
ui.rule()
|
|
502
535
|
ui.out(_result_banner(verdict), style=_style_for(verdict))
|
|
@@ -522,6 +555,10 @@ def rerun(ctx: AppContext, options: ReproOptions) -> Outcome:
|
|
|
522
555
|
"reason": whyfail_result.reason,
|
|
523
556
|
"summary": whyfail_result.summary(),
|
|
524
557
|
}
|
|
558
|
+
if same_diagnosis:
|
|
559
|
+
rerun_entry["diagnosis"]["same_as_saved"] = [signature_label(s) for s in same_diagnosis]
|
|
560
|
+
if same_diagnosis:
|
|
561
|
+
rerun_entry["diagnosis"]["same_as_saved"] = [signature_label(s) for s in same_diagnosis]
|
|
525
562
|
reruns.append(rerun_entry)
|
|
526
563
|
payload["reruns"] = reruns
|
|
527
564
|
whyfail_doc = _whyfail_payload_doc(whyfail_result, whyfail_reason)
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
|
-
from replico.cli import main
|
|
7
|
+
from replico.cli import main, rep_main
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def test_version(capsys):
|
|
@@ -18,6 +18,14 @@ def test_version_command(capsys):
|
|
|
18
18
|
assert capsys.readouterr().out.startswith("replico ")
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def test_rep_alias(capsys):
|
|
22
|
+
"""`rep` is a real console-script alias of `replico`."""
|
|
23
|
+
assert rep_main(["--version"]) == 0
|
|
24
|
+
assert capsys.readouterr().out.startswith("replico ")
|
|
25
|
+
assert rep_main(["version"]) == 0
|
|
26
|
+
assert capsys.readouterr().out.startswith("replico ")
|
|
27
|
+
|
|
28
|
+
|
|
21
29
|
def test_help(capsys):
|
|
22
30
|
assert main(["help"]) == 0
|
|
23
31
|
assert "reproduce" in capsys.readouterr().out
|
|
@@ -48,6 +56,19 @@ def test_missing_target_exit_3(capsys):
|
|
|
48
56
|
assert "missing run" in capsys.readouterr().out
|
|
49
57
|
|
|
50
58
|
|
|
59
|
+
def test_reproduce_without_target_ignores_ambient_ci_run(capsys, monkeypatch):
|
|
60
|
+
"""A bare `replico reproduce` must not silently target $GITHUB_RUN_ID.
|
|
61
|
+
|
|
62
|
+
Regression for a bug where running inside GitHub Actions (where
|
|
63
|
+
GITHUB_RUN_ID is set) made `replico reproduce` with no target target the
|
|
64
|
+
ambient CI run instead of reporting a missing run.
|
|
65
|
+
"""
|
|
66
|
+
monkeypatch.setenv("GITHUB_REPOSITORY", "octocat/demo")
|
|
67
|
+
monkeypatch.setenv("GITHUB_RUN_ID", "123456789")
|
|
68
|
+
assert main(["reproduce"]) == 3
|
|
69
|
+
assert "missing run" in capsys.readouterr().out
|
|
70
|
+
|
|
71
|
+
|
|
51
72
|
def test_invalid_url_exit_3(capsys):
|
|
52
73
|
assert main(["https://example.com/not-a-run"]) == 3
|
|
53
74
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
49c8c90f-46e1-4c45-a454-0861ed60b128
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|