oneport-debug-cicd 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.
@@ -0,0 +1,24 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """
4
+ oneport-debug-cicd: Non-interactive CI/CD background debugging worker.
5
+
6
+ Use case — The MNC problem:
7
+ A Jenkins build breaks at 3 AM on the release branch. Tests fail.
8
+ On-call developer gets paged. They spend 45 minutes reading build logs,
9
+ checking git blame, and running tests locally to understand what changed.
10
+
11
+ Instead, add to your Jenkinsfile:
12
+ post { failure { sh 'oneport-cicd-worker --build $BUILD_URL --post-jira --post-slack' } }
13
+
14
+ → Worker boots non-interactively in a Docker sandbox
15
+ → Fetches the failed build log from Jenkins API
16
+ → Diffs the failing commit against the last green build
17
+ → Runs the failing tests in isolation to confirm the regression
18
+ → AI generates a patch, creates a Jira ticket, and posts to #builds Slack
19
+ → On-call sees: "Build #1247 failed. Root cause: UserService.findById() returns null
20
+ when user has pending_deletion status. Introduced in commit f3a2b1c by bob@corp.com.
21
+ Fix: add null check at UserService.java:L89. Patch attached."
22
+ """
23
+
24
+ __version__ = "0.1.0"
@@ -0,0 +1,193 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """
4
+ FixGenerator — turns an RCAResult into an actionable unified diff patch.
5
+
6
+ The AI model already identified root cause + recommended fix in the RCAResult.
7
+ This module takes that free-text fix and:
8
+ 1. Reads the actual source file at the blamed line
9
+ 2. Calls the LLM again with targeted "generate a patch" prompt
10
+ 3. Parses the LLM response for a unified diff block
11
+ 4. Validates the diff is syntactically well-formed
12
+ 5. Returns a FixProposal with the diff ready to apply via `git apply`
13
+
14
+ The generated patch is intentionally minimal — one focused change, not a refactor.
15
+
16
+ Enterprise note:
17
+ - The patch is NOT auto-applied. It's attached to the Jira ticket / Slack block.
18
+ - The developer reviews and applies with: git apply < fix.patch
19
+ - This satisfies SOX change-management requirements (human approval gate).
20
+
21
+ Usage:
22
+ generator = FixGenerator(orchestrator)
23
+ proposal = await generator.generate(rca_result, repo_path)
24
+ if proposal:
25
+ print(proposal.unified_diff)
26
+ proposal.write(Path("fix.patch"))
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ import structlog
36
+
37
+ from oneport_debug_core.engine.orchestrator import Orchestrator
38
+ from oneport_debug_core.llm.base import LLMCompletionOptions
39
+ from oneport_debug_core.models.rca import RCAResult, Evidence
40
+
41
+ log = structlog.get_logger(__name__)
42
+
43
+ _MAX_CONTEXT_LINES = 40 # lines of code around the blame line to include in prompt
44
+
45
+
46
+ @dataclass
47
+ class FixProposal:
48
+ unified_diff: str # ready to pass to `git apply`
49
+ target_file: str # repo-relative file path
50
+ target_line: int # approximate line number
51
+ confidence: float # 0.0–1.0 from model
52
+ explanation: str # human-readable description of the fix
53
+
54
+ def write(self, path: Path) -> None:
55
+ path.write_text(self.unified_diff, encoding="utf-8")
56
+ log.info("fix_generator.patch_written", path=str(path))
57
+
58
+ @property
59
+ def is_valid(self) -> bool:
60
+ """Basic sanity check — diff must start with --- and have at least one hunk."""
61
+ return "---" in self.unified_diff and "@@" in self.unified_diff
62
+
63
+
64
+ class FixGenerator:
65
+ def __init__(self, orchestrator: Orchestrator) -> None:
66
+ self._orchestrator = orchestrator
67
+
68
+ async def generate(
69
+ self,
70
+ rca: RCAResult,
71
+ repo_root: Path,
72
+ ) -> FixProposal | None:
73
+ """
74
+ Generate a patch for the first high-confidence code location in the RCA.
75
+ Returns None if no actionable location exists or the LLM can't produce a valid diff.
76
+ """
77
+ if not rca.locations:
78
+ log.info("fix_generator.no_locations")
79
+ return None
80
+
81
+ # Pick the highest-confidence location we can read
82
+ loc = next(
83
+ (l for l in rca.locations
84
+ if l.file_path and (repo_root / l.file_path).exists()),
85
+ None,
86
+ )
87
+ if not loc:
88
+ log.info("fix_generator.no_readable_location")
89
+ return None
90
+
91
+ file_path = repo_root / loc.file_path
92
+ source_lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
93
+ line_no = loc.line_number or 1
94
+
95
+ # Extract context window
96
+ start = max(0, line_no - _MAX_CONTEXT_LINES // 2)
97
+ end = min(len(source_lines), line_no + _MAX_CONTEXT_LINES // 2)
98
+ context = "\n".join(
99
+ f"{i + 1:4d} {line}"
100
+ for i, line in enumerate(source_lines[start:end], start=start)
101
+ )
102
+
103
+ prompt = f"""You are an expert software engineer. Generate a minimal unified diff patch to fix the following bug.
104
+
105
+ ## Bug Summary
106
+ {rca.summary}
107
+
108
+ ## Root Cause
109
+ {rca.root_cause}
110
+
111
+ ## Recommended Fix
112
+ {rca.recommended_fix or "Not specified"}
113
+
114
+ ## File: {loc.file_path} (around line {line_no})
115
+ ```
116
+ {context}
117
+ ```
118
+
119
+ ## Instructions
120
+ 1. Output a syntactically valid unified diff that can be applied with `git apply`
121
+ 2. Patch ONLY the minimum necessary lines — do not refactor or add features
122
+ 3. Use the exact file path from above in the `---` / `+++` header
123
+ 4. After the diff, add a JSON block with: {{"confidence": 0.0-1.0, "explanation": "one sentence"}}
124
+ 5. If you cannot generate a safe patch, output: {{"confidence": 0, "explanation": "reason"}}
125
+
126
+ Output format:
127
+ ```diff
128
+ --- a/{loc.file_path}
129
+ +++ b/{loc.file_path}
130
+ @@ ... @@
131
+ ...patch content...
132
+ ```
133
+ ```json
134
+ {{"confidence": 0.85, "explanation": "Fixed null check before accessing user.groups"}}
135
+ ```"""
136
+
137
+ try:
138
+ raw = await self._orchestrator.llm.complete(
139
+ prompt,
140
+ LLMCompletionOptions(temperature=0.05, max_tokens=2000),
141
+ )
142
+ except Exception as err:
143
+ log.warning("fix_generator.llm_failed", error=str(err))
144
+ return None
145
+
146
+ return self._parse_response(raw, loc.file_path, line_no)
147
+
148
+ def _parse_response(self, raw: str, file_path: str, line_no: int) -> FixProposal | None:
149
+ # Extract unified diff
150
+ diff_match = re.search(r"```diff\s*([\s\S]*?)```", raw, re.IGNORECASE)
151
+ if not diff_match:
152
+ # Try without fence
153
+ diff_start = raw.find("---")
154
+ if diff_start == -1:
155
+ log.warning("fix_generator.no_diff_found")
156
+ return None
157
+ diff_text = raw[diff_start:]
158
+ # Cut at next ``` if present
159
+ fence_end = diff_text.find("```")
160
+ if fence_end != -1:
161
+ diff_text = diff_text[:fence_end]
162
+ else:
163
+ diff_text = diff_match.group(1)
164
+
165
+ diff_text = diff_text.strip()
166
+ if not diff_text or "@@" not in diff_text:
167
+ log.warning("fix_generator.invalid_diff")
168
+ return None
169
+
170
+ # Extract JSON metadata
171
+ confidence = 0.7
172
+ explanation = "AI-generated patch"
173
+ json_match = re.search(r"```json\s*([\s\S]*?)```", raw, re.IGNORECASE)
174
+ if json_match:
175
+ try:
176
+ import json
177
+ meta = json.loads(json_match.group(1))
178
+ confidence = float(meta.get("confidence", 0.7))
179
+ explanation = meta.get("explanation", explanation)
180
+ except Exception:
181
+ pass
182
+
183
+ if confidence == 0:
184
+ log.info("fix_generator.model_declined", explanation=explanation)
185
+ return None
186
+
187
+ return FixProposal(
188
+ unified_diff=diff_text,
189
+ target_file=file_path,
190
+ target_line=line_no,
191
+ confidence=min(1.0, max(0.0, confidence)),
192
+ explanation=explanation,
193
+ )
@@ -0,0 +1,119 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """
4
+ oneport-cicd CLI — manual trigger and status management.
5
+
6
+ The worker.py is for automated CI hooks.
7
+ This CLI is for developers to manually trigger analysis, check status, and replay.
8
+
9
+ Usage:
10
+ # Manually trigger analysis of a failed GitHub Actions run
11
+ oneport-cicd analyze --platform github-actions --run-id 12345678 --repo myorg/payment-service
12
+
13
+ # View the last N RCAs from the audit log
14
+ oneport-cicd history --last 10
15
+
16
+ # Re-run analysis on a cached build log
17
+ oneport-cicd replay --log-file ./saved_build.log --platform github-actions
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import click
27
+
28
+ from oneport_debug_core.config.settings import load_config
29
+ from oneport_debug_core.cli.output import print_rca, print_error, print_step, console
30
+
31
+
32
+ @click.group()
33
+ @click.option("--config", default=None, type=click.Path())
34
+ @click.pass_context
35
+ def main(ctx: click.Context, config: str | None) -> None:
36
+ """oneport-debug-cicd — CI/CD failure root cause analysis."""
37
+ from oneport_debug_core.cli.output import ensure_utf8_console
38
+ ensure_utf8_console()
39
+ ctx.ensure_object(dict)
40
+ ctx.obj["config"] = load_config(Path(config) if config else None)
41
+
42
+
43
+ @main.command()
44
+ @click.option("--format", "output_format", default="rich", type=click.Choice(["rich", "json"]), help="Output format")
45
+ def demo(output_format: str) -> None:
46
+ """Run a zero-config demo on a bundled CI regression (no CI server/API key needed)."""
47
+ from oneport_debug_cicd.demo import run_demo
48
+ run_demo(json_output=(output_format == "json"))
49
+
50
+
51
+ @main.command()
52
+ @click.option("--platform", required=True, type=click.Choice(["github-actions", "gitlab", "jenkins"]))
53
+ @click.option("--run-id", default=None)
54
+ @click.option("--repo", default=None)
55
+ @click.option("--pipeline-id", default=None)
56
+ @click.option("--project-id", default=None)
57
+ @click.option("--build-url", default=None)
58
+ @click.option("--post-jira", is_flag=True)
59
+ @click.option("--post-slack", is_flag=True)
60
+ @click.option("--format", "output_format", default="rich", type=click.Choice(["rich", "json"]))
61
+ @click.pass_context
62
+ def analyze(ctx: click.Context, platform, run_id, repo, pipeline_id, project_id, build_url, post_jira, post_slack, output_format):
63
+ """Manually analyze a failed CI build and produce an RCA."""
64
+ from oneport_debug_cicd.worker import _run
65
+ config = ctx.obj["config"]
66
+ print_step(f"Analyzing {platform} build failure...")
67
+ exit_code = asyncio.run(_run(config, platform, run_id, repo, pipeline_id, project_id, build_url, post_jira, post_slack, output_format))
68
+ sys.exit(exit_code)
69
+
70
+
71
+ @main.command()
72
+ @click.option("--log", "log_path", default="/var/log/oneport-debug/audit.jsonl", type=click.Path())
73
+ @click.option("--last", default=10, show_default=True)
74
+ def history(log_path: str, last: int) -> None:
75
+ """Show the last N CI/CD analysis sessions from the audit log."""
76
+ path = Path(log_path)
77
+ if not path.exists():
78
+ print_error(f"Audit log not found: {log_path}")
79
+ return
80
+
81
+ sessions = [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
82
+ cicd_sessions = [s for s in sessions if s.get("module") == "cicd"][-last:]
83
+
84
+ console.print(f"\n[bold]Last {len(cicd_sessions)} CI/CD analyses:[/bold]\n")
85
+ for s in reversed(cicd_sessions):
86
+ color = "red" if float(s.get("confidence", 0)) < 0.5 else "green"
87
+ console.print(
88
+ f" [{color}]●[/{color}] {s.get('timestamp', '')} "
89
+ f"confidence={s.get('confidence', 0):.0%} "
90
+ f"provider={s.get('provider', '?')} "
91
+ f"duration={s.get('duration_ms', 0)}ms"
92
+ )
93
+
94
+
95
+ @main.command()
96
+ @click.option("--log-file", required=True, type=click.Path(exists=True))
97
+ @click.option("--platform", default="github-actions")
98
+ @click.option("--format", "output_format", default="rich", type=click.Choice(["rich", "json"]))
99
+ @click.pass_context
100
+ def replay(ctx: click.Context, log_file: str, platform: str, output_format: str) -> None:
101
+ """Re-run analysis on a saved build log file."""
102
+ config = ctx.obj["config"]
103
+ from oneport_debug_core.engine.orchestrator import Orchestrator
104
+ from oneport_debug_core.models.rca import Evidence
105
+
106
+ try:
107
+ log_text = Path(log_file).read_text(encoding="utf-8", errors="replace")
108
+ evidence = Evidence(source=f"{platform}-replay", raw={"build_log": log_text[-6000:]})
109
+ orchestrator = Orchestrator(config)
110
+
111
+ result = asyncio.run(orchestrator.analyze(
112
+ module="cicd",
113
+ evidence=[evidence],
114
+ extra_context={"platform": platform, "source": "replay"},
115
+ ))
116
+ print_rca(result, output_format)
117
+ except Exception as err:
118
+ print_error(str(err), hint="Check the log file and your model configuration (ONEPORT_MODE / API keys)")
119
+ sys.exit(1)
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """Zero-config demo for oneport-cicd — runs the real RegressionDetector on a bundled failure."""
4
+
5
+ from oneport_debug_cicd.demo.runner import run_demo
6
+
7
+ __all__ = ["run_demo"]
@@ -0,0 +1,220 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """
4
+ Runner for `oneport-cicd demo`.
5
+
6
+ Materializes a real two-commit git repository (a green build, then a commit that
7
+ regresses it) and runs the *real* RegressionDetector over it — the same code path
8
+ used against a live GitHub Actions / Jenkins / GitLab failure. The culprit commit,
9
+ author, changed file, and changed function are all computed live; only the AI
10
+ narrative is curated so the demo needs no API key or network.
11
+
12
+ This shows the thing Claude Code structurally cannot do: it knows the diff, but
13
+ not *which commit since the last green build* broke things, or *who* owns it.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import tempfile
21
+ from pathlib import Path
22
+
23
+ from rich import box
24
+ from rich.panel import Panel
25
+ from rich.table import Table
26
+ from rich.text import Text
27
+
28
+ from oneport_debug_core.cli.output import console, print_rca
29
+ from oneport_debug_core.models.rca import CodeLocation, RCAResult, Severity
30
+ from oneport_debug_cicd.demo import sample_data as sd
31
+ from oneport_debug_cicd.sandbox.regression_detector import RegressionDetector, RegressionReport
32
+
33
+
34
+ def _quiet_logs() -> None:
35
+ import logging
36
+ import structlog
37
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(logging.WARNING))
38
+
39
+
40
+ def run_demo(json_output: bool = False) -> RCAResult:
41
+ """Materialize the sample repo, run the real regression detector, render the RCA."""
42
+ _quiet_logs()
43
+
44
+ tmp = Path(tempfile.mkdtemp(prefix="oneport-cicd-demo-"))
45
+ try:
46
+ green_sha, red_sha = _materialize_repo(tmp)
47
+ report = RegressionDetector(tmp).detect(red_sha, green_sha)
48
+ rca = _build_rca(report, red_sha)
49
+
50
+ if json_output:
51
+ console.print_json(rca.model_dump_json(indent=2))
52
+ return rca
53
+
54
+ _render_intro()
55
+ _render_build_log()
56
+ _render_regression(report)
57
+ console.print()
58
+ print_rca(rca, output_format="rich")
59
+ _render_outro()
60
+ return rca
61
+ finally:
62
+ shutil.rmtree(tmp, ignore_errors=True)
63
+
64
+
65
+ # --------------------------------------------------------------------------- #
66
+ # Repo materialization #
67
+ # --------------------------------------------------------------------------- #
68
+
69
+ def _git(args: list[str], cwd: Path, author: tuple[str, str] | None = None) -> str:
70
+ env = dict(os.environ)
71
+ # Deterministic, isolated identity — never touches the user's git config.
72
+ env.update({
73
+ "GIT_CONFIG_GLOBAL": os.devnull,
74
+ "GIT_CONFIG_SYSTEM": os.devnull,
75
+ "GIT_TERMINAL_PROMPT": "0",
76
+ })
77
+ if author:
78
+ name, email = author
79
+ env.update({
80
+ "GIT_AUTHOR_NAME": name, "GIT_AUTHOR_EMAIL": email,
81
+ "GIT_COMMITTER_NAME": name, "GIT_COMMITTER_EMAIL": email,
82
+ })
83
+ out = subprocess.run(
84
+ ["git", *args], cwd=str(cwd), env=env,
85
+ capture_output=True, text=True, check=True,
86
+ )
87
+ return out.stdout.strip()
88
+
89
+
90
+ def _materialize_repo(root: Path) -> tuple[str, str]:
91
+ """Create services/payment.py + a test, commit green, then commit the regression."""
92
+ (root / "services").mkdir(parents=True, exist_ok=True)
93
+ (root / "tests").mkdir(parents=True, exist_ok=True)
94
+ (root / "services" / "__init__.py").write_text("", encoding="utf-8")
95
+
96
+ _git(["init", "-q", "-b", "main"], root)
97
+
98
+ payment = root / "services" / "payment.py"
99
+ test = root / "tests" / "test_payment.py"
100
+
101
+ payment.write_text(sd.GREEN_PAYMENT, encoding="utf-8")
102
+ test.write_text(sd.TEST_FILE, encoding="utf-8")
103
+ _git(["add", "-A"], root)
104
+ _git(["commit", "-q", "-m", sd.GREEN_MESSAGE], root, author=sd.GREEN_AUTHOR)
105
+ green_sha = _git(["rev-parse", "HEAD"], root)
106
+
107
+ payment.write_text(sd.RED_PAYMENT, encoding="utf-8")
108
+ _git(["add", "-A"], root)
109
+ _git(["commit", "-q", "-m", sd.RED_MESSAGE], root, author=sd.RED_AUTHOR)
110
+ red_sha = _git(["rev-parse", "HEAD"], root)
111
+
112
+ return green_sha, red_sha
113
+
114
+
115
+ # --------------------------------------------------------------------------- #
116
+ # RCA construction (real regression facts + curated narrative) #
117
+ # --------------------------------------------------------------------------- #
118
+
119
+ def _build_rca(report: RegressionReport, red_sha: str) -> RCAResult:
120
+ culprit_sha = report.likely_culprit_sha or red_sha
121
+ culprit_author = report.likely_culprit_author or sd.RED_AUTHOR[0]
122
+
123
+ prod = next((f for f in report.changed_files if not f.is_test_file and not f.is_config_file), None)
124
+ file_path = prod.file_path if prod else "services/payment.py"
125
+ function = (prod.changed_functions[0] if prod and prod.changed_functions else "charge")
126
+ line = (prod.added_lines[0] if prod and prod.added_lines else 6)
127
+
128
+ root_cause = sd.CURATED_RCA["root_cause_template"].format(
129
+ culprit=culprit_sha[:8], author=culprit_author,
130
+ )
131
+ return RCAResult(
132
+ module="cicd",
133
+ severity=Severity(sd.CURATED_RCA["severity"]),
134
+ summary=sd.CURATED_RCA["summary"],
135
+ root_cause=root_cause,
136
+ recommended_fix=sd.CURATED_RCA["recommended_fix"],
137
+ confidence=sd.CURATED_RCA["confidence"],
138
+ locations=[CodeLocation(
139
+ repo=sd.REPO,
140
+ file_path=file_path,
141
+ line_number=line,
142
+ function_name=function,
143
+ commit_sha=culprit_sha,
144
+ blame_author=culprit_author,
145
+ )],
146
+ affected_services=[sd.REPO],
147
+ tags={
148
+ "platform": "github-actions",
149
+ "branch": sd.BRANCH,
150
+ "build": sd.BUILD_NUMBER,
151
+ "culprit_commit": culprit_sha[:8],
152
+ "culprit_author": culprit_author,
153
+ },
154
+ model_used="bundled sample (offline demo)",
155
+ )
156
+
157
+
158
+ # --------------------------------------------------------------------------- #
159
+ # Rendering #
160
+ # --------------------------------------------------------------------------- #
161
+
162
+ def _render_intro() -> None:
163
+ body = Text()
164
+ body.append("Running on a freshly materialized git repo — no CI server, no API key, no setup.\n\n", style="dim")
165
+ body.append("Scenario: ", style="bold")
166
+ body.append(f"build #{sd.BUILD_NUMBER} of ")
167
+ body.append(sd.REPO, style="cyan")
168
+ body.append(" on ")
169
+ body.append(sd.BRANCH, style="cyan")
170
+ body.append(" went ")
171
+ body.append("RED", style="bold red")
172
+ body.append(".\n")
173
+ body.append("The last build was green. One commit since then broke it.", style="dim")
174
+ console.print()
175
+ console.print(Panel(body, title="[bold]OnePort Debug — oneport-cicd demo[/bold]",
176
+ border_style="cyan", box=box.ROUNDED))
177
+
178
+
179
+ def _render_build_log() -> None:
180
+ body = Text(sd.BUILD_LOG.rstrip(), style="dim")
181
+ console.print()
182
+ console.print(Panel(body, title="[bold]Failing build log (from CI)[/bold]",
183
+ border_style="red", box=box.ROUNDED))
184
+
185
+
186
+ def _render_regression(report: RegressionReport) -> None:
187
+ table = Table(title="Regression analysis (failing build vs last green)",
188
+ box=box.SIMPLE_HEAVY, title_style="bold", show_lines=False)
189
+ table.add_column("", style="cyan", no_wrap=True)
190
+ table.add_column("", style="white")
191
+
192
+ commits = report.commits_since_green
193
+ table.add_row("Commits since green", str(len(commits)))
194
+ for c in commits:
195
+ table.add_row("", f"[yellow]{c['sha'][:8]}[/yellow] {c['message']} — {c['author']}")
196
+
197
+ for f in report.changed_files:
198
+ kind = "test" if f.is_test_file else ("config" if f.is_config_file else "prod")
199
+ fns = ", ".join(f.changed_functions) or "—"
200
+ table.add_row("Changed file", f"{f.file_path} [dim]({kind})[/dim] → {fns}()")
201
+
202
+ table.add_row("", "")
203
+ table.add_row("[bold]Likely culprit[/bold]",
204
+ f"[bold red]{(report.likely_culprit_sha or '')[:8]}[/bold red] "
205
+ f"by [bold]{report.likely_culprit_author or '?'}[/bold]")
206
+ console.print()
207
+ console.print(table)
208
+
209
+
210
+ def _render_outro() -> None:
211
+ body = Text()
212
+ body.append("This ran the real RegressionDetector on a real git history.\n", style="dim")
213
+ body.append("Wire it into your pipeline's failure hook:\n\n", style="dim")
214
+ body.append(" # GitHub Actions\n", style="dim")
215
+ body.append(" - if: failure()\n", style="green")
216
+ body.append(" run: oneport-cicd-worker --platform github-actions \\\n", style="green")
217
+ body.append(" --run-id ${{ github.run_id }} --repo ${{ github.repository }} \\\n", style="green")
218
+ body.append(" --post-jira --post-slack\n", style="green")
219
+ console.print()
220
+ console.print(Panel(body, title="[bold]Next[/bold]", border_style="dim", box=box.ROUNDED))
@@ -0,0 +1,107 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """
4
+ Bundled sample data for `oneport-cicd demo`.
5
+
6
+ A realistic 3 AM regression: the `release/2.4` build of payment-service goes red.
7
+ The last green build was fine. Between green and red, one commit "refactored" a
8
+ guard clause and silently broke the zero-amount rejection — a test caught it, but
9
+ the on-call engineer still has to figure out *which* commit, *who* wrote it, and
10
+ *what* changed.
11
+
12
+ The demo materializes a real two-commit git repo and runs the REAL
13
+ RegressionDetector over it, so the culprit commit, author, and changed function
14
+ are computed live — not hard-coded. Only the AI narrative is curated so the demo
15
+ needs no API key.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ REPO = "payment-service"
20
+ BRANCH = "release/2.4"
21
+ BUILD_NUMBER = "1247"
22
+ WORKFLOW = "ci"
23
+
24
+ # Last-known-good ("green") version of the file.
25
+ GREEN_PAYMENT = """\
26
+ class PaymentService:
27
+ \"\"\"Charges a customer's card through the payment gateway.\"\"\"
28
+
29
+ def charge(self, amount: int) -> str:
30
+ # Reject non-positive amounts before we ever hit the gateway.
31
+ if amount <= 0:
32
+ raise ValueError("amount must be positive")
33
+ return f"charged {amount}"
34
+ """
35
+
36
+ # The "refactor" that broke the build: <= became <, so charge(0) is now accepted.
37
+ RED_PAYMENT = """\
38
+ class PaymentService:
39
+ \"\"\"Charges a customer's card through the payment gateway.\"\"\"
40
+
41
+ def charge(self, amount: int) -> str:
42
+ # Reject non-positive amounts before we ever hit the gateway.
43
+ if amount < 0:
44
+ raise ValueError("amount must be positive")
45
+ return f"charged {amount}"
46
+ """
47
+
48
+ TEST_FILE = """\
49
+ import pytest
50
+ from services.payment import PaymentService
51
+
52
+
53
+ def test_rejects_zero():
54
+ with pytest.raises(ValueError):
55
+ PaymentService().charge(0)
56
+ """
57
+
58
+ # Authors used for the two commits (so blame is meaningful in the demo).
59
+ GREEN_AUTHOR = ("Alice Green", "alice@corp.com")
60
+ GREEN_MESSAGE = "Add zero-amount guard to PaymentService.charge"
61
+ RED_AUTHOR = ("Bob Builder", "bob@corp.com")
62
+ RED_MESSAGE = "Refactor charge() guard clause"
63
+
64
+ # The build log the CI runner would have surfaced (last failing job).
65
+ BUILD_LOG = """\
66
+ === Job: test (step: Run pytest) ===
67
+ ============================= test session starts =============================
68
+ collected 5 items
69
+
70
+ tests/test_payment.py::test_rejects_zero FAILED [ 20%]
71
+
72
+ ================================== FAILURES ===================================
73
+ ______________________________ test_rejects_zero ______________________________
74
+
75
+ def test_rejects_zero():
76
+ with pytest.raises(ValueError):
77
+ > PaymentService().charge(0)
78
+ E Failed: DID NOT RAISE <class 'ValueError'>
79
+
80
+ tests/test_payment.py:6: Failed
81
+ =========================== short test summary info ===========================
82
+ FAILED tests/test_payment.py::test_rejects_zero - Failed: DID NOT RAISE Value...
83
+ ========================= 1 failed, 4 passed in 0.42s =========================
84
+ """
85
+
86
+ # Curated AI narrative. The {culprit}/{author}/{loc} placeholders are filled in
87
+ # at runtime with the values the real RegressionDetector computes.
88
+ CURATED_RCA = {
89
+ "severity": "high",
90
+ "summary": (
91
+ f"Build #{BUILD_NUMBER} on {BRANCH} failed: test_rejects_zero now fails "
92
+ "because PaymentService.charge() no longer rejects a zero amount."
93
+ ),
94
+ "root_cause_template": (
95
+ "Commit {culprit} by {author} changed the guard in "
96
+ "PaymentService.charge from `amount <= 0` to `amount < 0`. charge(0) is "
97
+ "now accepted instead of raising ValueError, so test_rejects_zero "
98
+ "(which asserts a zero charge is rejected) fails. This is the only "
99
+ "production change between the last green build and this one."
100
+ ),
101
+ "recommended_fix": (
102
+ "Restore the inclusive comparison in services/payment.py: change "
103
+ "`if amount < 0:` back to `if amount <= 0:`. If zero charges are now "
104
+ "intentionally allowed, update test_rejects_zero to match the new policy."
105
+ ),
106
+ "confidence": 0.93,
107
+ }
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561. This package ships inline type hints.
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ from oneport_debug_cicd.reporters.jira_reporter import JiraReporter
4
+ from oneport_debug_cicd.reporters.slack_reporter import SlackReporter
5
+
6
+ __all__ = ["JiraReporter", "SlackReporter"]