stackgen-sdk 0.1.1__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 (73) hide show
  1. stackgen_sdk-0.1.1/.gitignore +36 -0
  2. stackgen_sdk-0.1.1/PKG-INFO +60 -0
  3. stackgen_sdk-0.1.1/README.md +39 -0
  4. stackgen_sdk-0.1.1/pyproject.toml +63 -0
  5. stackgen_sdk-0.1.1/src/stackgen/__init__.py +52 -0
  6. stackgen_sdk-0.1.1/src/stackgen/_http.py +96 -0
  7. stackgen_sdk-0.1.1/src/stackgen/_version.py +24 -0
  8. stackgen_sdk-0.1.1/src/stackgen/aiden/__init__.py +9 -0
  9. stackgen_sdk-0.1.1/src/stackgen/aiden/namespace.py +26 -0
  10. stackgen_sdk-0.1.1/src/stackgen/aiden/runs.py +36 -0
  11. stackgen_sdk-0.1.1/src/stackgen/aiden/webhook.py +185 -0
  12. stackgen_sdk-0.1.1/src/stackgen/cli.py +33 -0
  13. stackgen_sdk-0.1.1/src/stackgen/client.py +31 -0
  14. stackgen_sdk-0.1.1/src/stackgen/config.py +88 -0
  15. stackgen_sdk-0.1.1/src/stackgen/errors.py +41 -0
  16. stackgen_sdk-0.1.1/src/stackgen/generated/.openapi-generator/FILES +141 -0
  17. stackgen_sdk-0.1.1/src/stackgen/generated/.openapi-generator/VERSION +1 -0
  18. stackgen_sdk-0.1.1/src/stackgen/generated/.openapi-generator-ignore +23 -0
  19. stackgen_sdk-0.1.1/src/stackgen/generated/__init__.py +131 -0
  20. stackgen_sdk-0.1.1/src/stackgen/generated/api/__init__.py +6 -0
  21. stackgen_sdk-0.1.1/src/stackgen/generated/api/aiden_api.py +1806 -0
  22. stackgen_sdk-0.1.1/src/stackgen/generated/api/sre_api.py +1618 -0
  23. stackgen_sdk-0.1.1/src/stackgen/generated/api_client.py +801 -0
  24. stackgen_sdk-0.1.1/src/stackgen/generated/api_response.py +21 -0
  25. stackgen_sdk-0.1.1/src/stackgen/generated/configuration.py +600 -0
  26. stackgen_sdk-0.1.1/src/stackgen/generated/exceptions.py +216 -0
  27. stackgen_sdk-0.1.1/src/stackgen/generated/models/__init__.py +57 -0
  28. stackgen_sdk-0.1.1/src/stackgen/generated/models/alert_analysis_status.py +40 -0
  29. stackgen_sdk-0.1.1/src/stackgen/generated/models/alert_attention.py +39 -0
  30. stackgen_sdk-0.1.1/src/stackgen/generated/models/alert_categorization_summary.py +97 -0
  31. stackgen_sdk-0.1.1/src/stackgen/generated/models/alert_role.py +38 -0
  32. stackgen_sdk-0.1.1/src/stackgen/generated/models/alert_sort_by.py +37 -0
  33. stackgen_sdk-0.1.1/src/stackgen/generated/models/alert_status.py +38 -0
  34. stackgen_sdk-0.1.1/src/stackgen/generated/models/alert_summary.py +95 -0
  35. stackgen_sdk-0.1.1/src/stackgen/generated/models/alert_sync_run_status.py +38 -0
  36. stackgen_sdk-0.1.1/src/stackgen/generated/models/alert_v1.py +213 -0
  37. stackgen_sdk-0.1.1/src/stackgen/generated/models/artifact_info.py +98 -0
  38. stackgen_sdk-0.1.1/src/stackgen/generated/models/error_response.py +91 -0
  39. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigate_alert_request.py +91 -0
  40. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigate_response.py +91 -0
  41. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation.py +167 -0
  42. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_evidence.py +114 -0
  43. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_evidence_kind.py +44 -0
  44. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_evidence_source.py +39 -0
  45. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_hypothesis.py +112 -0
  46. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_plain_summary.py +104 -0
  47. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_prior_incident.py +109 -0
  48. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_recommended_next_step.py +101 -0
  49. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_ref.py +118 -0
  50. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_status.py +42 -0
  51. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_structured_hypothesis_entry.py +115 -0
  52. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_structured_limitation_entry.py +120 -0
  53. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_structured_rca.py +139 -0
  54. stackgen_sdk-0.1.1/src/stackgen/generated/models/investigation_triage_metadata.py +134 -0
  55. stackgen_sdk-0.1.1/src/stackgen/generated/models/json_error.py +91 -0
  56. stackgen_sdk-0.1.1/src/stackgen/generated/models/list_alerts_response.py +119 -0
  57. stackgen_sdk-0.1.1/src/stackgen/generated/models/list_investigations_response.py +101 -0
  58. stackgen_sdk-0.1.1/src/stackgen/generated/models/pagination.py +91 -0
  59. stackgen_sdk-0.1.1/src/stackgen/generated/models/schedule_run_status.py +39 -0
  60. stackgen_sdk-0.1.1/src/stackgen/generated/models/schedule_target_type.py +38 -0
  61. stackgen_sdk-0.1.1/src/stackgen/generated/models/session.py +141 -0
  62. stackgen_sdk-0.1.1/src/stackgen/generated/models/session_responder_kind.py +40 -0
  63. stackgen_sdk-0.1.1/src/stackgen/generated/models/signal_severity.py +40 -0
  64. stackgen_sdk-0.1.1/src/stackgen/generated/models/sync_response.py +136 -0
  65. stackgen_sdk-0.1.1/src/stackgen/generated/models/trigger_webhook202_response.py +98 -0
  66. stackgen_sdk-0.1.1/src/stackgen/generated/models/webhook_run.py +129 -0
  67. stackgen_sdk-0.1.1/src/stackgen/generated/models/webhook_run_detail.py +136 -0
  68. stackgen_sdk-0.1.1/src/stackgen/generated/models/webhook_run_list_response.py +97 -0
  69. stackgen_sdk-0.1.1/src/stackgen/generated/rest.py +258 -0
  70. stackgen_sdk-0.1.1/src/stackgen/sre/__init__.py +5 -0
  71. stackgen_sdk-0.1.1/src/stackgen/sre/namespace.py +57 -0
  72. stackgen_sdk-0.1.1/src/stackgen/vault/__init__.py +5 -0
  73. stackgen_sdk-0.1.1/src/stackgen/vault/namespace.py +17 -0
