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