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.
@@ -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)