@@ -0,0 +1,36 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+ .venv/
9
+ venv/
10
+ .venv/**
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+
15
+ # Go
16
+ clients/go/generated/.openapi-generator/
17
+ *.exe
18
+ *.test
19
+ coverage.out
20
+
21
+ # OpenAPI generator scratch
22
+ **/deleteme/
23
+ **/.openapi-generator-ignore.bak
24
+
25
+ # Editors / OS
26
+ .DS_Store
27
+ .idea/
28
+ .vscode/
29
+ *.swp
30
+
31
+ # Local env
32
+ .env
33
+ .env.*
34
+
35
+ # hatch-vcs generated version module (written at build time)
36
+ clients/python/src/stackgen/_version.py
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: stackgen-sdk
3
+ Version: 0.1.1
4
+ Summary: StackGen SDK — Aiden, SRE, and Vault from one client
5
+ Project-URL: Homepage, https://github.com/appcd-dev/stackgen-sdk
6
+ Project-URL: Documentation, https://github.com/appcd-dev/stackgen-sdk/tree/main/docs
7
+ Project-URL: Repository, https://github.com/appcd-dev/stackgen-sdk
8
+ Project-URL: Issues, https://github.com/appcd-dev/stackgen-sdk/issues
9
+ Author: StackGen
10
+ License-Expression: Apache-2.0
11
+ Keywords: aiden,openapi,sre,stackgen
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: pydantic>=2.0
14
+ Requires-Dist: python-dateutil>=2.8
15
+ Requires-Dist: typing-extensions>=4.0
16
+ Requires-Dist: urllib3>=2.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: build>=1.2; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # StackGen Python SDK
23
+
24
+ Install (PyPI):
25
+
26
+ ```bash
27
+ pip install stackgen-sdk==0.1.1
28
+ ```
29
+
30
+ Developers with private repo access:
31
+
32
+ ```bash
33
+ pip install "git+ssh://git@github.com/appcd-dev/stackgen-sdk.git@v0.1.1#subdirectory=clients/python"
34
+ ```
35
+
36
+ Quick start:
37
+
38
+ ```python
39
+ from stackgen import StackgenClient
40
+
41
+ client = StackgenClient.from_env()
42
+ result = client.aiden.run_webhook_and_download_report(open("alert.json").read())
43
+ print(result.session_id, result.output_path)
44
+
45
+ alerts = client.sre.list_alerts()
46
+ ```
47
+
48
+ Environment:
49
+
50
+ | Variable | Purpose |
51
+ |----------|---------|
52
+ | `STACKGEN_URL` | Mothership URL (no `/guild`) |
53
+ | `STACKGEN_TOKEN` | API token (`stackgen_…`) |
54
+ | `WEBHOOK_TOKEN` | Webhook token (`sg_aios_…`) for triggers |
55
+ | `ORG_ID` | Organization UUID |
56
+ | `WEBHOOK_ID` | Optional; older trigger 202 bodies |
57
+
58
+ See [docs/PRODUCT.md](../../docs/PRODUCT.md), [docs/python/IMPLEMENTATION.md](../../docs/python/IMPLEMENTATION.md), and [docs/PUBLISH.md](../../docs/PUBLISH.md).
59
+
60
+ Generated OpenAPI client code lives under `stackgen.generated` (unstable; prefer `StackgenClient`).
@@ -0,0 +1,39 @@
1
+ # StackGen Python SDK
2
+
3
+ Install (PyPI):
4
+
5
+ ```bash
6
+ pip install stackgen-sdk==0.1.1
7
+ ```
8
+
9
+ Developers with private repo access:
10
+
11
+ ```bash
12
+ pip install "git+ssh://git@github.com/appcd-dev/stackgen-sdk.git@v0.1.1#subdirectory=clients/python"
13
+ ```
14
+
15
+ Quick start:
16
+
17
+ ```python
18
+ from stackgen import StackgenClient
19
+
20
+ client = StackgenClient.from_env()
21
+ result = client.aiden.run_webhook_and_download_report(open("alert.json").read())
22
+ print(result.session_id, result.output_path)
23
+
24
+ alerts = client.sre.list_alerts()
25
+ ```
26
+
27
+ Environment:
28
+
29
+ | Variable | Purpose |
30
+ |----------|---------|
31
+ | `STACKGEN_URL` | Mothership URL (no `/guild`) |
32
+ | `STACKGEN_TOKEN` | API token (`stackgen_…`) |
33
+ | `WEBHOOK_TOKEN` | Webhook token (`sg_aios_…`) for triggers |
34
+ | `ORG_ID` | Organization UUID |
35
+ | `WEBHOOK_ID` | Optional; older trigger 202 bodies |
36
+
37
+ See [docs/PRODUCT.md](../../docs/PRODUCT.md), [docs/python/IMPLEMENTATION.md](../../docs/python/IMPLEMENTATION.md), and [docs/PUBLISH.md](../../docs/PUBLISH.md).
38
+
39
+ Generated OpenAPI client code lives under `stackgen.generated` (unstable; prefer `StackgenClient`).
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25", "hatch-vcs>=0.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "stackgen-sdk"
7
+ dynamic = ["version"]
8
+ description = "StackGen SDK — Aiden, SRE, and Vault from one client"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "StackGen" }]
13
+ keywords = ["stackgen", "aiden", "sre", "openapi"]
14
+ dependencies = [
15
+ "urllib3>=2.0",
16
+ "pydantic>=2.0",
17
+ "python-dateutil>=2.8",
18
+ "typing-extensions>=4.0",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/appcd-dev/stackgen-sdk"
23
+ Documentation = "https://github.com/appcd-dev/stackgen-sdk/tree/main/docs"
24
+ Repository = "https://github.com/appcd-dev/stackgen-sdk"
25
+ Issues = "https://github.com/appcd-dev/stackgen-sdk/issues"
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8.0", "build>=1.2"]
29
+
30
+ [project.scripts]
31
+ stackgen-aiden-webhook = "stackgen.cli:main"
32
+
33
+ [tool.hatch.version]
34
+ source = "vcs"
35
+
36
+ [tool.hatch.version.raw-options]
37
+ # Repo root is two levels above clients/python/pyproject.toml
38
+ root = "../.."
39
+ # Prefer annotated release tags like v0.1.0 → version 0.1.0
40
+ tag_regex = "^v(?P<version>[0-9].*)$"
41
+ # PyPI rejects local version segments (+gHASH)
42
+ local_scheme = "no-local-version"
43
+
44
+ [tool.hatch.build.hooks.vcs]
45
+ version-file = "src/stackgen/_version.py"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/stackgen"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ include = [
52
+ "src/stackgen",
53
+ "README.md",
54
+ "pyproject.toml",
55
+ ]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+ pythonpath = ["src"]
60
+ markers = [
61
+ "unit: fast isolated unit tests",
62
+ "integration: tests that need network or Docker",
63
+ ]
@@ -0,0 +1,52 @@
1
+ """StackGen Python SDK.
2
+
3
+ Public entrypoint::
4
+
5
+ from stackgen import StackgenClient, StackgenConfig
6
+
7
+ client = StackgenClient.from_env()
8
+ result = client.aiden.run_webhook_and_download_report(payload)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from stackgen.client import StackgenClient
14
+ from stackgen.config import StackgenConfig
15
+ from stackgen.errors import HttpError, NotAllowlistedError, StackgenError, TimeoutError
16
+
17
+ __all__ = [
18
+ "HttpError",
19
+ "NotAllowlistedError",
20
+ "StackgenClient",
21
+ "StackgenConfig",
22
+ "StackgenError",
23
+ "TimeoutError",
24
+ ]
25
+
26
+
27
+ def _resolve_version() -> str:
28
+ try:
29
+ from importlib.metadata import PackageNotFoundError, version
30
+ except ImportError: # pragma: no cover
31
+ from importlib_metadata import PackageNotFoundError, version # type: ignore
32
+
33
+ try:
34
+ return version("stackgen-sdk")
35
+ except PackageNotFoundError:
36
+ try:
37
+ return version("stackgen")
38
+ except PackageNotFoundError:
39
+ try:
40
+ from stackgen._version import __version__ as vcs_version
41
+
42
+ return vcs_version
43
+ except ImportError:
44
+ try:
45
+ from stackgen._version import version as vcs_version
46
+
47
+ return vcs_version
48
+ except ImportError:
49
+ return "0.0.0"
50
+
51
+
52
+ __version__ = _resolve_version()
@@ -0,0 +1,96 @@
1
+ """Shared HTTP helpers (stdlib)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import ssl
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from typing import Any, Callable
12
+
13
+ from stackgen.errors import HttpError, TimeoutError
14
+
15
+ _RETRYABLE = {429, 502, 503, 504}
16
+
17
+
18
+ def query(org_id: str) -> str:
19
+ if not org_id:
20
+ return ""
21
+ return "?" + urllib.parse.urlencode({"orgId": org_id})
22
+
23
+
24
+ def request(
25
+ url: str,
26
+ token: str,
27
+ *,
28
+ method: str = "GET",
29
+ body: bytes | None = None,
30
+ content_type: str | None = None,
31
+ expect_json: bool = True,
32
+ retries: int = 3,
33
+ ) -> Any:
34
+ """Bearer HTTP call with bounded retries on transient GET failures."""
35
+ headers = {"Authorization": f"Bearer {token}"}
36
+ if content_type:
37
+ headers["Content-Type"] = content_type
38
+
39
+ attempt = 0
40
+ while True:
41
+ attempt += 1
42
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
43
+ try:
44
+ with urllib.request.urlopen(req, context=ssl.create_default_context()) as resp:
45
+ raw = resp.read()
46
+ if not expect_json:
47
+ return raw
48
+ if not raw:
49
+ return {}
50
+ return json.loads(raw.decode("utf-8"))
51
+ except urllib.error.HTTPError as err:
52
+ detail = err.read().decode("utf-8", errors="replace")
53
+ if method == "GET" and err.code in _RETRYABLE and attempt < retries:
54
+ time.sleep(min(2**attempt, 8))
55
+ continue
56
+ raise HttpError(
57
+ f"{method} {url} -> HTTP {err.code}: {detail}",
58
+ status_code=err.code,
59
+ ) from err
60
+ except urllib.error.URLError as err:
61
+ if method == "GET" and attempt < retries:
62
+ time.sleep(min(2**attempt, 8))
63
+ continue
64
+ raise HttpError(f"{method} {url} -> {err}") from err
65
+
66
+
67
+ def as_list(payload: Any) -> list[dict[str, Any]]:
68
+ if isinstance(payload, list):
69
+ return [item for item in payload if isinstance(item, dict)]
70
+ if isinstance(payload, dict):
71
+ items = payload.get("items")
72
+ if isinstance(items, list):
73
+ return [item for item in items if isinstance(item, dict)]
74
+ return []
75
+
76
+
77
+ def text(value: Any) -> str:
78
+ if value is None:
79
+ return ""
80
+ return str(value).strip()
81
+
82
+
83
+ def poll_until(
84
+ deadline: float,
85
+ interval: float,
86
+ tick: Callable[[], Any | None],
87
+ *,
88
+ label: str,
89
+ timeout_seconds: int,
90
+ ) -> Any:
91
+ while time.monotonic() < deadline:
92
+ result = tick()
93
+ if result is not None:
94
+ return result
95
+ time.sleep(interval)
96
+ raise TimeoutError(label, timeout_seconds)
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.1'
22
+ __version_tuple__ = version_tuple = (0, 1, 1)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,9 @@
1
+ """Aiden (platform) namespace — webhooks, sessions, artifacts."""
2
+
3
+ from stackgen.aiden.namespace import AidenNamespace
4
+ from stackgen.aiden.webhook import WebhookReportResult
5
+
6
+ __all__ = [
7
+ "AidenNamespace",
8
+ "WebhookReportResult",
9
+ ]
@@ -0,0 +1,26 @@
1
+ """Aiden namespace — public methods for platform APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from stackgen.aiden.webhook import WebhookReportJourney, WebhookReportResult
6
+ from stackgen.config import StackgenConfig
7
+
8
+
9
+ class AidenNamespace:
10
+ """Aiden platform APIs (sessions, webhooks, artifacts)."""
11
+
12
+ def __init__(self, config: StackgenConfig) -> None:
13
+ self._config = config
14
+ self._webhook = WebhookReportJourney(config)
15
+
16
+ def run_webhook_and_download_report(
17
+ self,
18
+ payload: str,
19
+ *,
20
+ artifact_name: str | None = None,
21
+ output_path: str | None = None,
22
+ ) -> WebhookReportResult:
23
+ """Trigger a webhook, wait for the session artifact, download it."""
24
+ return self._webhook.run(
25
+ payload, artifact_name=artifact_name, output_path=output_path
26
+ )
@@ -0,0 +1,36 @@
1
+ """Pure helpers for webhook run selection (unit-testable without HTTP)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from stackgen import _http
8
+
9
+
10
+ def pick_run(
11
+ runs: list[dict[str, Any]],
12
+ dispatch_run_id: str,
13
+ known_run_ids: frozenset[str],
14
+ ) -> dict[str, Any] | None:
15
+ """Choose the webhook run that corresponds to a new trigger.
16
+
17
+ Preference order:
18
+ 1. Exact match on ``id`` / ``run_id`` to ``dispatch_run_id``
19
+ 2. First run whose id is not in ``known_run_ids`` (new since pre-trigger snapshot)
20
+ 3. If no known ids and runs exist, the first run (best-effort on older motherships)
21
+ """
22
+ if dispatch_run_id:
23
+ for item in runs:
24
+ if dispatch_run_id in {
25
+ _http.text(item.get("id")),
26
+ _http.text(item.get("run_id")),
27
+ }:
28
+ return item
29
+ for item in runs:
30
+ if _http.text(item.get("id")) not in known_run_ids:
31
+ return item
32
+ if not runs:
33
+ return None
34
+ if not known_run_ids:
35
+ return runs[0]
36
+ return None
@@ -0,0 +1,185 @@
1
+ """Aiden webhook → session-report journey."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.parse
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from stackgen import _http
13
+ from stackgen.aiden.runs import pick_run
14
+ from stackgen.config import StackgenConfig
15
+ from stackgen.errors import StackgenError, TimeoutError
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class WebhookReportResult:
20
+ """Result of trigger → wait → download session-report."""
21
+
22
+ webhook_id: str
23
+ invocation_id: str
24
+ session_id: str
25
+ artifacts: tuple[dict[str, Any], ...]
26
+ output_path: Path
27
+
28
+
29
+ class WebhookReportJourney:
30
+ """Trigger a webhook, wait for the session artifact, and download it.
31
+
32
+ Uses ``webhook_token`` for POST trigger and ``api_token`` for poll/download.
33
+ """
34
+
35
+ def __init__(self, config: StackgenConfig) -> None:
36
+ self._config = config
37
+
38
+ def run(
39
+ self,
40
+ payload: str,
41
+ *,
42
+ artifact_name: str | None = None,
43
+ output_path: str | None = None,
44
+ ) -> WebhookReportResult:
45
+ cfg = self._config
46
+ if not cfg.webhook_token:
47
+ raise ValueError("webhook_token is required for run_webhook_and_download_report")
48
+
49
+ artifact = artifact_name or cfg.artifact_name
50
+ dest = output_path or cfg.output_path
51
+
52
+ known_run_ids: frozenset[str] = frozenset()
53
+ if cfg.webhook_id:
54
+ known_run_ids = frozenset(
55
+ _http.text(item.get("id"))
56
+ for item in self._list_runs(cfg.webhook_id)
57
+ if _http.text(item.get("id"))
58
+ )
59
+
60
+ trigger = self._trigger(payload)
61
+ webhook_id = _http.text(trigger.get("webhook_id")) or _http.text(cfg.webhook_id)
62
+ invocation_id = _http.text(trigger.get("invocation_id"))
63
+ session_id = _http.text(trigger.get("session_id"))
64
+
65
+ if not session_id and not webhook_id:
66
+ raise StackgenError(
67
+ "trigger 202 did not include session_id or webhook_id; "
68
+ "set webhook_id for older motherships. "
69
+ f"body={json.dumps(trigger)}"
70
+ )
71
+
72
+ if not session_id:
73
+ session_id = self._wait_for_session(
74
+ webhook_id,
75
+ invocation_id,
76
+ _http.text(trigger.get("run_id")),
77
+ known_run_ids,
78
+ )
79
+
80
+ artifacts = self._wait_for_artifact(session_id, artifact)
81
+ path = self._download_artifact(session_id, artifact, dest)
82
+ return WebhookReportResult(
83
+ webhook_id=webhook_id,
84
+ invocation_id=invocation_id,
85
+ session_id=session_id,
86
+ artifacts=tuple(artifacts),
87
+ output_path=path,
88
+ )
89
+
90
+ def _trigger(self, payload: str) -> dict[str, Any]:
91
+ url = f"{self._config.aiden_url()}/api/v1/webhooks/trigger{_http.query(self._config.org_id)}"
92
+ data = _http.request(
93
+ url,
94
+ self._config.webhook_token,
95
+ method="POST",
96
+ body=payload.encode("utf-8"),
97
+ content_type="text/plain",
98
+ )
99
+ if not isinstance(data, dict):
100
+ raise StackgenError(f"unexpected trigger payload: {data!r}")
101
+ return data
102
+
103
+ def _list_runs(self, webhook_id: str) -> list[dict[str, Any]]:
104
+ url = (
105
+ f"{self._config.aiden_url()}/api/v1/webhooks/{webhook_id}/runs"
106
+ f"{_http.query(self._config.org_id)}"
107
+ )
108
+ return _http.as_list(_http.request(url, self._config.api_token))
109
+
110
+ def _get_run(self, webhook_id: str, run_id: str) -> dict[str, Any]:
111
+ url = (
112
+ f"{self._config.aiden_url()}/api/v1/webhooks/{webhook_id}/runs/{run_id}"
113
+ f"{_http.query(self._config.org_id)}"
114
+ )
115
+ data = _http.request(url, self._config.api_token)
116
+ if not isinstance(data, dict):
117
+ raise StackgenError(f"unexpected run payload: {data!r}")
118
+ return data
119
+
120
+ def _wait_for_session(
121
+ self,
122
+ webhook_id: str,
123
+ invocation_id: str,
124
+ dispatch_run_id: str,
125
+ known_run_ids: frozenset[str],
126
+ ) -> str:
127
+ cfg = self._config
128
+ deadline = time.monotonic() + cfg.timeout_seconds
129
+ run_id_hint = invocation_id or dispatch_run_id
130
+ while time.monotonic() < deadline:
131
+ run: dict[str, Any] | None = None
132
+ if run_id_hint:
133
+ try:
134
+ run = self._get_run(webhook_id, run_id_hint)
135
+ except StackgenError:
136
+ run = None
137
+ if run is None:
138
+ run = pick_run(self._list_runs(webhook_id), dispatch_run_id, known_run_ids)
139
+ if run is None:
140
+ time.sleep(cfg.poll_interval_seconds)
141
+ continue
142
+ if not run_id_hint:
143
+ run_id_hint = _http.text(run.get("id")) or _http.text(run.get("run_id"))
144
+ status = _http.text(run.get("status")).casefold()
145
+ session_id = _http.text(run.get("session_id"))
146
+ if status in {"failed", "skipped"}:
147
+ raise StackgenError(
148
+ f"webhook invocation ended with status {status}: "
149
+ f"{run.get('error') or 'no error detail'}"
150
+ )
151
+ if session_id:
152
+ return session_id
153
+ time.sleep(cfg.poll_interval_seconds)
154
+ raise TimeoutError("webhook session", cfg.timeout_seconds)
155
+
156
+ def _wait_for_artifact(
157
+ self, session_id: str, artifact_name: str
158
+ ) -> list[dict[str, Any]]:
159
+ cfg = self._config
160
+ deadline = time.monotonic() + cfg.timeout_seconds
161
+ url = (
162
+ f"{cfg.aiden_url()}/api/v1/sessions/{session_id}/artifacts"
163
+ f"{_http.query(cfg.org_id)}"
164
+ )
165
+ want = artifact_name.casefold()
166
+ artifacts: list[dict[str, Any]] = []
167
+ while time.monotonic() < deadline:
168
+ artifacts = _http.as_list(_http.request(url, cfg.api_token))
169
+ if any(_http.text(item.get("name")).casefold() == want for item in artifacts):
170
+ return artifacts
171
+ time.sleep(cfg.poll_interval_seconds)
172
+ raise TimeoutError(artifact_name, cfg.timeout_seconds)
173
+
174
+ def _download_artifact(
175
+ self, session_id: str, artifact_name: str, output_path: str
176
+ ) -> Path:
177
+ encoded = urllib.parse.quote(artifact_name, safe="")
178
+ url = (
179
+ f"{self._config.aiden_url()}/api/v1/sessions/{session_id}/artifacts/"
180
+ f"{encoded}/download{_http.query(self._config.org_id)}"
181
+ )
182
+ raw = _http.request(url, self._config.api_token, expect_json=False)
183
+ dest = Path(output_path)
184
+ dest.write_bytes(raw)
185
+ return dest
@@ -0,0 +1,33 @@
1
+ """CLI: trigger webhook and download session-report.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from stackgen.client import StackgenClient
9
+ from stackgen.config import StackgenConfig
10
+ from stackgen.errors import StackgenError
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> None:
14
+ args = list(sys.argv[1:] if argv is None else argv)
15
+ payload = "{}"
16
+ if args:
17
+ path = Path(args[0])
18
+ if not path.is_file():
19
+ print(f"error: payload file does not exist: {path}", file=sys.stderr)
20
+ sys.exit(1)
21
+ payload = path.read_text(encoding="utf-8")
22
+ try:
23
+ client = StackgenClient(StackgenConfig.from_env())
24
+ result = client.aiden.run_webhook_and_download_report(payload)
25
+ except (StackgenError, ValueError) as err:
26
+ print(f"error: {err}", file=sys.stderr)
27
+ sys.exit(1)
28
+ print(f"session_id={result.session_id}")
29
+ print(f"output_path={result.output_path}")
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()