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/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
@@ -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"]