forgesight-github 0.1.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.
- forgesight_github-0.1.0/.gitignore +38 -0
- forgesight_github-0.1.0/PKG-INFO +90 -0
- forgesight_github-0.1.0/README.md +65 -0
- forgesight_github-0.1.0/pyproject.toml +41 -0
- forgesight_github-0.1.0/src/forgesight_github/__init__.py +34 -0
- forgesight_github-0.1.0/src/forgesight_github/bootstrap.py +127 -0
- forgesight_github-0.1.0/src/forgesight_github/interceptor.py +30 -0
- forgesight_github-0.1.0/src/forgesight_github/metadata.py +73 -0
- forgesight_github-0.1.0/src/forgesight_github/oidc.py +48 -0
- forgesight_github-0.1.0/src/forgesight_github/py.typed +0 -0
- forgesight_github-0.1.0/src/forgesight_github/summary.py +90 -0
- forgesight_github-0.1.0/tests/test_github.py +416 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# venv / tooling
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
.uv/
|
|
14
|
+
uv.lock
|
|
15
|
+
|
|
16
|
+
# test / type / lint caches
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
.coverage
|
|
21
|
+
.coverage.*
|
|
22
|
+
coverage.xml
|
|
23
|
+
htmlcov/
|
|
24
|
+
|
|
25
|
+
# secrets / local env (never commit)
|
|
26
|
+
.env
|
|
27
|
+
.env.*
|
|
28
|
+
|
|
29
|
+
# editor / OS
|
|
30
|
+
.DS_Store
|
|
31
|
+
.idea/
|
|
32
|
+
.vscode/
|
|
33
|
+
|
|
34
|
+
# local-only session working state (per the workspace pipeline)
|
|
35
|
+
.claude/state/
|
|
36
|
+
|
|
37
|
+
# local-only launch planning (not part of the published repo)
|
|
38
|
+
/launch/
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forgesight-github
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ForgeSight GitHub Actions integration — one-line CI bootstrap; run↔commit/PR/job correlation.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Scaffoldic/forgesight
|
|
6
|
+
Project-URL: Repository, https://github.com/Scaffoldic/forgesight
|
|
7
|
+
Project-URL: Issues, https://github.com/Scaffoldic/forgesight/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md
|
|
9
|
+
Author: kjoshi
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: ai-agents,ci,forgesight,github-actions,observability
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Information Technology
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: forgesight-core
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# forgesight-github
|
|
27
|
+
|
|
28
|
+
The GitHub Actions integration for [ForgeSight](https://github.com/Scaffoldic/forgesight).
|
|
29
|
+
One line in your CI entry script correlates every agent run with the commit / PR / job /
|
|
30
|
+
workflow that triggered it, and writes a cost+duration+status summary to the job page.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install forgesight-github
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from forgesight_github import bootstrap
|
|
38
|
+
bootstrap() # configure() + attach GITHUB_* metadata + job summary on exit
|
|
39
|
+
|
|
40
|
+
# Unchanged agent code — every run in this job is now correlated and flushed cleanly.
|
|
41
|
+
result = await pr_reviewer.run(task)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
# .github/workflows/review.yml
|
|
46
|
+
jobs:
|
|
47
|
+
review:
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
permissions:
|
|
50
|
+
id-token: write # only if using OIDC exporter auth
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/checkout@v4
|
|
53
|
+
- run: pip install forgesight-github
|
|
54
|
+
- run: python review_agent.py
|
|
55
|
+
env:
|
|
56
|
+
FORGESIGHT_EXPORTERS: otlp
|
|
57
|
+
FORGESIGHT_OTLP_ENDPOINT: ${{ vars.OTEL_COLLECTOR }}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## What you get
|
|
61
|
+
|
|
62
|
+
- **Run↔commit/PR/job link for free.** Every run carries `vcs.repository.name`,
|
|
63
|
+
`vcs.ref.head.revision` (sha), `vcs.ref.head.name` (ref), `vcs.change.id` (PR number),
|
|
64
|
+
`cicd.pipeline.run.id` / `.attempt`, `cicd.pipeline.name` (workflow), and
|
|
65
|
+
`cicd.pipeline.task.name` (job) as run-scoped metadata (FR-5), so "agent spend on PR #1234"
|
|
66
|
+
or "spend by workflow" is a one-line backend query.
|
|
67
|
+
- **PR number resolved correctly.** Parsed from the event payload JSON (`$GITHUB_EVENT_PATH`)
|
|
68
|
+
for `pull_request*` events — absent (not fabricated) otherwise.
|
|
69
|
+
- **A useful job summary, automatically.** A markdown block —
|
|
70
|
+
`status · cost_usd · duration_ms · n_tool_calls` — is appended to `$GITHUB_STEP_SUMMARY`
|
|
71
|
+
on exit. Best-effort; never fails the job.
|
|
72
|
+
- **Zero lost CI telemetry.** An `atexit` hook calls `force_flush()` + `shutdown()` so the
|
|
73
|
+
ephemeral runner never drops the buffered batch.
|
|
74
|
+
- **Safe exporter auth (opt-in).** `bootstrap(oidc=True)` fetches the runner's short-lived
|
|
75
|
+
OIDC id-token (requires `id-token: write`) instead of a static secret; falls back to
|
|
76
|
+
configured auth if unavailable.
|
|
77
|
+
- **Runs locally too.** Outside CI (`GITHUB_ACTIONS` unset) it falls back to a plain
|
|
78
|
+
`configure()` and warns once.
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
| Key | Env | Default |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `write_summary` | `FORGESIGHT_GITHUB_SUMMARY` | `true` |
|
|
85
|
+
| `oidc` | — (kwarg) | `false` |
|
|
86
|
+
| `capture_env` | — (kwarg) | the documented `GITHUB_*` set |
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
Apache-2.0
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# forgesight-github
|
|
2
|
+
|
|
3
|
+
The GitHub Actions integration for [ForgeSight](https://github.com/Scaffoldic/forgesight).
|
|
4
|
+
One line in your CI entry script correlates every agent run with the commit / PR / job /
|
|
5
|
+
workflow that triggered it, and writes a cost+duration+status summary to the job page.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install forgesight-github
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from forgesight_github import bootstrap
|
|
13
|
+
bootstrap() # configure() + attach GITHUB_* metadata + job summary on exit
|
|
14
|
+
|
|
15
|
+
# Unchanged agent code — every run in this job is now correlated and flushed cleanly.
|
|
16
|
+
result = await pr_reviewer.run(task)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```yaml
|
|
20
|
+
# .github/workflows/review.yml
|
|
21
|
+
jobs:
|
|
22
|
+
review:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
permissions:
|
|
25
|
+
id-token: write # only if using OIDC exporter auth
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- run: pip install forgesight-github
|
|
29
|
+
- run: python review_agent.py
|
|
30
|
+
env:
|
|
31
|
+
FORGESIGHT_EXPORTERS: otlp
|
|
32
|
+
FORGESIGHT_OTLP_ENDPOINT: ${{ vars.OTEL_COLLECTOR }}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What you get
|
|
36
|
+
|
|
37
|
+
- **Run↔commit/PR/job link for free.** Every run carries `vcs.repository.name`,
|
|
38
|
+
`vcs.ref.head.revision` (sha), `vcs.ref.head.name` (ref), `vcs.change.id` (PR number),
|
|
39
|
+
`cicd.pipeline.run.id` / `.attempt`, `cicd.pipeline.name` (workflow), and
|
|
40
|
+
`cicd.pipeline.task.name` (job) as run-scoped metadata (FR-5), so "agent spend on PR #1234"
|
|
41
|
+
or "spend by workflow" is a one-line backend query.
|
|
42
|
+
- **PR number resolved correctly.** Parsed from the event payload JSON (`$GITHUB_EVENT_PATH`)
|
|
43
|
+
for `pull_request*` events — absent (not fabricated) otherwise.
|
|
44
|
+
- **A useful job summary, automatically.** A markdown block —
|
|
45
|
+
`status · cost_usd · duration_ms · n_tool_calls` — is appended to `$GITHUB_STEP_SUMMARY`
|
|
46
|
+
on exit. Best-effort; never fails the job.
|
|
47
|
+
- **Zero lost CI telemetry.** An `atexit` hook calls `force_flush()` + `shutdown()` so the
|
|
48
|
+
ephemeral runner never drops the buffered batch.
|
|
49
|
+
- **Safe exporter auth (opt-in).** `bootstrap(oidc=True)` fetches the runner's short-lived
|
|
50
|
+
OIDC id-token (requires `id-token: write`) instead of a static secret; falls back to
|
|
51
|
+
configured auth if unavailable.
|
|
52
|
+
- **Runs locally too.** Outside CI (`GITHUB_ACTIONS` unset) it falls back to a plain
|
|
53
|
+
`configure()` and warns once.
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
| Key | Env | Default |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `write_summary` | `FORGESIGHT_GITHUB_SUMMARY` | `true` |
|
|
60
|
+
| `oidc` | — (kwarg) | `false` |
|
|
61
|
+
| `capture_env` | — (kwarg) | the documented `GITHUB_*` set |
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
Apache-2.0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "forgesight-github"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "ForgeSight GitHub Actions integration — one-line CI bootstrap; run↔commit/PR/job correlation."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
authors = [{ name = "kjoshi" }]
|
|
9
|
+
keywords = ["observability", "github-actions", "ci", "ai-agents", "forgesight"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Intended Audience :: Information Technology",
|
|
14
|
+
"Topic :: System :: Monitoring",
|
|
15
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["forgesight-core"]
|
|
23
|
+
|
|
24
|
+
[project.entry-points."forgesight.integrations"]
|
|
25
|
+
github = "forgesight_github:install"
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/Scaffoldic/forgesight"
|
|
29
|
+
Repository = "https://github.com/Scaffoldic/forgesight"
|
|
30
|
+
Issues = "https://github.com/Scaffoldic/forgesight/issues"
|
|
31
|
+
Changelog = "https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/forgesight_github"]
|
|
39
|
+
|
|
40
|
+
[tool.uv.sources]
|
|
41
|
+
forgesight-core = { workspace = true }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""ForgeSight GitHub Actions integration — one-line CI bootstrap; run↔commit/PR/job link.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from forgesight_github import bootstrap
|
|
5
|
+
bootstrap() # configure() + attach GITHUB_* metadata + job summary on exit
|
|
6
|
+
```
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .bootstrap import bootstrap, in_github_actions, install, run_exit_hook
|
|
12
|
+
from .interceptor import GitHubMetadataInterceptor
|
|
13
|
+
from .metadata import GITHUB_ENV_MAP, github_metadata, pr_number
|
|
14
|
+
from .oidc import fetch_oidc_token
|
|
15
|
+
from .summary import DEFAULT_SUMMARY_METRICS, SummaryCollector, format_summary, write_summary
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DEFAULT_SUMMARY_METRICS",
|
|
21
|
+
"GITHUB_ENV_MAP",
|
|
22
|
+
"GitHubMetadataInterceptor",
|
|
23
|
+
"SummaryCollector",
|
|
24
|
+
"__version__",
|
|
25
|
+
"bootstrap",
|
|
26
|
+
"fetch_oidc_token",
|
|
27
|
+
"format_summary",
|
|
28
|
+
"github_metadata",
|
|
29
|
+
"in_github_actions",
|
|
30
|
+
"install",
|
|
31
|
+
"pr_number",
|
|
32
|
+
"run_exit_hook",
|
|
33
|
+
"write_summary",
|
|
34
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""``bootstrap()`` — the one-line CI wiring, plus the ``forgesight.integrations`` ``install``.
|
|
2
|
+
|
|
3
|
+
``bootstrap()`` does three things in order: read ``GITHUB_*`` metadata, ``configure()`` the
|
|
4
|
+
SDK and attach that metadata to every record (FR-5), and register an exit hook that flushes
|
|
5
|
+
telemetry (so a deploy/ephemeral runner never drops it) and writes a job summary. Outside CI
|
|
6
|
+
it falls back to a plain ``configure()`` and warns once, so the same script runs locally.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
from forgesight_core import Runtime, configure
|
|
17
|
+
|
|
18
|
+
from .interceptor import GitHubMetadataInterceptor
|
|
19
|
+
from .metadata import github_metadata
|
|
20
|
+
from .oidc import fetch_oidc_token
|
|
21
|
+
from .summary import DEFAULT_SUMMARY_METRICS, SummaryCollector, write_summary
|
|
22
|
+
|
|
23
|
+
_log = logging.getLogger("forgesight.github")
|
|
24
|
+
|
|
25
|
+
OIDC_TOKEN_ENV = "FORGESIGHT_OTLP_TOKEN" # documented hand-off for the collector/exporter
|
|
26
|
+
_warned_not_ci = False
|
|
27
|
+
_installed_config: dict[str, object] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def in_github_actions(env: Mapping[str, str] | None = None) -> bool:
|
|
31
|
+
source = os.environ if env is None else env
|
|
32
|
+
return source.get("GITHUB_ACTIONS") == "true"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def bootstrap(
|
|
36
|
+
*,
|
|
37
|
+
write_summary: bool = True,
|
|
38
|
+
summary_metrics: Sequence[str] = DEFAULT_SUMMARY_METRICS,
|
|
39
|
+
oidc: bool = False,
|
|
40
|
+
extra_metadata: Mapping[str, str] | None = None,
|
|
41
|
+
_register_exit: bool = True,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""One-line CI bootstrap: ``configure()``, attach ``GITHUB_*`` metadata, summary-on-exit."""
|
|
44
|
+
in_ci = in_github_actions()
|
|
45
|
+
if not in_ci:
|
|
46
|
+
_warn_not_ci()
|
|
47
|
+
|
|
48
|
+
metadata: dict[str, str] = github_metadata() if in_ci else {}
|
|
49
|
+
if extra_metadata:
|
|
50
|
+
metadata.update(extra_metadata)
|
|
51
|
+
|
|
52
|
+
if oidc:
|
|
53
|
+
_apply_oidc()
|
|
54
|
+
|
|
55
|
+
runtime = configure()
|
|
56
|
+
if metadata:
|
|
57
|
+
runtime.add_interceptor(GitHubMetadataInterceptor(metadata))
|
|
58
|
+
|
|
59
|
+
do_summary = write_summary and in_ci and _summary_enabled()
|
|
60
|
+
collector: SummaryCollector | None = None
|
|
61
|
+
if do_summary:
|
|
62
|
+
collector = SummaryCollector()
|
|
63
|
+
runtime.add_listener(collector)
|
|
64
|
+
|
|
65
|
+
fields = tuple(summary_metrics)
|
|
66
|
+
if _register_exit:
|
|
67
|
+
atexit.register(run_exit_hook, runtime, collector, fields)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_exit_hook(
|
|
71
|
+
runtime: Runtime, collector: SummaryCollector | None, fields: Sequence[str]
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Flush telemetry then (best-effort) write the job summary. Safe to call directly."""
|
|
74
|
+
timeout = runtime.config.export_timeout_millis
|
|
75
|
+
try:
|
|
76
|
+
runtime.force_flush(timeout)
|
|
77
|
+
finally:
|
|
78
|
+
runtime.shutdown(timeout)
|
|
79
|
+
if collector is not None:
|
|
80
|
+
write_summary(collector, fields)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _apply_oidc() -> None:
|
|
84
|
+
token = fetch_oidc_token()
|
|
85
|
+
if token is None:
|
|
86
|
+
_log.warning(
|
|
87
|
+
"forgesight-github: OIDC requested but no runner id-token endpoint; "
|
|
88
|
+
"falling back to configured exporter auth"
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
os.environ.setdefault(OIDC_TOKEN_ENV, token)
|
|
92
|
+
_log.info("forgesight-github: obtained runner OIDC token (handed off via %s)", OIDC_TOKEN_ENV)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _summary_enabled() -> bool:
|
|
96
|
+
env = os.environ.get("FORGESIGHT_GITHUB_SUMMARY")
|
|
97
|
+
if env is not None:
|
|
98
|
+
return env.strip().lower() in ("1", "true", "yes", "on")
|
|
99
|
+
if "write_summary" in _installed_config:
|
|
100
|
+
return bool(_installed_config["write_summary"])
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _warn_not_ci() -> None:
|
|
105
|
+
global _warned_not_ci
|
|
106
|
+
if not _warned_not_ci:
|
|
107
|
+
_log.warning(
|
|
108
|
+
"forgesight-github: GITHUB_ACTIONS unset; bootstrap() falls back to plain configure()"
|
|
109
|
+
)
|
|
110
|
+
_warned_not_ci = True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def install(config: dict[str, object] | None = None) -> bool:
|
|
114
|
+
"""The ``forgesight.integrations`` entry point: stash config defaults for ``bootstrap()``."""
|
|
115
|
+
cfg = dict(config or {})
|
|
116
|
+
_installed_config.clear()
|
|
117
|
+
if not cfg.get("enabled", True):
|
|
118
|
+
return False
|
|
119
|
+
_installed_config.update(cfg)
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _reset_for_tests() -> None:
|
|
124
|
+
"""Clear module state so tests don't leak the not-in-CI warning / installed config."""
|
|
125
|
+
global _warned_not_ci
|
|
126
|
+
_warned_not_ci = False
|
|
127
|
+
_installed_config.clear()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""``GitHubMetadataInterceptor`` — attach CI correlation metadata to every record.
|
|
2
|
+
|
|
3
|
+
``bootstrap()`` runs once at process start, before any run opens, so there is no live run
|
|
4
|
+
to ``set_metadata`` on. Instead this interceptor merges the ``GITHUB_*`` metadata onto every
|
|
5
|
+
record's attributes (FR-5) — so each span carries the repo / sha / PR / workflow / job and
|
|
6
|
+
"spend on PR #1234" is a one-line backend query. Per-call metadata wins (``setdefault``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
|
|
15
|
+
from forgesight_api import Record
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitHubMetadataInterceptor:
|
|
19
|
+
"""Merge fixed CI metadata into each record's attributes without overwriting per-call keys."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, metadata: Mapping[str, str]) -> None:
|
|
22
|
+
self._metadata = dict(metadata)
|
|
23
|
+
|
|
24
|
+
def intercept(self, record: Record) -> Record | None:
|
|
25
|
+
if not self._metadata:
|
|
26
|
+
return record
|
|
27
|
+
attrs = dict(record.attributes)
|
|
28
|
+
for key, value in self._metadata.items():
|
|
29
|
+
attrs.setdefault(key, value) # don't clobber metadata the caller set explicitly
|
|
30
|
+
return replace(record, attributes=MappingProxyType(attrs))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""``GITHUB_*`` → business-metadata mapping (FR-5), per the OTel VCS / CICD conventions.
|
|
2
|
+
|
|
3
|
+
Pure: reads the runner environment and parses the PR number from the event payload JSON
|
|
4
|
+
(the one field that is not a plain env var). Maps onto the current ``vcs.*`` / ``cicd.*``
|
|
5
|
+
semconv, with ``agentforge.*`` extensions where no convention exists yet. Absent fields are
|
|
6
|
+
omitted, never fabricated.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
_log = logging.getLogger("forgesight.github")
|
|
17
|
+
|
|
18
|
+
# GITHUB_* env var → telemetry attribute key (the documented capture set).
|
|
19
|
+
GITHUB_ENV_MAP: Mapping[str, str] = {
|
|
20
|
+
"GITHUB_REPOSITORY": "vcs.repository.name",
|
|
21
|
+
"GITHUB_SHA": "vcs.ref.head.revision",
|
|
22
|
+
"GITHUB_REF": "vcs.ref.head.name",
|
|
23
|
+
"GITHUB_RUN_ID": "cicd.pipeline.run.id",
|
|
24
|
+
"GITHUB_RUN_ATTEMPT": "cicd.pipeline.run.attempt",
|
|
25
|
+
"GITHUB_WORKFLOW": "cicd.pipeline.name",
|
|
26
|
+
"GITHUB_JOB": "cicd.pipeline.task.name",
|
|
27
|
+
"GITHUB_ACTOR": "vcs.change.author", # agentforge extension where no semconv exists
|
|
28
|
+
"GITHUB_EVENT_NAME": "cicd.pipeline.run.trigger",
|
|
29
|
+
}
|
|
30
|
+
PR_NUMBER_KEY = "vcs.change.id"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def github_metadata(
|
|
34
|
+
*,
|
|
35
|
+
capture_env: Sequence[str] | None = None,
|
|
36
|
+
env: Mapping[str, str] | None = None,
|
|
37
|
+
) -> dict[str, str]:
|
|
38
|
+
"""Return the ``GITHUB_*`` → metadata mapping (repo, sha, ref, run id/attempt, workflow,
|
|
39
|
+
job, actor, event, PR number). ``capture_env`` restricts which ``GITHUB_*`` keys to read.
|
|
40
|
+
"""
|
|
41
|
+
source = os.environ if env is None else env
|
|
42
|
+
allowed = set(capture_env) if capture_env is not None else None
|
|
43
|
+
out: dict[str, str] = {}
|
|
44
|
+
for env_key, attr in GITHUB_ENV_MAP.items():
|
|
45
|
+
if allowed is not None and env_key not in allowed:
|
|
46
|
+
continue
|
|
47
|
+
value = source.get(env_key)
|
|
48
|
+
if value:
|
|
49
|
+
out[attr] = value
|
|
50
|
+
pr = pr_number(source)
|
|
51
|
+
if pr is not None:
|
|
52
|
+
out[PR_NUMBER_KEY] = pr
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def pr_number(env: Mapping[str, str]) -> str | None:
|
|
57
|
+
"""PR number from the event payload JSON for ``pull_request*`` events, else ``None``."""
|
|
58
|
+
event_name = env.get("GITHUB_EVENT_NAME", "")
|
|
59
|
+
if not event_name.startswith("pull_request"):
|
|
60
|
+
return None
|
|
61
|
+
path = env.get("GITHUB_EVENT_PATH")
|
|
62
|
+
if not path or not os.path.exists(path):
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
with open(path, encoding="utf-8") as handle:
|
|
66
|
+
payload = json.load(handle)
|
|
67
|
+
except (OSError, ValueError): # unreadable / malformed payload — don't fabricate
|
|
68
|
+
_log.warning("forgesight-github: could not read event payload at %s", path)
|
|
69
|
+
return None
|
|
70
|
+
candidate = payload.get("pull_request", {}).get("number")
|
|
71
|
+
if candidate is None:
|
|
72
|
+
candidate = payload.get("number")
|
|
73
|
+
return str(candidate) if candidate is not None else None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Best-effort GitHub Actions OIDC id-token fetch (stdlib HTTP, no GitHub SDK).
|
|
2
|
+
|
|
3
|
+
CI should authenticate to the collector with the runner's short-lived OIDC token rather than
|
|
4
|
+
a static secret. The runner exposes a token endpoint via ``ACTIONS_ID_TOKEN_REQUEST_URL`` +
|
|
5
|
+
``ACTIONS_ID_TOKEN_REQUEST_TOKEN`` (only when the job has ``id-token: write``). This fetch is
|
|
6
|
+
best-effort: any failure returns ``None`` so ``bootstrap`` falls back to configured exporter
|
|
7
|
+
auth and never fails the job (P6).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_log = logging.getLogger("forgesight.github")
|
|
20
|
+
|
|
21
|
+
_URL_ENV = "ACTIONS_ID_TOKEN_REQUEST_URL"
|
|
22
|
+
_TOKEN_ENV = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fetch_oidc_token(
|
|
26
|
+
*, audience: str | None = None, env: Mapping[str, str] | None = None, timeout: float = 5.0
|
|
27
|
+
) -> str | None:
|
|
28
|
+
"""Return the runner OIDC id-token, or ``None`` if unavailable / on any error."""
|
|
29
|
+
import os
|
|
30
|
+
|
|
31
|
+
source = os.environ if env is None else env
|
|
32
|
+
url = source.get(_URL_ENV)
|
|
33
|
+
bearer = source.get(_TOKEN_ENV)
|
|
34
|
+
if not url or not bearer:
|
|
35
|
+
return None
|
|
36
|
+
if audience:
|
|
37
|
+
url = f"{url}&audience={urllib.parse.quote(audience)}"
|
|
38
|
+
request = urllib.request.Request(url, headers={"Authorization": f"Bearer {bearer}"})
|
|
39
|
+
try:
|
|
40
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
41
|
+
payload: Any = json.loads(response.read().decode("utf-8"))
|
|
42
|
+
except Exception: # network / parse / auth — best-effort, never raise (P6)
|
|
43
|
+
_log.warning(
|
|
44
|
+
"forgesight-github: OIDC token request failed; falling back to configured auth"
|
|
45
|
+
)
|
|
46
|
+
return None
|
|
47
|
+
value = payload.get("value") if isinstance(payload, dict) else None
|
|
48
|
+
return str(value) if value else None
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Per-job run summary written to ``$GITHUB_STEP_SUMMARY`` on exit.
|
|
2
|
+
|
|
3
|
+
A :class:`SummaryCollector` (an ``EventListener``) tallies the runs the SDK saw in the
|
|
4
|
+
process — status, cost (summed from LLM calls), duration, tool-call count. On exit a markdown
|
|
5
|
+
block is appended to the Actions job summary so the UI shows "run: ok · cost $0.12 · 38s · 3
|
|
6
|
+
tool calls" with no author effort. The write is best-effort and never fails the job (P6).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
from forgesight_api import EventType, LifecycleEvent, RunStatus
|
|
16
|
+
|
|
17
|
+
_log = logging.getLogger("forgesight.github")
|
|
18
|
+
|
|
19
|
+
DEFAULT_SUMMARY_METRICS: tuple[str, ...] = ("status", "cost_usd", "duration_ms", "n_tool_calls")
|
|
20
|
+
_RUN_DONE = frozenset({EventType.RUN_COMPLETED, EventType.RUN_FAILED})
|
|
21
|
+
_TOOL_DONE = frozenset({EventType.TOOL_EXECUTED, EventType.MCP_EXECUTED})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SummaryCollector:
|
|
25
|
+
"""Tally runs / cost / tool-calls from lifecycle events (an ``EventListener``)."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self.run_statuses: list[str] = []
|
|
29
|
+
self.total_duration_ms: float = 0.0
|
|
30
|
+
self.total_cost_usd: float = 0.0
|
|
31
|
+
self.n_tool_calls: int = 0
|
|
32
|
+
|
|
33
|
+
def on_event(self, event: LifecycleEvent) -> None:
|
|
34
|
+
record = event.record
|
|
35
|
+
if event.type in _RUN_DONE and record is not None:
|
|
36
|
+
self.run_statuses.append(record.status.value)
|
|
37
|
+
if record.duration_ms is not None:
|
|
38
|
+
self.total_duration_ms += record.duration_ms
|
|
39
|
+
elif event.type is EventType.LLM_EXECUTED and record is not None and record.llm is not None:
|
|
40
|
+
if record.llm.cost_usd is not None:
|
|
41
|
+
self.total_cost_usd += record.llm.cost_usd
|
|
42
|
+
elif event.type in _TOOL_DONE:
|
|
43
|
+
self.n_tool_calls += 1
|
|
44
|
+
|
|
45
|
+
# --- rendering --------------------------------------------------------
|
|
46
|
+
def status(self) -> str:
|
|
47
|
+
if not self.run_statuses:
|
|
48
|
+
return "no runs"
|
|
49
|
+
if all(s == RunStatus.OK.value for s in self.run_statuses):
|
|
50
|
+
return "ok"
|
|
51
|
+
return "error"
|
|
52
|
+
|
|
53
|
+
def values(self) -> dict[str, str]:
|
|
54
|
+
return {
|
|
55
|
+
"status": self.status(),
|
|
56
|
+
"cost_usd": f"${self.total_cost_usd:.4f}",
|
|
57
|
+
"duration_ms": f"{self.total_duration_ms:.0f}",
|
|
58
|
+
"n_tool_calls": str(self.n_tool_calls),
|
|
59
|
+
"runs": str(len(self.run_statuses)),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def format_summary(collector: SummaryCollector, fields: Sequence[str]) -> str:
|
|
64
|
+
"""Render the markdown block appended to the job summary."""
|
|
65
|
+
n = len(collector.run_statuses)
|
|
66
|
+
values = collector.values()
|
|
67
|
+
heading = f"### 🤖 ForgeSight agent run{'s' if n != 1 else ''}"
|
|
68
|
+
lines = [heading]
|
|
69
|
+
if n > 1:
|
|
70
|
+
lines.append(f"- **runs**: {n}")
|
|
71
|
+
for field in fields:
|
|
72
|
+
if field in values:
|
|
73
|
+
lines.append(f"- **{field}**: {values[field]}")
|
|
74
|
+
return "\n".join(lines) + "\n"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def write_summary(
|
|
78
|
+
collector: SummaryCollector, fields: Sequence[str], *, path: str | None = None
|
|
79
|
+
) -> bool:
|
|
80
|
+
"""Append the summary to ``$GITHUB_STEP_SUMMARY`` (or ``path``). Never raises (P6)."""
|
|
81
|
+
target = path or os.environ.get("GITHUB_STEP_SUMMARY")
|
|
82
|
+
if not target:
|
|
83
|
+
return False
|
|
84
|
+
try:
|
|
85
|
+
with open(target, "a", encoding="utf-8") as handle:
|
|
86
|
+
handle.write(format_summary(collector, fields))
|
|
87
|
+
except OSError: # a failed summary write must never fail the job (P6)
|
|
88
|
+
_log.warning("forgesight-github: could not write job summary to %s", target)
|
|
89
|
+
return False
|
|
90
|
+
return True
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Tests for the GitHub Actions integration: metadata, bootstrap, summary, OIDC, fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from forgesight_api import (
|
|
12
|
+
EventType,
|
|
13
|
+
Kind,
|
|
14
|
+
LifecycleEvent,
|
|
15
|
+
LLMCall,
|
|
16
|
+
Record,
|
|
17
|
+
RunStatus,
|
|
18
|
+
)
|
|
19
|
+
from forgesight_core import InMemoryExporter, get_runtime, reset_runtime, telemetry
|
|
20
|
+
from forgesight_github import (
|
|
21
|
+
GitHubMetadataInterceptor,
|
|
22
|
+
SummaryCollector,
|
|
23
|
+
bootstrap,
|
|
24
|
+
fetch_oidc_token,
|
|
25
|
+
format_summary,
|
|
26
|
+
github_metadata,
|
|
27
|
+
in_github_actions,
|
|
28
|
+
install,
|
|
29
|
+
pr_number,
|
|
30
|
+
run_exit_hook,
|
|
31
|
+
write_summary,
|
|
32
|
+
)
|
|
33
|
+
from forgesight_github.bootstrap import _reset_for_tests
|
|
34
|
+
|
|
35
|
+
CI_ENV = {
|
|
36
|
+
"GITHUB_ACTIONS": "true",
|
|
37
|
+
"GITHUB_REPOSITORY": "acme/agents",
|
|
38
|
+
"GITHUB_SHA": "abc123",
|
|
39
|
+
"GITHUB_REF": "refs/pull/42/merge",
|
|
40
|
+
"GITHUB_RUN_ID": "9999",
|
|
41
|
+
"GITHUB_RUN_ATTEMPT": "2",
|
|
42
|
+
"GITHUB_WORKFLOW": "review",
|
|
43
|
+
"GITHUB_JOB": "pr-review",
|
|
44
|
+
"GITHUB_ACTOR": "octocat",
|
|
45
|
+
"GITHUB_EVENT_NAME": "pull_request",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture(autouse=True)
|
|
50
|
+
def _clean(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
|
51
|
+
# CI itself sets GITHUB_* / GITHUB_STEP_SUMMARY — scrub the ambient env so tests are
|
|
52
|
+
# deterministic whether or not they run inside GitHub Actions.
|
|
53
|
+
for key in (
|
|
54
|
+
*CI_ENV,
|
|
55
|
+
"GITHUB_EVENT_PATH",
|
|
56
|
+
"GITHUB_STEP_SUMMARY",
|
|
57
|
+
"FORGESIGHT_GITHUB_SUMMARY",
|
|
58
|
+
"FORGESIGHT_OTLP_TOKEN",
|
|
59
|
+
"ACTIONS_ID_TOKEN_REQUEST_URL",
|
|
60
|
+
"ACTIONS_ID_TOKEN_REQUEST_TOKEN",
|
|
61
|
+
):
|
|
62
|
+
monkeypatch.delenv(key, raising=False)
|
|
63
|
+
_reset_for_tests()
|
|
64
|
+
yield
|
|
65
|
+
reset_runtime()
|
|
66
|
+
_reset_for_tests()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _apply_env(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
|
70
|
+
for key in (*CI_ENV, "GITHUB_EVENT_PATH", "GITHUB_STEP_SUMMARY"):
|
|
71
|
+
monkeypatch.delenv(key, raising=False)
|
|
72
|
+
for key, value in env.items():
|
|
73
|
+
monkeypatch.setenv(key, value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- github_metadata ----------------------------------------------------------
|
|
77
|
+
def test_metadata_maps_all_github_vars(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
78
|
+
_apply_env(monkeypatch, CI_ENV)
|
|
79
|
+
meta = github_metadata()
|
|
80
|
+
assert meta["vcs.repository.name"] == "acme/agents"
|
|
81
|
+
assert meta["vcs.ref.head.revision"] == "abc123"
|
|
82
|
+
assert meta["vcs.ref.head.name"] == "refs/pull/42/merge"
|
|
83
|
+
assert meta["cicd.pipeline.run.id"] == "9999"
|
|
84
|
+
assert meta["cicd.pipeline.run.attempt"] == "2"
|
|
85
|
+
assert meta["cicd.pipeline.name"] == "review"
|
|
86
|
+
assert meta["cicd.pipeline.task.name"] == "pr-review"
|
|
87
|
+
assert meta["vcs.change.author"] == "octocat"
|
|
88
|
+
assert meta["cicd.pipeline.run.trigger"] == "pull_request"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_pr_number_from_event_payload(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
92
|
+
event = tmp_path / "event.json"
|
|
93
|
+
event.write_text(json.dumps({"pull_request": {"number": 42}}))
|
|
94
|
+
_apply_env(monkeypatch, {**CI_ENV, "GITHUB_EVENT_PATH": str(event)})
|
|
95
|
+
assert github_metadata()["vcs.change.id"] == "42"
|
|
96
|
+
assert pr_number(dict(__import__("os").environ)) == "42"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_pr_number_top_level_number(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
100
|
+
event = tmp_path / "event.json"
|
|
101
|
+
event.write_text(json.dumps({"number": 7}))
|
|
102
|
+
_apply_env(monkeypatch, {**CI_ENV, "GITHUB_EVENT_PATH": str(event)})
|
|
103
|
+
assert github_metadata()["vcs.change.id"] == "7"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_pr_number_absent_for_push(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
107
|
+
event = tmp_path / "event.json"
|
|
108
|
+
event.write_text(json.dumps({"pull_request": {"number": 42}}))
|
|
109
|
+
_apply_env(
|
|
110
|
+
monkeypatch,
|
|
111
|
+
{**CI_ENV, "GITHUB_EVENT_NAME": "push", "GITHUB_EVENT_PATH": str(event)},
|
|
112
|
+
)
|
|
113
|
+
assert "vcs.change.id" not in github_metadata() # not a PR event ⇒ not fabricated
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_pr_number_missing_payload_file(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
117
|
+
_apply_env(monkeypatch, {**CI_ENV, "GITHUB_EVENT_PATH": "/nonexistent/event.json"})
|
|
118
|
+
assert "vcs.change.id" not in github_metadata()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_pr_number_malformed_payload(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
122
|
+
event = tmp_path / "event.json"
|
|
123
|
+
event.write_text("{not json")
|
|
124
|
+
_apply_env(monkeypatch, {**CI_ENV, "GITHUB_EVENT_PATH": str(event)})
|
|
125
|
+
assert "vcs.change.id" not in github_metadata()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_capture_env_restriction(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
129
|
+
_apply_env(monkeypatch, CI_ENV)
|
|
130
|
+
meta = github_metadata(capture_env=["GITHUB_REPOSITORY", "GITHUB_SHA"])
|
|
131
|
+
assert set(meta) == {"vcs.repository.name", "vcs.ref.head.revision"} # actor dropped
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_metadata_via_explicit_env() -> None:
|
|
135
|
+
meta = github_metadata(env={"GITHUB_REPOSITORY": "x/y"})
|
|
136
|
+
assert meta == {"vcs.repository.name": "x/y"}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --- interceptor --------------------------------------------------------------
|
|
140
|
+
def _record(**attrs: object) -> Record:
|
|
141
|
+
from types import MappingProxyType
|
|
142
|
+
|
|
143
|
+
return Record(
|
|
144
|
+
kind=Kind.AGENT,
|
|
145
|
+
run_id="r",
|
|
146
|
+
trace_id="4bf92f3577b34da6a3ce929d0e0e4736",
|
|
147
|
+
span_id="00f067aa0ba902b7",
|
|
148
|
+
parent_span_id=None,
|
|
149
|
+
name="c",
|
|
150
|
+
status=RunStatus.OK,
|
|
151
|
+
start_unix_nanos=1,
|
|
152
|
+
end_unix_nanos=2,
|
|
153
|
+
attributes=MappingProxyType(dict(attrs)),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_interceptor_merges_without_clobbering() -> None:
|
|
158
|
+
interceptor = GitHubMetadataInterceptor({"vcs.repository.name": "acme/agents", "team": "ci"})
|
|
159
|
+
out = interceptor.intercept(_record(team="explicit"))
|
|
160
|
+
assert out is not None
|
|
161
|
+
assert out.attributes["vcs.repository.name"] == "acme/agents" # added
|
|
162
|
+
assert out.attributes["team"] == "explicit" # per-call metadata wins
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_interceptor_empty_is_passthrough() -> None:
|
|
166
|
+
record = _record()
|
|
167
|
+
assert GitHubMetadataInterceptor({}).intercept(record) is record
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- bootstrap ----------------------------------------------------------------
|
|
171
|
+
def test_bootstrap_attaches_metadata_to_runs(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
172
|
+
_apply_env(monkeypatch, CI_ENV)
|
|
173
|
+
bootstrap(write_summary=False, _register_exit=False)
|
|
174
|
+
exporter = InMemoryExporter()
|
|
175
|
+
runtime = get_runtime()
|
|
176
|
+
runtime.add_exporter(exporter)
|
|
177
|
+
with telemetry.agent_run("classifier"):
|
|
178
|
+
pass
|
|
179
|
+
runtime.force_flush()
|
|
180
|
+
run = next(r for r in exporter.records if r.kind is Kind.AGENT)
|
|
181
|
+
assert run.attributes["vcs.repository.name"] == "acme/agents"
|
|
182
|
+
assert run.attributes["cicd.pipeline.name"] == "review"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_bootstrap_extra_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
186
|
+
_apply_env(monkeypatch, CI_ENV)
|
|
187
|
+
bootstrap(write_summary=False, extra_metadata={"team": "payments"}, _register_exit=False)
|
|
188
|
+
exporter = InMemoryExporter()
|
|
189
|
+
get_runtime().add_exporter(exporter)
|
|
190
|
+
with telemetry.agent_run("c"):
|
|
191
|
+
pass
|
|
192
|
+
get_runtime().force_flush()
|
|
193
|
+
run = next(r for r in exporter.records if r.kind is Kind.AGENT)
|
|
194
|
+
assert run.attributes["team"] == "payments"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_bootstrap_not_in_ci_warns_once(
|
|
198
|
+
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
199
|
+
) -> None:
|
|
200
|
+
_apply_env(monkeypatch, {}) # GITHUB_ACTIONS unset
|
|
201
|
+
assert in_github_actions() is False
|
|
202
|
+
with caplog.at_level("WARNING"):
|
|
203
|
+
bootstrap(write_summary=False, _register_exit=False)
|
|
204
|
+
bootstrap(write_summary=False, _register_exit=False)
|
|
205
|
+
warnings = [r for r in caplog.records if "falls back to plain configure" in r.message]
|
|
206
|
+
assert len(warnings) == 1 # warned once, not twice
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_bootstrap_not_in_ci_no_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
210
|
+
_apply_env(monkeypatch, {"GITHUB_REPOSITORY": "x/y"}) # set but GITHUB_ACTIONS unset
|
|
211
|
+
bootstrap(write_summary=False, _register_exit=False)
|
|
212
|
+
exporter = InMemoryExporter()
|
|
213
|
+
get_runtime().add_exporter(exporter)
|
|
214
|
+
with telemetry.agent_run("c"):
|
|
215
|
+
pass
|
|
216
|
+
get_runtime().force_flush()
|
|
217
|
+
run = next(r for r in exporter.records if r.kind is Kind.AGENT)
|
|
218
|
+
assert "vcs.repository.name" not in run.attributes # no CI metadata outside CI
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --- summary collector --------------------------------------------------------
|
|
222
|
+
def _event(event_type: EventType, record: Record | None = None) -> LifecycleEvent:
|
|
223
|
+
return LifecycleEvent(type=event_type, run_id="r", unix_nanos=1, record=record)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_summary_collector_tallies() -> None:
|
|
227
|
+
collector = SummaryCollector()
|
|
228
|
+
llm = Record(
|
|
229
|
+
kind=Kind.LLM,
|
|
230
|
+
run_id="r",
|
|
231
|
+
trace_id="t",
|
|
232
|
+
span_id="s",
|
|
233
|
+
parent_span_id=None,
|
|
234
|
+
name="m",
|
|
235
|
+
status=RunStatus.OK,
|
|
236
|
+
start_unix_nanos=0,
|
|
237
|
+
end_unix_nanos=1_000_000,
|
|
238
|
+
llm=LLMCall(provider="anthropic", request_model="m", cost_usd=0.05),
|
|
239
|
+
)
|
|
240
|
+
run = _record()
|
|
241
|
+
collector.on_event(_event(EventType.LLM_EXECUTED, llm))
|
|
242
|
+
collector.on_event(_event(EventType.TOOL_EXECUTED))
|
|
243
|
+
collector.on_event(_event(EventType.MCP_EXECUTED))
|
|
244
|
+
collector.on_event(_event(EventType.RUN_COMPLETED, run))
|
|
245
|
+
assert collector.total_cost_usd == 0.05
|
|
246
|
+
assert collector.n_tool_calls == 2
|
|
247
|
+
assert collector.status() == "ok"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_summary_status_error_on_failed_run() -> None:
|
|
251
|
+
collector = SummaryCollector()
|
|
252
|
+
failed = Record(
|
|
253
|
+
kind=Kind.AGENT,
|
|
254
|
+
run_id="r",
|
|
255
|
+
trace_id="t",
|
|
256
|
+
span_id="s",
|
|
257
|
+
parent_span_id=None,
|
|
258
|
+
name="c",
|
|
259
|
+
status=RunStatus.ERROR,
|
|
260
|
+
start_unix_nanos=1,
|
|
261
|
+
end_unix_nanos=2,
|
|
262
|
+
)
|
|
263
|
+
collector.on_event(_event(EventType.RUN_FAILED, failed))
|
|
264
|
+
assert collector.status() == "error"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_summary_status_no_runs() -> None:
|
|
268
|
+
assert SummaryCollector().status() == "no runs"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_format_summary_single_and_multi() -> None:
|
|
272
|
+
collector = SummaryCollector()
|
|
273
|
+
collector.on_event(_event(EventType.RUN_COMPLETED, _record()))
|
|
274
|
+
single = format_summary(collector, DEFAULT := ("status", "cost_usd", "n_tool_calls"))
|
|
275
|
+
assert "### 🤖 ForgeSight agent run" in single
|
|
276
|
+
assert "- **status**: ok" in single
|
|
277
|
+
collector.on_event(_event(EventType.RUN_COMPLETED, _record()))
|
|
278
|
+
multi = format_summary(collector, DEFAULT)
|
|
279
|
+
assert "runs" in multi # rollup shows run count for multi-run jobs
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_write_summary_to_file(tmp_path: Path) -> None:
|
|
283
|
+
collector = SummaryCollector()
|
|
284
|
+
collector.on_event(_event(EventType.RUN_COMPLETED, _record()))
|
|
285
|
+
summary = tmp_path / "summary.md"
|
|
286
|
+
assert write_summary(collector, ("status", "cost_usd"), path=str(summary)) is True
|
|
287
|
+
assert "status" in summary.read_text()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_write_summary_no_target_is_false() -> None:
|
|
291
|
+
assert write_summary(SummaryCollector(), ("status",), path=None) is False
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_write_summary_failure_is_isolated(tmp_path: Path) -> None:
|
|
295
|
+
# a directory path can't be opened for append ⇒ returns False, never raises (P6)
|
|
296
|
+
assert write_summary(SummaryCollector(), ("status",), path=str(tmp_path)) is False
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# --- exit hook ----------------------------------------------------------------
|
|
300
|
+
def test_run_exit_hook_flushes_and_writes_summary(
|
|
301
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
302
|
+
) -> None:
|
|
303
|
+
summary = tmp_path / "step_summary.md"
|
|
304
|
+
_apply_env(monkeypatch, {**CI_ENV, "GITHUB_STEP_SUMMARY": str(summary)})
|
|
305
|
+
bootstrap(_register_exit=False)
|
|
306
|
+
exporter = InMemoryExporter()
|
|
307
|
+
runtime = get_runtime()
|
|
308
|
+
runtime.add_exporter(exporter)
|
|
309
|
+
collector = next(c for c in runtime.listeners if isinstance(c, SummaryCollector))
|
|
310
|
+
with (
|
|
311
|
+
telemetry.agent_run("classifier"),
|
|
312
|
+
telemetry.current_run().llm_call("anthropic", "m") as call,
|
|
313
|
+
): # type: ignore[union-attr]
|
|
314
|
+
call.record_usage(input=10, output=5)
|
|
315
|
+
run_exit_hook(runtime, collector, ("status", "cost_usd", "n_tool_calls"))
|
|
316
|
+
assert summary.exists()
|
|
317
|
+
assert "ForgeSight agent run" in summary.read_text()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_bootstrap_summary_disabled_via_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
321
|
+
_apply_env(monkeypatch, {**CI_ENV, "FORGESIGHT_GITHUB_SUMMARY": "false"})
|
|
322
|
+
bootstrap(_register_exit=False)
|
|
323
|
+
assert not any(isinstance(c, SummaryCollector) for c in get_runtime().listeners)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# --- OIDC ---------------------------------------------------------------------
|
|
327
|
+
def test_oidc_absent_endpoint_returns_none() -> None:
|
|
328
|
+
assert fetch_oidc_token(env={}) is None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_oidc_fetch_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
332
|
+
import urllib.request
|
|
333
|
+
|
|
334
|
+
class _Resp:
|
|
335
|
+
def __enter__(self) -> _Resp:
|
|
336
|
+
return self
|
|
337
|
+
|
|
338
|
+
def __exit__(self, *a: object) -> None:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
def read(self) -> bytes:
|
|
342
|
+
return json.dumps({"value": "tok-123"}).encode()
|
|
343
|
+
|
|
344
|
+
monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **k: _Resp())
|
|
345
|
+
token = fetch_oidc_token(
|
|
346
|
+
audience="collector",
|
|
347
|
+
env={
|
|
348
|
+
"ACTIONS_ID_TOKEN_REQUEST_URL": "https://runner/token?x=1",
|
|
349
|
+
"ACTIONS_ID_TOKEN_REQUEST_TOKEN": "bearer",
|
|
350
|
+
},
|
|
351
|
+
)
|
|
352
|
+
assert token == "tok-123"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_oidc_fetch_error_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
356
|
+
import urllib.request
|
|
357
|
+
|
|
358
|
+
def _boom(*a: object, **k: object) -> object:
|
|
359
|
+
raise OSError("network down")
|
|
360
|
+
|
|
361
|
+
monkeypatch.setattr(urllib.request, "urlopen", _boom)
|
|
362
|
+
token = fetch_oidc_token(
|
|
363
|
+
env={
|
|
364
|
+
"ACTIONS_ID_TOKEN_REQUEST_URL": "https://runner/token",
|
|
365
|
+
"ACTIONS_ID_TOKEN_REQUEST_TOKEN": "bearer",
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
assert token is None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_bootstrap_oidc_fallback_when_unavailable(
|
|
372
|
+
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
373
|
+
) -> None:
|
|
374
|
+
_apply_env(monkeypatch, CI_ENV) # no id-token endpoint
|
|
375
|
+
with caplog.at_level("WARNING"):
|
|
376
|
+
bootstrap(write_summary=False, oidc=True, _register_exit=False)
|
|
377
|
+
assert any("OIDC requested but no runner id-token" in r.message for r in caplog.records)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# --- install ------------------------------------------------------------------
|
|
381
|
+
def test_install_stashes_config_and_summary_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
382
|
+
_apply_env(monkeypatch, CI_ENV)
|
|
383
|
+
assert install({"enabled": True, "write_summary": False}) is True
|
|
384
|
+
bootstrap(_register_exit=False) # write_summary default reads installed config ⇒ off
|
|
385
|
+
assert not any(isinstance(c, SummaryCollector) for c in get_runtime().listeners)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_install_disabled_returns_false() -> None:
|
|
389
|
+
assert install({"enabled": False}) is False
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_bootstrap_registers_exit_hook(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
393
|
+
import sys
|
|
394
|
+
|
|
395
|
+
_apply_env(monkeypatch, CI_ENV)
|
|
396
|
+
registered: list[object] = []
|
|
397
|
+
bs = sys.modules["forgesight_github.bootstrap"] # function shadows submodule on the package
|
|
398
|
+
|
|
399
|
+
monkeypatch.setattr(bs.atexit, "register", lambda fn, *a: registered.append((fn, a)))
|
|
400
|
+
bootstrap(write_summary=False) # _register_exit defaults True
|
|
401
|
+
assert registered
|
|
402
|
+
assert registered[0][0] is run_exit_hook
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_bootstrap_oidc_success_sets_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
406
|
+
import os
|
|
407
|
+
import sys
|
|
408
|
+
|
|
409
|
+
_apply_env(monkeypatch, CI_ENV)
|
|
410
|
+
monkeypatch.delenv("FORGESIGHT_OTLP_TOKEN", raising=False)
|
|
411
|
+
bs = sys.modules["forgesight_github.bootstrap"]
|
|
412
|
+
|
|
413
|
+
monkeypatch.setattr(bs, "fetch_oidc_token", lambda: "tok-xyz")
|
|
414
|
+
bootstrap(write_summary=False, oidc=True, _register_exit=False)
|
|
415
|
+
assert os.environ["FORGESIGHT_OTLP_TOKEN"] == "tok-xyz"
|
|
416
|
+
monkeypatch.delenv("FORGESIGHT_OTLP_TOKEN", raising=False)
|