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.
Files changed (65) hide show
  1. {replico-0.2.0 → replico-0.2.3}/.github/workflows/ci.yml +3 -2
  2. {replico-0.2.0 → replico-0.2.3}/.gitignore +3 -0
  3. {replico-0.2.0 → replico-0.2.3}/CHANGELOG.md +50 -0
  4. {replico-0.2.0 → replico-0.2.3}/PKG-INFO +2 -2
  5. {replico-0.2.0 → replico-0.2.3}/README.md +1 -1
  6. replico-0.2.3/logs.zip +5 -0
  7. {replico-0.2.0 → replico-0.2.3}/pyproject.toml +2 -1
  8. {replico-0.2.0 → replico-0.2.3}/src/replico/__init__.py +1 -1
  9. {replico-0.2.0 → replico-0.2.3}/src/replico/analysis/whyfail_adapter.py +76 -0
  10. {replico-0.2.0 → replico-0.2.3}/src/replico/cli.py +14 -2
  11. {replico-0.2.0 → replico-0.2.3}/src/replico/environments/python.py +27 -2
  12. {replico-0.2.0 → replico-0.2.3}/src/replico/flows.py +58 -21
  13. {replico-0.2.0 → replico-0.2.3}/tests/test_cli.py +22 -1
  14. replico-0.2.0/.freebuff/project-id +0 -1
  15. {replico-0.2.0 → replico-0.2.3}/LICENSE +0 -0
  16. {replico-0.2.0 → replico-0.2.3}/SECURITY.md +0 -0
  17. {replico-0.2.0 → replico-0.2.3}/src/replico/__main__.py +0 -0
  18. {replico-0.2.0 → replico-0.2.3}/src/replico/analysis/__init__.py +0 -0
  19. {replico-0.2.0 → replico-0.2.3}/src/replico/analysis/classifier.py +0 -0
  20. {replico-0.2.0 → replico-0.2.3}/src/replico/analysis/logs.py +0 -0
  21. {replico-0.2.0 → replico-0.2.3}/src/replico/cmds.py +0 -0
  22. {replico-0.2.0 → replico-0.2.3}/src/replico/config.py +0 -0
  23. {replico-0.2.0 → replico-0.2.3}/src/replico/environments/__init__.py +0 -0
  24. {replico-0.2.0 → replico-0.2.3}/src/replico/environments/base.py +0 -0
  25. {replico-0.2.0 → replico-0.2.3}/src/replico/environments/fingerprint.py +0 -0
  26. {replico-0.2.0 → replico-0.2.3}/src/replico/environments/node.py +0 -0
  27. {replico-0.2.0 → replico-0.2.3}/src/replico/errors.py +0 -0
  28. {replico-0.2.0 → replico-0.2.3}/src/replico/execution/__init__.py +0 -0
  29. {replico-0.2.0 → replico-0.2.3}/src/replico/execution/docker.py +0 -0
  30. {replico-0.2.0 → replico-0.2.3}/src/replico/execution/runner.py +0 -0
  31. {replico-0.2.0 → replico-0.2.3}/src/replico/github/__init__.py +0 -0
  32. {replico-0.2.0 → replico-0.2.3}/src/replico/github/client.py +0 -0
  33. {replico-0.2.0 → replico-0.2.3}/src/replico/github/refs.py +0 -0
  34. {replico-0.2.0 → replico-0.2.3}/src/replico/gitrepo.py +0 -0
  35. {replico-0.2.0 → replico-0.2.3}/src/replico/models.py +0 -0
  36. {replico-0.2.0 → replico-0.2.3}/src/replico/pipeline.py +0 -0
  37. {replico-0.2.0 → replico-0.2.3}/src/replico/security/__init__.py +0 -0
  38. {replico-0.2.0 → replico-0.2.3}/src/replico/security/guard.py +0 -0
  39. {replico-0.2.0 → replico-0.2.3}/src/replico/security/redaction.py +0 -0
  40. {replico-0.2.0 → replico-0.2.3}/src/replico/storage/__init__.py +0 -0
  41. {replico-0.2.0 → replico-0.2.3}/src/replico/storage/store.py +0 -0
  42. {replico-0.2.0 → replico-0.2.3}/src/replico/ui.py +0 -0
  43. {replico-0.2.0 → replico-0.2.3}/src/replico/util.py +0 -0
  44. {replico-0.2.0 → replico-0.2.3}/src/replico/workflow/__init__.py +0 -0
  45. {replico-0.2.0 → replico-0.2.3}/src/replico/workflow/detector.py +0 -0
  46. {replico-0.2.0 → replico-0.2.3}/src/replico/workflow/matcher.py +0 -0
  47. {replico-0.2.0 → replico-0.2.3}/src/replico/workflow/parser.py +0 -0
  48. {replico-0.2.0 → replico-0.2.3}/tests/conftest.py +0 -0
  49. {replico-0.2.0 → replico-0.2.3}/tests/test_classifier.py +0 -0
  50. {replico-0.2.0 → replico-0.2.3}/tests/test_config.py +0 -0
  51. {replico-0.2.0 → replico-0.2.3}/tests/test_exit_codes.py +0 -0
  52. {replico-0.2.0 → replico-0.2.3}/tests/test_fingerprint.py +0 -0
  53. {replico-0.2.0 → replico-0.2.3}/tests/test_github_client.py +0 -0
  54. {replico-0.2.0 → replico-0.2.3}/tests/test_gitrepo.py +0 -0
  55. {replico-0.2.0 → replico-0.2.3}/tests/test_integration_reproduce.py +0 -0
  56. {replico-0.2.0 → replico-0.2.3}/tests/test_integration_whyfail.py +0 -0
  57. {replico-0.2.0 → replico-0.2.3}/tests/test_logs.py +0 -0
  58. {replico-0.2.0 → replico-0.2.3}/tests/test_refs.py +0 -0
  59. {replico-0.2.0 → replico-0.2.3}/tests/test_runner.py +0 -0
  60. {replico-0.2.0 → replico-0.2.3}/tests/test_security_guard.py +0 -0
  61. {replico-0.2.0 → replico-0.2.3}/tests/test_security_redact.py +0 -0
  62. {replico-0.2.0 → replico-0.2.3}/tests/test_store.py +0 -0
  63. {replico-0.2.0 → replico-0.2.3}/tests/test_whyfail_adapter.py +0 -0
  64. {replico-0.2.0 → replico-0.2.3}/tests/test_workflow_detector.py +0 -0
  65. {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
@@ -34,3 +34,6 @@ Thumbs.db
34
34
 
35
35
  # Scratch / probe files
36
36
  _probe_*.py
37
+
38
+ # Agent tooling state
39
+ .freebuff/
@@ -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.0
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
  ↓
@@ -44,7 +44,7 @@ Developer pushes code → CI fails → Developer reads logs
44
44
  ```text
45
45
  GitHub Actions fails
46
46
  ↓
47
- replico <run-url> # or: replico run <run-id>
47
+ replico <run-url> # or: replico run <run-id>; `rep` works too
48
48
  ↓
49
49
  Analyze the failed workflow
50
50
  ↓
replico-0.2.3/logs.zip ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "message": "Not Found",
3
+ "documentation_url": "https://docs.github.com/rest/actions/workflow-runs#download-workflow-run-logs",
4
+ "status": "404"
5
+ }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "replico"
7
- version = "0.2.0"
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"]
@@ -4,4 +4,4 @@ Replico turns a failed GitHub Actions run into a locally reproducible
4
4
  failure whenever it can reconstruct the relevant conditions.
5
5
  """
6
6
 
7
- __version__ = "0.2.0"
7
+ __version__ = "0.2.3"
@@ -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` may read the run id from the environment.
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 = bool(target and version[:2] == target)
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=bool(target and version[:2] == target),
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 not ci_tests and same_category:
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
- # -- diagnosis (WhyFail) ---------------------------------------------------
481
- whyfail_result: WhyFailResult | None = None
482
- whyfail_reason = ""
483
- if now_failed or options.diagnose is True:
484
- plan["target_entries"] = [target]
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