bugyi-chops 0.7.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,5 @@
1
+ """Personal SASE axe chops for notifications, releases, and maintenance proposals."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.6.0"
bugyi_chops/_common.py ADDED
@@ -0,0 +1,168 @@
1
+ """Shared result and target helpers for bugyi-chops."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import traceback
9
+ from collections.abc import Callable, Mapping
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from sase.chops import (
14
+ ChopInvocation,
15
+ ChopReport,
16
+ ChopResultBuilder,
17
+ ChopResultStatus,
18
+ emit_summary,
19
+ load_chop_invocation,
20
+ )
21
+
22
+ ChopBody = Callable[[ChopInvocation], ChopResultBuilder]
23
+
24
+
25
+ def first_nonblank(*values: object) -> str | None:
26
+ """Return the first non-blank string from *values*."""
27
+
28
+ for value in values:
29
+ if isinstance(value, str) and value.strip():
30
+ return value.strip()
31
+ return None
32
+
33
+
34
+ def context_target(invocation: ChopInvocation) -> Mapping[str, Any]:
35
+ return invocation.context.target or {}
36
+
37
+
38
+ def context_vars(invocation: ChopInvocation) -> Mapping[str, Any]:
39
+ return invocation.context.vars or {}
40
+
41
+
42
+ def target_label(invocation: ChopInvocation, *, default: str = "sase") -> str:
43
+ target = context_target(invocation)
44
+ variables = context_vars(invocation)
45
+ return (
46
+ first_nonblank(
47
+ variables.get("project"),
48
+ target.get("name"),
49
+ target.get("project"),
50
+ os.getenv("BUGYI_CHOPS_PROJECT"),
51
+ )
52
+ or default
53
+ )
54
+
55
+
56
+ def normalize_workspace(value: str) -> str:
57
+ """Normalize a proposal workspace ref and reject malformed values."""
58
+
59
+ workspace = value.strip().removeprefix("#")
60
+ if not workspace or ":" not in workspace or any(char.isspace() for char in workspace):
61
+ raise ValueError(f"invalid workspace ref: {value!r}")
62
+ return workspace
63
+
64
+
65
+ def proposal_workspace(
66
+ invocation: ChopInvocation,
67
+ *,
68
+ default: str | None = None,
69
+ ) -> str:
70
+ target = context_target(invocation)
71
+ variables = context_vars(invocation)
72
+ value = first_nonblank(
73
+ variables.get("workspace"),
74
+ variables.get("launch_ref"),
75
+ target.get("workspace"),
76
+ os.getenv("BUGYI_CHOPS_WORKSPACE"),
77
+ default,
78
+ )
79
+ if value is None:
80
+ raise ValueError(
81
+ "a workspace ref is required in target.workspace, vars.workspace, "
82
+ "or BUGYI_CHOPS_WORKSPACE"
83
+ )
84
+ return normalize_workspace(value)
85
+
86
+
87
+ def target_workspace_dir(invocation: ChopInvocation) -> Path | None:
88
+ target = context_target(invocation)
89
+ variables = context_vars(invocation)
90
+ value = first_nonblank(
91
+ variables.get("repo_root"),
92
+ variables.get("workspace_dir"),
93
+ target.get("workspace_dir"),
94
+ )
95
+ if value is None:
96
+ return None
97
+ try:
98
+ path = Path(value).expanduser().resolve(strict=True)
99
+ except OSError as error:
100
+ raise ValueError(f"failed to resolve workspace directory {value!r}: {error}") from error
101
+ if not path.is_dir():
102
+ raise ValueError(f"workspace directory is not a directory: {path}")
103
+ return path
104
+
105
+
106
+ def safe_fragment(value: str, *, fallback: str = "repo") -> str:
107
+ return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._-") or fallback
108
+
109
+
110
+ def git_head(repo_root: Path | None) -> tuple[str | None, str | None]:
111
+ """Return full and abbreviated HEAD without making audit proposals depend on git."""
112
+
113
+ if repo_root is None:
114
+ return None, None
115
+ result = subprocess.run(
116
+ ["git", "rev-parse", "HEAD"],
117
+ cwd=repo_root,
118
+ capture_output=True,
119
+ text=True,
120
+ check=False,
121
+ )
122
+ if result.returncode != 0 or not result.stdout.strip():
123
+ return None, None
124
+ head = result.stdout.strip()
125
+ return head, head[:12]
126
+
127
+
128
+ def result_with_summary(
129
+ invocation: ChopInvocation,
130
+ name: str,
131
+ counters: Mapping[str, int],
132
+ *,
133
+ status: ChopResultStatus = "ok",
134
+ reason: str | None = None,
135
+ report: ChopReport | Mapping[str, Any] | None = None,
136
+ ) -> ChopResultBuilder:
137
+ line = emit_summary(name, counters, reason=reason, logger=invocation.logger)
138
+ return ChopResultBuilder(
139
+ status=status,
140
+ summary=line,
141
+ reason=reason,
142
+ counters=dict(counters),
143
+ report=report,
144
+ )
145
+
146
+
147
+ def run_chop(name: str, description: str, body: ChopBody) -> None:
148
+ """Load a chop invocation, fail closed into a typed result, and write it."""
149
+
150
+ invocation = load_chop_invocation(description=description)
151
+ try:
152
+ result = body(invocation)
153
+ except Exception as error:
154
+ invocation.logger.error(f"{name} check failed: {error}")
155
+ invocation.logger.debug(traceback.format_exc().rstrip())
156
+ report = ChopReport(title=name.replace("_", " ").upper()).headline(
157
+ f"Check failed: {error}",
158
+ tone="error",
159
+ )
160
+ result = result_with_summary(
161
+ invocation,
162
+ name,
163
+ {"proposals": 0},
164
+ status="check_error",
165
+ reason="check_failed",
166
+ report=report,
167
+ )
168
+ result.write(context=invocation.context)
bugyi_chops/_report.py ADDED
@@ -0,0 +1,72 @@
1
+ """Shared presentation helpers for bugyi-chops reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from rich.cells import cell_len
8
+ from sase.chops import ChopReport, Tone
9
+
10
+ SEVERITY_TONES: Mapping[str, Tone] = {
11
+ "violation": "error",
12
+ "warning": "warn",
13
+ "fyi": "info",
14
+ "neutral": "muted",
15
+ "unknown": "muted",
16
+ }
17
+
18
+
19
+ def start_report(title: str) -> ChopReport:
20
+ """Start one report using the package-wide title convention."""
21
+
22
+ return ChopReport(title=title.upper())
23
+
24
+
25
+ def severity_tone(severity: str) -> Tone:
26
+ """Map a package severity to the shared semantic tone vocabulary."""
27
+
28
+ return SEVERITY_TONES.get(severity, "neutral")
29
+
30
+
31
+ def elide_path(path: str, max_cells: int) -> str:
32
+ """Elide a path from the left while respecting rendered cell width."""
33
+
34
+ if max_cells <= 0:
35
+ return ""
36
+ if cell_len(path) <= max_cells:
37
+ return path
38
+
39
+ prefix = "…/"
40
+ if max_cells <= cell_len(prefix):
41
+ return prefix[:max_cells]
42
+ for start, character in enumerate(path):
43
+ if character != "/":
44
+ continue
45
+ candidate = prefix + path[start + 1 :]
46
+ if cell_len(candidate) <= max_cells:
47
+ return candidate
48
+ for start in range(1, len(path) + 1):
49
+ candidate = prefix + path[start:].lstrip("/")
50
+ if cell_len(candidate) <= max_cells:
51
+ return candidate
52
+ return prefix
53
+
54
+
55
+ def add_facts_footer(
56
+ report: ChopReport,
57
+ facts: Mapping[str, str],
58
+ *,
59
+ tone: Tone = "muted",
60
+ ) -> ChopReport:
61
+ """Finish a report with the shared divider and factual key/value footer."""
62
+
63
+ return report.divider().kv(facts, tone=tone)
64
+
65
+
66
+ __all__ = [
67
+ "SEVERITY_TONES",
68
+ "add_facts_footer",
69
+ "elide_path",
70
+ "severity_tone",
71
+ "start_report",
72
+ ]