sediment-api 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.
- sediment_api/__init__.py +6 -0
- sediment_api/config.py +230 -0
- sediment_api/database.py +43 -0
- sediment_api/deps.py +191 -0
- sediment_api/main.py +212 -0
- sediment_api/mirror_gc.py +151 -0
- sediment_api/reports/__init__.py +9 -0
- sediment_api/reports/abandonment_report.py +123 -0
- sediment_api/reports/attribution_share_report.py +298 -0
- sediment_api/reports/dataset_diagnostics.py +311 -0
- sediment_api/reports/label_confidence_inspection.py +438 -0
- sediment_api/reports/lifecycle_report.py +73 -0
- sediment_api/reports/merge_retention_report.py +165 -0
- sediment_api/reports/model_report.py +1534 -0
- sediment_api/reports/precision_report.py +182 -0
- sediment_api/reports/recovery_yield_report.py +132 -0
- sediment_api/routers/__init__.py +3 -0
- sediment_api/routers/ci_vendor.py +149 -0
- sediment_api/routers/forge.py +452 -0
- sediment_api/routers/gateway.py +137 -0
- sediment_api/routers/otlp.py +120 -0
- sediment_api/routers/query.py +1010 -0
- sediment_api/routers/reports.py +116 -0
- sediment_api/routers/v1.py +170 -0
- sediment_api/services/__init__.py +2 -0
- sediment_api/services/operational_reports.py +391 -0
- sediment_api/worker.py +242 -0
- sediment_api/workers.py +382 -0
- sediment_api-0.1.0.dist-info/METADATA +20 -0
- sediment_api-0.1.0.dist-info/RECORD +32 -0
- sediment_api-0.1.0.dist-info/WHEEL +4 -0
- sediment_api-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
GitHub webhook ingest routes, mounted under /ingest/github in main.py.
|
|
4
|
+
|
|
5
|
+
One module for both routes: they share the HMAC intake and differ only in
|
|
6
|
+
which parser/store pair the payload walks through. The signature is
|
|
7
|
+
verified BEFORE the event type is examined: a wrong-secret delivery must
|
|
8
|
+
401 even for event types a route skips, or GitHub's setup ping would
|
|
9
|
+
green-light a misconfigured webhook. The push route stores the Push fact,
|
|
10
|
+
then — when SEDIMENT_MIRROR_PATH is set — schedules an (org, repo) mirror
|
|
11
|
+
refresh as a background task that runs AFTER the response. Best-effort
|
|
12
|
+
substrate: the fetch is off both the event loop and the request path, and
|
|
13
|
+
no failure ever becomes a 4xx/5xx because the stored fact is the contract
|
|
14
|
+
(ADR 0001).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from collections import Counter
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from fastapi import APIRouter, BackgroundTasks, Depends, Header, Request
|
|
25
|
+
from sediment_capture import (
|
|
26
|
+
parse_pull_request_merge,
|
|
27
|
+
parse_pull_request_revision,
|
|
28
|
+
parse_push,
|
|
29
|
+
parse_repository_rename,
|
|
30
|
+
parse_workflow_run,
|
|
31
|
+
)
|
|
32
|
+
from sediment_core import (
|
|
33
|
+
FactStore,
|
|
34
|
+
REPOSITORY_IDENTITY_LIMIT,
|
|
35
|
+
OperationalReportLimitExceeded,
|
|
36
|
+
Push,
|
|
37
|
+
SessionCommitObservation,
|
|
38
|
+
)
|
|
39
|
+
from sediment_derive import (
|
|
40
|
+
AttributionSource,
|
|
41
|
+
AttributionPolicy,
|
|
42
|
+
MirrorError,
|
|
43
|
+
MirrorManager,
|
|
44
|
+
MirrorPolicy,
|
|
45
|
+
MirrorPolicyError,
|
|
46
|
+
derive_attribution_result,
|
|
47
|
+
read_repository_context,
|
|
48
|
+
build_repository_context,
|
|
49
|
+
)
|
|
50
|
+
from sediment_derive.notes import read_commit_note
|
|
51
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
52
|
+
from starlette.concurrency import run_in_threadpool
|
|
53
|
+
|
|
54
|
+
from ..config import settings
|
|
55
|
+
from ..deps import get_store, read_verified_webhook
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger("sediment.api.forge")
|
|
58
|
+
router = APIRouter(tags=["forge"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _refresh_mirror(
|
|
62
|
+
push: Push,
|
|
63
|
+
store: FactStore,
|
|
64
|
+
*,
|
|
65
|
+
fetch_repo: str | None = None,
|
|
66
|
+
fetch_clone_url: str | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Fetch the retained Push's repository mirror, then re-derive the repo's
|
|
69
|
+
attributions. The background supervisor runs it in a disposable process,
|
|
70
|
+
keeping ``git fetch`` off the event loop and the request-serving pool.
|
|
71
|
+
Clone-URL confinement is enforced only in production, so the
|
|
72
|
+
dev/test file:// bare-remote fixtures keep working; injected here rather
|
|
73
|
+
than read from settings inside the derive package.
|
|
74
|
+
|
|
75
|
+
Swallows every failure with a severity-appropriate log: the Push fact is
|
|
76
|
+
already stored and is the contract (ADR 0001), so a refresh problem must
|
|
77
|
+
never escape (a raising background task would surface after the response).
|
|
78
|
+
Invalid or unavailable repository context stops before Git work. After a
|
|
79
|
+
valid context, a failed fetch still permits a pure Derivation over retained
|
|
80
|
+
mirror state.
|
|
81
|
+
|
|
82
|
+
The process supervisor bounds this complete chain and terminates its Git
|
|
83
|
+
descendants at the deadline. A child owns its database engine.
|
|
84
|
+
"""
|
|
85
|
+
# Context reads finish before Git locks. The full population prevents a
|
|
86
|
+
# selected Push from hiding another lifetime that claims its fetch location.
|
|
87
|
+
try:
|
|
88
|
+
boundary = datetime.now(UTC)
|
|
89
|
+
with store.read_snapshot() as snapshot:
|
|
90
|
+
context = build_repository_context(
|
|
91
|
+
snapshot.read_repository_identities(
|
|
92
|
+
push.org_id,
|
|
93
|
+
captured_through=boundary,
|
|
94
|
+
limit=REPOSITORY_IDENTITY_LIMIT,
|
|
95
|
+
),
|
|
96
|
+
snapshot.read_repository_renames(
|
|
97
|
+
push.org_id,
|
|
98
|
+
captured_through=boundary,
|
|
99
|
+
limit=REPOSITORY_IDENTITY_LIMIT,
|
|
100
|
+
),
|
|
101
|
+
push.org_id,
|
|
102
|
+
as_of=boundary,
|
|
103
|
+
)
|
|
104
|
+
except SQLAlchemyError:
|
|
105
|
+
logger.error(
|
|
106
|
+
"repository_context_database_unavailable repo=%s count=1", push.repo
|
|
107
|
+
)
|
|
108
|
+
return
|
|
109
|
+
except OperationalReportLimitExceeded:
|
|
110
|
+
logger.warning("repository_context_declined reason=population_limit count=1")
|
|
111
|
+
return
|
|
112
|
+
except Exception:
|
|
113
|
+
logger.warning("repository_context_declined reason=invalid_population count=1")
|
|
114
|
+
return
|
|
115
|
+
policy = MirrorPolicy(
|
|
116
|
+
enforce=not settings.dev_mode,
|
|
117
|
+
allowed_hosts=frozenset(settings.allowed_clone_hosts),
|
|
118
|
+
)
|
|
119
|
+
mirrors = MirrorManager(settings.mirror_path, policy=policy)
|
|
120
|
+
location = dict(
|
|
121
|
+
repository_context=context,
|
|
122
|
+
fetch_repo=fetch_repo,
|
|
123
|
+
fetch_clone_url=fetch_clone_url,
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
with mirrors.observation_capture(push, **location):
|
|
127
|
+
with mirrors.refresh_snapshot(push, **location) as mirror:
|
|
128
|
+
try:
|
|
129
|
+
observations = _capture_session_commit_observations(
|
|
130
|
+
push,
|
|
131
|
+
mirror,
|
|
132
|
+
push.push_id,
|
|
133
|
+
max_commits=AttributionPolicy().max_commits_per_push,
|
|
134
|
+
)
|
|
135
|
+
except Exception:
|
|
136
|
+
logger.exception(
|
|
137
|
+
"session_commit_observation_error repo=%s", push.repo
|
|
138
|
+
)
|
|
139
|
+
observations = []
|
|
140
|
+
_store_session_commit_observations(push, observations, store)
|
|
141
|
+
except MirrorPolicyError as exc:
|
|
142
|
+
# A source/target identity or clone policy decline leaves refs intact.
|
|
143
|
+
logger.warning(
|
|
144
|
+
"mirror_refresh_skipped repo=%s reason=%s count=1", push.repo, exc
|
|
145
|
+
)
|
|
146
|
+
except MirrorError as exc:
|
|
147
|
+
# Transient git/network failure: the next push retries the fetch.
|
|
148
|
+
logger.warning(
|
|
149
|
+
"mirror_refresh_failed repo=%s ref=%s after_sha=%s error=%s",
|
|
150
|
+
push.repo,
|
|
151
|
+
push.ref,
|
|
152
|
+
push.after_sha,
|
|
153
|
+
exc,
|
|
154
|
+
)
|
|
155
|
+
except Exception:
|
|
156
|
+
# A genuine bug in the mirror path — keep the traceback, but still
|
|
157
|
+
# never let it escape into (or after) the webhook response.
|
|
158
|
+
logger.exception(
|
|
159
|
+
"mirror_refresh_error repo=%s ref=%s after_sha=%s",
|
|
160
|
+
push.repo,
|
|
161
|
+
push.ref,
|
|
162
|
+
push.after_sha,
|
|
163
|
+
)
|
|
164
|
+
_derive_push_attributions(push, mirrors, store)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _capture_session_commit_observations(
|
|
168
|
+
push: Push,
|
|
169
|
+
mirror,
|
|
170
|
+
source_push_id: str,
|
|
171
|
+
*,
|
|
172
|
+
max_commits: int,
|
|
173
|
+
) -> list[SessionCommitObservation]:
|
|
174
|
+
"""Collect Git-note edges exposed by one stable bounded refresh."""
|
|
175
|
+
captured_at = datetime.now(UTC)
|
|
176
|
+
observations: list[SessionCommitObservation] = []
|
|
177
|
+
for commit_sha in mirror.list_push_commits(push, max_commits):
|
|
178
|
+
note = read_commit_note(mirror.path, commit_sha)
|
|
179
|
+
if note is None:
|
|
180
|
+
continue
|
|
181
|
+
for session_id in sorted({session.session_id for session in note.sessions}):
|
|
182
|
+
try:
|
|
183
|
+
observation = SessionCommitObservation(
|
|
184
|
+
org_id=push.org_id,
|
|
185
|
+
repo=push.repo,
|
|
186
|
+
repository_provider=push.repository_provider,
|
|
187
|
+
repository_host=push.repository_host,
|
|
188
|
+
repository_id=push.repository_id,
|
|
189
|
+
commit_sha=commit_sha,
|
|
190
|
+
session_id=session_id,
|
|
191
|
+
source_push_id=source_push_id,
|
|
192
|
+
captured_at=captured_at,
|
|
193
|
+
)
|
|
194
|
+
except ValueError:
|
|
195
|
+
logger.warning(
|
|
196
|
+
"session_commit_observation_invalid repo=%s commit_sha=%s",
|
|
197
|
+
push.repo,
|
|
198
|
+
commit_sha,
|
|
199
|
+
)
|
|
200
|
+
continue
|
|
201
|
+
observations.append(observation)
|
|
202
|
+
return observations
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _store_session_commit_observations(
|
|
206
|
+
push: Push,
|
|
207
|
+
observations: list[SessionCommitObservation],
|
|
208
|
+
store: FactStore,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Persist collected observations after releasing the mirror lock."""
|
|
211
|
+
stored_count = 0
|
|
212
|
+
duplicate_count = 0
|
|
213
|
+
for observation in observations:
|
|
214
|
+
try:
|
|
215
|
+
stored = store.store_session_commit_observation(observation)
|
|
216
|
+
except SQLAlchemyError:
|
|
217
|
+
logger.error(
|
|
218
|
+
"session_commit_observation_database_unavailable repo=%s",
|
|
219
|
+
push.repo,
|
|
220
|
+
)
|
|
221
|
+
return
|
|
222
|
+
except Exception:
|
|
223
|
+
logger.exception(
|
|
224
|
+
"session_commit_observation_error repo=%s commit_sha=%s",
|
|
225
|
+
push.repo,
|
|
226
|
+
observation.commit_sha,
|
|
227
|
+
)
|
|
228
|
+
continue
|
|
229
|
+
stored_count += int(stored)
|
|
230
|
+
duplicate_count += int(not stored)
|
|
231
|
+
logger.info(
|
|
232
|
+
"session_commit_observations_captured repo=%s stored=%d duplicates=%d",
|
|
233
|
+
push.repo,
|
|
234
|
+
stored_count,
|
|
235
|
+
duplicate_count,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _derive_push_attributions(
|
|
240
|
+
push: Push, mirrors: MirrorManager, store: FactStore
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Run the attribution derivation for THIS push's commits and log
|
|
243
|
+
structured counts. A trigger and a log line, NOT a cache: the result is
|
|
244
|
+
discarded — it is recomputable on demand (ADR 0001), and caching is
|
|
245
|
+
deferred until a consumer needs it. Scoped to the arriving push so the
|
|
246
|
+
per-webhook cost is bounded by one push's commits, not the repo's whole
|
|
247
|
+
history, and the logged counts describe this push. The PostgreSQL store
|
|
248
|
+
borrows connections from the lifespan-owned engine across worker threads.
|
|
249
|
+
Same fail-soft contract as the refresh: no failure ever escapes."""
|
|
250
|
+
try:
|
|
251
|
+
# Observation capture has finished. Use a fresh snapshot so context,
|
|
252
|
+
# quarantine visibility, and the Derivation include those stored Facts.
|
|
253
|
+
with store.read_snapshot() as snapshot:
|
|
254
|
+
context = read_repository_context(snapshot, push.org_id)
|
|
255
|
+
result = derive_attribution_result(
|
|
256
|
+
snapshot,
|
|
257
|
+
mirrors,
|
|
258
|
+
push.org_id,
|
|
259
|
+
pushes=[push],
|
|
260
|
+
repository_context=context,
|
|
261
|
+
as_of=context.as_of,
|
|
262
|
+
)
|
|
263
|
+
attributions = result.attributions
|
|
264
|
+
by_source = Counter(c.attribution_source for c in attributions)
|
|
265
|
+
logger.info(
|
|
266
|
+
"attributions_derived repo=%s attributed=%d notes=%d jaccard=%d",
|
|
267
|
+
push.repo,
|
|
268
|
+
len(attributions),
|
|
269
|
+
by_source[AttributionSource.GIT_NOTES],
|
|
270
|
+
by_source[AttributionSource.JACCARD],
|
|
271
|
+
)
|
|
272
|
+
except SQLAlchemyError:
|
|
273
|
+
# SQLAlchemy exception text can contain statements, parameters, and
|
|
274
|
+
# driver diagnostics. Keep the fact-first background path fail-soft
|
|
275
|
+
# without putting any of that secret-bearing context in the log.
|
|
276
|
+
logger.error("attribution_derivation_database_unavailable repo=%s", push.repo)
|
|
277
|
+
except Exception:
|
|
278
|
+
logger.exception("attribution_derivation_error repo=%s", push.repo)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@router.post(
|
|
282
|
+
"/push",
|
|
283
|
+
responses={
|
|
284
|
+
503: {
|
|
285
|
+
"description": "Database unavailable, or stored Push awaits retry after mirror admission failure."
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
async def ingest_push(
|
|
290
|
+
request: Request,
|
|
291
|
+
background: BackgroundTasks,
|
|
292
|
+
# None (not required): a missing signature must 401 as an auth failure
|
|
293
|
+
# inside read_verified_webhook, not 422 as a schema error. The event
|
|
294
|
+
# header stays required — absent only on non-GitHub malformed requests.
|
|
295
|
+
x_hub_signature_256: str | None = Header(None),
|
|
296
|
+
x_github_event: str = Header(...),
|
|
297
|
+
store: FactStore = Depends(get_store),
|
|
298
|
+
) -> dict[str, Any]:
|
|
299
|
+
payload = await read_verified_webhook(request, x_hub_signature_256)
|
|
300
|
+
# GitHub delivers every subscribed event (incl. ping) to the one URL;
|
|
301
|
+
# the X-GitHub-Event header is the discriminator.
|
|
302
|
+
if x_github_event != "push":
|
|
303
|
+
return {"skipped": True, "reason": "not a push event"}
|
|
304
|
+
push = parse_push(payload, org_id=settings.org_id, github_host=settings.github_host)
|
|
305
|
+
if push is None:
|
|
306
|
+
return {"skipped": True, "reason": "not a storable push"}
|
|
307
|
+
receipt = await run_in_threadpool(store.store_push_receipt, push)
|
|
308
|
+
fetch_repo, fetch_clone_url = push.repo, push.clone_url
|
|
309
|
+
push = receipt.fact
|
|
310
|
+
stored = receipt.stored
|
|
311
|
+
logger.info(
|
|
312
|
+
"push_stored fact_id=%s stored=%s repo=%s ref=%s",
|
|
313
|
+
push.push_id,
|
|
314
|
+
stored,
|
|
315
|
+
push.repo,
|
|
316
|
+
push.ref,
|
|
317
|
+
)
|
|
318
|
+
# Schedule the refresh on redeliveries too (stored=False): the fetch is
|
|
319
|
+
# idempotent and a redelivery may be the retry after a failed mirror.
|
|
320
|
+
# Skip repo-less pushes: parse_push stores them (facts first) but the
|
|
321
|
+
# mirror has no repo to fetch, and repo="" would collapse distinct repos
|
|
322
|
+
# into one shared mirror dir where fetch --prune thrashes their refs.
|
|
323
|
+
if settings.mirror_path and push.repo:
|
|
324
|
+
task = request.app.state.workers.submit_push(
|
|
325
|
+
push, fetch_repo=fetch_repo, fetch_clone_url=fetch_clone_url
|
|
326
|
+
)
|
|
327
|
+
background.add_task(request.app.state.workers.wait_background, task)
|
|
328
|
+
return {"fact_id": push.push_id, "stored": stored}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@router.post(
|
|
332
|
+
"/repository",
|
|
333
|
+
response_model=dict[str, Any],
|
|
334
|
+
responses={
|
|
335
|
+
503: {
|
|
336
|
+
"description": "Mirror work capacity, storage, or execution budget exceeded."
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
)
|
|
340
|
+
async def ingest_repository(
|
|
341
|
+
request: Request,
|
|
342
|
+
x_hub_signature_256: str | None = Header(None),
|
|
343
|
+
x_github_event: str = Header(...),
|
|
344
|
+
x_github_delivery: str | None = Header(None),
|
|
345
|
+
store: FactStore = Depends(get_store),
|
|
346
|
+
) -> dict[str, Any]:
|
|
347
|
+
"""Store an immutable rename receipt independently of mirror availability."""
|
|
348
|
+
payload = await read_verified_webhook(request, x_hub_signature_256)
|
|
349
|
+
if x_github_event != "repository":
|
|
350
|
+
return {"skipped": True, "reason": "not a repository event"}
|
|
351
|
+
rename, reason = parse_repository_rename(
|
|
352
|
+
payload,
|
|
353
|
+
org_id=settings.org_id,
|
|
354
|
+
github_host=settings.github_host,
|
|
355
|
+
source_event_id=x_github_delivery,
|
|
356
|
+
)
|
|
357
|
+
if rename is None:
|
|
358
|
+
assert reason is not None
|
|
359
|
+
return {"skipped": True, "reason": reason.value}
|
|
360
|
+
receipt = await run_in_threadpool(store.store_repository_rename_receipt, rename)
|
|
361
|
+
logger.info(
|
|
362
|
+
"repository_rename_stored fact_id=%s stored=%s", receipt.fact_id, receipt.stored
|
|
363
|
+
)
|
|
364
|
+
return {"fact_id": receipt.fact_id, "stored": receipt.stored}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@router.post("/ci")
|
|
368
|
+
async def ingest_ci(
|
|
369
|
+
request: Request,
|
|
370
|
+
x_hub_signature_256: str | None = Header(None),
|
|
371
|
+
x_github_event: str = Header(...),
|
|
372
|
+
x_github_delivery: str | None = Header(None),
|
|
373
|
+
store: FactStore = Depends(get_store),
|
|
374
|
+
) -> dict[str, Any]:
|
|
375
|
+
payload = await read_verified_webhook(request, x_hub_signature_256)
|
|
376
|
+
if x_github_event != "workflow_run":
|
|
377
|
+
return {"skipped": True, "reason": "not a workflow_run event"}
|
|
378
|
+
outcome = parse_workflow_run(
|
|
379
|
+
payload,
|
|
380
|
+
org_id=settings.org_id,
|
|
381
|
+
source_event_id=x_github_delivery,
|
|
382
|
+
github_host=settings.github_host,
|
|
383
|
+
)
|
|
384
|
+
if outcome is None:
|
|
385
|
+
return {"skipped": True, "reason": "not a completed workflow_run"}
|
|
386
|
+
receipt = await run_in_threadpool(store.store_ci_outcome_receipt, outcome)
|
|
387
|
+
outcome = receipt.fact
|
|
388
|
+
stored = receipt.stored
|
|
389
|
+
logger.info(
|
|
390
|
+
"ci_outcome_stored fact_id=%s stored=%s repo=%s result=%s",
|
|
391
|
+
outcome.outcome_id,
|
|
392
|
+
stored,
|
|
393
|
+
outcome.repo,
|
|
394
|
+
outcome.result,
|
|
395
|
+
)
|
|
396
|
+
return {"fact_id": outcome.outcome_id, "stored": stored}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@router.post("/pull-request")
|
|
400
|
+
async def ingest_pull_request(
|
|
401
|
+
request: Request,
|
|
402
|
+
x_hub_signature_256: str | None = Header(None),
|
|
403
|
+
x_github_event: str = Header(...),
|
|
404
|
+
x_github_delivery: str | None = Header(None),
|
|
405
|
+
store: FactStore = Depends(get_store),
|
|
406
|
+
) -> dict[str, Any]:
|
|
407
|
+
payload = await read_verified_webhook(request, x_hub_signature_256)
|
|
408
|
+
if x_github_event != "pull_request":
|
|
409
|
+
return {"skipped": True, "reason": "not a pull_request event"}
|
|
410
|
+
if payload.get("action") == "closed":
|
|
411
|
+
merge = parse_pull_request_merge(
|
|
412
|
+
payload,
|
|
413
|
+
org_id=settings.org_id,
|
|
414
|
+
source_event_id=x_github_delivery,
|
|
415
|
+
github_host=settings.github_host,
|
|
416
|
+
)
|
|
417
|
+
if merge is None:
|
|
418
|
+
return {"skipped": True, "reason": "not a merged pull_request"}
|
|
419
|
+
receipt = await run_in_threadpool(store.store_pull_request_merge_receipt, merge)
|
|
420
|
+
merge = receipt.fact
|
|
421
|
+
stored = receipt.stored
|
|
422
|
+
logger.info(
|
|
423
|
+
"pull_request_merge_stored fact_id=%s stored=%s repo=%s pr_number=%s",
|
|
424
|
+
merge.merge_id,
|
|
425
|
+
stored,
|
|
426
|
+
merge.repo,
|
|
427
|
+
merge.pr_number,
|
|
428
|
+
)
|
|
429
|
+
return {"fact_id": merge.merge_id, "stored": stored}
|
|
430
|
+
|
|
431
|
+
revision, skip_reason = parse_pull_request_revision(
|
|
432
|
+
payload,
|
|
433
|
+
org_id=settings.org_id,
|
|
434
|
+
source_event_id=x_github_delivery,
|
|
435
|
+
github_host=settings.github_host,
|
|
436
|
+
)
|
|
437
|
+
if revision is None:
|
|
438
|
+
assert skip_reason is not None
|
|
439
|
+
return {"skipped": True, "reason": skip_reason.value}
|
|
440
|
+
receipt = await run_in_threadpool(
|
|
441
|
+
store.store_pull_request_revision_receipt, revision
|
|
442
|
+
)
|
|
443
|
+
revision = receipt.fact
|
|
444
|
+
stored = receipt.stored
|
|
445
|
+
logger.info(
|
|
446
|
+
"pull_request_revision_stored fact_id=%s stored=%s repo=%s pr_number=%s",
|
|
447
|
+
revision.revision_id,
|
|
448
|
+
stored,
|
|
449
|
+
revision.repo,
|
|
450
|
+
revision.pr_number,
|
|
451
|
+
)
|
|
452
|
+
return {"fact_id": revision.revision_id, "stored": stored}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""POST /ingest/gateway — gateway payloads into inference-call facts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
12
|
+
from sediment_capture import ADAPTERS, resolve_identity
|
|
13
|
+
from sediment_core import (
|
|
14
|
+
FactStore,
|
|
15
|
+
GatewayProvider,
|
|
16
|
+
InferenceCallIdentityConflict,
|
|
17
|
+
NonEmptyId,
|
|
18
|
+
)
|
|
19
|
+
from sediment_core.models import AwareDatetime
|
|
20
|
+
|
|
21
|
+
from ..config import settings
|
|
22
|
+
from ..deps import get_store, verify_ingest_token
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("sediment.api.gateway")
|
|
25
|
+
router = APIRouter(tags=["gateway"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GatewayCapture(BaseModel):
|
|
29
|
+
"""Identity and observation instant prepared once by the gateway callback."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
|
|
33
|
+
id: UUID
|
|
34
|
+
observed_at: AwareDatetime
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GatewayIngestRequest(BaseModel):
|
|
38
|
+
# extra="forbid": the envelope carries no org — tenancy binds to the
|
|
39
|
+
# credential — so a client naming one is rejected, not silently
|
|
40
|
+
# ignored. Ids are optional because the thinned callback forwards
|
|
41
|
+
# payloads without extracting identity and the server resolves it; when
|
|
42
|
+
# present they win, and empty/whitespace-only values are still rejected
|
|
43
|
+
# (NonEmptyId, the same validator InferenceCall enforces): placeholder
|
|
44
|
+
# sessions are banned (ADR 0002), and a 422 here beats a 500 at fact
|
|
45
|
+
# construction.
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
provider: GatewayProvider
|
|
49
|
+
session_id: NonEmptyId | None = None
|
|
50
|
+
user_id: NonEmptyId | None = None
|
|
51
|
+
payload: dict[str, Any]
|
|
52
|
+
capture: GatewayCapture | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.post("/gateway")
|
|
56
|
+
def ingest_gateway(
|
|
57
|
+
body: GatewayIngestRequest,
|
|
58
|
+
_: None = Depends(verify_ingest_token),
|
|
59
|
+
store: FactStore = Depends(get_store),
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
adapter = ADAPTERS.get(body.provider)
|
|
62
|
+
if adapter is None:
|
|
63
|
+
raise HTTPException(
|
|
64
|
+
status_code=400, detail=f"Unsupported provider: {body.provider}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
session_id, user_id = body.session_id, body.user_id
|
|
68
|
+
if session_id is None or user_id is None:
|
|
69
|
+
# Server-side resolution is LiteLLM-SLO-specific: another
|
|
70
|
+
# provider's payload must never have those heuristics run over it —
|
|
71
|
+
# its missing ids go straight to the no_session skip.
|
|
72
|
+
identity = (
|
|
73
|
+
resolve_identity(body.payload)
|
|
74
|
+
if body.provider == GatewayProvider.LITELLM
|
|
75
|
+
else None
|
|
76
|
+
)
|
|
77
|
+
if identity is not None:
|
|
78
|
+
session_id = session_id or identity.session_id
|
|
79
|
+
user_id = user_id or identity.user_id
|
|
80
|
+
if session_id is None:
|
|
81
|
+
# Absent, never guessed (ADR 0002): no placeholder session id
|
|
82
|
+
# may reach the store. 200 so the forwarder does not retry.
|
|
83
|
+
logger.warning(
|
|
84
|
+
"gateway_ingest_skipped_no_session provider=%s call_id=%s",
|
|
85
|
+
body.provider,
|
|
86
|
+
body.payload.get("litellm_call_id"),
|
|
87
|
+
)
|
|
88
|
+
return {"skipped": True, "reason": "no_session"}
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
inference_call = adapter.normalize(
|
|
92
|
+
body.payload,
|
|
93
|
+
session_id=session_id,
|
|
94
|
+
user_id=user_id,
|
|
95
|
+
org_id=settings.org_id,
|
|
96
|
+
capture_id=body.capture.id if body.capture is not None else None,
|
|
97
|
+
observed_at=body.capture.observed_at if body.capture is not None else None,
|
|
98
|
+
)
|
|
99
|
+
except ValidationError as exc:
|
|
100
|
+
# The adapter degrades hostile payload fields by design; identity is
|
|
101
|
+
# the one construction-raise path left, and payload-derived identity
|
|
102
|
+
# widened it. A malformed body from an authenticated caller is a
|
|
103
|
+
# 422, never a 500 (AGENTS.md §API Conventions).
|
|
104
|
+
logger.warning(
|
|
105
|
+
"gateway_ingest_invalid_payload provider=%s call_id=%s",
|
|
106
|
+
body.provider,
|
|
107
|
+
body.payload.get("litellm_call_id"),
|
|
108
|
+
extra={
|
|
109
|
+
"reason": "invalid_fact",
|
|
110
|
+
"source": body.provider.value,
|
|
111
|
+
"record_position": 0,
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
raise HTTPException(
|
|
115
|
+
status_code=422,
|
|
116
|
+
detail="payload does not normalize to a valid inference call",
|
|
117
|
+
) from exc
|
|
118
|
+
try:
|
|
119
|
+
receipt = store.store_inference_call_receipt(inference_call)
|
|
120
|
+
except InferenceCallIdentityConflict as exc:
|
|
121
|
+
logger.warning(
|
|
122
|
+
"gateway_ingest_identity_conflict",
|
|
123
|
+
extra={
|
|
124
|
+
"org_id": settings.org_id,
|
|
125
|
+
"reason": "inference_call_identity_conflict",
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
raise HTTPException(
|
|
129
|
+
status_code=409,
|
|
130
|
+
detail={"code": "inference_call_identity_conflict"},
|
|
131
|
+
) from exc
|
|
132
|
+
logger.info(
|
|
133
|
+
"inference_call_stored fact_id=%s stored=%s",
|
|
134
|
+
receipt.fact_id,
|
|
135
|
+
receipt.stored,
|
|
136
|
+
)
|
|
137
|
+
return {"fact_id": receipt.fact_id, "stored": receipt.stored}
|