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,151 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Mirror garbage collection operator CLI.
3
+
4
+ Thin stdout wrapper over ``sediment_derive.gc.gc_mirrors`` — the reusable
5
+ retention logic lives there; this script only reads args, supplies the one
6
+ legitimate wall-clock read (``now``), and prints. Dry-run by default: pass
7
+ ``--apply`` to actually delete anything, since a removed mirror is not
8
+ undone by anything short of a fresh clone.
9
+
10
+ sediment mirror-gc --org acme-corp
11
+ sediment mirror-gc --org acme-corp --retention-days 30 --apply
12
+ sediment mirror-gc --org acme-corp --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
+ from datetime import UTC, datetime
23
+
24
+ from sediment_core import normalize_org_id
25
+ from sediment_derive import MirrorManager
26
+ from sediment_derive.gc import MirrorGCPolicy, MirrorGCResult, gc_mirrors
27
+
28
+ from .database import add_database_url_argument, one_shot_fact_store
29
+
30
+ _DEFAULT_MIRROR_PATH = "./mirrors"
31
+
32
+
33
+ def _result_dict(
34
+ result: MirrorGCResult, *, dry_run: bool, policy: MirrorGCPolicy
35
+ ) -> dict[str, object]:
36
+ row = asdict(result)
37
+ row["action"] = result.action.value
38
+ if result.last_push_captured_at is not None:
39
+ row["last_push_captured_at"] = result.last_push_captured_at.isoformat()
40
+ # A dry run and an applied run both report action="removed" for an
41
+ # eligible mirror -- without these, a --json consumer reading that
42
+ # value from a dry run misreads it as actually deleted.
43
+ row["dry_run"] = dry_run
44
+ row["retention_days"] = policy.retention_days
45
+ row["policy_version"] = policy.policy_version
46
+ return row
47
+
48
+
49
+ def _print_table(results: list[MirrorGCResult], *, dry_run: bool) -> None:
50
+ if not results:
51
+ print("no mirrored repos found")
52
+ return
53
+ mode = "dry-run (pass --apply to delete)" if dry_run else "applied"
54
+ print(f"mode: {mode}")
55
+ for result in results:
56
+ last_push = (
57
+ result.last_push_captured_at.isoformat()
58
+ if result.last_push_captured_at is not None
59
+ else "never"
60
+ )
61
+ detail = f" ({result.skip_reason})" if result.skip_reason else ""
62
+ repository = result.repo if result.repo is not None else "name absent"
63
+ if result.repository_identity is not None:
64
+ identity = result.repository_identity
65
+ repository += (
66
+ f" [{identity.provider}:{identity.host}/{identity.repository_id}]"
67
+ )
68
+ print(f"{repository}\t{result.action.value}{detail}\tlast_push={last_push}")
69
+
70
+
71
+ def build_parser() -> argparse.ArgumentParser:
72
+ """The argv contract, extracted so the generated CLI reference can
73
+ walk it."""
74
+ parser = argparse.ArgumentParser(
75
+ prog="mirror_gc",
76
+ description="Reclaim bare mirrors for repos with no recent Push activity.",
77
+ )
78
+ parser.add_argument(
79
+ "--org",
80
+ default=os.environ.get("SEDIMENT_ORG_ID"),
81
+ required=not os.environ.get("SEDIMENT_ORG_ID"),
82
+ help="org id (default: $SEDIMENT_ORG_ID)",
83
+ )
84
+ parser.add_argument(
85
+ "--retention-days",
86
+ type=int,
87
+ default=MirrorGCPolicy().retention_days,
88
+ help=f"days of no Push before a mirror is eligible for removal "
89
+ f"(default: {MirrorGCPolicy().retention_days})",
90
+ )
91
+ parser.add_argument(
92
+ "--apply",
93
+ action="store_true",
94
+ help="actually delete eligible mirrors (default: dry-run, print only)",
95
+ )
96
+ parser.add_argument(
97
+ "--json", action="store_true", help="emit JSON instead of a text report"
98
+ )
99
+ add_database_url_argument(parser)
100
+ parser.add_argument(
101
+ "--mirror-path",
102
+ default=os.environ.get("SEDIMENT_MIRROR_PATH", _DEFAULT_MIRROR_PATH),
103
+ help="base dir for git mirrors (default: $SEDIMENT_MIRROR_PATH or "
104
+ f"{_DEFAULT_MIRROR_PATH!r})",
105
+ )
106
+ return parser
107
+
108
+
109
+ def main(argv: list[str] | None = None) -> int:
110
+ args = build_parser().parse_args(argv)
111
+
112
+ # Reject at the trust boundary, before FactStore or MirrorManager touch
113
+ # anything: a zero/negative retention flips the cutoff into the future
114
+ # and (with --apply) removes every mirror that has push history.
115
+ if args.retention_days < 1:
116
+ print(
117
+ f"error: --retention-days must be >= 1, got {args.retention_days}",
118
+ file=sys.stderr,
119
+ )
120
+ return 2
121
+
122
+ try:
123
+ org_id = normalize_org_id(args.org)
124
+ except ValueError as exc:
125
+ print(f"error: {exc}", file=sys.stderr)
126
+ return 2
127
+
128
+ policy = MirrorGCPolicy(retention_days=args.retention_days)
129
+ dry_run = not args.apply
130
+ with one_shot_fact_store(
131
+ args.database_url, operation="garbage-collect mirrors"
132
+ ) as store:
133
+ results = gc_mirrors(
134
+ store,
135
+ MirrorManager(args.mirror_path),
136
+ org_id,
137
+ policy,
138
+ now=datetime.now(UTC),
139
+ dry_run=dry_run,
140
+ )
141
+
142
+ if args.json:
143
+ rows = [_result_dict(r, dry_run=dry_run, policy=policy) for r in results]
144
+ print(json.dumps(rows, indent=2))
145
+ else:
146
+ _print_table(results, dry_run=dry_run)
147
+ return 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
@@ -0,0 +1,9 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Read-only operator reports — the ``sediment report`` subcommands.
3
+
4
+ Each module keeps the shared script convention (AGENTS.md's scripts section):
5
+ ``--org`` (defaulting from ``SEDIMENT_ORG_ID``), ``--database-url``/
6
+ ``SEDIMENT_DATABASE_URL``, ``--json``, and empty result = "no data" + exit 0.
7
+ None of them import ``sediment_api.config`` — a report never needs API auth
8
+ config to read a store.
9
+ """
@@ -0,0 +1,123 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Accepted Session status from captured Session-to-commit observations.
3
+
4
+ Missing observations remain unknown and count under session_commit_unobserved.
5
+ Legacy horizon and mirror options remain accepted for command compatibility.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import sys
14
+ from dataclasses import asdict
15
+
16
+ from sediment_core import normalize_org_id
17
+ from sediment_derive import AbandonmentPolicy, MirrorManager, derive_abandonment
18
+
19
+ from ..database import add_database_url_argument, one_shot_fact_store
20
+
21
+ _DEFAULT_MIRROR_PATH = "./mirrors"
22
+
23
+
24
+ def _print_table(result, policy: AbandonmentPolicy) -> None:
25
+ print(f"grace_horizon_days: {policy.grace_horizon_days}")
26
+ print(f"as_of: {result.as_of.isoformat() if result.as_of else 'none'}")
27
+ print(f"abandoned_sessions: {len(result.sessions)}")
28
+ print(f"unshipped_accepts: {sum(s.accepted_decisions for s in result.sessions)}")
29
+ print(
30
+ f"observed_committed_sessions: {sum(item.status == 'committed' for item in result.outcomes)}"
31
+ )
32
+ print(
33
+ f"unknown_sessions: {sum(item.status == 'attribution_unavailable' for item in result.outcomes)}"
34
+ )
35
+ print("skipped:")
36
+ if not result.skipped:
37
+ print(" none")
38
+ else:
39
+ for reason, count in sorted(result.skipped.items()):
40
+ print(f" {reason}: {count}")
41
+ if result.sessions:
42
+ print("sessions:")
43
+ for session in result.sessions:
44
+ print(
45
+ f" {session.session_id} accepts={session.accepted_decisions}"
46
+ f" last_decision_at={session.last_decision_at.isoformat()}"
47
+ )
48
+
49
+
50
+ def build_parser() -> argparse.ArgumentParser:
51
+ """The argv contract for this report, extracted so the generated CLI
52
+ reference can walk it; ``main`` is unchanged."""
53
+ parser = argparse.ArgumentParser(
54
+ prog="abandonment_report", description=__doc__.splitlines()[0]
55
+ )
56
+ parser.add_argument(
57
+ "--org",
58
+ default=os.environ.get("SEDIMENT_ORG_ID"),
59
+ required=not os.environ.get("SEDIMENT_ORG_ID"),
60
+ help="org id (default: $SEDIMENT_ORG_ID)",
61
+ )
62
+ parser.add_argument(
63
+ "--json", action="store_true", help="emit a JSON row instead of a table"
64
+ )
65
+ add_database_url_argument(parser)
66
+ parser.add_argument(
67
+ "--mirror-path",
68
+ default=os.environ.get("SEDIMENT_MIRROR_PATH", _DEFAULT_MIRROR_PATH),
69
+ help="base dir for git mirrors (default: $SEDIMENT_MIRROR_PATH or "
70
+ f"{_DEFAULT_MIRROR_PATH!r})",
71
+ )
72
+ parser.add_argument(
73
+ "--grace-horizon-days",
74
+ type=int,
75
+ default=AbandonmentPolicy().grace_horizon_days,
76
+ help="legacy horizon for policy compatibility; missing observations remain unknown "
77
+ f"(default: {AbandonmentPolicy().grace_horizon_days})",
78
+ )
79
+ return parser
80
+
81
+
82
+ def main(argv: list[str] | None = None) -> int:
83
+ args = build_parser().parse_args(argv)
84
+
85
+ try:
86
+ org_id = normalize_org_id(args.org)
87
+ policy = AbandonmentPolicy(grace_horizon_days=args.grace_horizon_days)
88
+ except ValueError as exc:
89
+ print(f"error: {exc}", file=sys.stderr)
90
+ return 2
91
+
92
+ with one_shot_fact_store(
93
+ args.database_url, operation="generate abandonment report"
94
+ ) as store:
95
+ result = derive_abandonment(
96
+ store, MirrorManager(args.mirror_path), org_id, policy
97
+ )
98
+
99
+ if args.json:
100
+ print(
101
+ json.dumps(
102
+ {
103
+ "org_id": org_id,
104
+ "grace_horizon_days": policy.grace_horizon_days,
105
+ "policy_version": policy.policy_version,
106
+ "as_of": result.as_of.isoformat() if result.as_of else None,
107
+ "abandoned_sessions": [asdict(s) for s in result.sessions],
108
+ "accepted_session_outcomes": [
109
+ asdict(item) for item in result.outcomes
110
+ ],
111
+ "skipped": dict(sorted(result.skipped.items())),
112
+ },
113
+ indent=2,
114
+ default=str,
115
+ )
116
+ )
117
+ else:
118
+ _print_table(result, policy)
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__": # pragma: no cover
123
+ sys.exit(main())
@@ -0,0 +1,298 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Attribution-share metric + decline-alert operator report.
3
+
4
+ Derives one org's per-repo notes-attribution share for the current
5
+ ``--window-days`` window, derives the same metric again for the trailing
6
+ ``--baseline-window-days`` window immediately preceding it, and reports any
7
+ ``check_attribution_share_alerts`` verdict between them.
8
+
9
+ ``--target-margin`` plans ``min_cases_for_decline_verdict`` via
10
+ ``scripts/corpus_sizing.py``'s worst-case-p=0.5 planner
11
+ (``required_labelled_examples_for_proportion_margin``) -- the same derivation
12
+ ``MIN_DRIFT_CASES`` uses for ``threshold_drift_report``, so the gate is a
13
+ computed corpus-size floor, never a hand-picked literal.
14
+
15
+ Like ``threshold_drift_report.py``, this script is deliberately
16
+ operator-facing only: every outcome -- an alert, or none -- exits 0. Only a
17
+ bad org id or an invalid ``--target-margin``/``--window-days`` exits 2. It is
18
+ not wired into any CI gate.
19
+
20
+ sediment report attribution-share --org acme-corp \
21
+ --target-margin 0.1
22
+ sediment report attribution-share --org acme-corp \
23
+ --target-margin 0.1 --json
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ import sys
32
+ from dataclasses import asdict, replace
33
+ from datetime import UTC, datetime, timedelta
34
+
35
+ from sediment_core import FactStore, Push, normalize_org_id
36
+ from sediment_derive import (
37
+ AttributionShareAlert,
38
+ AttributionSharePolicy,
39
+ MirrorManager,
40
+ RepoAttributionShare,
41
+ check_attribution_share_alerts,
42
+ derive_attribution_share,
43
+ read_repository_context,
44
+ RepositoryContext,
45
+ RepositoryIdentity,
46
+ IdentifiedRepositoryKey,
47
+ LegacyRepositoryKey,
48
+ )
49
+ from sediment_derive.precision_harness import (
50
+ required_labelled_examples_for_proportion_margin,
51
+ )
52
+
53
+ from ..database import add_database_url_argument, one_shot_fact_store
54
+
55
+ _DEFAULT_MIRROR_PATH = "./mirrors"
56
+ _WORST_CASE_ASSUMED_RATE = 0.5
57
+
58
+
59
+ def _baseline_rows(
60
+ store: FactStore,
61
+ mirrors: MirrorManager,
62
+ org_id: str,
63
+ policy: AttributionSharePolicy,
64
+ current: list[RepoAttributionShare],
65
+ *,
66
+ repository_context: RepositoryContext | None = None,
67
+ ) -> list[RepoAttributionShare]:
68
+ """The trailing baseline window per repo in ``current``: the
69
+ ``baseline_window_days`` immediately preceding that repo's current
70
+ ``window_start``. Bounds are enforced here (not left to the derive
71
+ function's latest-push anchor) so a cadence gap before the current
72
+ window cannot re-anchor the baseline at a stale pre-current push --
73
+ matching the contiguous-bounds discipline the model-report path
74
+ (``derive_model_report_attribution_share``) already follows. The
75
+ ``_window`` reuse lives in ``derive_attribution_share`` itself; this is
76
+ only the bounded-selection + bounds-forcing wiring the interim CLI owns.
77
+
78
+ Both bounds stay data-driven (ADR 0001 -- never wall-clock):
79
+ ``current.window_start`` is ``latest_push.captured_at - window_days``
80
+ (derived from push facts by ``derive_attribution_share``), and the
81
+ baseline lower bound is arithmetic on that value, never ``now()``."""
82
+ if repository_context is None:
83
+ with store.read_snapshot() as snapshot:
84
+ context = read_repository_context(snapshot, org_id, as_of=datetime.now(UTC))
85
+ return _baseline_rows(
86
+ snapshot, mirrors, org_id, policy, current, repository_context=context
87
+ )
88
+
89
+ def row_key(row: RepoAttributionShare):
90
+ return (
91
+ IdentifiedRepositoryKey(row.org_id, row.repository_identity)
92
+ if row.repository_identity is not None
93
+ else LegacyRepositoryKey(row.org_id, row.repo)
94
+ )
95
+
96
+ current_window_start = {row_key(row): row.window_start for row in current}
97
+ if not current_window_start:
98
+ return []
99
+ baseline_start = {
100
+ repo: ws - timedelta(days=policy.baseline_window_days)
101
+ for repo, ws in current_window_start.items()
102
+ }
103
+ older_pushes: list[Push] = [
104
+ push
105
+ for push in store.read_pushes(org_id, captured_through=repository_context.as_of)
106
+ if (key := repository_context.resolve_fact(push).key) in current_window_start
107
+ and baseline_start[key] <= push.captured_at < current_window_start[key]
108
+ ]
109
+ if not older_pushes:
110
+ return []
111
+ baseline_policy = replace(policy, window_days=policy.baseline_window_days)
112
+ derived = derive_attribution_share(
113
+ store,
114
+ mirrors,
115
+ org_id,
116
+ baseline_policy,
117
+ pushes=older_pushes,
118
+ repository_context=repository_context,
119
+ as_of=repository_context.as_of,
120
+ )
121
+ return [
122
+ replace(
123
+ row,
124
+ window_start=baseline_start[row_key(row)],
125
+ window_end=current_window_start[row_key(row)],
126
+ )
127
+ for row in derived
128
+ ]
129
+
130
+
131
+ def attribution_share_row_payload(row: RepoAttributionShare) -> dict[str, object]:
132
+ payload = asdict(row)
133
+ payload["window_start"] = row.window_start.isoformat()
134
+ payload["window_end"] = row.window_end.isoformat()
135
+ return payload
136
+
137
+
138
+ def attribution_share_alert_payload(
139
+ alert: AttributionShareAlert,
140
+ ) -> dict[str, object]:
141
+ return {
142
+ "org_id": alert.org_id,
143
+ "repo": alert.repo,
144
+ "repository_identity": asdict(alert.repository_identity)
145
+ if alert.repository_identity
146
+ else None,
147
+ "kind": alert.kind.value,
148
+ "current": attribution_share_row_payload(alert.current),
149
+ "baseline": (
150
+ attribution_share_row_payload(alert.baseline)
151
+ if alert.baseline is not None
152
+ else None
153
+ ),
154
+ "reason": alert.reason,
155
+ }
156
+
157
+
158
+ def format_repository_label(repo: str, identity: RepositoryIdentity | None) -> str:
159
+ """Show the repository lifetime beside its representative display label."""
160
+ return (
161
+ repo
162
+ if identity is None
163
+ else f"{repo} [{identity.provider}/{identity.host}/{identity.repository_id}]"
164
+ )
165
+
166
+
167
+ def _print_row(row: RepoAttributionShare) -> None:
168
+ label = format_repository_label(row.repo, row.repository_identity)
169
+ print(
170
+ f" {label}: git_notes_share={row.git_notes_share:.4f} "
171
+ f"ci=({row.git_notes_share_ci[0]:.4f}, {row.git_notes_share_ci[1]:.4f}) "
172
+ f"agent_plausible_commits={row.agent_plausible_commits} "
173
+ f"git_notes={row.git_notes_attributed} jaccard={row.jaccard_attributed} "
174
+ f"unattributed={row.unattributed}"
175
+ )
176
+
177
+
178
+ def build_parser() -> argparse.ArgumentParser:
179
+ """The argv contract for this report, extracted so the generated
180
+ CLI reference can walk it."""
181
+ parser = argparse.ArgumentParser(
182
+ prog="attribution_share_report",
183
+ description=(
184
+ "Report notes-attribution share per repo and flag a material "
185
+ "decline against a trailing baseline window."
186
+ ),
187
+ )
188
+ parser.add_argument(
189
+ "--org",
190
+ default=os.environ.get("SEDIMENT_ORG_ID"),
191
+ required=not os.environ.get("SEDIMENT_ORG_ID"),
192
+ help="org id (default: $SEDIMENT_ORG_ID)",
193
+ )
194
+ parser.add_argument(
195
+ "--target-margin",
196
+ type=float,
197
+ required=True,
198
+ help=(
199
+ "target CI half-width in raw proportion points for the decline "
200
+ "verdict's min-cases gate, e.g. 0.1 for +/-10pp (worst-case "
201
+ "p=0.5 planner -- see scripts/corpus_sizing.py)"
202
+ ),
203
+ )
204
+ parser.add_argument(
205
+ "--window-days",
206
+ type=int,
207
+ default=7,
208
+ help="current window width in days (default: 7)",
209
+ )
210
+ parser.add_argument(
211
+ "--baseline-window-days",
212
+ type=int,
213
+ default=28,
214
+ help="trailing baseline window width in days (default: 28)",
215
+ )
216
+ parser.add_argument(
217
+ "--json", action="store_true", help="emit JSON instead of a text report"
218
+ )
219
+ add_database_url_argument(parser)
220
+ parser.add_argument(
221
+ "--mirror-path",
222
+ default=os.environ.get("SEDIMENT_MIRROR_PATH", _DEFAULT_MIRROR_PATH),
223
+ help="base dir for git mirrors (default: $SEDIMENT_MIRROR_PATH or "
224
+ f"{_DEFAULT_MIRROR_PATH!r})",
225
+ )
226
+ return parser
227
+
228
+
229
+ def main(argv: list[str] | None = None) -> int:
230
+ args = build_parser().parse_args(argv)
231
+
232
+ try:
233
+ org_id = normalize_org_id(args.org)
234
+ min_cases = required_labelled_examples_for_proportion_margin(
235
+ _WORST_CASE_ASSUMED_RATE, args.target_margin
236
+ )
237
+ policy = AttributionSharePolicy(
238
+ window_days=args.window_days,
239
+ baseline_window_days=args.baseline_window_days,
240
+ min_cases_for_decline_verdict=min_cases,
241
+ )
242
+ except ValueError as exc:
243
+ print(f"error: {exc}", file=sys.stderr)
244
+ return 2
245
+
246
+ with one_shot_fact_store(
247
+ args.database_url, operation="generate attribution-share report"
248
+ ) as store:
249
+ mirrors = MirrorManager(args.mirror_path)
250
+ with store.read_snapshot() as snapshot:
251
+ context = read_repository_context(snapshot, org_id, as_of=datetime.now(UTC))
252
+ current = derive_attribution_share(
253
+ snapshot,
254
+ mirrors,
255
+ org_id,
256
+ policy,
257
+ repository_context=context,
258
+ as_of=context.as_of,
259
+ )
260
+ baseline = _baseline_rows(
261
+ snapshot, mirrors, org_id, policy, current, repository_context=context
262
+ )
263
+ alerts = check_attribution_share_alerts(current, baseline, policy)
264
+
265
+ if args.json:
266
+ print(
267
+ json.dumps(
268
+ {
269
+ "min_cases_for_decline_verdict": min_cases,
270
+ "current": [attribution_share_row_payload(row) for row in current],
271
+ "baseline": [
272
+ attribution_share_row_payload(row) for row in baseline
273
+ ],
274
+ "alerts": [
275
+ attribution_share_alert_payload(alert) for alert in alerts
276
+ ],
277
+ },
278
+ indent=2,
279
+ )
280
+ )
281
+ else:
282
+ print(f"min_cases_for_decline_verdict: {min_cases}")
283
+ print("current:")
284
+ for row in current:
285
+ _print_row(row)
286
+ print("baseline:")
287
+ for row in baseline:
288
+ _print_row(row)
289
+ print("alerts:")
290
+ for alert in alerts:
291
+ print(
292
+ f" {format_repository_label(alert.repo, alert.repository_identity)}: {alert.kind.value} -- {alert.reason}"
293
+ )
294
+ return 0
295
+
296
+
297
+ if __name__ == "__main__":
298
+ raise SystemExit(main())