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.
- hexastack_tools/__init__.py +3 -0
- hexastack_tools/adapters/github/__init__.py +11 -0
- hexastack_tools/adapters/github/client.py +398 -0
- hexastack_tools/adapters/presenters/__init__.py +37 -0
- hexastack_tools/adapters/presenters/checks.py +106 -0
- hexastack_tools/adapters/presenters/common.py +26 -0
- hexastack_tools/adapters/presenters/pr.py +289 -0
- hexastack_tools/adapters/presenters/security.py +129 -0
- hexastack_tools/commands/__init__.py +93 -0
- hexastack_tools/commands/all_statements.py +170 -0
- hexastack_tools/commands/checks.py +64 -0
- hexastack_tools/commands/code_scanning.py +225 -0
- hexastack_tools/commands/codeql_scan.py +216 -0
- hexastack_tools/commands/deptry.py +90 -0
- hexastack_tools/commands/import_linter.py +206 -0
- hexastack_tools/commands/inline_snapshot.py +78 -0
- hexastack_tools/commands/mutmut.py +94 -0
- hexastack_tools/commands/pr_examine.py +267 -0
- hexastack_tools/commands/pydeps.py +135 -0
- hexastack_tools/commands/pypi.py +175 -0
- hexastack_tools/commands/pytest_runner.py +172 -0
- hexastack_tools/commands/rope.py +146 -0
- hexastack_tools/commands/security.py +54 -0
- hexastack_tools/commands/test_parity.py +167 -0
- hexastack_tools/commands/usage_docs.py +320 -0
- hexastack_tools/domain/__init__.py +21 -0
- hexastack_tools/domain/github.py +128 -0
- hexastack_tools/ports/__init__.py +7 -0
- hexastack_tools/ports/github.py +106 -0
- hexastack_tools/utils/__init__.py +37 -0
- hexastack_tools/utils/help_extractor.py +147 -0
- hexastack_tools/utils/workspace.py +341 -0
- hexastack_tools-0.2.0.dist-info/METADATA +67 -0
- hexastack_tools-0.2.0.dist-info/RECORD +36 -0
- hexastack_tools-0.2.0.dist-info/WHEEL +4 -0
- hexastack_tools-0.2.0.dist-info/entry_points.txt +26 -0
|
@@ -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
|
+
]
|