hexastack-tools 0.2.0__tar.gz

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-0.2.0/PKG-INFO +67 -0
  2. hexastack_tools-0.2.0/README.md +51 -0
  3. hexastack_tools-0.2.0/pyproject.toml +72 -0
  4. hexastack_tools-0.2.0/pyproject.toml.orig +68 -0
  5. hexastack_tools-0.2.0/src/hexastack_tools/__init__.py +3 -0
  6. hexastack_tools-0.2.0/src/hexastack_tools/adapters/github/__init__.py +11 -0
  7. hexastack_tools-0.2.0/src/hexastack_tools/adapters/github/client.py +398 -0
  8. hexastack_tools-0.2.0/src/hexastack_tools/adapters/presenters/__init__.py +37 -0
  9. hexastack_tools-0.2.0/src/hexastack_tools/adapters/presenters/checks.py +106 -0
  10. hexastack_tools-0.2.0/src/hexastack_tools/adapters/presenters/common.py +26 -0
  11. hexastack_tools-0.2.0/src/hexastack_tools/adapters/presenters/pr.py +289 -0
  12. hexastack_tools-0.2.0/src/hexastack_tools/adapters/presenters/security.py +129 -0
  13. hexastack_tools-0.2.0/src/hexastack_tools/commands/__init__.py +93 -0
  14. hexastack_tools-0.2.0/src/hexastack_tools/commands/all_statements.py +170 -0
  15. hexastack_tools-0.2.0/src/hexastack_tools/commands/checks.py +64 -0
  16. hexastack_tools-0.2.0/src/hexastack_tools/commands/code_scanning.py +225 -0
  17. hexastack_tools-0.2.0/src/hexastack_tools/commands/codeql_scan.py +216 -0
  18. hexastack_tools-0.2.0/src/hexastack_tools/commands/deptry.py +90 -0
  19. hexastack_tools-0.2.0/src/hexastack_tools/commands/import_linter.py +206 -0
  20. hexastack_tools-0.2.0/src/hexastack_tools/commands/inline_snapshot.py +78 -0
  21. hexastack_tools-0.2.0/src/hexastack_tools/commands/mutmut.py +94 -0
  22. hexastack_tools-0.2.0/src/hexastack_tools/commands/pr_examine.py +267 -0
  23. hexastack_tools-0.2.0/src/hexastack_tools/commands/pydeps.py +135 -0
  24. hexastack_tools-0.2.0/src/hexastack_tools/commands/pypi.py +175 -0
  25. hexastack_tools-0.2.0/src/hexastack_tools/commands/pytest_runner.py +172 -0
  26. hexastack_tools-0.2.0/src/hexastack_tools/commands/rope.py +146 -0
  27. hexastack_tools-0.2.0/src/hexastack_tools/commands/security.py +54 -0
  28. hexastack_tools-0.2.0/src/hexastack_tools/commands/test_parity.py +167 -0
  29. hexastack_tools-0.2.0/src/hexastack_tools/commands/usage_docs.py +320 -0
  30. hexastack_tools-0.2.0/src/hexastack_tools/domain/__init__.py +21 -0
  31. hexastack_tools-0.2.0/src/hexastack_tools/domain/github.py +128 -0
  32. hexastack_tools-0.2.0/src/hexastack_tools/ports/__init__.py +7 -0
  33. hexastack_tools-0.2.0/src/hexastack_tools/ports/github.py +106 -0
  34. hexastack_tools-0.2.0/src/hexastack_tools/utils/__init__.py +37 -0
  35. hexastack_tools-0.2.0/src/hexastack_tools/utils/help_extractor.py +147 -0
  36. hexastack_tools-0.2.0/src/hexastack_tools/utils/workspace.py +341 -0
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: hexastack-tools
3
+ Version: 0.2.0
4
+ Summary: Developer tooling and governance suite for Hexastack dogfooding
5
+ Author: Richard West
6
+ Author-email: Richard West <dopplereffect.us@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ Requires-Dist: hexastack-cli
9
+ Requires-Dist: hexastack-core
10
+ Requires-Dist: hexastack-cqrs
11
+ Requires-Dist: httpx>=0.28.0
12
+ Requires-Dist: rich>=13.8.0
13
+ Requires-Dist: typer>=0.27.1
14
+ Requires-Python: >=3.13
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Hexastack Tools (`packages/hexastack_tools`)
18
+
19
+ Developer tooling, repository governance, code-scanning analysis, and CI automation suite for the Hexastack monorepo.
20
+
21
+ ---
22
+
23
+ ## 📖 Complete Documentation & Usage
24
+
25
+ For full CLI usage instructions and command examples, see the canonical [**USAGE Guide**](USAGE.md) or the [online developer documentation](file:///docs/tools.md).
26
+
27
+ ---
28
+
29
+ ## 🏛️ Architectural Intent
30
+
31
+ `hexastack-tools` strictly dogfoods Hexastack's own hexagonal architecture with:
32
+ - **`domain/`**: Pure data contracts (`PrSummary`, `CheckRunFinding`, `ReviewThread`, `OutputFormat`).
33
+ - **`ports/`**: Decoupled port interfaces (`GitHubApiPort`).
34
+ - **`adapters/`**: REST/GraphQL HTTP adapters (`GitHubHttpAdapter`) and multi-format presenters (`pr.py`, `checks.py`, `security.py`).
35
+ - **`commands/`**: Clean Typer CLI entrypoints.
36
+ - **`utils/`**: Shared monorepo workspace discovery and package graph resolvers.
37
+
38
+ ---
39
+
40
+ ## ⚙️ Output Presentation Modes
41
+
42
+ All CLI commands support multi-format presenters:
43
+ 1. **`auto` (default)**: Renders interactive Rich tables/panels if connected to a terminal TTY, or automatically switches to plain TSV when output is piped to tools like `grep`, `awk`, `cut`, or `xargs`.
44
+ 2. **`rich`**: Colorized ANSI dashboards with status icons and panels.
45
+ 3. **`json`**: Structured JSON payload for CI integration or agent tooling.
46
+ 4. **`plain`**: Clean, newline- and tab-delimited (TSV) stream.
47
+
48
+ ---
49
+
50
+ ## 🛠️ Quick Command Reference
51
+
52
+ | Command | Purpose |
53
+ |---|---|
54
+ | `uv run gh-pr-examine [pr]` | Full PR dashboard inspecting checks, review threads, failed CI logs, and conflicts. |
55
+ | `uv run gh-pr-examine runs [branch]` | Lists recent workflow runs for a branch. |
56
+ | `uv run gh-checks [pr/ref]` | Detailed status checks inspector. |
57
+ | `uv run gh-security [pr]` | Review comments and bot discussion thread auditor. |
58
+ | `uv run gh-code-scanning` | CodeQL SAST security alerts browser. |
59
+ | `uv run check-test-parity` | Validates 1:1 mirroring between `src/` and `tests/unit/`. |
60
+ | `uv run check-all-statements` | Validates `__all__` alphabetical sorting. |
61
+ | `uv run fix-all-statements` | Automatically sorts and alphabetizes `__all__`. |
62
+ | `uv run import-linter-run` | Validates package and hexagonal architecture layer boundaries. |
63
+ | `uv run pypi-build` | Builds sdist and wheel packages across workspace. |
64
+ | `uv run pypi-check` | Verifies build metadata and checks for PyPI release collisions. |
65
+ | `uv run codeql-scan` | Runs local SARIF CodeQL SAST security scan. |
66
+
67
+ For detailed syntax and flags, see [**USAGE.md**](USAGE.md).
@@ -0,0 +1,51 @@
1
+ # Hexastack Tools (`packages/hexastack_tools`)
2
+
3
+ Developer tooling, repository governance, code-scanning analysis, and CI automation suite for the Hexastack monorepo.
4
+
5
+ ---
6
+
7
+ ## 📖 Complete Documentation & Usage
8
+
9
+ For full CLI usage instructions and command examples, see the canonical [**USAGE Guide**](USAGE.md) or the [online developer documentation](file:///docs/tools.md).
10
+
11
+ ---
12
+
13
+ ## 🏛️ Architectural Intent
14
+
15
+ `hexastack-tools` strictly dogfoods Hexastack's own hexagonal architecture with:
16
+ - **`domain/`**: Pure data contracts (`PrSummary`, `CheckRunFinding`, `ReviewThread`, `OutputFormat`).
17
+ - **`ports/`**: Decoupled port interfaces (`GitHubApiPort`).
18
+ - **`adapters/`**: REST/GraphQL HTTP adapters (`GitHubHttpAdapter`) and multi-format presenters (`pr.py`, `checks.py`, `security.py`).
19
+ - **`commands/`**: Clean Typer CLI entrypoints.
20
+ - **`utils/`**: Shared monorepo workspace discovery and package graph resolvers.
21
+
22
+ ---
23
+
24
+ ## ⚙️ Output Presentation Modes
25
+
26
+ All CLI commands support multi-format presenters:
27
+ 1. **`auto` (default)**: Renders interactive Rich tables/panels if connected to a terminal TTY, or automatically switches to plain TSV when output is piped to tools like `grep`, `awk`, `cut`, or `xargs`.
28
+ 2. **`rich`**: Colorized ANSI dashboards with status icons and panels.
29
+ 3. **`json`**: Structured JSON payload for CI integration or agent tooling.
30
+ 4. **`plain`**: Clean, newline- and tab-delimited (TSV) stream.
31
+
32
+ ---
33
+
34
+ ## 🛠️ Quick Command Reference
35
+
36
+ | Command | Purpose |
37
+ |---|---|
38
+ | `uv run gh-pr-examine [pr]` | Full PR dashboard inspecting checks, review threads, failed CI logs, and conflicts. |
39
+ | `uv run gh-pr-examine runs [branch]` | Lists recent workflow runs for a branch. |
40
+ | `uv run gh-checks [pr/ref]` | Detailed status checks inspector. |
41
+ | `uv run gh-security [pr]` | Review comments and bot discussion thread auditor. |
42
+ | `uv run gh-code-scanning` | CodeQL SAST security alerts browser. |
43
+ | `uv run check-test-parity` | Validates 1:1 mirroring between `src/` and `tests/unit/`. |
44
+ | `uv run check-all-statements` | Validates `__all__` alphabetical sorting. |
45
+ | `uv run fix-all-statements` | Automatically sorts and alphabetizes `__all__`. |
46
+ | `uv run import-linter-run` | Validates package and hexagonal architecture layer boundaries. |
47
+ | `uv run pypi-build` | Builds sdist and wheel packages across workspace. |
48
+ | `uv run pypi-check` | Verifies build metadata and checks for PyPI release collisions. |
49
+ | `uv run codeql-scan` | Runs local SARIF CodeQL SAST security scan. |
50
+
51
+ For detailed syntax and flags, see [**USAGE.md**](USAGE.md).
@@ -0,0 +1,72 @@
1
+ [project]
2
+ name = "hexastack-tools"
3
+ version = "0.2.0"
4
+ description = "Developer tooling and governance suite for Hexastack dogfooding"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.13"
8
+ dependencies = [
9
+ "hexastack-cli",
10
+ "hexastack-core",
11
+ "hexastack-cqrs",
12
+ "httpx>=0.28.0",
13
+ "rich>=13.8.0",
14
+ "typer>=0.27.1",
15
+ ]
16
+
17
+ [[project.authors]]
18
+ name = "Richard West"
19
+ email = "dopplereffect.us@gmail.com"
20
+
21
+ [project.scripts]
22
+ alphabetizer = "hexastack_tools.commands.rope:alphabetize_main"
23
+ check-all-statements = "hexastack_tools.commands.all_statements:check_main"
24
+ check-test-parity = "hexastack_tools.commands.test_parity:main"
25
+ codeql-scan = "hexastack_tools.commands.codeql_scan:main"
26
+ deptry-run = "hexastack_tools.commands.deptry:main"
27
+ fix-all-statements = "hexastack_tools.commands.all_statements:fix_main"
28
+ generate-usage-docs = "hexastack_tools.commands.usage_docs:main"
29
+ gh-checks = "hexastack_tools.commands.checks:main"
30
+ gh-code-scanning = "hexastack_tools.commands.code_scanning:main"
31
+ gh-pr-examine = "hexastack_tools.commands.pr_examine:main"
32
+ gh-security = "hexastack_tools.commands.security:main"
33
+ import-linter-generate = "hexastack_tools.commands.import_linter:generate_main"
34
+ import-linter-run = "hexastack_tools.commands.import_linter:run_main"
35
+ inline-snapshot-update = "hexastack_tools.commands.inline_snapshot:main"
36
+ mutmut-inspect = "hexastack_tools.commands.mutmut:inspect_main"
37
+ mutmut-run = "hexastack_tools.commands.mutmut:run_main"
38
+ pydeps-generate = "hexastack_tools.commands.pydeps:generate_main"
39
+ pypi-build = "hexastack_tools.commands.pypi:build_main"
40
+ pypi-check = "hexastack_tools.commands.pypi:check_main"
41
+ pypi-publish = "hexastack_tools.commands.pypi:publish_main"
42
+ pytest-archon-generate = "hexastack_tools.commands.pytest_runner:archon_generate_main"
43
+ pytest-run = "hexastack_tools.commands.pytest_runner:run_main"
44
+ rope-alphabetizer = "hexastack_tools.commands.rope:alphabetize_main"
45
+ rope-run = "hexastack_tools.commands.rope:run_main"
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.12.3,<0.13.0"]
49
+ build-backend = "uv_build"
50
+
51
+ [tool.uv.sources.hexastack-cli]
52
+ workspace = true
53
+
54
+ [tool.uv.sources.hexastack-core]
55
+ workspace = true
56
+
57
+ [tool.uv.sources.hexastack-cqrs]
58
+ workspace = true
59
+
60
+ [tool.importlinter]
61
+ root_packages = ["hexastack_tools"]
62
+
63
+ [[tool.importlinter.contracts]]
64
+ name = "Hexagonal architecture layer hierarchy"
65
+ type = "layers"
66
+ containers = ["hexastack_tools"]
67
+ layers = [
68
+ "adapters",
69
+ "infra",
70
+ "ports",
71
+ "domain",
72
+ ]
@@ -0,0 +1,68 @@
1
+ [project]
2
+ name = "hexastack-tools"
3
+ version = "0.2.0"
4
+ description = "Developer tooling and governance suite for Hexastack dogfooding"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ authors = [
8
+ { name = "Richard West", email = "dopplereffect.us@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "hexastack-cli",
13
+ "hexastack-core",
14
+ "hexastack-cqrs",
15
+ "httpx>=0.28.0",
16
+ "rich>=13.8.0",
17
+ "typer>=0.27.1",
18
+ ]
19
+
20
+ [project.scripts]
21
+ alphabetizer = "hexastack_tools.commands.rope:alphabetize_main"
22
+ check-all-statements = "hexastack_tools.commands.all_statements:check_main"
23
+ check-test-parity = "hexastack_tools.commands.test_parity:main"
24
+ codeql-scan = "hexastack_tools.commands.codeql_scan:main"
25
+ deptry-run = "hexastack_tools.commands.deptry:main"
26
+ fix-all-statements = "hexastack_tools.commands.all_statements:fix_main"
27
+ generate-usage-docs = "hexastack_tools.commands.usage_docs:main"
28
+ gh-checks = "hexastack_tools.commands.checks:main"
29
+
30
+ gh-code-scanning = "hexastack_tools.commands.code_scanning:main"
31
+ gh-pr-examine = "hexastack_tools.commands.pr_examine:main"
32
+ gh-security = "hexastack_tools.commands.security:main"
33
+ import-linter-generate = "hexastack_tools.commands.import_linter:generate_main"
34
+ import-linter-run = "hexastack_tools.commands.import_linter:run_main"
35
+ inline-snapshot-update = "hexastack_tools.commands.inline_snapshot:main"
36
+ mutmut-inspect = "hexastack_tools.commands.mutmut:inspect_main"
37
+ mutmut-run = "hexastack_tools.commands.mutmut:run_main"
38
+ pydeps-generate = "hexastack_tools.commands.pydeps:generate_main"
39
+ pypi-build = "hexastack_tools.commands.pypi:build_main"
40
+ pypi-check = "hexastack_tools.commands.pypi:check_main"
41
+ pypi-publish = "hexastack_tools.commands.pypi:publish_main"
42
+ pytest-archon-generate = "hexastack_tools.commands.pytest_runner:archon_generate_main"
43
+ pytest-run = "hexastack_tools.commands.pytest_runner:run_main"
44
+ rope-alphabetizer = "hexastack_tools.commands.rope:alphabetize_main"
45
+ rope-run = "hexastack_tools.commands.rope:run_main"
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.12.3,<0.13.0"]
49
+ build-backend = "uv_build"
50
+
51
+ [tool.uv.sources]
52
+ hexastack-cli = { workspace = true }
53
+ hexastack-core = { workspace = true }
54
+ hexastack-cqrs = { workspace = true }
55
+
56
+ [tool.importlinter]
57
+ root_packages = ["hexastack_tools"]
58
+
59
+ [[tool.importlinter.contracts]]
60
+ name = "Hexagonal architecture layer hierarchy"
61
+ type = "layers"
62
+ containers = ["hexastack_tools"]
63
+ layers = [
64
+ "adapters",
65
+ "infra",
66
+ "ports",
67
+ "domain",
68
+ ]
@@ -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
+ ]