debugbrief 1.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.
- debugbrief/__init__.py +13 -0
- debugbrief/__main__.py +10 -0
- debugbrief/cli.py +843 -0
- debugbrief/command_runner.py +233 -0
- debugbrief/derive.py +353 -0
- debugbrief/doctor.py +324 -0
- debugbrief/filters.py +280 -0
- debugbrief/git_utils.py +268 -0
- debugbrief/models.py +320 -0
- debugbrief/paths.py +130 -0
- debugbrief/redaction.py +103 -0
- debugbrief/reporters/__init__.py +108 -0
- debugbrief/reporters/base.py +396 -0
- debugbrief/reporters/handoff.py +96 -0
- debugbrief/reporters/incident.py +99 -0
- debugbrief/reporters/pr.py +28 -0
- debugbrief/reports_index.py +50 -0
- debugbrief/session_manager.py +361 -0
- debugbrief/sessions_index.py +78 -0
- debugbrief/utils.py +151 -0
- debugbrief-1.1.0.dist-info/METADATA +143 -0
- debugbrief-1.1.0.dist-info/RECORD +26 -0
- debugbrief-1.1.0.dist-info/WHEEL +5 -0
- debugbrief-1.1.0.dist-info/entry_points.txt +2 -0
- debugbrief-1.1.0.dist-info/licenses/LICENSE +21 -0
- debugbrief-1.1.0.dist-info/top_level.txt +1 -0
debugbrief/paths.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Project-root detection and the on-disk storage layout for DebugBrief.
|
|
2
|
+
|
|
3
|
+
All state lives under ``<project_root>/.debugbrief/``:
|
|
4
|
+
|
|
5
|
+
.debugbrief/
|
|
6
|
+
active_session.json
|
|
7
|
+
sessions/<session_id>.json
|
|
8
|
+
reports/<session_id>-<mode>.md
|
|
9
|
+
|
|
10
|
+
The project root is the enclosing Git repo root when inside a repo, otherwise
|
|
11
|
+
the current working directory.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from . import git_utils
|
|
21
|
+
|
|
22
|
+
DEBUGBRIEF_DIRNAME = ".debugbrief"
|
|
23
|
+
ACTIVE_SESSION_FILENAME = "active_session.json"
|
|
24
|
+
SESSIONS_DIRNAME = "sessions"
|
|
25
|
+
REPORTS_DIRNAME = "reports"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ProjectPaths:
|
|
30
|
+
"""Resolved storage locations for a given project root."""
|
|
31
|
+
|
|
32
|
+
project_root: Path
|
|
33
|
+
is_git_repo: bool
|
|
34
|
+
repo_root: Optional[Path] = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def base_dir(self) -> Path:
|
|
38
|
+
return self.project_root / DEBUGBRIEF_DIRNAME
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def active_session_file(self) -> Path:
|
|
42
|
+
return self.base_dir / ACTIVE_SESSION_FILENAME
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def sessions_dir(self) -> Path:
|
|
46
|
+
return self.base_dir / SESSIONS_DIRNAME
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def reports_dir(self) -> Path:
|
|
50
|
+
return self.base_dir / REPORTS_DIRNAME
|
|
51
|
+
|
|
52
|
+
def session_file(self, session_id: str) -> Path:
|
|
53
|
+
return self.sessions_dir / f"{session_id}.json"
|
|
54
|
+
|
|
55
|
+
def report_file(self, session_id: str, mode: str) -> Path:
|
|
56
|
+
return self.reports_dir / f"{session_id}-{mode}.md"
|
|
57
|
+
|
|
58
|
+
def report_json_file(self, session_id: str, mode: str) -> Path:
|
|
59
|
+
return self.reports_dir / f"{session_id}-{mode}.json"
|
|
60
|
+
|
|
61
|
+
def ensure_directories(self) -> None:
|
|
62
|
+
"""Create the .debugbrief directory tree if it does not exist."""
|
|
63
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
self.reports_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_project_paths(start: Optional[Path] = None) -> ProjectPaths:
|
|
69
|
+
"""Resolve the project root and storage layout from ``start`` (or cwd).
|
|
70
|
+
|
|
71
|
+
If inside a Git repo, the repo root is used as the project root. Otherwise
|
|
72
|
+
the current working directory is used and we continue safely.
|
|
73
|
+
"""
|
|
74
|
+
cwd = Path(start).resolve() if start is not None else Path.cwd().resolve()
|
|
75
|
+
repo_root = git_utils.find_repo_root(cwd)
|
|
76
|
+
if repo_root is not None:
|
|
77
|
+
root_path = Path(repo_root).resolve()
|
|
78
|
+
return ProjectPaths(
|
|
79
|
+
project_root=root_path, is_git_repo=True, repo_root=root_path
|
|
80
|
+
)
|
|
81
|
+
return ProjectPaths(project_root=cwd, is_git_repo=False, repo_root=None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ensure_local_ignore(paths: ProjectPaths) -> Tuple[bool, List[str]]:
|
|
85
|
+
"""Ensure ``.debugbrief/`` is ignored locally via ``.git/info/exclude``.
|
|
86
|
+
|
|
87
|
+
We never touch a shared/tracked ``.gitignore`` by default; ``.git/info/exclude``
|
|
88
|
+
is local to the clone and not committed. Returns (changed, warnings) where
|
|
89
|
+
``changed`` indicates whether we wrote a new entry.
|
|
90
|
+
|
|
91
|
+
Safe and non-fatal: when not in a repo, or the exclude file is unavailable,
|
|
92
|
+
we return a warning instead of raising.
|
|
93
|
+
"""
|
|
94
|
+
warnings: List[str] = []
|
|
95
|
+
if not paths.is_git_repo or paths.repo_root is None:
|
|
96
|
+
return False, warnings
|
|
97
|
+
|
|
98
|
+
git_dir = paths.repo_root / ".git"
|
|
99
|
+
# Handle worktrees / submodules where .git is a file pointing elsewhere.
|
|
100
|
+
if git_dir.is_file():
|
|
101
|
+
warnings.append(
|
|
102
|
+
"Could not update .git/info/exclude (this looks like a git worktree "
|
|
103
|
+
"or submodule). .debugbrief/ may not be ignored automatically; add it "
|
|
104
|
+
"to your ignore rules manually if desired."
|
|
105
|
+
)
|
|
106
|
+
return False, warnings
|
|
107
|
+
|
|
108
|
+
info_dir = git_dir / "info"
|
|
109
|
+
exclude_file = info_dir / "exclude"
|
|
110
|
+
entry = f"{DEBUGBRIEF_DIRNAME}/"
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
if exclude_file.exists():
|
|
114
|
+
existing = exclude_file.read_text(encoding="utf-8")
|
|
115
|
+
existing_lines = {line.strip() for line in existing.splitlines()}
|
|
116
|
+
if entry in existing_lines or DEBUGBRIEF_DIRNAME in existing_lines:
|
|
117
|
+
return False, warnings
|
|
118
|
+
prefix = "" if existing.endswith("\n") or existing == "" else "\n"
|
|
119
|
+
with open(exclude_file, "a", encoding="utf-8") as handle:
|
|
120
|
+
handle.write(f"{prefix}{entry}\n")
|
|
121
|
+
return True, warnings
|
|
122
|
+
info_dir.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
exclude_file.write_text(f"{entry}\n", encoding="utf-8")
|
|
124
|
+
return True, warnings
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
warnings.append(
|
|
127
|
+
f"Could not update .git/info/exclude ({exc}). .debugbrief/ may not be "
|
|
128
|
+
"ignored automatically; add it to your ignore rules manually if desired."
|
|
129
|
+
)
|
|
130
|
+
return False, warnings
|
debugbrief/redaction.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Conservative, best-effort secret redaction applied at capture time.
|
|
2
|
+
|
|
3
|
+
Reports are pasted into pull requests and handoff docs, so captured output and
|
|
4
|
+
command text are scrubbed before anything is written to disk. This is pure
|
|
5
|
+
standard-library regex with no dependency.
|
|
6
|
+
|
|
7
|
+
Scope is deliberately limited: it masks common, recognizable secret shapes and
|
|
8
|
+
nothing more. It will miss secrets that do not match a known pattern, so it is
|
|
9
|
+
never presented as a guarantee. When a value is masked it is replaced with the
|
|
10
|
+
literal placeholder ``[redacted]``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from typing import Callable, List, Tuple, Union
|
|
17
|
+
|
|
18
|
+
PLACEHOLDER = "[redacted]"
|
|
19
|
+
|
|
20
|
+
# Tokens that mark a key as sensitive in a key/value pair. These must appear as a
|
|
21
|
+
# whole segment of the key name (delimited by the start/end of the key or by a
|
|
22
|
+
# ``_``/``-``/``.`` separator) so embedded substrings like the "key" in "monkey"
|
|
23
|
+
# or the "api" in "rapid_mode" are not mistaken for secrets.
|
|
24
|
+
_SENSITIVE_KEY = (
|
|
25
|
+
r"(?:password|passwd|pwd|secret|token|api[_\-]?key|apikey|credential|api|key)"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# A key name is sensitive when one of its segments is a sensitive token. The
|
|
29
|
+
# token must be flanked by a non-alphanumeric boundary on both sides (start/end
|
|
30
|
+
# of the key or a separator), while still allowing other segments around it.
|
|
31
|
+
# The match starts at the token itself; any prefix segments are simply left in
|
|
32
|
+
# place by the replacement. Anchoring at the token instead of lazily scanning
|
|
33
|
+
# the whole key keeps the pass linear on long unbroken text, where the lazy
|
|
34
|
+
# prefix walk was quadratic.
|
|
35
|
+
_SENSITIVE_KEY_NAME = (
|
|
36
|
+
r"(?P<key>(?<![A-Za-z0-9])"
|
|
37
|
+
+ _SENSITIVE_KEY
|
|
38
|
+
+ r"(?![A-Za-z0-9])[A-Za-z0-9_.\-]*\s*[:=]\s*)"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# A redaction replacement is either a literal string or a match-to-string
|
|
42
|
+
# callback, matching the two forms accepted by ``re.Pattern.subn``.
|
|
43
|
+
_Replacement = Union[str, Callable[["re.Match[str]"], str]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _kv_repl(match: "re.Match[str]") -> str:
|
|
47
|
+
# Preserve the key, separator and any surrounding quotes; mask the value.
|
|
48
|
+
open_quote = match.group("q") or ""
|
|
49
|
+
return f"{match.group('key')}{open_quote}{PLACEHOLDER}{open_quote}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Order matters: multi-line and structured shapes first, broad key/value last.
|
|
53
|
+
_RULES: List[Tuple["re.Pattern[str]", _Replacement]] = [
|
|
54
|
+
# PEM-style private key blocks (any key type), including the body.
|
|
55
|
+
(
|
|
56
|
+
re.compile(
|
|
57
|
+
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
|
|
58
|
+
re.DOTALL,
|
|
59
|
+
),
|
|
60
|
+
PLACEHOLDER,
|
|
61
|
+
),
|
|
62
|
+
# Connection strings: scheme://user:password@host -> mask only the password.
|
|
63
|
+
(
|
|
64
|
+
re.compile(r"\b([a-zA-Z][a-zA-Z0-9+.\-]*://[^\s:/@]+):[^\s:/@]+@"),
|
|
65
|
+
r"\1:" + PLACEHOLDER + "@",
|
|
66
|
+
),
|
|
67
|
+
# Authorization headers (value may be a Bearer/Basic token).
|
|
68
|
+
(
|
|
69
|
+
re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer\s+|basic\s+)?\S+"),
|
|
70
|
+
r"\1" + PLACEHOLDER,
|
|
71
|
+
),
|
|
72
|
+
# Standalone bearer tokens.
|
|
73
|
+
(
|
|
74
|
+
re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._\-]+"),
|
|
75
|
+
"Bearer " + PLACEHOLDER,
|
|
76
|
+
),
|
|
77
|
+
# Provider key shapes.
|
|
78
|
+
(re.compile(r"\bsk-[A-Za-z0-9_\-]{16,}\b"), PLACEHOLDER), # OpenAI-style
|
|
79
|
+
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), PLACEHOLDER), # AWS access key id
|
|
80
|
+
(re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), PLACEHOLDER), # GitHub tokens
|
|
81
|
+
# Generic key/value pairs whose key name looks sensitive.
|
|
82
|
+
(
|
|
83
|
+
re.compile(
|
|
84
|
+
r"(?i)" + _SENSITIVE_KEY_NAME + r"(?P<q>[\"'])?(?P<val>[^\s\"',;]+)(?P=q)?"
|
|
85
|
+
),
|
|
86
|
+
_kv_repl,
|
|
87
|
+
),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def redact_text(text: str) -> Tuple[str, int]:
|
|
92
|
+
"""Return ``(redacted_text, count)`` where ``count`` is the number of masks.
|
|
93
|
+
|
|
94
|
+
Best effort: applies each rule in turn. A ``count`` greater than zero means
|
|
95
|
+
at least one secret-shaped value was replaced.
|
|
96
|
+
"""
|
|
97
|
+
if not text:
|
|
98
|
+
return text, 0
|
|
99
|
+
total = 0
|
|
100
|
+
for pattern, repl in _RULES:
|
|
101
|
+
text, n = pattern.subn(repl, text)
|
|
102
|
+
total += n
|
|
103
|
+
return text, total
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Report rendering: dispatch a finalized session to a mode-specific reporter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Type
|
|
6
|
+
|
|
7
|
+
from ..models import Session
|
|
8
|
+
from .base import BaseReporter, build_context
|
|
9
|
+
|
|
10
|
+
VALID_MODES = ("pr", "handoff", "incident")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _reporters() -> Dict[str, Type[BaseReporter]]:
|
|
14
|
+
# Imported lazily so the mode modules can import from .base without a cycle.
|
|
15
|
+
from .handoff import HandoffReporter
|
|
16
|
+
from .incident import IncidentReporter
|
|
17
|
+
from .pr import PRReporter
|
|
18
|
+
|
|
19
|
+
return {"pr": PRReporter, "handoff": HandoffReporter, "incident": IncidentReporter}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_mode(mode: str) -> None:
|
|
23
|
+
if mode not in VALID_MODES:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"Unknown report mode {mode!r}. Valid modes: {', '.join(VALID_MODES)}."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_report(session: Session, mode: str) -> str:
|
|
30
|
+
"""Render a markdown report for ``session`` in the requested ``mode``."""
|
|
31
|
+
_check_mode(mode)
|
|
32
|
+
context = build_context(session)
|
|
33
|
+
reporter = _reporters()[mode](context)
|
|
34
|
+
return reporter.render()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render_report_json(session: Session, mode: str) -> Dict[str, Any]:
|
|
38
|
+
"""Render the same derived content as a structured JSON-ready dict.
|
|
39
|
+
|
|
40
|
+
The keys mirror the markdown report's derived sections so the two formats
|
|
41
|
+
stay in sync. Like the markdown, fields with no evidence are null/empty.
|
|
42
|
+
"""
|
|
43
|
+
_check_mode(mode)
|
|
44
|
+
context = build_context(session)
|
|
45
|
+
d = context.derivation
|
|
46
|
+
s = session
|
|
47
|
+
|
|
48
|
+
rtg = None
|
|
49
|
+
if d.red_to_green is not None:
|
|
50
|
+
rtg = {
|
|
51
|
+
"command": d.red_to_green.command,
|
|
52
|
+
"failed_at": d.red_to_green.failed_at,
|
|
53
|
+
"passed_at": d.red_to_green.passed_at,
|
|
54
|
+
"window_seconds": d.red_to_green.window_seconds,
|
|
55
|
+
"changed_files": list(d.red_to_green.changed_files),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"mode": mode,
|
|
60
|
+
"session_id": s.session_id,
|
|
61
|
+
"title": s.title,
|
|
62
|
+
"status": s.status,
|
|
63
|
+
"project_root": s.project_root,
|
|
64
|
+
"started_at": s.timestamps.start,
|
|
65
|
+
"ended_at": s.timestamps.end,
|
|
66
|
+
"git": {
|
|
67
|
+
"is_repo": s.git.is_repo,
|
|
68
|
+
"branch": s.git.branch,
|
|
69
|
+
"detached_head": s.git.detached_head,
|
|
70
|
+
"initial_sha": s.git.initial_sha,
|
|
71
|
+
"final_sha": s.git.final_sha,
|
|
72
|
+
},
|
|
73
|
+
"counts": {
|
|
74
|
+
"notes": s.summary.notes_count,
|
|
75
|
+
"commands": s.summary.commands_count,
|
|
76
|
+
"failed_commands": s.summary.failed_commands_count,
|
|
77
|
+
},
|
|
78
|
+
"one_liner": d.one_liner,
|
|
79
|
+
"reproduce_command": d.reproduce_command,
|
|
80
|
+
"verify_command": d.verify_command,
|
|
81
|
+
"red_to_green": rtg,
|
|
82
|
+
"observed_error": d.observed_error,
|
|
83
|
+
"ruled_out": [
|
|
84
|
+
{
|
|
85
|
+
"command": r.command,
|
|
86
|
+
"status": r.status,
|
|
87
|
+
"exit_code": r.exit_code,
|
|
88
|
+
"timestamp": r.timestamp,
|
|
89
|
+
}
|
|
90
|
+
for r in d.ruled_out
|
|
91
|
+
],
|
|
92
|
+
"timeline": [
|
|
93
|
+
{"timestamp": e.timestamp, "kind": e.kind, "text": e.text}
|
|
94
|
+
for e in context.timeline
|
|
95
|
+
],
|
|
96
|
+
"changed_files": [
|
|
97
|
+
{"status": fc.status, "path": fc.path} for fc in s.summary.file_changes
|
|
98
|
+
],
|
|
99
|
+
"verification": [
|
|
100
|
+
{"command": rc.command, "tool": rc.tool, "is_test": rc.is_test}
|
|
101
|
+
for rc in context.verification_commands
|
|
102
|
+
],
|
|
103
|
+
"notes": [text for _ts, text in context.notes],
|
|
104
|
+
"redaction_applied": d.redaction_applied,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = ["render_report", "render_report_json", "VALID_MODES", "build_context"]
|