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.
- oneport_debug_cicd/__init__.py +24 -0
- oneport_debug_cicd/analyzers/fix_generator.py +193 -0
- oneport_debug_cicd/cli.py +119 -0
- oneport_debug_cicd/demo/__init__.py +7 -0
- oneport_debug_cicd/demo/runner.py +220 -0
- oneport_debug_cicd/demo/sample_data.py +107 -0
- oneport_debug_cicd/py.typed +1 -0
- oneport_debug_cicd/reporters/__init__.py +6 -0
- oneport_debug_cicd/reporters/jira_reporter.py +120 -0
- oneport_debug_cicd/reporters/slack_reporter.py +63 -0
- oneport_debug_cicd/runners/__init__.py +5 -0
- oneport_debug_cicd/runners/github_actions.py +163 -0
- oneport_debug_cicd/sandbox/__init__.py +5 -0
- oneport_debug_cicd/sandbox/docker_executor.py +214 -0
- oneport_debug_cicd/sandbox/regression_detector.py +154 -0
- oneport_debug_cicd/worker.py +322 -0
- oneport_debug_cicd-0.1.0.dist-info/METADATA +148 -0
- oneport_debug_cicd-0.1.0.dist-info/RECORD +20 -0
- oneport_debug_cicd-0.1.0.dist-info/WHEEL +4 -0
- oneport_debug_cicd-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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,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"]
|