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,1107 @@
|
|
|
1
|
+
"""Proposals, reviews, merges and Conflict resolution (design 6.8 to 6.10, 10.3, 11.1).
|
|
2
|
+
|
|
3
|
+
| Method and path | Permission | Notes |
|
|
4
|
+
|-----------------|------------|-------|
|
|
5
|
+
| `POST .../proposals` | `proposal.create` (source), `data.read` (target) | 201; key replays 200 |
|
|
6
|
+
| `GET .../proposals` | `data.read` | filters `status`, `origin`, `source`, `target`, `author` |
|
|
7
|
+
| `GET .../proposals/{p}` | `data.read` (both branches) | reviews, approvals, latest attempt |
|
|
8
|
+
| `PATCH .../proposals/{p}` | author, else `proposal.merge` | title, description |
|
|
9
|
+
| `POST .../proposals/{p}/close` | author, else `proposal.merge` | attempts abandoned |
|
|
10
|
+
| `POST .../proposals/{p}/reviews` | `proposal.review` (target) | 201 |
|
|
11
|
+
| `POST .../proposals/{p}/merge` | `proposal.merge` (target), read | 200; 409 `merge-conflicts` |
|
|
12
|
+
| `GET .../proposals/{p}/conflicts` | `data.read` (both branches) | attempt, page of Conflicts |
|
|
13
|
+
| `POST .../proposals/{p}/conflicts/{c}/resolution` | `conflict.resolve` (target), read | stores |
|
|
14
|
+
| `POST .../proposals/{p}/conflicts:resolve` | `conflict.resolve` (target), read | stores, re-runs |
|
|
15
|
+
|
|
16
|
+
The merge answers 200 with the merge outcome when the target moved (merge Revision,
|
|
17
|
+
fast-forward or already up to date) and 409 `merge-conflicts` when Conflicts remain; that
|
|
18
|
+
problem carries the stored attempt (`attempt`, same shape as the Conflict listing, open
|
|
19
|
+
Conflicts first page). Conflicts are identified by their fingerprint. Reading the Conflicts of
|
|
20
|
+
an open Proposal recomputes its attempt when a head moved since (6.10, stale attempts).
|
|
21
|
+
|
|
22
|
+
Reads are authorized against the source and the target branch (`services.proposal_reads`):
|
|
23
|
+
listings keep the Proposals the caller may read, so a page may hold fewer items than asked
|
|
24
|
+
while `next_cursor` is set; callers limited to Kinds see the Conflicts of those Kinds only.
|
|
25
|
+
Writes keep that scope ("read" above): merging and resolving need the read access of the
|
|
26
|
+
Conflict listing, a resolution naming a Conflict outside it is refused (403), and the
|
|
27
|
+
attempts they return list only the Conflicts the caller may read. Proposal details count only
|
|
28
|
+
those Conflicts too, and the merge check of a caller who does not see every Conflict states no
|
|
29
|
+
count.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from datetime import datetime
|
|
38
|
+
from typing import Annotated, Any, Literal
|
|
39
|
+
|
|
40
|
+
from fastapi import APIRouter, Depends, Query, Request, Response
|
|
41
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
42
|
+
|
|
43
|
+
from lorebase_core.canonical import hex
|
|
44
|
+
from lorebase_core.merge import ConflictKind
|
|
45
|
+
from lorebase_core.model import Schema, Sensitivity
|
|
46
|
+
from lorebase_keeper.auth.audit import annotate
|
|
47
|
+
from lorebase_keeper.auth.deps import Access, AuthDep, PrincipalDep, authorize, require
|
|
48
|
+
from lorebase_keeper.auth.permissions import Permission
|
|
49
|
+
from lorebase_keeper.auth.rbac import AccessContext
|
|
50
|
+
from lorebase_keeper.context import StoreDep
|
|
51
|
+
from lorebase_keeper.deps import IdempotencyKeyDep, KeeperSettingsDep
|
|
52
|
+
from lorebase_keeper.errors import VALIDATION_FAILED, Problem, ProblemError, ValidationProblem
|
|
53
|
+
from lorebase_keeper.pagination import Page, PageParamsDep, encode_cursor
|
|
54
|
+
from lorebase_keeper.reading.points import read_guard
|
|
55
|
+
from lorebase_keeper.reading.sensitivity import (
|
|
56
|
+
Disclosure,
|
|
57
|
+
Masking,
|
|
58
|
+
collapse_issues,
|
|
59
|
+
conflict_hidden,
|
|
60
|
+
default_schema,
|
|
61
|
+
mask_conflict,
|
|
62
|
+
masking_for,
|
|
63
|
+
)
|
|
64
|
+
from lorebase_keeper.reading.visibility import projection
|
|
65
|
+
from lorebase_keeper.routes.domains import REPLAYED_HEADER
|
|
66
|
+
from lorebase_keeper.services import agent_writes
|
|
67
|
+
from lorebase_keeper.services import proposals as service
|
|
68
|
+
from lorebase_keeper.services.errors import commit_problem, findings_of
|
|
69
|
+
from lorebase_keeper.services.pii_import import require_schema_change
|
|
70
|
+
from lorebase_keeper.services.proposal_reads import (
|
|
71
|
+
ConflictScope,
|
|
72
|
+
conflict_scope,
|
|
73
|
+
readable_proposals,
|
|
74
|
+
require_target_read,
|
|
75
|
+
visible_scope,
|
|
76
|
+
)
|
|
77
|
+
from lorebase_keeper.services.reads import ReadGuard
|
|
78
|
+
from lorebase_keeper.settings import KeeperSettings
|
|
79
|
+
from lorebase_store.commit import CommitError, ValidationFailedError
|
|
80
|
+
from lorebase_store.commit.schema import load_compiled
|
|
81
|
+
from lorebase_store.models.enums import ProposalOrigin, ProposalStatus, ReviewVerdict
|
|
82
|
+
from lorebase_store.repositories import (
|
|
83
|
+
ConflictRow,
|
|
84
|
+
MaterializedRepository,
|
|
85
|
+
MergeAttemptRow,
|
|
86
|
+
ProposalFilter,
|
|
87
|
+
ProposalRow,
|
|
88
|
+
ResolutionRow,
|
|
89
|
+
ReviewRow,
|
|
90
|
+
scope_to_domain,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
router = APIRouter(tags=["proposals"])
|
|
94
|
+
|
|
95
|
+
PROPOSALS = "/domains/{domain_id}/proposals"
|
|
96
|
+
PROPOSAL = PROPOSALS + "/{proposal_id}"
|
|
97
|
+
|
|
98
|
+
DataRead = Annotated[ReadGuard, Depends(read_guard())]
|
|
99
|
+
|
|
100
|
+
_ERRORS: dict[int | str, dict[str, Any]] = {
|
|
101
|
+
403: {"model": Problem, "description": "Missing permission, or self-approval refused."},
|
|
102
|
+
404: {"model": Problem, "description": "Domain, Proposal, branch or Conflict not found."},
|
|
103
|
+
409: {
|
|
104
|
+
"model": Problem,
|
|
105
|
+
"description": "Proposal not open (`proposal-not-open`), approvals missing "
|
|
106
|
+
"(`approvals-required`), Conflicts remain (`merge-conflicts`, with `attempt`), a "
|
|
107
|
+
"branch is gone (`proposal-branch-missing`), duplicate or idempotency conflict.",
|
|
108
|
+
},
|
|
109
|
+
422: {"model": ValidationProblem, "description": "Invalid request or resolution."},
|
|
110
|
+
503: {"model": Problem, "description": "The target branch is locked by another writer."},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_CODES = frozenset(kind.value for kind in ConflictKind)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Models -----------------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ProposalCreate(BaseModel):
|
|
120
|
+
"""A new Proposal: merge `source_ref` into `target_ref`."""
|
|
121
|
+
|
|
122
|
+
source_ref: str = Field(min_length=1, max_length=200)
|
|
123
|
+
target_ref: str = Field(min_length=1, max_length=200)
|
|
124
|
+
title: str = Field(min_length=1, max_length=300)
|
|
125
|
+
description: str | None = Field(default=None, max_length=20000)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ProposalUpdate(BaseModel):
|
|
129
|
+
"""Title and description; omitted members are left unchanged."""
|
|
130
|
+
|
|
131
|
+
title: str | None = Field(default=None, min_length=1, max_length=300)
|
|
132
|
+
description: str | None = Field(default=None, max_length=20000)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class CheckOut(BaseModel):
|
|
136
|
+
"""One Proposal check (design 10.3)."""
|
|
137
|
+
|
|
138
|
+
name: str
|
|
139
|
+
status: Literal["passed", "failed", "pending"]
|
|
140
|
+
message: str | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ProposalOut(BaseModel):
|
|
144
|
+
id: str
|
|
145
|
+
title: str
|
|
146
|
+
description: str | None
|
|
147
|
+
source_ref: str
|
|
148
|
+
target_ref: str
|
|
149
|
+
origin: ProposalOrigin
|
|
150
|
+
status: ProposalStatus
|
|
151
|
+
author: str
|
|
152
|
+
created_at: datetime
|
|
153
|
+
updated_at: datetime | None
|
|
154
|
+
merged_revision: str | None
|
|
155
|
+
policy: dict[str, Any] = Field(description="Merge policy options differing from defaults.")
|
|
156
|
+
checks: list[CheckOut]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ReviewIn(BaseModel):
|
|
160
|
+
verdict: ReviewVerdict
|
|
161
|
+
comment: str | None = Field(default=None, max_length=20000)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ReviewOut(BaseModel):
|
|
165
|
+
id: str
|
|
166
|
+
reviewer: str
|
|
167
|
+
verdict: ReviewVerdict
|
|
168
|
+
comment: str | None
|
|
169
|
+
at: datetime
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ApprovalOut(BaseModel):
|
|
173
|
+
"""Review state against the target branch's protection."""
|
|
174
|
+
|
|
175
|
+
protected: bool
|
|
176
|
+
required: int
|
|
177
|
+
approvals: int
|
|
178
|
+
approvers: list[str]
|
|
179
|
+
changes_requested: list[str]
|
|
180
|
+
satisfied: bool
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ResolutionIn(BaseModel):
|
|
184
|
+
"""A decision for one Conflict (`value` or per-member `values` for `custom` only)."""
|
|
185
|
+
|
|
186
|
+
choice: str
|
|
187
|
+
value: JsonValue = None
|
|
188
|
+
values: dict[str, JsonValue] | None = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class FingerprintedResolutionIn(ResolutionIn):
|
|
192
|
+
fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ResolveManyIn(BaseModel):
|
|
196
|
+
resolutions: list[FingerprintedResolutionIn] = Field(min_length=1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ResolutionOut(BaseModel):
|
|
200
|
+
"""A stored resolution; `matched` is false when its Conflict is gone from the attempt."""
|
|
201
|
+
|
|
202
|
+
fingerprint: str
|
|
203
|
+
choice: str
|
|
204
|
+
value: JsonValue = None
|
|
205
|
+
values: dict[str, JsonValue] | None = None
|
|
206
|
+
resolver: str
|
|
207
|
+
at: datetime
|
|
208
|
+
matched: bool = True
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class AttemptOut(BaseModel):
|
|
212
|
+
"""A merge attempt (design 6.8 step 5) and the resolutions of its Proposal."""
|
|
213
|
+
|
|
214
|
+
id: str
|
|
215
|
+
proposal: str
|
|
216
|
+
status: str
|
|
217
|
+
result: str | None = Field(description="`conflicts`, `merged`, `fast_forward` or `up_to_date`.")
|
|
218
|
+
base: str | None
|
|
219
|
+
ours: str
|
|
220
|
+
theirs: str
|
|
221
|
+
virtual_base: bool
|
|
222
|
+
created_at: datetime
|
|
223
|
+
created_by: str | None
|
|
224
|
+
revision: str | None = Field(description="Target head after the merge committed.")
|
|
225
|
+
policy: dict[str, Any]
|
|
226
|
+
open_conflicts: int
|
|
227
|
+
resolved_conflicts: int
|
|
228
|
+
counts: dict[str, dict[str, int]] = Field(description="Per Conflict code: open, resolved.")
|
|
229
|
+
recomputed: bool = Field(description="This request ran the merge.")
|
|
230
|
+
stale: bool = Field(description="A head moved since the previous attempt.")
|
|
231
|
+
resolutions: list[ResolutionOut]
|
|
232
|
+
unmatched_resolutions: list[str]
|
|
233
|
+
auto_resolutions: list[dict[str, Any]]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class ConflictOut(BaseModel):
|
|
237
|
+
"""A Conflict (core `Conflict`) with its id, state and members."""
|
|
238
|
+
|
|
239
|
+
model_config = {"extra": "allow"}
|
|
240
|
+
|
|
241
|
+
id: str
|
|
242
|
+
status: Literal["open", "resolved"]
|
|
243
|
+
resolution: dict[str, Any] | None = None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class ConflictsOut(AttemptOut):
|
|
247
|
+
"""An attempt with one page of its Conflicts."""
|
|
248
|
+
|
|
249
|
+
conflicts: list[ConflictOut]
|
|
250
|
+
next_cursor: str | None = None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class ProposalDetailOut(ProposalOut):
|
|
254
|
+
reviews: list[ReviewOut]
|
|
255
|
+
approval: ApprovalOut
|
|
256
|
+
attempt: AttemptOut | None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class ReviewResult(BaseModel):
|
|
260
|
+
review: ReviewOut
|
|
261
|
+
approval: ApprovalOut
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class MergeOut(BaseModel):
|
|
265
|
+
"""A merged Proposal."""
|
|
266
|
+
|
|
267
|
+
status: Literal["merged"] = "merged"
|
|
268
|
+
result: str = Field(description="`merged`, `fast_forward` or `up_to_date`.")
|
|
269
|
+
revision: str = Field(description="Head of the target branch after the merge.")
|
|
270
|
+
proposal: ProposalOut
|
|
271
|
+
attempt: ConflictsOut
|
|
272
|
+
source_deleted: bool
|
|
273
|
+
replayed: bool
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# Mapping ----------------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _checks(
|
|
280
|
+
row: ProposalRow, approval: service.Approval | None = None, *, counted: bool = True
|
|
281
|
+
) -> list[CheckOut]:
|
|
282
|
+
"""The checks of `row`; `counted` false leaves the number of open Conflicts and of
|
|
283
|
+
extraction items out (callers who do not see every Conflict: items may be about Kinds
|
|
284
|
+
they may not read)."""
|
|
285
|
+
checks: list[CheckOut] = []
|
|
286
|
+
merge = (row.checks or {}).get("merge")
|
|
287
|
+
if isinstance(merge, dict):
|
|
288
|
+
open_count = int(merge.get("open_conflicts") or 0)
|
|
289
|
+
status: Literal["passed", "failed", "pending"] = "passed" if open_count == 0 else "failed"
|
|
290
|
+
if open_count == 0:
|
|
291
|
+
message = "No Conflicts."
|
|
292
|
+
elif counted:
|
|
293
|
+
message = f"{open_count} open Conflict(s) at {str(merge.get('ours'))[:12]}."
|
|
294
|
+
else:
|
|
295
|
+
message = f"Open Conflicts at {str(merge.get('ours'))[:12]}."
|
|
296
|
+
checks.append(CheckOut(name="merge", status=status, message=message))
|
|
297
|
+
else:
|
|
298
|
+
checks.append(CheckOut(name="merge", status="pending", message="Not computed yet."))
|
|
299
|
+
extraction = (row.checks or {}).get("extraction")
|
|
300
|
+
if isinstance(extraction, dict):
|
|
301
|
+
decisions = extraction.get("decisions") or {}
|
|
302
|
+
pending = int(decisions.get("pending") or 0)
|
|
303
|
+
checks.append(
|
|
304
|
+
CheckOut(
|
|
305
|
+
name="extraction",
|
|
306
|
+
status="pending" if pending else "passed",
|
|
307
|
+
message=(
|
|
308
|
+
f"{extraction.get('items', 0)} item(s): "
|
|
309
|
+
f"{decisions.get('accepted', 0)} accepted, {pending} pending, "
|
|
310
|
+
f"{decisions.get('rejected', 0)} rejected."
|
|
311
|
+
if counted
|
|
312
|
+
else "Items pending review."
|
|
313
|
+
if pending
|
|
314
|
+
else "Items reviewed."
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
if approval is not None and approval.protected:
|
|
319
|
+
checks.append(
|
|
320
|
+
CheckOut(
|
|
321
|
+
name="approvals",
|
|
322
|
+
status="passed" if approval.satisfied else "pending",
|
|
323
|
+
message=f"{len(approval.approvers)} of {approval.required} approval(s)"
|
|
324
|
+
+ (
|
|
325
|
+
f"; changes requested by {', '.join(approval.changes_requested)}"
|
|
326
|
+
if approval.changes_requested
|
|
327
|
+
else ""
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
return checks
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _proposal(
|
|
335
|
+
row: ProposalRow, approval: service.Approval | None = None, *, counted: bool = True
|
|
336
|
+
) -> ProposalOut:
|
|
337
|
+
return ProposalOut(
|
|
338
|
+
id=row.id,
|
|
339
|
+
title=row.title,
|
|
340
|
+
description=row.description,
|
|
341
|
+
source_ref=row.source_ref,
|
|
342
|
+
target_ref=row.target_ref,
|
|
343
|
+
origin=row.origin,
|
|
344
|
+
status=row.status,
|
|
345
|
+
author=row.author,
|
|
346
|
+
created_at=row.created_at,
|
|
347
|
+
updated_at=row.updated_at,
|
|
348
|
+
merged_revision=None if row.merged_revision is None else hex(row.merged_revision),
|
|
349
|
+
policy=dict((row.policies or {}).get(service.POLICY_KEY) or {}),
|
|
350
|
+
checks=_checks(row, approval, counted=counted),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _review(row: ReviewRow) -> ReviewOut:
|
|
355
|
+
return ReviewOut(
|
|
356
|
+
id=row.id, reviewer=row.reviewer, verdict=row.verdict, comment=row.comment, at=row.at
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _approval(approval: service.Approval) -> ApprovalOut:
|
|
361
|
+
return ApprovalOut(
|
|
362
|
+
protected=approval.protected,
|
|
363
|
+
required=approval.required,
|
|
364
|
+
approvals=len(approval.approvers),
|
|
365
|
+
approvers=list(approval.approvers),
|
|
366
|
+
changes_requested=list(approval.changes_requested),
|
|
367
|
+
satisfied=approval.satisfied,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _resolution(
|
|
372
|
+
row: ResolutionRow, unmatched: frozenset[str], masked: str | None = None
|
|
373
|
+
) -> ResolutionOut:
|
|
374
|
+
"""A stored resolution; with `masked`, its custom values are replaced by it."""
|
|
375
|
+
body = row.resolution
|
|
376
|
+
value, values = body.get("value"), body.get("values")
|
|
377
|
+
if masked is not None:
|
|
378
|
+
value = None if value is None else masked
|
|
379
|
+
values = None if values is None else dict.fromkeys(values, masked)
|
|
380
|
+
return ResolutionOut(
|
|
381
|
+
fingerprint=row.fingerprint,
|
|
382
|
+
choice=str(body.get("choice")),
|
|
383
|
+
value=value,
|
|
384
|
+
values=values,
|
|
385
|
+
resolver=row.resolved_by,
|
|
386
|
+
at=row.at,
|
|
387
|
+
matched=row.fingerprint not in unmatched,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _attempt_fields(
|
|
392
|
+
attempt: MergeAttemptRow,
|
|
393
|
+
counts: dict[tuple[str, bool], int],
|
|
394
|
+
resolutions: list[ResolutionRow],
|
|
395
|
+
*,
|
|
396
|
+
recomputed: bool,
|
|
397
|
+
stale: bool,
|
|
398
|
+
masked: Callable[[str], str | None] = lambda _fingerprint: None,
|
|
399
|
+
) -> dict[str, Any]:
|
|
400
|
+
outcome = attempt.outcome or {}
|
|
401
|
+
unmatched = [str(f) for f in outcome.get("unmatched_resolutions") or []]
|
|
402
|
+
per_code: dict[str, dict[str, int]] = {}
|
|
403
|
+
for (code, resolved), count in sorted(counts.items()):
|
|
404
|
+
entry = per_code.setdefault(code, {"open": 0, "resolved": 0})
|
|
405
|
+
entry["resolved" if resolved else "open"] += count
|
|
406
|
+
return {
|
|
407
|
+
"id": attempt.id,
|
|
408
|
+
"proposal": attempt.proposal_id,
|
|
409
|
+
"status": attempt.status.value,
|
|
410
|
+
"result": attempt.result,
|
|
411
|
+
"base": None if attempt.base is None else hex(attempt.base),
|
|
412
|
+
"ours": hex(attempt.ours),
|
|
413
|
+
"theirs": hex(attempt.theirs),
|
|
414
|
+
"virtual_base": attempt.virtual_base,
|
|
415
|
+
"created_at": attempt.created_at,
|
|
416
|
+
"created_by": attempt.created_by,
|
|
417
|
+
"revision": None if attempt.revision is None else hex(attempt.revision),
|
|
418
|
+
"policy": attempt.policy or {},
|
|
419
|
+
"open_conflicts": sum(c["open"] for c in per_code.values()),
|
|
420
|
+
"resolved_conflicts": sum(c["resolved"] for c in per_code.values()),
|
|
421
|
+
"counts": per_code,
|
|
422
|
+
"recomputed": recomputed,
|
|
423
|
+
"stale": stale,
|
|
424
|
+
"resolutions": [
|
|
425
|
+
_resolution(r, frozenset(unmatched), masked(r.fingerprint)) for r in resolutions
|
|
426
|
+
],
|
|
427
|
+
"unmatched_resolutions": unmatched,
|
|
428
|
+
"auto_resolutions": list(outcome.get("auto_resolutions") or []),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _attempt_summary(attempt: MergeAttemptRow) -> AttemptOut:
|
|
433
|
+
"""Attempt without per-code counts and resolutions (Proposal detail)."""
|
|
434
|
+
outcome = attempt.outcome or {}
|
|
435
|
+
fields = _attempt_fields(attempt, {}, [], recomputed=False, stale=False)
|
|
436
|
+
fields["open_conflicts"] = int(outcome.get("open") or 0)
|
|
437
|
+
fields["resolved_conflicts"] = int(outcome.get("resolved") or 0)
|
|
438
|
+
return AttemptOut(**fields)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@dataclass(frozen=True)
|
|
442
|
+
class _Visibility:
|
|
443
|
+
"""What the caller may see of Conflict values: the target branch's schema (None when it
|
|
444
|
+
is not materialized) and the caller's masking."""
|
|
445
|
+
|
|
446
|
+
schema: Schema | None
|
|
447
|
+
masking: Masking
|
|
448
|
+
left_out: frozenset[str] = frozenset()
|
|
449
|
+
"""Ids the caller's projection of the schema leaves out (Kinds invisible to it, design
|
|
450
|
+
13.3): Conflicts and automatic resolutions never show them."""
|
|
451
|
+
|
|
452
|
+
def hidden(self, payload: Mapping[str, Any]) -> bool:
|
|
453
|
+
return conflict_hidden(payload, self.schema, self.masking)
|
|
454
|
+
|
|
455
|
+
def names_left_out(self, value: Any) -> bool:
|
|
456
|
+
if not self.left_out:
|
|
457
|
+
return False
|
|
458
|
+
text = json.dumps(value, default=str)
|
|
459
|
+
return any(i in text for i in self.left_out)
|
|
460
|
+
|
|
461
|
+
def scrub(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
462
|
+
"""`payload` without the values of Attributes left out, and without a message or
|
|
463
|
+
details naming something left out."""
|
|
464
|
+
if not self.left_out:
|
|
465
|
+
return payload
|
|
466
|
+
out = dict(payload)
|
|
467
|
+
for name in (*_CONFLICT_SIDES, "values"):
|
|
468
|
+
out[name] = _without(out.get(name), self.left_out)
|
|
469
|
+
if self.names_left_out([out.get("message"), out.get("details")]):
|
|
470
|
+
out["message"] = "The versions differ."
|
|
471
|
+
out["details"] = {}
|
|
472
|
+
return out
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
_CONFLICT_SIDES = ("base", "ours", "theirs")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _without(value: Any, ids: frozenset[str]) -> Any:
|
|
479
|
+
"""A value map (or an element body holding `values`) without the entries of `ids`."""
|
|
480
|
+
if not isinstance(value, dict):
|
|
481
|
+
return value
|
|
482
|
+
if isinstance(value.get("values"), dict):
|
|
483
|
+
return {**value, "values": _without(value["values"], ids)}
|
|
484
|
+
return {k: v for k, v in value.items() if k not in ids}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
async def _visibility(
|
|
488
|
+
store: StoreDep,
|
|
489
|
+
guard: ReadGuard,
|
|
490
|
+
domain_id: str,
|
|
491
|
+
target: str,
|
|
492
|
+
keeper: KeeperSettings,
|
|
493
|
+
scope: ConflictScope | None = None,
|
|
494
|
+
) -> _Visibility:
|
|
495
|
+
writes = store.runtime.settings.writes
|
|
496
|
+
async with store.sessions() as session, session.begin():
|
|
497
|
+
await scope_to_domain(session, domain_id)
|
|
498
|
+
compiled = await MaterializedRepository(session, domain_id, target, writes=writes).schema()
|
|
499
|
+
reference = await default_schema(session, domain_id, store.runtime)
|
|
500
|
+
schema = None if compiled is None else load_compiled(compiled.compiled)
|
|
501
|
+
masking = await masking_for(
|
|
502
|
+
guard.authorizer, guard.principal, domain_id, reference, keeper.reads.sensitivity
|
|
503
|
+
)
|
|
504
|
+
left_out: frozenset[str] = frozenset()
|
|
505
|
+
if schema is not None and scope is not None and scope.invisible and scope.kinds is not None:
|
|
506
|
+
left_out = projection(schema, scope.kinds).hidden
|
|
507
|
+
return _Visibility(schema, masking if schema is None else masking.over(schema), left_out)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _conflict(row: ConflictRow, seen: _Visibility) -> ConflictOut:
|
|
511
|
+
payload = seen.scrub(mask_conflict(row.payload, seen.schema, seen.masking))
|
|
512
|
+
resolution = None if row.resolution is None else seen.scrub(row.resolution)
|
|
513
|
+
if resolution is not None and seen.hidden(row.payload):
|
|
514
|
+
resolution = {
|
|
515
|
+
**resolution,
|
|
516
|
+
**{k: seen.masking.masked for k in ("value", "values") if resolution.get(k)},
|
|
517
|
+
}
|
|
518
|
+
return ConflictOut(
|
|
519
|
+
id=row.fingerprint,
|
|
520
|
+
status="resolved" if row.resolved else "open",
|
|
521
|
+
resolution=resolution,
|
|
522
|
+
**payload,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
async def _attempt(
|
|
527
|
+
view: service.AttemptView,
|
|
528
|
+
store: StoreDep,
|
|
529
|
+
guard: ReadGuard,
|
|
530
|
+
domain_id: str,
|
|
531
|
+
target: str,
|
|
532
|
+
keeper: KeeperSettings,
|
|
533
|
+
scope: ConflictScope | None = None,
|
|
534
|
+
) -> ConflictsOut:
|
|
535
|
+
"""The attempt as the caller may see it: values masked by schema (`_conflicts`), then
|
|
536
|
+
the vault references left in it resolved as on every read (`Disclosure`)."""
|
|
537
|
+
seen = await _visibility(store, guard, domain_id, target, keeper, scope)
|
|
538
|
+
attempt = _conflicts(view, seen)
|
|
539
|
+
schemas = () if seen.schema is None else (seen.schema,)
|
|
540
|
+
async with store.sessions() as session, session.begin():
|
|
541
|
+
await scope_to_domain(session, domain_id)
|
|
542
|
+
disclosure = Disclosure(session, domain_id, schemas, seen.masking, store.runtime.vault)
|
|
543
|
+
body = await disclosure.model(attempt)
|
|
544
|
+
return ConflictsOut.model_validate_json(body)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _conflicts(view: service.AttemptView, seen: _Visibility) -> ConflictsOut:
|
|
548
|
+
hidden = {row.fingerprint for row in view.conflicts if seen.hidden(row.payload)}
|
|
549
|
+
shown = {row.fingerprint for row in view.conflicts} - hidden
|
|
550
|
+
full = seen.masking.clearance.covers(Sensitivity, ())
|
|
551
|
+
|
|
552
|
+
def masked(fingerprint: str) -> str | None:
|
|
553
|
+
# Resolutions of Conflicts outside this page are masked unless the caller is cleared
|
|
554
|
+
# for everything: their Attribute is not known here.
|
|
555
|
+
if fingerprint in shown or (fingerprint not in hidden and full):
|
|
556
|
+
return None
|
|
557
|
+
return seen.masking.masked
|
|
558
|
+
|
|
559
|
+
fields = _attempt_fields(
|
|
560
|
+
view.attempt,
|
|
561
|
+
view.counts,
|
|
562
|
+
view.resolutions,
|
|
563
|
+
recomputed=view.recomputed,
|
|
564
|
+
stale=view.stale,
|
|
565
|
+
masked=masked,
|
|
566
|
+
)
|
|
567
|
+
fields["auto_resolutions"] = [
|
|
568
|
+
a for a in fields["auto_resolutions"] if not seen.names_left_out(a)
|
|
569
|
+
]
|
|
570
|
+
return ConflictsOut(
|
|
571
|
+
**fields,
|
|
572
|
+
conflicts=[_conflict(row, seen) for row in view.conflicts],
|
|
573
|
+
next_cursor=None if view.next_after is None else encode_cursor({"after": view.next_after}),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _detail(view: service.ProposalView, scope: ConflictScope) -> ProposalDetailOut:
|
|
578
|
+
"""`view`, whose attempt `service.scoped` limited to `scope`."""
|
|
579
|
+
base = _proposal(view.proposal, view.approval, counted=scope.everything)
|
|
580
|
+
return ProposalDetailOut(
|
|
581
|
+
**base.model_dump(),
|
|
582
|
+
reviews=[_review(r) for r in view.reviews],
|
|
583
|
+
approval=_approval(view.approval),
|
|
584
|
+
attempt=None if view.attempt is None else _attempt_summary(view.attempt),
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _codes(kind: str | None) -> list[str]:
|
|
589
|
+
if not kind:
|
|
590
|
+
return []
|
|
591
|
+
codes = [code.strip() for code in kind.split(",") if code.strip()]
|
|
592
|
+
unknown = [code for code in codes if code not in _CODES]
|
|
593
|
+
if unknown:
|
|
594
|
+
raise ProblemError(
|
|
595
|
+
VALIDATION_FAILED,
|
|
596
|
+
"One or more request members are invalid.",
|
|
597
|
+
extensions={
|
|
598
|
+
"errors": [
|
|
599
|
+
{
|
|
600
|
+
"pointer": "/query/kind",
|
|
601
|
+
"message": f"unknown Conflict code {unknown[0]!r}",
|
|
602
|
+
"code": "invalid",
|
|
603
|
+
}
|
|
604
|
+
]
|
|
605
|
+
},
|
|
606
|
+
)
|
|
607
|
+
return codes
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
async def _check(
|
|
611
|
+
request: Request,
|
|
612
|
+
auth: AuthDep,
|
|
613
|
+
principal: PrincipalDep,
|
|
614
|
+
permission: Permission,
|
|
615
|
+
domain_id: str,
|
|
616
|
+
branch: str,
|
|
617
|
+
) -> None:
|
|
618
|
+
await authorize(request, auth, principal, permission, domain_id, AccessContext(branch=branch))
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
async def _readable(
|
|
622
|
+
store: StoreDep, guard: ReadGuard, domain_id: str, proposal_id: str
|
|
623
|
+
) -> ConflictScope:
|
|
624
|
+
"""What the caller may read of the Proposal (403 when nothing; unknown Proposals are
|
|
625
|
+
403 for callers whose grants are narrowed)."""
|
|
626
|
+
await guard.precheck()
|
|
627
|
+
try:
|
|
628
|
+
proposal = await service.target_of(store, domain_id, proposal_id)
|
|
629
|
+
except ProblemError as error:
|
|
630
|
+
raise guard.unknown(error, wide=await guard.wide()) from None
|
|
631
|
+
return await conflict_scope(guard, proposal)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
async def _store_problem(
|
|
635
|
+
error: CommitError, retry_after_s: int, seen: Callable[[], Awaitable[_Visibility]]
|
|
636
|
+
) -> ProblemError:
|
|
637
|
+
"""The problem answering a store failure; validation issues about values the caller may
|
|
638
|
+
not read (`seen`) have a generic code."""
|
|
639
|
+
if not isinstance(error, ValidationFailedError):
|
|
640
|
+
return commit_problem(error, max_issues=1, retry_after_s=retry_after_s)
|
|
641
|
+
visible = await seen()
|
|
642
|
+
schemas = () if visible.schema is None else (visible.schema,)
|
|
643
|
+
return commit_problem(
|
|
644
|
+
error,
|
|
645
|
+
max_issues=1,
|
|
646
|
+
retry_after_s=retry_after_s,
|
|
647
|
+
locate=lambda issues: findings_of(collapse_issues(issues, schemas, visible.masking)),
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# Routes -----------------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@router.post(PROPOSALS, status_code=201, summary="Create a Proposal", responses=_ERRORS)
|
|
655
|
+
async def create_proposal(
|
|
656
|
+
domain_id: str,
|
|
657
|
+
body: ProposalCreate,
|
|
658
|
+
request: Request,
|
|
659
|
+
response: Response,
|
|
660
|
+
principal: PrincipalDep,
|
|
661
|
+
auth: AuthDep,
|
|
662
|
+
store: StoreDep,
|
|
663
|
+
keeper: KeeperSettingsDep,
|
|
664
|
+
idempotency_key: IdempotencyKeyDep,
|
|
665
|
+
guard: DataRead,
|
|
666
|
+
) -> ProposalDetailOut:
|
|
667
|
+
"""Open a Proposal and compute its merge preview (Conflict count).
|
|
668
|
+
|
|
669
|
+
The merge policy comes from the Domain's `merge_policy` setting. With an
|
|
670
|
+
`Idempotency-Key`, a retried create returns the Proposal with status 200 and
|
|
671
|
+
`Idempotent-Replayed: true`.
|
|
672
|
+
"""
|
|
673
|
+
await _check(request, auth, principal, Permission.PROPOSAL_CREATE, domain_id, body.source_ref)
|
|
674
|
+
await require_target_read(guard, body.target_ref)
|
|
675
|
+
created = await service.create(
|
|
676
|
+
store,
|
|
677
|
+
domain_id,
|
|
678
|
+
service.NewProposal(
|
|
679
|
+
source_ref=body.source_ref,
|
|
680
|
+
target_ref=body.target_ref,
|
|
681
|
+
title=body.title,
|
|
682
|
+
description=body.description,
|
|
683
|
+
),
|
|
684
|
+
principal,
|
|
685
|
+
keeper.proposals,
|
|
686
|
+
idempotency_key=idempotency_key,
|
|
687
|
+
)
|
|
688
|
+
proposal = created.view.proposal
|
|
689
|
+
annotate(
|
|
690
|
+
request,
|
|
691
|
+
action="proposal.create",
|
|
692
|
+
target=proposal.id,
|
|
693
|
+
source=proposal.source_ref,
|
|
694
|
+
target_branch=proposal.target_ref,
|
|
695
|
+
replayed=created.replayed,
|
|
696
|
+
)
|
|
697
|
+
if created.replayed:
|
|
698
|
+
response.status_code = 200
|
|
699
|
+
response.headers[REPLAYED_HEADER] = "true"
|
|
700
|
+
response.headers["Location"] = (
|
|
701
|
+
f"{keeper.api_prefix}/domains/{domain_id}/proposals/{proposal.id}"
|
|
702
|
+
)
|
|
703
|
+
scope = await visible_scope(guard, proposal)
|
|
704
|
+
return _detail(await service.scoped(store, domain_id, created.view, scope), scope)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
@router.get(PROPOSALS, summary="List Proposals", responses=_ERRORS)
|
|
708
|
+
async def list_proposals(
|
|
709
|
+
domain_id: str,
|
|
710
|
+
store: StoreDep,
|
|
711
|
+
params: PageParamsDep,
|
|
712
|
+
guard: DataRead,
|
|
713
|
+
status: ProposalStatus | None = None,
|
|
714
|
+
origin: ProposalOrigin | None = None,
|
|
715
|
+
source: Annotated[str | None, Query(description="Source branch.")] = None,
|
|
716
|
+
target: Annotated[str | None, Query(description="Target branch.")] = None,
|
|
717
|
+
author: str | None = None,
|
|
718
|
+
) -> Page[ProposalOut]:
|
|
719
|
+
"""Proposals the caller may read, newest first."""
|
|
720
|
+
await guard.precheck()
|
|
721
|
+
after = None
|
|
722
|
+
if params.position is not None:
|
|
723
|
+
try:
|
|
724
|
+
after = (
|
|
725
|
+
datetime.fromisoformat(str(params.position["at"])),
|
|
726
|
+
str(params.position["id"]),
|
|
727
|
+
)
|
|
728
|
+
except (KeyError, ValueError):
|
|
729
|
+
raise ProblemError(VALIDATION_FAILED, "The cursor is not a Proposal cursor.") from None
|
|
730
|
+
rows = await service.page(
|
|
731
|
+
store,
|
|
732
|
+
domain_id,
|
|
733
|
+
ProposalFilter(
|
|
734
|
+
status=status, origin=origin, source_ref=source, target_ref=target, author=author
|
|
735
|
+
),
|
|
736
|
+
limit=params.limit + 1,
|
|
737
|
+
after=after,
|
|
738
|
+
)
|
|
739
|
+
more = len(rows) > params.limit
|
|
740
|
+
rows = rows[: params.limit]
|
|
741
|
+
next_cursor = (
|
|
742
|
+
encode_cursor({"at": rows[-1].created_at.isoformat(), "id": rows[-1].id})
|
|
743
|
+
if more and rows
|
|
744
|
+
else None
|
|
745
|
+
)
|
|
746
|
+
items = [
|
|
747
|
+
_proposal(r, counted=(await visible_scope(guard, r)).everything)
|
|
748
|
+
for r in await readable_proposals(guard, rows)
|
|
749
|
+
]
|
|
750
|
+
return Page[ProposalOut](items=items, next_cursor=next_cursor)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
@router.get(PROPOSAL, summary="Get a Proposal", responses=_ERRORS)
|
|
754
|
+
async def get_proposal(
|
|
755
|
+
domain_id: str,
|
|
756
|
+
proposal_id: str,
|
|
757
|
+
store: StoreDep,
|
|
758
|
+
keeper: KeeperSettingsDep,
|
|
759
|
+
guard: DataRead,
|
|
760
|
+
) -> ProposalDetailOut:
|
|
761
|
+
"""The Proposal with its reviews, approval state and latest merge attempt."""
|
|
762
|
+
scope = await _readable(store, guard, domain_id, proposal_id)
|
|
763
|
+
view = await service.get(store, domain_id, proposal_id, keeper.proposals)
|
|
764
|
+
return _detail(await service.scoped(store, domain_id, view, scope), scope)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
async def _author_or_merger(
|
|
768
|
+
request: Request,
|
|
769
|
+
auth: AuthDep,
|
|
770
|
+
principal: PrincipalDep,
|
|
771
|
+
store: StoreDep,
|
|
772
|
+
domain_id: str,
|
|
773
|
+
proposal_id: str,
|
|
774
|
+
) -> None:
|
|
775
|
+
proposal = await service.target_of(store, domain_id, proposal_id)
|
|
776
|
+
if principal.id != proposal.author:
|
|
777
|
+
await _check(
|
|
778
|
+
request, auth, principal, Permission.PROPOSAL_MERGE, domain_id, proposal.target_ref
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
@router.patch(PROPOSAL, summary="Update a Proposal", responses=_ERRORS)
|
|
783
|
+
async def update_proposal(
|
|
784
|
+
domain_id: str,
|
|
785
|
+
proposal_id: str,
|
|
786
|
+
body: ProposalUpdate,
|
|
787
|
+
request: Request,
|
|
788
|
+
principal: PrincipalDep,
|
|
789
|
+
auth: AuthDep,
|
|
790
|
+
store: StoreDep,
|
|
791
|
+
keeper: KeeperSettingsDep,
|
|
792
|
+
guard: DataRead,
|
|
793
|
+
_: Annotated[Access, Depends(require(Permission.DATA_READ))],
|
|
794
|
+
) -> ProposalDetailOut:
|
|
795
|
+
"""Change the title or description of an open Proposal (its author or a maintainer)."""
|
|
796
|
+
await _author_or_merger(request, auth, principal, store, domain_id, proposal_id)
|
|
797
|
+
view = await service.update(
|
|
798
|
+
store,
|
|
799
|
+
domain_id,
|
|
800
|
+
proposal_id,
|
|
801
|
+
keeper.proposals,
|
|
802
|
+
title=body.title,
|
|
803
|
+
description=body.description,
|
|
804
|
+
)
|
|
805
|
+
annotate(
|
|
806
|
+
request,
|
|
807
|
+
action="proposal.update",
|
|
808
|
+
target=proposal_id,
|
|
809
|
+
fields=sorted(body.model_dump(exclude_unset=True)),
|
|
810
|
+
)
|
|
811
|
+
scope = await visible_scope(guard, view.proposal)
|
|
812
|
+
return _detail(await service.scoped(store, domain_id, view, scope), scope)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
@router.post(PROPOSAL + "/close", summary="Close a Proposal", responses=_ERRORS)
|
|
816
|
+
async def close_proposal(
|
|
817
|
+
domain_id: str,
|
|
818
|
+
proposal_id: str,
|
|
819
|
+
request: Request,
|
|
820
|
+
principal: PrincipalDep,
|
|
821
|
+
auth: AuthDep,
|
|
822
|
+
store: StoreDep,
|
|
823
|
+
keeper: KeeperSettingsDep,
|
|
824
|
+
guard: DataRead,
|
|
825
|
+
_: Annotated[Access, Depends(require(Permission.DATA_READ))],
|
|
826
|
+
) -> ProposalDetailOut:
|
|
827
|
+
"""Close without merging; the merge attempt is discarded and the target is unchanged."""
|
|
828
|
+
await _author_or_merger(request, auth, principal, store, domain_id, proposal_id)
|
|
829
|
+
view = await service.close(store, domain_id, proposal_id, keeper.proposals)
|
|
830
|
+
released = await agent_writes.release_branch(store, domain_id, view.proposal, keeper)
|
|
831
|
+
annotate(request, action="proposal.close", target=proposal_id, branch_deleted=released)
|
|
832
|
+
scope = await visible_scope(guard, view.proposal)
|
|
833
|
+
return _detail(await service.scoped(store, domain_id, view, scope), scope)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
@router.post(PROPOSAL + "/reviews", status_code=201, summary="Review a Proposal", responses=_ERRORS)
|
|
837
|
+
async def review_proposal(
|
|
838
|
+
domain_id: str,
|
|
839
|
+
proposal_id: str,
|
|
840
|
+
body: ReviewIn,
|
|
841
|
+
request: Request,
|
|
842
|
+
principal: PrincipalDep,
|
|
843
|
+
auth: AuthDep,
|
|
844
|
+
store: StoreDep,
|
|
845
|
+
keeper: KeeperSettingsDep,
|
|
846
|
+
) -> ReviewResult:
|
|
847
|
+
"""Approve, request changes or comment."""
|
|
848
|
+
proposal = await service.target_of(store, domain_id, proposal_id)
|
|
849
|
+
await _check(
|
|
850
|
+
request, auth, principal, Permission.PROPOSAL_REVIEW, domain_id, proposal.target_ref
|
|
851
|
+
)
|
|
852
|
+
row, view = await service.review(
|
|
853
|
+
store,
|
|
854
|
+
domain_id,
|
|
855
|
+
proposal_id,
|
|
856
|
+
principal,
|
|
857
|
+
keeper.proposals,
|
|
858
|
+
verdict=body.verdict,
|
|
859
|
+
comment=body.comment,
|
|
860
|
+
)
|
|
861
|
+
annotate(request, action="proposal.review", target=proposal_id, verdict=body.verdict.value)
|
|
862
|
+
return ReviewResult(review=_review(row), approval=_approval(view.approval))
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
class MergeIn(BaseModel):
|
|
866
|
+
message: str | None = Field(default=None, max_length=2000)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
@router.post(PROPOSAL + "/merge", summary="Merge a Proposal", responses=_ERRORS)
|
|
870
|
+
async def merge_proposal(
|
|
871
|
+
domain_id: str,
|
|
872
|
+
proposal_id: str,
|
|
873
|
+
request: Request,
|
|
874
|
+
response: Response,
|
|
875
|
+
principal: PrincipalDep,
|
|
876
|
+
auth: AuthDep,
|
|
877
|
+
store: StoreDep,
|
|
878
|
+
keeper: KeeperSettingsDep,
|
|
879
|
+
idempotency_key: IdempotencyKeyDep,
|
|
880
|
+
guard: DataRead,
|
|
881
|
+
body: MergeIn | None = None,
|
|
882
|
+
) -> MergeOut:
|
|
883
|
+
"""Run or re-run the merge with the stored resolutions.
|
|
884
|
+
|
|
885
|
+
200 with the new target head when the merge committed (merge Revision with parents
|
|
886
|
+
`[target head, source head]`, fast-forward, or nothing to do); 409 `merge-conflicts`
|
|
887
|
+
with the attempt when Conflicts remain. With an `Idempotency-Key`, retrying a merge that
|
|
888
|
+
committed returns it again (`Idempotent-Replayed: true`).
|
|
889
|
+
"""
|
|
890
|
+
proposal = await service.target_of(store, domain_id, proposal_id)
|
|
891
|
+
await _check(
|
|
892
|
+
request, auth, principal, Permission.PROPOSAL_MERGE, domain_id, proposal.target_ref
|
|
893
|
+
)
|
|
894
|
+
scope = await conflict_scope(guard, proposal)
|
|
895
|
+
try:
|
|
896
|
+
outcome = await service.merge(
|
|
897
|
+
store,
|
|
898
|
+
domain_id,
|
|
899
|
+
proposal_id,
|
|
900
|
+
principal,
|
|
901
|
+
keeper.proposals,
|
|
902
|
+
idempotency_key=idempotency_key,
|
|
903
|
+
message=None if body is None else body.message,
|
|
904
|
+
page_limit=keeper.pagination.default_limit,
|
|
905
|
+
scope=scope,
|
|
906
|
+
schema_guard=lambda old, new: require_schema_change(
|
|
907
|
+
auth.authorizer, principal, domain_id, old, new
|
|
908
|
+
),
|
|
909
|
+
)
|
|
910
|
+
except CommitError as error:
|
|
911
|
+
raise await _store_problem(
|
|
912
|
+
error,
|
|
913
|
+
keeper.branches.lock_retry_after_s,
|
|
914
|
+
lambda: _visibility(store, guard, domain_id, proposal.target_ref, keeper, scope),
|
|
915
|
+
) from None
|
|
916
|
+
attempt = await _attempt(
|
|
917
|
+
outcome.view, store, guard, domain_id, proposal.target_ref, keeper, scope
|
|
918
|
+
)
|
|
919
|
+
annotate(
|
|
920
|
+
request,
|
|
921
|
+
action="proposal.merge",
|
|
922
|
+
target=proposal_id,
|
|
923
|
+
attempt=outcome.view.attempt.id,
|
|
924
|
+
result=outcome.view.attempt.result,
|
|
925
|
+
revision=None if outcome.head is None else hex(outcome.head),
|
|
926
|
+
open_conflicts=attempt.open_conflicts,
|
|
927
|
+
replayed=outcome.replayed,
|
|
928
|
+
)
|
|
929
|
+
if outcome.head is None:
|
|
930
|
+
raise ProblemError(
|
|
931
|
+
service.MERGE_CONFLICTS,
|
|
932
|
+
f"{attempt.open_conflicts} Conflict(s) remain; resolve them and merge again.",
|
|
933
|
+
extensions={"attempt": attempt.model_dump(mode="json")},
|
|
934
|
+
)
|
|
935
|
+
source_deleted = outcome.source_deleted
|
|
936
|
+
if not outcome.replayed and not source_deleted:
|
|
937
|
+
source_deleted = await agent_writes.release_branch(
|
|
938
|
+
store, domain_id, outcome.proposal, keeper
|
|
939
|
+
)
|
|
940
|
+
annotate(request, branch_deleted=source_deleted)
|
|
941
|
+
if outcome.replayed:
|
|
942
|
+
response.headers[REPLAYED_HEADER] = "true"
|
|
943
|
+
return MergeOut(
|
|
944
|
+
result=outcome.view.attempt.result or "merged",
|
|
945
|
+
revision=hex(outcome.head),
|
|
946
|
+
proposal=_proposal(outcome.proposal, counted=scope.everything),
|
|
947
|
+
attempt=attempt,
|
|
948
|
+
source_deleted=source_deleted,
|
|
949
|
+
replayed=outcome.replayed,
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
@router.get(PROPOSAL + "/conflicts", summary="List the Conflicts of a Proposal", responses=_ERRORS)
|
|
954
|
+
async def list_conflicts(
|
|
955
|
+
domain_id: str,
|
|
956
|
+
proposal_id: str,
|
|
957
|
+
principal: PrincipalDep,
|
|
958
|
+
store: StoreDep,
|
|
959
|
+
keeper: KeeperSettingsDep,
|
|
960
|
+
params: PageParamsDep,
|
|
961
|
+
guard: DataRead,
|
|
962
|
+
kind: Annotated[
|
|
963
|
+
str | None, Query(description="Conflict codes, comma-separated (`DV1,XS8`).")
|
|
964
|
+
] = None,
|
|
965
|
+
status: Annotated[Literal["open", "resolved"] | None, Query()] = None,
|
|
966
|
+
) -> ConflictsOut:
|
|
967
|
+
"""The current merge attempt with one page of its Conflicts, ordered by id."""
|
|
968
|
+
scope = await _readable(store, guard, domain_id, proposal_id)
|
|
969
|
+
proposal = await service.target_of(store, domain_id, proposal_id)
|
|
970
|
+
after = None if params.position is None else str(params.position.get("after", "")) or None
|
|
971
|
+
try:
|
|
972
|
+
view = await service.conflicts(
|
|
973
|
+
store,
|
|
974
|
+
domain_id,
|
|
975
|
+
proposal_id,
|
|
976
|
+
principal,
|
|
977
|
+
keeper.proposals,
|
|
978
|
+
service.ConflictQuery(
|
|
979
|
+
codes=_codes(kind),
|
|
980
|
+
resolved=None if status is None else status == "resolved",
|
|
981
|
+
limit=params.limit,
|
|
982
|
+
after=after,
|
|
983
|
+
scope=scope,
|
|
984
|
+
),
|
|
985
|
+
)
|
|
986
|
+
except CommitError as error:
|
|
987
|
+
raise await _store_problem(
|
|
988
|
+
error,
|
|
989
|
+
keeper.branches.lock_retry_after_s,
|
|
990
|
+
lambda: _visibility(store, guard, domain_id, proposal.target_ref, keeper, scope),
|
|
991
|
+
) from None
|
|
992
|
+
return await _attempt(view, store, guard, domain_id, proposal.target_ref, keeper, scope)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
@router.post(
|
|
996
|
+
PROPOSAL + "/conflicts/{conflict_id}/resolution",
|
|
997
|
+
summary="Resolve one Conflict",
|
|
998
|
+
responses=_ERRORS,
|
|
999
|
+
)
|
|
1000
|
+
async def resolve_conflict(
|
|
1001
|
+
domain_id: str,
|
|
1002
|
+
proposal_id: str,
|
|
1003
|
+
conflict_id: str,
|
|
1004
|
+
body: ResolutionIn,
|
|
1005
|
+
request: Request,
|
|
1006
|
+
principal: PrincipalDep,
|
|
1007
|
+
auth: AuthDep,
|
|
1008
|
+
store: StoreDep,
|
|
1009
|
+
keeper: KeeperSettingsDep,
|
|
1010
|
+
guard: DataRead,
|
|
1011
|
+
) -> ResolutionOut:
|
|
1012
|
+
"""Store the resolution of one Conflict of the current attempt (no merge run)."""
|
|
1013
|
+
proposal = await service.target_of(store, domain_id, proposal_id)
|
|
1014
|
+
await _check(
|
|
1015
|
+
request, auth, principal, Permission.CONFLICT_RESOLVE, domain_id, proposal.target_ref
|
|
1016
|
+
)
|
|
1017
|
+
scope = await conflict_scope(guard, proposal)
|
|
1018
|
+
try:
|
|
1019
|
+
rows, _view = await service.resolve(
|
|
1020
|
+
store,
|
|
1021
|
+
domain_id,
|
|
1022
|
+
proposal_id,
|
|
1023
|
+
principal,
|
|
1024
|
+
keeper.proposals,
|
|
1025
|
+
[
|
|
1026
|
+
service.ResolutionInput(
|
|
1027
|
+
fingerprint=conflict_id,
|
|
1028
|
+
choice=body.choice,
|
|
1029
|
+
value=body.value,
|
|
1030
|
+
values=body.values,
|
|
1031
|
+
)
|
|
1032
|
+
],
|
|
1033
|
+
rerun=False,
|
|
1034
|
+
page_limit=keeper.pagination.default_limit,
|
|
1035
|
+
scope=scope,
|
|
1036
|
+
)
|
|
1037
|
+
except CommitError as error:
|
|
1038
|
+
raise await _store_problem(
|
|
1039
|
+
error,
|
|
1040
|
+
keeper.branches.lock_retry_after_s,
|
|
1041
|
+
lambda: _visibility(store, guard, domain_id, proposal.target_ref, keeper, scope),
|
|
1042
|
+
) from None
|
|
1043
|
+
annotate(request, action="proposal.resolve", target=proposal_id, conflicts=[conflict_id])
|
|
1044
|
+
return _resolution(rows[0], frozenset())
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
@router.post(
|
|
1048
|
+
PROPOSAL + "/conflicts:resolve", summary="Resolve Conflicts in bulk", responses=_ERRORS
|
|
1049
|
+
)
|
|
1050
|
+
async def resolve_conflicts(
|
|
1051
|
+
domain_id: str,
|
|
1052
|
+
proposal_id: str,
|
|
1053
|
+
body: ResolveManyIn,
|
|
1054
|
+
request: Request,
|
|
1055
|
+
principal: PrincipalDep,
|
|
1056
|
+
auth: AuthDep,
|
|
1057
|
+
store: StoreDep,
|
|
1058
|
+
keeper: KeeperSettingsDep,
|
|
1059
|
+
guard: DataRead,
|
|
1060
|
+
) -> ConflictsOut:
|
|
1061
|
+
"""Store several resolutions, re-run the merge and return the new attempt."""
|
|
1062
|
+
if len(body.resolutions) > keeper.proposals.max_resolutions:
|
|
1063
|
+
raise ProblemError(
|
|
1064
|
+
VALIDATION_FAILED,
|
|
1065
|
+
f"At most {keeper.proposals.max_resolutions} resolutions per request.",
|
|
1066
|
+
)
|
|
1067
|
+
proposal = await service.target_of(store, domain_id, proposal_id)
|
|
1068
|
+
await _check(
|
|
1069
|
+
request, auth, principal, Permission.CONFLICT_RESOLVE, domain_id, proposal.target_ref
|
|
1070
|
+
)
|
|
1071
|
+
scope = await conflict_scope(guard, proposal)
|
|
1072
|
+
try:
|
|
1073
|
+
_rows, view = await service.resolve(
|
|
1074
|
+
store,
|
|
1075
|
+
domain_id,
|
|
1076
|
+
proposal_id,
|
|
1077
|
+
principal,
|
|
1078
|
+
keeper.proposals,
|
|
1079
|
+
[
|
|
1080
|
+
service.ResolutionInput(
|
|
1081
|
+
fingerprint=item.fingerprint,
|
|
1082
|
+
choice=item.choice,
|
|
1083
|
+
value=item.value,
|
|
1084
|
+
values=item.values,
|
|
1085
|
+
pointer=f"/body/resolutions/{index}",
|
|
1086
|
+
)
|
|
1087
|
+
for index, item in enumerate(body.resolutions)
|
|
1088
|
+
],
|
|
1089
|
+
rerun=True,
|
|
1090
|
+
page_limit=keeper.pagination.default_limit,
|
|
1091
|
+
scope=scope,
|
|
1092
|
+
)
|
|
1093
|
+
except CommitError as error:
|
|
1094
|
+
raise await _store_problem(
|
|
1095
|
+
error,
|
|
1096
|
+
keeper.branches.lock_retry_after_s,
|
|
1097
|
+
lambda: _visibility(store, guard, domain_id, proposal.target_ref, keeper, scope),
|
|
1098
|
+
) from None
|
|
1099
|
+
assert view is not None
|
|
1100
|
+
annotate(
|
|
1101
|
+
request,
|
|
1102
|
+
action="proposal.resolve",
|
|
1103
|
+
target=proposal_id,
|
|
1104
|
+
conflicts=[item.fingerprint for item in body.resolutions],
|
|
1105
|
+
attempt=view.attempt.id,
|
|
1106
|
+
)
|
|
1107
|
+
return await _attempt(view, store, guard, domain_id, proposal.target_ref, keeper, scope)
|