lorebase-keeper 0.2.3__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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,850 @@
1
+ """Proposal use cases (design 6.8 to 6.10, 10.3): create, read, review, merge, resolve, close.
2
+
3
+ Authorization happens in the routes; this module enforces the Proposal lifecycle and the
4
+ review policy:
5
+
6
+ - a Proposal is `open` until it is merged or closed; only open Proposals are reviewed,
7
+ resolved and merged;
8
+ - merging into a protected branch needs `required_approvals` approvals (setting
9
+ `keeper.proposals.required_approvals`, overridden by the Domain setting of the same name):
10
+ the latest approve or request-changes verdict of each reviewer counts, the author's only
11
+ when self-approval is allowed, and an outstanding request for changes blocks the merge;
12
+ - the merge policy of a Proposal is the Domain's `merge_policy` setting when it is created.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass, field, replace
19
+ from datetime import datetime
20
+ from typing import Any
21
+
22
+ from pydantic import JsonValue, ValidationError
23
+ from sqlalchemy.exc import IntegrityError
24
+ from sqlalchemy.ext.asyncio import AsyncSession
25
+
26
+ from lorebase_core.merge import Conflict, MergePolicy, Resolution, ResolutionError
27
+ from lorebase_core.model import new_ulid
28
+ from lorebase_keeper.auth.principals import Principal
29
+ from lorebase_keeper.context import StoreAccess, require_branch, require_domain, require_revision
30
+ from lorebase_keeper.errors import NOT_FOUND, VALIDATION_FAILED, ProblemError, ProblemType
31
+ from lorebase_keeper.services.config import ProposalSettings
32
+ from lorebase_keeper.services.errors import ALREADY_EXISTS, IDEMPOTENCY_CONFLICT
33
+ from lorebase_keeper.services.proposal_reads import (
34
+ FULL_SCOPE,
35
+ ConflictScope,
36
+ require_visible,
37
+ unknown_conflict,
38
+ visible_partitions,
39
+ )
40
+ from lorebase_keeper.services.schema import SchemaGuard, schema_at
41
+ from lorebase_store.merging import (
42
+ AttemptResult,
43
+ MergeRequest,
44
+ MergeService,
45
+ ProposalBranchMissingError,
46
+ check_resolution,
47
+ )
48
+ from lorebase_store.models.enums import (
49
+ MergeAttemptStatus,
50
+ ProposalOrigin,
51
+ ProposalStatus,
52
+ ReviewVerdict,
53
+ )
54
+ from lorebase_store.repositories import (
55
+ EVERY_PARTITION,
56
+ ConflictRepository,
57
+ ConflictRow,
58
+ DomainRow,
59
+ MergeAttemptRepository,
60
+ MergeAttemptRow,
61
+ PartitionFilter,
62
+ ProposalFilter,
63
+ ProposalRepository,
64
+ ProposalRow,
65
+ RefRepository,
66
+ ResolutionRepository,
67
+ ResolutionRow,
68
+ ReviewRepository,
69
+ ReviewRow,
70
+ )
71
+
72
+ PROPOSAL_PREFIX = "prp"
73
+ REVIEW_PREFIX = "prr"
74
+ POLICY_KEY = "merge_policy"
75
+ """Key of the merge policy in a Domain's settings and in a Proposal's `policies`."""
76
+ APPROVALS_KEY = "required_approvals"
77
+ """Domain setting overriding `keeper.proposals.required_approvals`."""
78
+
79
+ PROPOSAL_NOT_OPEN = ProblemType("proposal-not-open", 409, "Proposal is not open")
80
+ APPROVALS_REQUIRED = ProblemType("approvals-required", 409, "Approvals required")
81
+ MERGE_CONFLICTS = ProblemType("merge-conflicts", 409, "Merge has Conflicts")
82
+ SELF_APPROVAL = ProblemType("self-approval", 403, "Self-approval is not allowed")
83
+ BRANCH_MISSING = ProblemType("proposal-branch-missing", 409, "Proposal branch missing")
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class NewProposal:
88
+ source_ref: str
89
+ target_ref: str
90
+ title: str
91
+ description: str | None = None
92
+ origin: ProposalOrigin = ProposalOrigin.MANUAL
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Approval:
97
+ """Review state of a Proposal against its target's protection."""
98
+
99
+ protected: bool
100
+ required: int
101
+ approvers: tuple[str, ...]
102
+ changes_requested: tuple[str, ...]
103
+ blocking: bool
104
+
105
+ @property
106
+ def satisfied(self) -> bool:
107
+ if not self.protected:
108
+ return True
109
+ blocked = self.blocking and bool(self.changes_requested)
110
+ return len(self.approvers) >= self.required and not blocked
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class ProposalView:
115
+ proposal: ProposalRow
116
+ reviews: list[ReviewRow]
117
+ approval: Approval
118
+ attempt: MergeAttemptRow | None
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class CreatedProposal:
123
+ view: ProposalView
124
+ replayed: bool = False
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class AttemptView:
129
+ """An attempt with one page of its Conflicts and the Proposal's resolutions."""
130
+
131
+ proposal: ProposalRow
132
+ attempt: MergeAttemptRow
133
+ conflicts: list[ConflictRow]
134
+ next_after: str | None
135
+ counts: dict[tuple[str, bool], int]
136
+ resolutions: list[ResolutionRow]
137
+ recomputed: bool = False
138
+ stale: bool = False
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class MergeOutcome:
143
+ """Result of a merge request: merged (`head` set) or the attempt with open Conflicts."""
144
+
145
+ proposal: ProposalRow
146
+ result: AttemptResult | None
147
+ view: AttemptView
148
+ head: bytes | None
149
+ replayed: bool = False
150
+ source_deleted: bool = False
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class ConflictQuery:
155
+ codes: Sequence[str] = ()
156
+ resolved: bool | None = None
157
+ limit: int = 100
158
+ after: str | None = None
159
+ scope: ConflictScope = field(default=FULL_SCOPE)
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class ResolutionInput:
164
+ fingerprint: str
165
+ choice: str
166
+ value: JsonValue = None
167
+ values: dict[str, JsonValue] | None = None
168
+ pointer: str = "/body"
169
+
170
+
171
+ # Helpers ----------------------------------------------------------------------------------
172
+
173
+
174
+ def domain_policy(domain: DomainRow) -> MergePolicy:
175
+ """The Domain's merge policy setting; 422 when it is not a valid policy."""
176
+ raw = (domain.settings or {}).get(POLICY_KEY) or {}
177
+ try:
178
+ return MergePolicy.model_validate(raw)
179
+ except ValidationError as error:
180
+ raise ProblemError(
181
+ VALIDATION_FAILED,
182
+ f"The Domain setting {POLICY_KEY} is not a valid merge policy: "
183
+ f"{error.errors()[0]['msg']}",
184
+ ) from None
185
+
186
+
187
+ def proposal_policy(proposal: ProposalRow) -> MergePolicy:
188
+ return MergePolicy.model_validate((proposal.policies or {}).get(POLICY_KEY) or {})
189
+
190
+
191
+ def required_approvals(domain: DomainRow, settings: ProposalSettings) -> int:
192
+ value = (domain.settings or {}).get(APPROVALS_KEY)
193
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
194
+ return value
195
+ return settings.required_approvals
196
+
197
+
198
+ def approval_of(
199
+ proposal: ProposalRow,
200
+ reviews: Sequence[ReviewRow],
201
+ *,
202
+ protected: bool,
203
+ required: int,
204
+ settings: ProposalSettings,
205
+ ) -> Approval:
206
+ """Latest decisive verdict per reviewer (comments do not change it)."""
207
+ latest: dict[str, ReviewVerdict] = {}
208
+ for review in reviews:
209
+ if review.verdict is ReviewVerdict.COMMENT:
210
+ continue
211
+ if review.reviewer == proposal.author and not settings.allow_self_approval:
212
+ continue
213
+ latest[review.reviewer] = review.verdict
214
+ return Approval(
215
+ protected=protected,
216
+ required=required,
217
+ approvers=tuple(sorted(r for r, v in latest.items() if v is ReviewVerdict.APPROVE)),
218
+ changes_requested=tuple(
219
+ sorted(r for r, v in latest.items() if v is ReviewVerdict.REQUEST_CHANGES)
220
+ ),
221
+ blocking=settings.changes_requested_blocks,
222
+ )
223
+
224
+
225
+ def _not_found(proposal_id: str) -> ProblemError:
226
+ return ProblemError(NOT_FOUND, f"Proposal {proposal_id!r} not found.")
227
+
228
+
229
+ def _not_open(proposal: ProposalRow) -> ProblemError:
230
+ return ProblemError(
231
+ PROPOSAL_NOT_OPEN,
232
+ f"Proposal {proposal.id!r} is {proposal.status.value}.",
233
+ extensions={"proposal": proposal.id, "status": proposal.status.value},
234
+ )
235
+
236
+
237
+ def _branch_missing(error: ProposalBranchMissingError) -> ProblemError:
238
+ return ProblemError(
239
+ BRANCH_MISSING,
240
+ f"The {error.side} branch {error.name!r} of the Proposal no longer exists; close it.",
241
+ extensions={"side": error.side, "branch": error.name},
242
+ )
243
+
244
+
245
+ def _invalid(pointer: str, message: str) -> ProblemError:
246
+ return ProblemError(
247
+ VALIDATION_FAILED,
248
+ "One or more request members are invalid.",
249
+ extensions={"errors": [{"pointer": pointer, "message": message, "code": "invalid"}]},
250
+ )
251
+
252
+
253
+ async def _proposal(
254
+ session: AsyncSession, domain_id: str, proposal_id: str, *, for_update: bool = False
255
+ ) -> ProposalRow:
256
+ found = await ProposalRepository(session, domain_id).get(proposal_id, for_update=for_update)
257
+ if found is None:
258
+ raise _not_found(proposal_id)
259
+ return found
260
+
261
+
262
+ async def _view(
263
+ session: AsyncSession, domain: DomainRow, proposal: ProposalRow, settings: ProposalSettings
264
+ ) -> ProposalView:
265
+ reviews = await ReviewRepository(session, domain.id).of(proposal.id)
266
+ target = await RefRepository(session, domain.id).get(proposal.target_ref)
267
+ approval = approval_of(
268
+ proposal,
269
+ reviews,
270
+ protected=target is not None and target.protected,
271
+ required=required_approvals(domain, settings),
272
+ settings=settings,
273
+ )
274
+ attempt = await MergeAttemptRepository(session, domain.id).latest(proposal.id)
275
+ return ProposalView(proposal, reviews, approval, attempt)
276
+
277
+
278
+ def _service(
279
+ store: StoreAccess, session: AsyncSession, domain_id: str, settings: ProposalSettings
280
+ ) -> MergeService:
281
+ return MergeService(
282
+ session,
283
+ domain_id,
284
+ runtime=store.runtime,
285
+ clock=store.clock,
286
+ retention=settings.attempt_retention,
287
+ )
288
+
289
+
290
+ def _request(proposal: ProposalRow, actor: str, **extra: Any) -> MergeRequest:
291
+ return MergeRequest(proposal=proposal, actor=actor, policy=proposal_policy(proposal), **extra)
292
+
293
+
294
+ # Use cases ---------------------------------------------------------------------------------
295
+
296
+
297
+ async def create(
298
+ store: StoreAccess,
299
+ domain_id: str,
300
+ new: NewProposal,
301
+ principal: Principal,
302
+ settings: ProposalSettings,
303
+ *,
304
+ idempotency_key: str | None,
305
+ ) -> CreatedProposal:
306
+ """Open a Proposal to merge `source_ref` into `target_ref`, with its merge preview.
307
+
308
+ With an idempotency key, a retried create returns the Proposal the same principal created
309
+ under that key (`replayed`); a key reused for other branches is a conflict.
310
+ """
311
+ if new.source_ref == new.target_ref:
312
+ raise _invalid("/body/target_ref", "must differ from source_ref")
313
+ try:
314
+ async with store.sessions() as session, session.begin():
315
+ domain = await require_domain(session, domain_id)
316
+ proposals = ProposalRepository(session, domain_id)
317
+ if idempotency_key is not None:
318
+ existing = await proposals.by_key(principal.id, idempotency_key)
319
+ if existing is not None:
320
+ return await _replay(session, domain, existing, new, settings)
321
+ await require_branch(session, domain_id, new.source_ref)
322
+ await require_branch(session, domain_id, new.target_ref)
323
+ duplicates = await proposals.page(
324
+ ProposalFilter(
325
+ status=ProposalStatus.OPEN,
326
+ source_ref=new.source_ref,
327
+ target_ref=new.target_ref,
328
+ ),
329
+ limit=1,
330
+ )
331
+ if duplicates:
332
+ raise ProblemError(
333
+ ALREADY_EXISTS,
334
+ f"Proposal {duplicates[0].id!r} already proposes {new.source_ref!r} into "
335
+ f"{new.target_ref!r}.",
336
+ extensions={"proposal": duplicates[0].id},
337
+ )
338
+ policy = domain_policy(domain)
339
+ now = store.clock()
340
+ row = ProposalRow(
341
+ id=f"{PROPOSAL_PREFIX}_{new_ulid()}",
342
+ title=new.title,
343
+ description=new.description,
344
+ source_ref=new.source_ref,
345
+ target_ref=new.target_ref,
346
+ origin=new.origin,
347
+ status=ProposalStatus.OPEN,
348
+ author=principal.id,
349
+ created_at=now,
350
+ checks={},
351
+ policies={POLICY_KEY: policy.model_dump(mode="json", exclude_defaults=True)},
352
+ idempotency_key=idempotency_key,
353
+ updated_at=now,
354
+ )
355
+ await proposals.insert(row)
356
+ if settings.preview_on_create:
357
+ await _service(store, session, domain_id, settings).recompute(
358
+ _request(row, principal.id)
359
+ )
360
+ created = await _proposal(session, domain_id, row.id)
361
+ return CreatedProposal(await _view(session, domain, created, settings))
362
+ except IntegrityError:
363
+ if idempotency_key is None:
364
+ raise
365
+ async with store.sessions() as session, session.begin():
366
+ domain = await require_domain(session, domain_id)
367
+ existing = await ProposalRepository(session, domain_id).by_key(
368
+ principal.id, idempotency_key
369
+ )
370
+ if existing is None:
371
+ raise
372
+ return await _replay(session, domain, existing, new, settings)
373
+
374
+
375
+ async def _replay(
376
+ session: AsyncSession,
377
+ domain: DomainRow,
378
+ existing: ProposalRow,
379
+ new: NewProposal,
380
+ settings: ProposalSettings,
381
+ ) -> CreatedProposal:
382
+ if (existing.source_ref, existing.target_ref) != (new.source_ref, new.target_ref):
383
+ raise ProblemError(
384
+ IDEMPOTENCY_CONFLICT,
385
+ "The idempotency key was used for another Proposal.",
386
+ extensions={"proposal": existing.id},
387
+ )
388
+ return CreatedProposal(await _view(session, domain, existing, settings), replayed=True)
389
+
390
+
391
+ async def get(
392
+ store: StoreAccess, domain_id: str, proposal_id: str, settings: ProposalSettings
393
+ ) -> ProposalView:
394
+ async with store.sessions() as session, session.begin():
395
+ domain = await require_domain(session, domain_id)
396
+ return await _view(
397
+ session, domain, await _proposal(session, domain_id, proposal_id), settings
398
+ )
399
+
400
+
401
+ async def page(
402
+ store: StoreAccess,
403
+ domain_id: str,
404
+ filters: ProposalFilter,
405
+ *,
406
+ limit: int,
407
+ after: tuple[datetime, str] | None,
408
+ ) -> list[ProposalRow]:
409
+ async with store.sessions() as session, session.begin():
410
+ await require_domain(session, domain_id)
411
+ return await ProposalRepository(session, domain_id).page(filters, limit=limit, after=after)
412
+
413
+
414
+ async def target_of(store: StoreAccess, domain_id: str, proposal_id: str) -> ProposalRow:
415
+ """The Proposal (for authorization against its branches); 404 when unknown."""
416
+ async with store.sessions() as session, session.begin():
417
+ await require_domain(session, domain_id)
418
+ return await _proposal(session, domain_id, proposal_id)
419
+
420
+
421
+ async def update(
422
+ store: StoreAccess,
423
+ domain_id: str,
424
+ proposal_id: str,
425
+ settings: ProposalSettings,
426
+ *,
427
+ title: str | None,
428
+ description: str | None,
429
+ ) -> ProposalView:
430
+ """Change the title or description of an open Proposal."""
431
+ async with store.sessions() as session, session.begin():
432
+ domain = await require_domain(session, domain_id)
433
+ proposal = await _proposal(session, domain_id, proposal_id, for_update=True)
434
+ if proposal.status is not ProposalStatus.OPEN:
435
+ raise _not_open(proposal)
436
+ values: dict[str, Any] = {}
437
+ if title is not None:
438
+ values["title"] = title
439
+ if description is not None:
440
+ values["description"] = description
441
+ if values:
442
+ updated = await ProposalRepository(session, domain_id).update(
443
+ proposal_id, at=store.clock(), **values
444
+ )
445
+ assert updated is not None
446
+ proposal = updated
447
+ return await _view(session, domain, proposal, settings)
448
+
449
+
450
+ async def close(
451
+ store: StoreAccess, domain_id: str, proposal_id: str, settings: ProposalSettings
452
+ ) -> ProposalView:
453
+ """Close an open Proposal; its merge attempts are abandoned. Closing twice is a no-op."""
454
+ async with store.sessions() as session, session.begin():
455
+ domain = await require_domain(session, domain_id)
456
+ proposal = await _proposal(session, domain_id, proposal_id, for_update=True)
457
+ if proposal.status is ProposalStatus.MERGED:
458
+ raise _not_open(proposal)
459
+ if proposal.status is ProposalStatus.OPEN:
460
+ await _service(store, session, domain_id, settings).abandon(proposal_id)
461
+ closed = await ProposalRepository(session, domain_id).update(
462
+ proposal_id, at=store.clock(), status=ProposalStatus.CLOSED
463
+ )
464
+ assert closed is not None
465
+ proposal = closed
466
+ return await _view(session, domain, proposal, settings)
467
+
468
+
469
+ async def review(
470
+ store: StoreAccess,
471
+ domain_id: str,
472
+ proposal_id: str,
473
+ principal: Principal,
474
+ settings: ProposalSettings,
475
+ *,
476
+ verdict: ReviewVerdict,
477
+ comment: str | None,
478
+ ) -> tuple[ReviewRow, ProposalView]:
479
+ """Record a review of an open Proposal."""
480
+ async with store.sessions() as session, session.begin():
481
+ domain = await require_domain(session, domain_id)
482
+ proposal = await _proposal(session, domain_id, proposal_id)
483
+ if proposal.status is not ProposalStatus.OPEN:
484
+ raise _not_open(proposal)
485
+ target = await RefRepository(session, domain_id).get(proposal.target_ref)
486
+ if (
487
+ verdict is ReviewVerdict.APPROVE
488
+ and principal.id == proposal.author
489
+ and not settings.allow_self_approval
490
+ and target is not None
491
+ and target.protected
492
+ ):
493
+ raise ProblemError(
494
+ SELF_APPROVAL,
495
+ f"Authors cannot approve their own Proposals into the protected branch "
496
+ f"{proposal.target_ref!r}.",
497
+ )
498
+ row = ReviewRow(
499
+ id=f"{REVIEW_PREFIX}_{new_ulid()}",
500
+ proposal_id=proposal_id,
501
+ reviewer=principal.id,
502
+ verdict=verdict,
503
+ comment=comment,
504
+ at=store.clock(),
505
+ )
506
+ await ReviewRepository(session, domain_id).insert(row)
507
+ return row, await _view(session, domain, proposal, settings)
508
+
509
+
510
+ async def merge(
511
+ store: StoreAccess,
512
+ domain_id: str,
513
+ proposal_id: str,
514
+ principal: Principal,
515
+ settings: ProposalSettings,
516
+ *,
517
+ idempotency_key: str | None,
518
+ message: str | None,
519
+ page_limit: int,
520
+ scope: ConflictScope = FULL_SCOPE,
521
+ schema_guard: SchemaGuard | None = None,
522
+ ) -> MergeOutcome:
523
+ """Run (or re-run) the merge of an open Proposal and commit it when no Conflict remains.
524
+
525
+ When the merge would remove sensitivity tags, `schema_guard(target schema, merged
526
+ schema)` decides (it raises to refuse); without one such merges are refused.
527
+
528
+ A retried merge with the key of the merge that committed returns that merge (`replayed`).
529
+ The returned attempt lists the Conflicts `scope` shows.
530
+ """
531
+ async with store.sessions() as session, session.begin():
532
+ domain = await require_domain(session, domain_id)
533
+ proposal = await _proposal(session, domain_id, proposal_id, for_update=True)
534
+ attempts = MergeAttemptRepository(session, domain_id)
535
+ if proposal.status is ProposalStatus.MERGED and idempotency_key is not None:
536
+ done = await attempts.by_key(proposal.id, idempotency_key)
537
+ if done is not None and done.status is MergeAttemptStatus.MERGED:
538
+ view = await _attempt_view(
539
+ session,
540
+ domain_id,
541
+ proposal,
542
+ done,
543
+ ConflictQuery(limit=page_limit),
544
+ partitions=await _partitions(store, session, domain_id, done, scope),
545
+ )
546
+ return MergeOutcome(proposal, None, view, head=done.revision, replayed=True)
547
+ if proposal.status is not ProposalStatus.OPEN:
548
+ raise _not_open(proposal)
549
+ view_now = await _view(session, domain, proposal, settings)
550
+ approval = view_now.approval
551
+ if not approval.satisfied:
552
+ raise ProblemError(
553
+ APPROVALS_REQUIRED,
554
+ f"Merging into the protected branch {proposal.target_ref!r} needs "
555
+ f"{approval.required} approval(s) and no outstanding request for changes.",
556
+ extensions={
557
+ "required": approval.required,
558
+ "approvals": len(approval.approvers),
559
+ "approvers": list(approval.approvers),
560
+ "changes_requested": list(approval.changes_requested),
561
+ },
562
+ )
563
+ refs = RefRepository(session, domain_id)
564
+ source = await refs.get(proposal.source_ref)
565
+ delete_source = (
566
+ settings.auto_close_source_branch
567
+ and source is not None
568
+ and not source.protected
569
+ and source.name != domain.default_branch
570
+ )
571
+ try:
572
+ result = await _service(store, session, domain_id, settings).merge(
573
+ _request(
574
+ proposal,
575
+ principal.id,
576
+ message=message,
577
+ idempotency_key=idempotency_key,
578
+ delete_source=delete_source,
579
+ schema_guard=schema_guard,
580
+ )
581
+ )
582
+ except ProposalBranchMissingError as error:
583
+ raise _branch_missing(error) from None
584
+ except ResolutionError as error:
585
+ raise _invalid("/body", str(error)) from None
586
+ updated = await _proposal(session, domain_id, proposal_id)
587
+ query = ConflictQuery(resolved=None if result.merged else False, limit=page_limit)
588
+ view = await _attempt_view(
589
+ session,
590
+ domain_id,
591
+ updated,
592
+ result.attempt,
593
+ query,
594
+ recomputed=True,
595
+ stale=result.stale_previous,
596
+ partitions=await _partitions(store, session, domain_id, result.attempt, scope),
597
+ )
598
+ return MergeOutcome(
599
+ updated,
600
+ result,
601
+ view,
602
+ head=result.head,
603
+ source_deleted=result.source_deleted,
604
+ )
605
+
606
+
607
+ async def conflicts(
608
+ store: StoreAccess,
609
+ domain_id: str,
610
+ proposal_id: str,
611
+ principal: Principal,
612
+ settings: ProposalSettings,
613
+ query: ConflictQuery,
614
+ ) -> AttemptView:
615
+ """The current attempt of a Proposal with a page of its Conflicts.
616
+
617
+ For an open Proposal the attempt is recomputed when there is none or a head moved since
618
+ (resolutions whose Conflict disappeared are reported as unmatched).
619
+ """
620
+ async with store.sessions() as session, session.begin():
621
+ await require_domain(session, domain_id)
622
+ proposal = await _proposal(session, domain_id, proposal_id)
623
+ if proposal.status is ProposalStatus.OPEN:
624
+ try:
625
+ result = await _service(store, session, domain_id, settings).current(
626
+ _request(proposal, principal.id)
627
+ )
628
+ except ProposalBranchMissingError as error:
629
+ raise _branch_missing(error) from None
630
+ proposal = await _proposal(session, domain_id, proposal_id)
631
+ return await _attempt_view(
632
+ session,
633
+ domain_id,
634
+ proposal,
635
+ result.attempt,
636
+ query,
637
+ recomputed=result.computation is not None,
638
+ stale=result.stale_previous,
639
+ partitions=await _partitions(
640
+ store, session, domain_id, result.attempt, query.scope
641
+ ),
642
+ )
643
+ latest = await MergeAttemptRepository(session, domain_id).latest(proposal_id)
644
+ if latest is None:
645
+ raise ProblemError(NOT_FOUND, f"Proposal {proposal_id!r} has no merge attempt.")
646
+ return await _attempt_view(
647
+ session,
648
+ domain_id,
649
+ proposal,
650
+ latest,
651
+ query,
652
+ partitions=await _partitions(store, session, domain_id, latest, query.scope),
653
+ )
654
+
655
+
656
+ async def _partitions(
657
+ store: StoreAccess,
658
+ session: AsyncSession,
659
+ domain_id: str,
660
+ attempt: MergeAttemptRow,
661
+ scope: ConflictScope,
662
+ ) -> PartitionFilter:
663
+ """The partitions whose Conflicts a reader limited to `scope` sees, by the schemas of the
664
+ attempt's Revisions."""
665
+ if scope.everything:
666
+ return EVERY_PARTITION
667
+ revisions = [r for r in (attempt.base, attempt.ours, attempt.theirs) if r is not None]
668
+ schemas = [
669
+ await schema_at(
670
+ session, domain_id, await require_revision(session, domain_id, r), store.runtime
671
+ )
672
+ for r in dict.fromkeys(revisions)
673
+ ]
674
+ return visible_partitions(scope, schemas)
675
+
676
+
677
+ async def resolve(
678
+ store: StoreAccess,
679
+ domain_id: str,
680
+ proposal_id: str,
681
+ principal: Principal,
682
+ settings: ProposalSettings,
683
+ inputs: Sequence[ResolutionInput],
684
+ *,
685
+ rerun: bool,
686
+ page_limit: int,
687
+ scope: ConflictScope = FULL_SCOPE,
688
+ ) -> tuple[list[ResolutionRow], AttemptView | None]:
689
+ """Store resolutions of Conflicts of the current attempt; with `rerun`, merge again.
690
+
691
+ Every resolution must name a Conflict of the current attempt (recomputed first when a
692
+ head moved, 404 otherwise) that `scope` shows (403 otherwise) and fit it (422
693
+ otherwise); on any error nothing is stored. The re-run attempt lists the Conflicts
694
+ `scope` shows.
695
+ """
696
+ async with store.sessions() as session, session.begin():
697
+ await require_domain(session, domain_id)
698
+ proposal = await _proposal(session, domain_id, proposal_id, for_update=True)
699
+ if proposal.status is not ProposalStatus.OPEN:
700
+ raise _not_open(proposal)
701
+ service = _service(store, session, domain_id, settings)
702
+ try:
703
+ current = await service.current(_request(proposal, principal.id))
704
+ except ProposalBranchMissingError as error:
705
+ raise _branch_missing(error) from None
706
+ partitions = await _partitions(store, session, domain_id, current.attempt, scope)
707
+ conflicts_repo = ConflictRepository(session, domain_id)
708
+ now = store.clock()
709
+ rows: list[ResolutionRow] = []
710
+ for item in inputs:
711
+ stored = await conflicts_repo.get(current.attempt.id, item.fingerprint)
712
+ if stored is None:
713
+ raise unknown_conflict(item.fingerprint, current.attempt.id)
714
+ require_visible(partitions, stored, current.attempt.id, invisible=scope.invisible)
715
+ resolution = _resolution(item, principal.id)
716
+ payload = {k: v for k, v in stored.payload.items() if k != "members"}
717
+ try:
718
+ check_resolution(
719
+ Conflict.model_validate(payload),
720
+ tuple(stored.payload.get("members") or ()),
721
+ resolution,
722
+ )
723
+ except ResolutionError as error:
724
+ raise _invalid(item.pointer, str(error)) from None
725
+ rows.append(
726
+ ResolutionRow(
727
+ fingerprint=item.fingerprint,
728
+ resolution=resolution.model_dump(mode="json", exclude={"resolver"}),
729
+ resolved_by=principal.id,
730
+ at=now,
731
+ )
732
+ )
733
+ await ResolutionRepository(session, domain_id).upsert_many(proposal_id, rows)
734
+ if not rerun:
735
+ return rows, None
736
+ try:
737
+ result = await service.recompute(_request(proposal, principal.id))
738
+ except ResolutionError as error:
739
+ raise _invalid("/body", str(error)) from None
740
+ proposal = await _proposal(session, domain_id, proposal_id)
741
+ view = await _attempt_view(
742
+ session,
743
+ domain_id,
744
+ proposal,
745
+ result.attempt,
746
+ ConflictQuery(limit=page_limit),
747
+ recomputed=True,
748
+ stale=result.stale_previous,
749
+ partitions=await _partitions(store, session, domain_id, result.attempt, scope),
750
+ )
751
+ return rows, view
752
+
753
+
754
+ def _resolution(item: ResolutionInput, resolver: str) -> Resolution:
755
+ try:
756
+ return Resolution.model_validate(
757
+ {
758
+ "fingerprint": item.fingerprint,
759
+ "choice": item.choice,
760
+ "value": item.value,
761
+ "values": item.values,
762
+ "resolver": resolver,
763
+ }
764
+ )
765
+ except ValidationError as error:
766
+ raise _invalid(item.pointer, error.errors()[0]["msg"]) from None
767
+
768
+
769
+ async def _attempt_view(
770
+ session: AsyncSession,
771
+ domain_id: str,
772
+ proposal: ProposalRow,
773
+ attempt: MergeAttemptRow,
774
+ query: ConflictQuery,
775
+ *,
776
+ recomputed: bool = False,
777
+ stale: bool = False,
778
+ partitions: PartitionFilter = EVERY_PARTITION,
779
+ ) -> AttemptView:
780
+ """One page of the Conflicts of `attempt` in `partitions`; a reader limited to some
781
+ partitions also sees only the resolutions and automatic resolutions of those."""
782
+ repository = ConflictRepository(session, domain_id)
783
+ rows = await repository.page(
784
+ attempt.id,
785
+ codes=query.codes,
786
+ resolved=query.resolved,
787
+ limit=query.limit + 1,
788
+ after=query.after,
789
+ partitions=partitions,
790
+ )
791
+ more = len(rows) > query.limit
792
+ rows = rows[: query.limit]
793
+ resolutions = await ResolutionRepository(session, domain_id).of(proposal.id)
794
+ if not partitions.everything:
795
+ visible = await repository.fingerprints(attempt.id, partitions)
796
+ resolutions = [r for r in resolutions if r.fingerprint in visible]
797
+ attempt = _limited(attempt, partitions)
798
+ return AttemptView(
799
+ proposal=proposal,
800
+ attempt=attempt,
801
+ conflicts=rows,
802
+ next_after=rows[-1].fingerprint if more and rows else None,
803
+ counts=await repository.counts(attempt.id, partitions),
804
+ resolutions=resolutions,
805
+ recomputed=recomputed,
806
+ stale=stale,
807
+ )
808
+
809
+
810
+ async def scoped(
811
+ store: StoreAccess, domain_id: str, view: ProposalView, scope: ConflictScope
812
+ ) -> ProposalView:
813
+ """`view` with its attempt summary limited to the Conflicts `scope` shows."""
814
+ if scope.everything or view.attempt is None:
815
+ return view
816
+ async with store.sessions() as session, session.begin():
817
+ await require_domain(session, domain_id)
818
+ partitions = await _partitions(store, session, domain_id, view.attempt, scope)
819
+ counts = await ConflictRepository(session, domain_id).counts(view.attempt.id, partitions)
820
+ return replace(view, attempt=limited_summary(view.attempt, partitions, counts))
821
+
822
+
823
+ def limited_summary(
824
+ attempt: MergeAttemptRow, partitions: PartitionFilter, counts: dict[tuple[str, bool], int]
825
+ ) -> MergeAttemptRow:
826
+ """`attempt` limited to `partitions` (see `_limited`), its open and resolved totals taken
827
+ from `counts`, the per-code counts of those partitions."""
828
+ limited = _limited(attempt, partitions)
829
+ outcome = dict(limited.outcome or {})
830
+ outcome["open"] = sum(n for (_, resolved), n in counts.items() if not resolved)
831
+ outcome["resolved"] = sum(n for (_, resolved), n in counts.items() if resolved)
832
+ return replace(limited, outcome=outcome)
833
+
834
+
835
+ def _limited(attempt: MergeAttemptRow, partitions: PartitionFilter) -> MergeAttemptRow:
836
+ """`attempt` with the outcome members a reader limited to `partitions` may see: automatic
837
+ resolutions in those partitions, and no unmatched resolutions (their Conflict, and so
838
+ its partition, is gone)."""
839
+ outcome = dict(attempt.outcome or {})
840
+ outcome["auto_resolutions"] = [
841
+ a
842
+ for a in outcome.get("auto_resolutions") or []
843
+ if isinstance(a, dict)
844
+ and partitions.allows(
845
+ str(a.get("partition")),
846
+ None if a.get("attribute") is None else str(a.get("attribute")),
847
+ )
848
+ ]
849
+ outcome["unmatched_resolutions"] = []
850
+ return replace(attempt, outcome=outcome)