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,182 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Attribution ground-truth precision report.
|
|
3
|
+
|
|
4
|
+
Loads a JSONL ground-truth manifest, derives one org's attributions, and
|
|
5
|
+
prints notes-attributed and jaccard-attributed precision/recall separately.
|
|
6
|
+
The reusable schema and scoring logic live in
|
|
7
|
+
``sediment_derive.precision_report`` beside the scorer precision harness; this
|
|
8
|
+
script is only the operator-facing stdout wrapper.
|
|
9
|
+
|
|
10
|
+
sediment report precision --org acme-corp \
|
|
11
|
+
--manifest packages/derive/tests/fixtures/ground_truth/illustrative_manifest.jsonl
|
|
12
|
+
sediment report precision --org acme-corp --manifest gt.jsonl --json
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import asdict
|
|
22
|
+
|
|
23
|
+
from sediment_core import normalize_org_id
|
|
24
|
+
from sediment_derive import MirrorManager
|
|
25
|
+
from sediment_derive.precision_report import (
|
|
26
|
+
DEFAULT_SWEEP_THRESHOLDS,
|
|
27
|
+
DEFAULT_THRESHOLD,
|
|
28
|
+
AttributionPrecisionReport,
|
|
29
|
+
generate_precision_report_for_org,
|
|
30
|
+
load_ground_truth_manifest,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from ..database import add_database_url_argument, one_shot_fact_store
|
|
34
|
+
|
|
35
|
+
_DEFAULT_MIRROR_PATH = "./mirrors"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_thresholds(raw: str) -> list[float]:
|
|
39
|
+
try:
|
|
40
|
+
thresholds = [float(part.strip()) for part in raw.split(",") if part.strip()]
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
raise argparse.ArgumentTypeError(
|
|
43
|
+
"thresholds must be comma-separated numbers"
|
|
44
|
+
) from exc
|
|
45
|
+
if not thresholds:
|
|
46
|
+
raise argparse.ArgumentTypeError("at least one threshold is required")
|
|
47
|
+
for threshold in thresholds:
|
|
48
|
+
if threshold < 0.0 or threshold > 1.0:
|
|
49
|
+
raise argparse.ArgumentTypeError("thresholds must be between 0.0 and 1.0")
|
|
50
|
+
return thresholds
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _result_dict(report: AttributionPrecisionReport) -> dict[str, object]:
|
|
54
|
+
return {
|
|
55
|
+
"threshold": report.threshold,
|
|
56
|
+
"by_source": {
|
|
57
|
+
source.value: _source_dict(source_report)
|
|
58
|
+
for source, source_report in report.by_source.items()
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _source_dict(report: AttributionPrecisionReport) -> dict[str, object]:
|
|
64
|
+
return {
|
|
65
|
+
"default": asdict(report.default),
|
|
66
|
+
"sweep": [asdict(row) for row in report.sweep],
|
|
67
|
+
"skipped_unlabelled_predictions": report.skipped_unlabelled_predictions,
|
|
68
|
+
"skipped_repository_ground_truth": report.skipped_repository_ground_truth,
|
|
69
|
+
"skipped_repository_predictions": report.skipped_repository_predictions,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _print_table(report: AttributionPrecisionReport) -> None:
|
|
74
|
+
print(f"default_threshold: {report.threshold:.2f}")
|
|
75
|
+
for source, source_report in report.by_source.items():
|
|
76
|
+
row = source_report.default
|
|
77
|
+
print(f"{source.value}:")
|
|
78
|
+
print(
|
|
79
|
+
" "
|
|
80
|
+
f"precision={row.precision:.4f} recall={row.recall:.4f} "
|
|
81
|
+
f"tp={row.true_positives} fp={row.false_positives} "
|
|
82
|
+
f"fn={row.false_negatives} tn={row.true_negatives}"
|
|
83
|
+
)
|
|
84
|
+
print(
|
|
85
|
+
" "
|
|
86
|
+
f"skipped_unlabelled_predictions="
|
|
87
|
+
f"{source_report.skipped_unlabelled_predictions}"
|
|
88
|
+
)
|
|
89
|
+
print(
|
|
90
|
+
f" skipped_repository_ground_truth={source_report.skipped_repository_ground_truth} "
|
|
91
|
+
f"skipped_repository_predictions={source_report.skipped_repository_predictions}"
|
|
92
|
+
)
|
|
93
|
+
print(" sweep:")
|
|
94
|
+
for sweep_row in source_report.sweep:
|
|
95
|
+
print(
|
|
96
|
+
" "
|
|
97
|
+
f"threshold={sweep_row.threshold:.2f} "
|
|
98
|
+
f"precision={sweep_row.precision:.4f} "
|
|
99
|
+
f"recall={sweep_row.recall:.4f} "
|
|
100
|
+
f"tp={sweep_row.true_positives} "
|
|
101
|
+
f"fp={sweep_row.false_positives} "
|
|
102
|
+
f"fn={sweep_row.false_negatives} "
|
|
103
|
+
f"tn={sweep_row.true_negatives}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
108
|
+
"""The argv contract for this report, extracted so the generated
|
|
109
|
+
CLI reference can walk it."""
|
|
110
|
+
parser = argparse.ArgumentParser(
|
|
111
|
+
prog="precision_report",
|
|
112
|
+
description="Score derived attributions against a ground-truth manifest.",
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--org",
|
|
116
|
+
default=os.environ.get("SEDIMENT_ORG_ID"),
|
|
117
|
+
required=not os.environ.get("SEDIMENT_ORG_ID"),
|
|
118
|
+
help="org id (default: $SEDIMENT_ORG_ID)",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument("--manifest", required=True, help="JSONL ground-truth manifest")
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--threshold",
|
|
123
|
+
type=float,
|
|
124
|
+
default=DEFAULT_THRESHOLD,
|
|
125
|
+
help=f"default scoring threshold (default: {DEFAULT_THRESHOLD})",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--sweep-thresholds",
|
|
129
|
+
type=_parse_thresholds,
|
|
130
|
+
default=list(DEFAULT_SWEEP_THRESHOLDS),
|
|
131
|
+
help="comma-separated sweep thresholds (default: 0.1,0.2,...,0.9)",
|
|
132
|
+
)
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--json", action="store_true", help="emit JSON instead of a text report"
|
|
135
|
+
)
|
|
136
|
+
add_database_url_argument(parser)
|
|
137
|
+
parser.add_argument(
|
|
138
|
+
"--mirror-path",
|
|
139
|
+
default=os.environ.get("SEDIMENT_MIRROR_PATH", _DEFAULT_MIRROR_PATH),
|
|
140
|
+
help="base dir for git mirrors (default: $SEDIMENT_MIRROR_PATH or "
|
|
141
|
+
f"{_DEFAULT_MIRROR_PATH!r})",
|
|
142
|
+
)
|
|
143
|
+
return parser
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main(argv: list[str] | None = None) -> int:
|
|
147
|
+
args = build_parser().parse_args(argv)
|
|
148
|
+
|
|
149
|
+
if not 0.0 <= args.threshold <= 1.0:
|
|
150
|
+
print(
|
|
151
|
+
"error: --threshold must be between 0.0 and 1.0",
|
|
152
|
+
file=sys.stderr,
|
|
153
|
+
)
|
|
154
|
+
return 2
|
|
155
|
+
try:
|
|
156
|
+
org_id = normalize_org_id(args.org)
|
|
157
|
+
manifest = load_ground_truth_manifest(args.manifest)
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
160
|
+
return 2
|
|
161
|
+
|
|
162
|
+
with one_shot_fact_store(
|
|
163
|
+
args.database_url, operation="generate precision report"
|
|
164
|
+
) as store:
|
|
165
|
+
report = generate_precision_report_for_org(
|
|
166
|
+
store,
|
|
167
|
+
MirrorManager(args.mirror_path),
|
|
168
|
+
org_id,
|
|
169
|
+
manifest,
|
|
170
|
+
threshold=args.threshold,
|
|
171
|
+
sweep_threshold_values=args.sweep_thresholds,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if args.json:
|
|
175
|
+
print(json.dumps(_result_dict(report), indent=2))
|
|
176
|
+
else:
|
|
177
|
+
_print_table(report)
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Recovery-pair yield report: failed CI runs, emitted pairs, skips.
|
|
3
|
+
|
|
4
|
+
Prints ``sediment_export.generate_recovery_yield_report`` for one org: a
|
|
5
|
+
compact stdout summary by default, or ``--json`` for machines.
|
|
6
|
+
|
|
7
|
+
sediment report recovery-yield --org acme-corp
|
|
8
|
+
sediment report recovery-yield --org acme-corp --json
|
|
9
|
+
|
|
10
|
+
Storage is read the same way ``model_report.py`` reads it:
|
|
11
|
+
``--database-url``/``SEDIMENT_DATABASE_URL`` and ``--mirror-path``/
|
|
12
|
+
``SEDIMENT_MIRROR_PATH``. A missing mirror path is not fatal; unmirrored repos
|
|
13
|
+
are counted in the recovery derivation's ``mirror_absent`` skip tally.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import asdict
|
|
23
|
+
|
|
24
|
+
from sediment_core import normalize_org_id
|
|
25
|
+
from sediment_derive import MirrorManager
|
|
26
|
+
from sediment_export import (
|
|
27
|
+
DiffSizeStats,
|
|
28
|
+
RecoveryYieldReport,
|
|
29
|
+
generate_recovery_yield_report,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from ..database import add_database_url_argument, one_shot_fact_store
|
|
33
|
+
|
|
34
|
+
_DEFAULT_MIRROR_PATH = "./mirrors"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _print_table(row: RecoveryYieldReport) -> None:
|
|
38
|
+
print(f"failed_ci_runs: {row.total_failed_ci_runs}")
|
|
39
|
+
print(f"recovery_pairs: {row.recovery_pairs}")
|
|
40
|
+
print(f"recovery_yield: {row.recovery_yield_rate:.1%}")
|
|
41
|
+
print("skipped:")
|
|
42
|
+
if not row.skipped:
|
|
43
|
+
print(" none")
|
|
44
|
+
else:
|
|
45
|
+
for reason, count in sorted(row.skipped.items()):
|
|
46
|
+
print(f" {reason}: {count}")
|
|
47
|
+
distribution = row.diff_size_distribution
|
|
48
|
+
print("diff_size_distribution:")
|
|
49
|
+
print(f" max_recovery_diff_lines: {distribution.max_recovery_diff_lines}")
|
|
50
|
+
print(" kept:")
|
|
51
|
+
_print_diff_size_stats(distribution.kept)
|
|
52
|
+
print(" dropped:")
|
|
53
|
+
_print_diff_size_stats(distribution.dropped)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _print_diff_size_stats(stats: DiffSizeStats) -> None:
|
|
57
|
+
print(f" count: {stats.count}")
|
|
58
|
+
print(f" min: {stats.min}")
|
|
59
|
+
print(f" median: {stats.median}")
|
|
60
|
+
print(f" p90: {stats.p90}")
|
|
61
|
+
print(f" max: {stats.max}")
|
|
62
|
+
print(" histogram:")
|
|
63
|
+
if not stats.histogram:
|
|
64
|
+
print(" none")
|
|
65
|
+
return
|
|
66
|
+
for bucket, count in stats.histogram.items():
|
|
67
|
+
print(f" {bucket}: {count}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _row_dict(row: RecoveryYieldReport) -> dict:
|
|
71
|
+
return {
|
|
72
|
+
"total_failed_ci_runs": row.total_failed_ci_runs,
|
|
73
|
+
"recovery_pairs": row.recovery_pairs,
|
|
74
|
+
"recovery_yield_rate": row.recovery_yield_rate,
|
|
75
|
+
"skipped": dict(row.skipped),
|
|
76
|
+
"diff_size_distribution": asdict(row.diff_size_distribution),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
81
|
+
"""The argv contract for this report, extracted so the generated
|
|
82
|
+
CLI reference can walk it."""
|
|
83
|
+
parser = argparse.ArgumentParser(
|
|
84
|
+
prog="recovery_yield_report", description=__doc__.splitlines()[0]
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--org",
|
|
88
|
+
default=os.environ.get("SEDIMENT_ORG_ID"),
|
|
89
|
+
required=not os.environ.get("SEDIMENT_ORG_ID"),
|
|
90
|
+
help="org id (default: $SEDIMENT_ORG_ID)",
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--json", action="store_true", help="emit a JSON row instead of a table"
|
|
94
|
+
)
|
|
95
|
+
add_database_url_argument(parser)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--mirror-path",
|
|
98
|
+
default=os.environ.get("SEDIMENT_MIRROR_PATH", _DEFAULT_MIRROR_PATH),
|
|
99
|
+
help="base dir for git mirrors (default: $SEDIMENT_MIRROR_PATH or "
|
|
100
|
+
f"{_DEFAULT_MIRROR_PATH!r})",
|
|
101
|
+
)
|
|
102
|
+
return parser
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv: list[str] | None = None) -> int:
|
|
106
|
+
args = build_parser().parse_args(argv)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
org_id = normalize_org_id(args.org)
|
|
110
|
+
except ValueError as exc:
|
|
111
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
112
|
+
return 2
|
|
113
|
+
|
|
114
|
+
with one_shot_fact_store(
|
|
115
|
+
args.database_url, operation="generate recovery-yield report"
|
|
116
|
+
) as store:
|
|
117
|
+
row = generate_recovery_yield_report(
|
|
118
|
+
store,
|
|
119
|
+
MirrorManager(args.mirror_path),
|
|
120
|
+
org_id,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if args.json:
|
|
124
|
+
print(json.dumps(_row_dict(row), indent=2))
|
|
125
|
+
else:
|
|
126
|
+
print(f"org: {org_id}")
|
|
127
|
+
_print_table(row)
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
POST /ingest/ci — vendor-neutral CI ingest. The GitHub webhook door lives
|
|
4
|
+
at /ingest/github/ci, so the plain name belongs to the door any CI can
|
|
5
|
+
call.
|
|
6
|
+
|
|
7
|
+
One authenticated bearer POST any CI pipeline can ``curl`` to store a
|
|
8
|
+
``CIOutcome`` fact. The caller normalizes its own CI system into a
|
|
9
|
+
``CIProvider`` value and the known ``CIResult`` vocabulary; the server
|
|
10
|
+
validates at the trust boundary and stores — no per-vendor parsing, no
|
|
11
|
+
new fact shape.
|
|
12
|
+
|
|
13
|
+
Dedup: the store's ``uq_ci_run`` UNIQUE index on provider run id and attempt
|
|
14
|
+
collapses redeliveries; ``stored: false`` means success, not an error.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from fastapi import APIRouter, Depends
|
|
22
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
23
|
+
from sediment_core import (
|
|
24
|
+
BranchName,
|
|
25
|
+
CIOutcome,
|
|
26
|
+
CIReason,
|
|
27
|
+
CIProvider,
|
|
28
|
+
CIResult,
|
|
29
|
+
CommitSha,
|
|
30
|
+
FactStore,
|
|
31
|
+
ForgeProvider,
|
|
32
|
+
ForgeHost,
|
|
33
|
+
ProviderRepositoryId,
|
|
34
|
+
NonEmptyId,
|
|
35
|
+
NonEmptyContent,
|
|
36
|
+
RepoSlug,
|
|
37
|
+
WorkflowName,
|
|
38
|
+
WorkflowPath,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from ..config import settings
|
|
42
|
+
from ..deps import get_store, verify_ingest_token
|
|
43
|
+
|
|
44
|
+
router = APIRouter(tags=["ci-vendor"])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class VendorCIRequest(BaseModel):
|
|
48
|
+
"""The vendor-neutral CI outcome payload — callers normalize their own
|
|
49
|
+
CI system into this shape. ``extra=\"forbid\"`` so a caller naming
|
|
50
|
+
an org or fabricating fields is rejected at the boundary.
|
|
51
|
+
|
|
52
|
+
The identity fields carry the shared core definitions on
|
|
53
|
+
the envelope, so malformed input 422s at the door rather than
|
|
54
|
+
surfacing as a ValidationError-turned-500 inside the handler:
|
|
55
|
+
``CommitSha`` (40- and 64-char object names — a SHA-256 repo can use
|
|
56
|
+
this door), ``RepoSlug`` (required — a vendor fact without a repo has
|
|
57
|
+
no CI-attach join), ``BranchName`` (``refs/heads/x`` normalizes to
|
|
58
|
+
``x``, one recovery lineage bucket per pipeline), and a required
|
|
59
|
+
non-empty ``run_id``. ``run_url`` is optional location metadata."""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(extra="forbid")
|
|
62
|
+
|
|
63
|
+
provider: CIProvider = Field(
|
|
64
|
+
description=(
|
|
65
|
+
"The normalized CI system. The sender declares it from integration "
|
|
66
|
+
"configuration; Sediment does not infer it from a URL"
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
run_id: NonEmptyId = Field(
|
|
70
|
+
description=(
|
|
71
|
+
"The provider-issued pipeline-run id, unique within the deployment "
|
|
72
|
+
"organization and provider namespace"
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
run_attempt: int | None = Field(default=None, ge=1, le=2**63 - 1, strict=True)
|
|
76
|
+
repo: RepoSlug
|
|
77
|
+
repository_provider: ForgeProvider | None = None
|
|
78
|
+
repository_host: ForgeHost | None = None
|
|
79
|
+
repository_id: ProviderRepositoryId | None = None
|
|
80
|
+
commit_sha: CommitSha
|
|
81
|
+
branch: BranchName
|
|
82
|
+
# Workflow names preserve descriptive content; paths identify definitions.
|
|
83
|
+
workflow_name: WorkflowName = ""
|
|
84
|
+
workflow_id: NonEmptyId | None = None
|
|
85
|
+
workflow_path: WorkflowPath | None = None
|
|
86
|
+
result: CIResult
|
|
87
|
+
run_url: NonEmptyContent | None = None
|
|
88
|
+
provider_result: NonEmptyContent | None = None
|
|
89
|
+
error_type: NonEmptyContent | None = None
|
|
90
|
+
reason: CIReason | None = None
|
|
91
|
+
source_event_type: NonEmptyContent | None = None
|
|
92
|
+
source_spec_version: NonEmptyContent | None = None
|
|
93
|
+
source_event_id: NonEmptyId | None = None
|
|
94
|
+
|
|
95
|
+
@model_validator(mode="after")
|
|
96
|
+
def _complete_repository_identity(self):
|
|
97
|
+
present = tuple(
|
|
98
|
+
value is not None
|
|
99
|
+
for value in (
|
|
100
|
+
self.repository_provider,
|
|
101
|
+
self.repository_host,
|
|
102
|
+
self.repository_id,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
if any(present) and not all(present):
|
|
106
|
+
raise ValueError("repository identity must be wholly present or absent")
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
@field_validator("repo")
|
|
110
|
+
@classmethod
|
|
111
|
+
def _repo_required(cls, v: str) -> str:
|
|
112
|
+
# RepoSlug admits "" as the webhook parsers' absent sentinel; a
|
|
113
|
+
# vendor caller controls its own pipeline and must name the repo.
|
|
114
|
+
if not v:
|
|
115
|
+
raise ValueError("repo is required (owner/repo)")
|
|
116
|
+
return v
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.post("/ci")
|
|
120
|
+
def ingest_ci_vendor(
|
|
121
|
+
body: VendorCIRequest,
|
|
122
|
+
_: None = Depends(verify_ingest_token),
|
|
123
|
+
store: FactStore = Depends(get_store),
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
outcome = CIOutcome(
|
|
126
|
+
org_id=settings.org_id,
|
|
127
|
+
provider=body.provider,
|
|
128
|
+
run_id=body.run_id,
|
|
129
|
+
run_attempt=body.run_attempt,
|
|
130
|
+
repo=body.repo,
|
|
131
|
+
repository_provider=body.repository_provider,
|
|
132
|
+
repository_host=body.repository_host,
|
|
133
|
+
repository_id=body.repository_id,
|
|
134
|
+
commit_sha=body.commit_sha,
|
|
135
|
+
branch=body.branch,
|
|
136
|
+
result=body.result,
|
|
137
|
+
workflow_name=body.workflow_name,
|
|
138
|
+
workflow_id=body.workflow_id,
|
|
139
|
+
workflow_path=body.workflow_path or None,
|
|
140
|
+
run_url=body.run_url,
|
|
141
|
+
provider_result=body.provider_result,
|
|
142
|
+
error_type=body.error_type,
|
|
143
|
+
reason=body.reason,
|
|
144
|
+
source_event_type=body.source_event_type,
|
|
145
|
+
source_spec_version=body.source_spec_version,
|
|
146
|
+
source_event_id=body.source_event_id,
|
|
147
|
+
)
|
|
148
|
+
receipt = store.store_ci_outcome_receipt(outcome)
|
|
149
|
+
return {"fact_id": receipt.fact_id, "stored": receipt.stored}
|