forgesight-github 0.1.0__py3-none-any.whl
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/__init__.py +34 -0
- forgesight_github/bootstrap.py +127 -0
- forgesight_github/interceptor.py +30 -0
- forgesight_github/metadata.py +73 -0
- forgesight_github/oidc.py +48 -0
- forgesight_github/py.typed +0 -0
- forgesight_github/summary.py +90 -0
- forgesight_github-0.1.0.dist-info/METADATA +90 -0
- forgesight_github-0.1.0.dist-info/RECORD +11 -0
- forgesight_github-0.1.0.dist-info/WHEEL +4 -0
- forgesight_github-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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,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,11 @@
|
|
|
1
|
+
forgesight_github/__init__.py,sha256=B1ulGxvM4vQ8ewryALhWw0QAgO5MIGcW7OXetY-dbNQ,940
|
|
2
|
+
forgesight_github/bootstrap.py,sha256=GMbN05lGPpTpOWTWewtSsIhgtLNsy4xXGYkAR8CRV4k,4146
|
|
3
|
+
forgesight_github/interceptor.py,sha256=NBUWhQotF9YCyUHXxZJ2ZDtUHeUvPDWod48-fRppdPE,1233
|
|
4
|
+
forgesight_github/metadata.py,sha256=LvD9ouY9KOfAt2axBOa4MZbuUGnd-M74lTqDzk_n6gU,2822
|
|
5
|
+
forgesight_github/oidc.py,sha256=4nRwVDVyw-V4mRD2WkMdLxvUQwaVMnkNsCmophwqpno,1858
|
|
6
|
+
forgesight_github/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
forgesight_github/summary.py,sha256=IerdIhcrbWETQF4u-ScyUavGCF_XNuIuaqDKTSZwXA4,3543
|
|
8
|
+
forgesight_github-0.1.0.dist-info/METADATA,sha256=cMlU_F22KY3utJHWgf4Q2OVH-RxH3w3T7u08BmUrQtM,3660
|
|
9
|
+
forgesight_github-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
forgesight_github-0.1.0.dist-info/entry_points.txt,sha256=1Py8HkDOmrMdALmWqkZYIEJlSuxxfoDyElU2a9glmDM,61
|
|
11
|
+
forgesight_github-0.1.0.dist-info/RECORD,,
|