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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- 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)
|