hexastack-tools 0.2.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.
Files changed (36) hide show
  1. hexastack_tools/__init__.py +3 -0
  2. hexastack_tools/adapters/github/__init__.py +11 -0
  3. hexastack_tools/adapters/github/client.py +398 -0
  4. hexastack_tools/adapters/presenters/__init__.py +37 -0
  5. hexastack_tools/adapters/presenters/checks.py +106 -0
  6. hexastack_tools/adapters/presenters/common.py +26 -0
  7. hexastack_tools/adapters/presenters/pr.py +289 -0
  8. hexastack_tools/adapters/presenters/security.py +129 -0
  9. hexastack_tools/commands/__init__.py +93 -0
  10. hexastack_tools/commands/all_statements.py +170 -0
  11. hexastack_tools/commands/checks.py +64 -0
  12. hexastack_tools/commands/code_scanning.py +225 -0
  13. hexastack_tools/commands/codeql_scan.py +216 -0
  14. hexastack_tools/commands/deptry.py +90 -0
  15. hexastack_tools/commands/import_linter.py +206 -0
  16. hexastack_tools/commands/inline_snapshot.py +78 -0
  17. hexastack_tools/commands/mutmut.py +94 -0
  18. hexastack_tools/commands/pr_examine.py +267 -0
  19. hexastack_tools/commands/pydeps.py +135 -0
  20. hexastack_tools/commands/pypi.py +175 -0
  21. hexastack_tools/commands/pytest_runner.py +172 -0
  22. hexastack_tools/commands/rope.py +146 -0
  23. hexastack_tools/commands/security.py +54 -0
  24. hexastack_tools/commands/test_parity.py +167 -0
  25. hexastack_tools/commands/usage_docs.py +320 -0
  26. hexastack_tools/domain/__init__.py +21 -0
  27. hexastack_tools/domain/github.py +128 -0
  28. hexastack_tools/ports/__init__.py +7 -0
  29. hexastack_tools/ports/github.py +106 -0
  30. hexastack_tools/utils/__init__.py +37 -0
  31. hexastack_tools/utils/help_extractor.py +147 -0
  32. hexastack_tools/utils/workspace.py +341 -0
  33. hexastack_tools-0.2.0.dist-info/METADATA +67 -0
  34. hexastack_tools-0.2.0.dist-info/RECORD +36 -0
  35. hexastack_tools-0.2.0.dist-info/WHEEL +4 -0
  36. hexastack_tools-0.2.0.dist-info/entry_points.txt +26 -0
