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.
- hexastack_tools-0.2.0/PKG-INFO +67 -0
- hexastack_tools-0.2.0/README.md +51 -0
- hexastack_tools-0.2.0/pyproject.toml +72 -0
- hexastack_tools-0.2.0/pyproject.toml.orig +68 -0
- hexastack_tools-0.2.0/src/hexastack_tools/__init__.py +3 -0
- hexastack_tools-0.2.0/src/hexastack_tools/adapters/github/__init__.py +11 -0
- hexastack_tools-0.2.0/src/hexastack_tools/adapters/github/client.py +398 -0
- hexastack_tools-0.2.0/src/hexastack_tools/adapters/presenters/__init__.py +37 -0
- hexastack_tools-0.2.0/src/hexastack_tools/adapters/presenters/checks.py +106 -0
- hexastack_tools-0.2.0/src/hexastack_tools/adapters/presenters/common.py +26 -0
- hexastack_tools-0.2.0/src/hexastack_tools/adapters/presenters/pr.py +289 -0
- hexastack_tools-0.2.0/src/hexastack_tools/adapters/presenters/security.py +129 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/__init__.py +93 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/all_statements.py +170 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/checks.py +64 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/code_scanning.py +225 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/codeql_scan.py +216 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/deptry.py +90 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/import_linter.py +206 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/inline_snapshot.py +78 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/mutmut.py +94 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/pr_examine.py +267 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/pydeps.py +135 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/pypi.py +175 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/pytest_runner.py +172 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/rope.py +146 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/security.py +54 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/test_parity.py +167 -0
- hexastack_tools-0.2.0/src/hexastack_tools/commands/usage_docs.py +320 -0
- hexastack_tools-0.2.0/src/hexastack_tools/domain/__init__.py +21 -0
- hexastack_tools-0.2.0/src/hexastack_tools/domain/github.py +128 -0
- hexastack_tools-0.2.0/src/hexastack_tools/ports/__init__.py +7 -0
- hexastack_tools-0.2.0/src/hexastack_tools/ports/github.py +106 -0
- hexastack_tools-0.2.0/src/hexastack_tools/utils/__init__.py +37 -0
- hexastack_tools-0.2.0/src/hexastack_tools/utils/help_extractor.py +147 -0
- 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,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
|
+
]
|