@@ -0,0 +1,3 @@
1
+ """Developer tooling, code scanning analysis, and CI automation suite for Hexastack."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,11 @@
1
+ """GitHub adapter package exports."""
2
+
3
+ from hexastack_tools.adapters.github.client import (
4
+ GitHubHttpAdapter,
5
+ get_github_token,
6
+ )
7
+
8
+ __all__ = [
9
+ "get_github_token",
10
+ "GitHubHttpAdapter",
11
+ ]
@@ -0,0 +1,398 @@
1
+ """Concrete adapter implementing GitHubApiPort using httpx and GitHub REST / GraphQL APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from hexastack_tools.domain.github import (
13
+ CheckRunFinding,
14
+ PrSummary,
15
+ ReviewComment,
16
+ ReviewThread,
17
+ SecurityAlert,
18
+ )
19
+ from hexastack_tools.ports.github import GitHubApiPort
20
+
21
+
22
+ def get_github_token() -> str | None:
23
+ """Retrieve GitHub token from environment or gh CLI."""
24
+ token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN")
25
+ if token:
26
+ return token.strip()
27
+
28
+ if shutil.which("gh"):
29
+ try:
30
+ res = subprocess.run(
31
+ ["gh", "auth", "token"],
32
+ capture_output=True,
33
+ text=True,
34
+ check=False,
35
+ )
36
+ if res.returncode == 0 and res.stdout.strip():
37
+ return res.stdout.strip()
38
+ except (subprocess.SubprocessError, OSError):
39
+ return None
40
+
41
+ return None
42
+
43
+
44
+ class GitHubHttpAdapter(GitHubApiPort):
45
+ """Adapter executing synchronous HTTP requests to GitHub REST and GraphQL APIs."""
46
+
47
+ def __init__(
48
+ self,
49
+ token: str | None = None,
50
+ owner: str = "TheTrueSCU",
51
+ repo: str = "hexastack",
52
+ ) -> None:
53
+ """Initialize GitHub HTTP client adapter.
54
+
55
+ Args:
56
+ token: Optional GitHub bearer token.
57
+ owner: Repository owner / organization.
58
+ repo: Repository name.
59
+ """
60
+ self.owner = owner
61
+ self.repo = repo
62
+ self.token = token or get_github_token()
63
+ headers = {
64
+ "Accept": "application/vnd.github+json",
65
+ "X-GitHub-Api-Version": "2022-11-28",
66
+ }
67
+ if self.token:
68
+ headers["Authorization"] = f"Bearer {self.token}"
69
+
70
+ self._client = httpx.Client(
71
+ base_url="https://api.github.com",
72
+ headers=headers,
73
+ timeout=30.0,
74
+ )
75
+
76
+ def close(self) -> None:
77
+ """Close underlying httpx client."""
78
+ self._client.close()
79
+
80
+ def __enter__(self) -> GitHubHttpAdapter:
81
+ """Context manager enter."""
82
+ return self
83
+
84
+ def __exit__(self, *args: Any) -> None:
85
+ """Context manager exit."""
86
+ self.close()
87
+
88
+ def get_pr_summary(self, pr_number: int) -> PrSummary:
89
+ """Fetch full aggregate summary for a pull request."""
90
+ resp = self._client.get(f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}")
91
+ resp.raise_for_status()
92
+ data = resp.json()
93
+
94
+ head_ref = data.get("head", {}).get("ref", "")
95
+ head_sha = data.get("head", {}).get("sha", "")
96
+
97
+ check_runs = self.get_check_runs(head_sha or head_ref)
98
+ review_threads = self.get_review_threads(pr_number)
99
+ alerts = self.get_code_scanning_alerts(ref=f"refs/pull/{pr_number}/merge")
100
+
101
+ # Fetch issue comments
102
+ comments_resp = self._client.get(
103
+ f"/repos/{self.owner}/{self.repo}/issues/{pr_number}/comments"
104
+ )
105
+ general_comments: list[ReviewComment] = []
106
+ if comments_resp.status_code == 200:
107
+ for c in comments_resp.json():
108
+ general_comments.append(
109
+ ReviewComment(
110
+ id=c.get("id", 0),
111
+ author=c.get("user", {}).get("login", "unknown"),
112
+ body=c.get("body", ""),
113
+ created_at=c.get("created_at", ""),
114
+ url=c.get("html_url", ""),
115
+ is_review_comment=False,
116
+ )
117
+ )
118
+
119
+ # Fetch inline review comments (including CodeQL and code reviews)
120
+ pull_comments_resp = self._client.get(
121
+ f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}/comments"
122
+ )
123
+ if pull_comments_resp.status_code == 200:
124
+ for c in pull_comments_resp.json():
125
+ general_comments.append(
126
+ ReviewComment(
127
+ id=c.get("id", 0),
128
+ author=c.get("user", {}).get("login", "unknown"),
129
+ body=c.get("body", ""),
130
+ created_at=c.get("created_at", ""),
131
+ path=c.get("path"),
132
+ line=c.get("line") or c.get("original_line"),
133
+ url=c.get("html_url", ""),
134
+ diff_hunk=c.get("diff_hunk"),
135
+ is_review_comment=True,
136
+ )
137
+ )
138
+
139
+ return PrSummary(
140
+ number=pr_number,
141
+ title=data.get("title", ""),
142
+ author=data.get("user", {}).get("login", "unknown"),
143
+ state=data.get("state", "open"),
144
+ mergeable=str(data.get("mergeable_state") or "unknown"),
145
+ is_draft=bool(data.get("draft", False)),
146
+ head_ref=head_ref,
147
+ base_ref=data.get("base", {}).get("ref", "main"),
148
+ html_url=data.get("html_url", ""),
149
+ check_runs=tuple(check_runs),
150
+ review_threads=tuple(review_threads),
151
+ security_alerts=tuple(alerts),
152
+ general_comments=tuple(general_comments),
153
+ )
154
+
155
+ def get_check_runs(self, ref: str) -> list[CheckRunFinding]:
156
+ """Fetch check runs and commit statuses for a ref."""
157
+ resp = self._client.get(
158
+ f"/repos/{self.owner}/{self.repo}/commits/{ref}/check-runs"
159
+ )
160
+ if resp.status_code != 200:
161
+ return []
162
+
163
+ runs = resp.json().get("check_runs", [])
164
+ findings: list[CheckRunFinding] = []
165
+ for r in runs:
166
+ findings.append(
167
+ CheckRunFinding(
168
+ name=r.get("name", "unknown"),
169
+ status=r.get("status", "unknown"),
170
+ conclusion=r.get("conclusion") or "in_progress",
171
+ details_url=r.get("html_url") or r.get("details_url", ""),
172
+ workflow_name=r.get("workflow_name"),
173
+ started_at=r.get("started_at"),
174
+ completed_at=r.get("completed_at"),
175
+ )
176
+ )
177
+ return findings
178
+
179
+ def get_review_threads(self, pr_number: int) -> list[ReviewThread]:
180
+ """Fetch review discussion threads and conversation resolution state via GraphQL."""
181
+ query = """
182
+ query($owner: String!, $repo: String!, $pr: Int!) {
183
+ repository(owner: $owner, name: $repo) {
184
+ pullRequest(number: $pr) {
185
+ reviewThreads(first: 50) {
186
+ nodes {
187
+ id
188
+ isResolved
189
+ resolvedBy { login }
190
+ comments(first: 20) {
191
+ nodes {
192
+ id
193
+ body
194
+ author { login }
195
+ path
196
+ line
197
+ createdAt
198
+ url
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ """
207
+ payload = {
208
+ "query": query,
209
+ "variables": {"owner": self.owner, "repo": self.repo, "pr": pr_number},
210
+ }
211
+ resp = self._client.post("/graphql", json=payload)
212
+ if resp.status_code != 200:
213
+ return []
214
+
215
+ data = resp.json()
216
+ threads_nodes = (
217
+ data.get("data", {})
218
+ .get("repository", {})
219
+ .get("pullRequest", {})
220
+ .get("reviewThreads", {})
221
+ .get("nodes", [])
222
+ )
223
+ results: list[ReviewThread] = []
224
+ for t in threads_nodes:
225
+ thread_id = t.get("id", "")
226
+ is_resolved = bool(t.get("isResolved", False))
227
+ resolved_by = (
228
+ t.get("resolvedBy", {}).get("login") if t.get("resolvedBy") else None
229
+ )
230
+
231
+ comments_list: list[ReviewComment] = []
232
+ for c in t.get("comments", {}).get("nodes", []):
233
+ comments_list.append(
234
+ ReviewComment(
235
+ id=c.get("id", ""),
236
+ author=c.get("author", {}).get("login", "unknown"),
237
+ body=c.get("body", ""),
238
+ created_at=c.get("createdAt", ""),
239
+ path=c.get("path"),
240
+ line=c.get("line"),
241
+ url=c.get("url"),
242
+ )
243
+ )
244
+
245
+ results.append(
246
+ ReviewThread(
247
+ id=thread_id,
248
+ is_resolved=is_resolved,
249
+ resolved_by=resolved_by,
250
+ comments=tuple(comments_list),
251
+ )
252
+ )
253
+ return results
254
+
255
+ def get_code_scanning_alerts(
256
+ self,
257
+ ref: str | None = None,
258
+ state: str = "open",
259
+ ) -> list[SecurityAlert]:
260
+ """Fetch CodeQL code scanning alerts."""
261
+ params: dict[str, str] = {"per_page": "100"}
262
+ if state != "all":
263
+ params["state"] = state
264
+ if ref:
265
+ params["ref"] = ref
266
+
267
+ resp = self._client.get(
268
+ f"/repos/{self.owner}/{self.repo}/code-scanning/alerts",
269
+ params=params,
270
+ )
271
+ if resp.status_code != 200:
272
+ return []
273
+
274
+ raw_alerts = resp.json()
275
+ if not isinstance(raw_alerts, list):
276
+ return []
277
+
278
+ results: list[SecurityAlert] = []
279
+ for a in raw_alerts:
280
+ rule = a.get("rule", {})
281
+ inst = a.get("most_recent_instance", {})
282
+ loc = inst.get("location", {})
283
+ results.append(
284
+ SecurityAlert(
285
+ number=a.get("number", 0),
286
+ rule_id=rule.get("id", "unknown"),
287
+ rule_description=rule.get("description", ""),
288
+ severity=rule.get("severity", "unknown"),
289
+ security_severity_level=rule.get("security_severity_level"),
290
+ state=a.get("state", "open"),
291
+ path=loc.get("path", "-"),
292
+ start_line=loc.get("start_line"),
293
+ end_line=loc.get("end_line"),
294
+ message=inst.get("message", {}).get("text", ""),
295
+ help_markdown=rule.get("help"),
296
+ )
297
+ )
298
+ return results
299
+
300
+ def get_single_alert(self, alert_number: int) -> SecurityAlert:
301
+ """Fetch full metadata for a single security alert."""
302
+ resp = self._client.get(
303
+ f"/repos/{self.owner}/{self.repo}/code-scanning/alerts/{alert_number}"
304
+ )
305
+ resp.raise_for_status()
306
+ a = resp.json()
307
+
308
+ rule = a.get("rule", {})
309
+ inst = a.get("most_recent_instance", {})
310
+ loc = inst.get("location", {})
311
+ return SecurityAlert(
312
+ number=a.get("number", alert_number),
313
+ rule_id=rule.get("id", "unknown"),
314
+ rule_description=rule.get("description", ""),
315
+ severity=rule.get("severity", "unknown"),
316
+ security_severity_level=rule.get("security_severity_level"),
317
+ state=a.get("state", "open"),
318
+ path=loc.get("path", "-"),
319
+ start_line=loc.get("start_line"),
320
+ end_line=loc.get("end_line"),
321
+ message=inst.get("message", {}).get("text", ""),
322
+ help_markdown=rule.get("help"),
323
+ )
324
+
325
+ def get_failed_run_logs(self, run_id: int | str) -> str | None:
326
+ """Fetch failed log output for a workflow run using gh CLI or REST API."""
327
+ if shutil.which("gh"):
328
+ try:
329
+ res = subprocess.run(
330
+ ["gh", "run", "view", str(run_id), "--log-failed"],
331
+ capture_output=True,
332
+ text=True,
333
+ check=False,
334
+ )
335
+ if res.returncode == 0 and res.stdout.strip():
336
+ return res.stdout.strip()
337
+ except (subprocess.SubprocessError, OSError):
338
+ # Ignore subprocess failure when retrieving failed run logs via gh CLI
339
+ pass
340
+
341
+ return None
342
+
343
+ def get_workflow_runs(
344
+ self,
345
+ branch: str | None = None,
346
+ limit: int = 5,
347
+ ) -> list[dict[str, Any]]:
348
+ """Fetch recent workflow runs for a branch."""
349
+ if shutil.which("gh"):
350
+ cmd = [
351
+ "gh",
352
+ "run",
353
+ "list",
354
+ "--json",
355
+ "databaseId,name,conclusion,headSha,event,status,displayTitle,url",
356
+ "--limit",
357
+ str(limit),
358
+ ]
359
+ if branch:
360
+ cmd.extend(["--branch", branch])
361
+ try:
362
+ res = subprocess.run(cmd, capture_output=True, text=True, check=False)
363
+ if res.returncode == 0 and res.stdout.strip():
364
+ import json
365
+
366
+ return json.loads(res.stdout.strip())
367
+ except (subprocess.SubprocessError, OSError, json.JSONDecodeError):
368
+ # Fall back to GitHub REST API if local gh CLI execution fails
369
+ pass
370
+
371
+ params: dict[str, Any] = {"per_page": limit}
372
+ if branch:
373
+ params["branch"] = branch
374
+ resp = self._client.get(
375
+ f"/repos/{self.owner}/{self.repo}/actions/runs", params=params
376
+ )
377
+ if resp.status_code != 200:
378
+ return []
379
+ data = resp.json().get("workflow_runs", [])
380
+ return [
381
+ {
382
+ "databaseId": r.get("id"),
383
+ "name": r.get("name"),
384
+ "conclusion": r.get("conclusion") or "",
385
+ "headSha": r.get("head_sha", ""),
386
+ "event": r.get("event", ""),
387
+ "status": r.get("status", ""),
388
+ "displayTitle": r.get("display_title", ""),
389
+ "url": r.get("html_url", ""),
390
+ }
391
+ for r in data
392
+ ]
393
+
394
+
395
+ __all__ = [
396
+ "get_github_token",
397
+ "GitHubHttpAdapter",
398
+ ]
@@ -0,0 +1,37 @@
1
+ """Presenters package export for hexastack_tools."""
2
+
3
+ from hexastack_tools.adapters.presenters.checks import (
4
+ build_checks_table,
5
+ present_checks,
6
+ render_checks_json,
7
+ render_checks_plain,
8
+ )
9
+ from hexastack_tools.adapters.presenters.common import resolve_output_format
10
+ from hexastack_tools.adapters.presenters.pr import (
11
+ present_pr_summary,
12
+ render_pr_summary_json,
13
+ render_pr_summary_plain,
14
+ render_pr_summary_rich,
15
+ )
16
+ from hexastack_tools.adapters.presenters.security import (
17
+ build_security_comments_table,
18
+ present_security_comments,
19
+ render_security_comments_json,
20
+ render_security_comments_plain,
21
+ )
22
+
23
+ __all__ = [
24
+ "build_checks_table",
25
+ "build_security_comments_table",
26
+ "present_checks",
27
+ "present_pr_summary",
28
+ "present_security_comments",
29
+ "render_checks_json",
30
+ "render_checks_plain",
31
+ "render_pr_summary_json",
32
+ "render_pr_summary_plain",
33
+ "render_pr_summary_rich",
34
+ "render_security_comments_json",
35
+ "render_security_comments_plain",
36
+ "resolve_output_format",
37
+ ]
@@ -0,0 +1,106 @@
1
+ """Checks Presenter supporting Rich ANSI tables, structured JSON, and plain TSV."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from hexastack_tools.adapters.presenters.common import resolve_output_format
12
+ from hexastack_tools.domain.github import CheckRunFinding, OutputFormat
13
+
14
+ console = Console()
15
+
16
+
17
+ def _render_check_conclusion(conclusion: str) -> str:
18
+ """Format conclusion with color styling."""
19
+ conc = conclusion.lower()
20
+ if conc == "success":
21
+ return "[bold green]✓ SUCCESS[/bold green]"
22
+ if conc == "failure":
23
+ return "[bold red]✗ FAILURE[/bold red]"
24
+ if conc == "skipped":
25
+ return "[dim]– SKIPPED[/dim]"
26
+ if conc == "neutral":
27
+ return "[dim cyan]○ NEUTRAL[/dim cyan]"
28
+ return f"[bold yellow]⏳ {conc.upper()}[/bold yellow]"
29
+
30
+
31
+ def build_checks_table(checks: list[CheckRunFinding], ref: str) -> Table:
32
+ """Construct Rich table for CI check runs."""
33
+ table = Table(
34
+ title=f"[bold cyan]GitHub CI / Check Runs for '{ref}' ({len(checks)} checks)[/bold cyan]",
35
+ show_header=True,
36
+ header_style="bold magenta",
37
+ )
38
+ table.add_column("Workflow", style="dim", width=22)
39
+ table.add_column("Job / Check Name", style="bold")
40
+ table.add_column("Status", width=12)
41
+ table.add_column("Conclusion", width=16)
42
+ table.add_column("Details URL", style="blue")
43
+
44
+ for c in checks:
45
+ table.add_row(
46
+ c.workflow_name or "CI",
47
+ c.name,
48
+ c.status,
49
+ _render_check_conclusion(c.conclusion),
50
+ c.details_url,
51
+ )
52
+ return table
53
+
54
+
55
+ def render_checks_json(checks: list[CheckRunFinding], ref: str) -> str:
56
+ """Serialize check runs to JSON."""
57
+ data = {
58
+ "ref": ref,
59
+ "total_checks": len(checks),
60
+ "checks": [
61
+ {
62
+ "name": c.name,
63
+ "workflow": c.workflow_name,
64
+ "status": c.status,
65
+ "conclusion": c.conclusion,
66
+ "details_url": c.details_url,
67
+ }
68
+ for c in checks
69
+ ],
70
+ }
71
+ return json.dumps(data, indent=2)
72
+
73
+
74
+ def render_checks_plain(checks: list[CheckRunFinding], ref: str) -> str:
75
+ """Serialize check runs to plain TSV lines."""
76
+ lines = [f"REF\t{ref}\t{len(checks)}"]
77
+ for c in checks:
78
+ lines.append(
79
+ f"CHECK\t{c.workflow_name or 'CI'}\t{c.name}\t{c.status}\t{c.conclusion}\t{c.details_url}"
80
+ )
81
+ return "\n".join(lines)
82
+
83
+
84
+ def present_checks(
85
+ checks: list[CheckRunFinding],
86
+ ref: str,
87
+ output_format: OutputFormat = OutputFormat.AUTO,
88
+ ) -> None:
89
+ """Unified entrypoint to present CI check runs in rich, json, plain, or auto-detected format."""
90
+ resolved_format = resolve_output_format(output_format)
91
+ if resolved_format == OutputFormat.JSON:
92
+ sys.stdout.write(render_checks_json(checks, ref) + "\n")
93
+ sys.stdout.flush()
94
+ elif resolved_format == OutputFormat.PLAIN:
95
+ sys.stdout.write(render_checks_plain(checks, ref) + "\n")
96
+ sys.stdout.flush()
97
+ else:
98
+ console.print(build_checks_table(checks, ref))
99
+
100
+
101
+ __all__ = [
102
+ "build_checks_table",
103
+ "present_checks",
104
+ "render_checks_json",
105
+ "render_checks_plain",
106
+ ]
@@ -0,0 +1,26 @@
1
+ """Common presentation utilities and pipe-detection for hexastack_tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from hexastack_tools.domain.github import OutputFormat
8
+
9
+
10
+ def resolve_output_format(output_format: OutputFormat) -> OutputFormat:
11
+ """Resolve auto output format: automatically selects PLAIN if stdout is piped (not a TTY).
12
+
13
+ Args:
14
+ output_format: Requested output format mode (AUTO, RICH, JSON, PLAIN).
15
+
16
+ Returns:
17
+ Resolved concrete OutputFormat (RICH, JSON, or PLAIN).
18
+ """
19
+ if output_format == OutputFormat.AUTO:
20
+ return OutputFormat.PLAIN if not sys.stdout.isatty() else OutputFormat.RICH
21
+ return output_format
22
+
23
+
24
+ __all__ = [
25
+ "resolve_output_format",
26
+ ]