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,337 @@
|
|
|
1
|
+
"""Branch and tag use cases (design 6.5): create, read, delete, protect."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
|
|
8
|
+
from sqlalchemy.exc import IntegrityError
|
|
9
|
+
|
|
10
|
+
from lorebase_keeper.auth.principals import Principal
|
|
11
|
+
from lorebase_keeper.context import (
|
|
12
|
+
StoreAccess,
|
|
13
|
+
require_branch,
|
|
14
|
+
require_domain,
|
|
15
|
+
require_revision,
|
|
16
|
+
)
|
|
17
|
+
from lorebase_keeper.errors import NOT_FOUND, VALIDATION_FAILED, ProblemError
|
|
18
|
+
from lorebase_keeper.services.config import BranchSettings
|
|
19
|
+
from lorebase_keeper.services.errors import (
|
|
20
|
+
ALREADY_EXISTS,
|
|
21
|
+
BRANCH_BUSY,
|
|
22
|
+
LIMIT_EXCEEDED,
|
|
23
|
+
PROTECTED_BRANCH,
|
|
24
|
+
)
|
|
25
|
+
from lorebase_store.materialize import (
|
|
26
|
+
BranchDeletingError,
|
|
27
|
+
BranchDeletion,
|
|
28
|
+
BranchExistsError,
|
|
29
|
+
BranchMode,
|
|
30
|
+
MaterializationMode,
|
|
31
|
+
create_branch,
|
|
32
|
+
delete_branch,
|
|
33
|
+
)
|
|
34
|
+
from lorebase_store.materialize.snapshot import SNAPSHOT_PREFIX
|
|
35
|
+
from lorebase_store.models.enums import Materialization, RefKind
|
|
36
|
+
from lorebase_store.repositories import (
|
|
37
|
+
BranchMaterializationRepository,
|
|
38
|
+
LockTimeoutError,
|
|
39
|
+
MaterializationState,
|
|
40
|
+
RefNotFoundError,
|
|
41
|
+
RefRepository,
|
|
42
|
+
RefRow,
|
|
43
|
+
RevisionRepository,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
TAG_CREATE_REASON = "tag.create"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class BranchState:
|
|
51
|
+
"""A branch with its position relative to the default branch and its background copy."""
|
|
52
|
+
|
|
53
|
+
ref: RefRow
|
|
54
|
+
default: bool
|
|
55
|
+
ahead: int
|
|
56
|
+
behind: int
|
|
57
|
+
materialization: MaterializationState | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class CreatedRef:
|
|
62
|
+
"""A new branch or tag; `mode` tells how a branch got its rows, `replayed` that it existed,
|
|
63
|
+
`materialization` the background copy of a large copy branch."""
|
|
64
|
+
|
|
65
|
+
ref: RefRow
|
|
66
|
+
mode: MaterializationMode | None = None
|
|
67
|
+
replayed: bool = False
|
|
68
|
+
default: bool = False
|
|
69
|
+
materialization: MaterializationState | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class RefPage:
|
|
74
|
+
"""A page of refs with the Domain's default branch and their background copies."""
|
|
75
|
+
|
|
76
|
+
default: str
|
|
77
|
+
refs: list[RefRow]
|
|
78
|
+
materializations: dict[str, MaterializationState]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_ref_name(name: str, settings: BranchSettings, pointer: str = "/body/name") -> None:
|
|
82
|
+
"""422 unless `name` is a valid branch or tag name (never a snapshot ref's name, whatever
|
|
83
|
+
the configured pattern)."""
|
|
84
|
+
if (
|
|
85
|
+
len(name) > settings.max_name_length
|
|
86
|
+
or not re.fullmatch(settings.name_pattern, name)
|
|
87
|
+
or name.startswith(SNAPSHOT_PREFIX)
|
|
88
|
+
):
|
|
89
|
+
raise ProblemError(
|
|
90
|
+
VALIDATION_FAILED,
|
|
91
|
+
"One or more request members are invalid.",
|
|
92
|
+
extensions={
|
|
93
|
+
"errors": [
|
|
94
|
+
{
|
|
95
|
+
"pointer": pointer,
|
|
96
|
+
"message": f"must match {settings.name_pattern} and be at most "
|
|
97
|
+
f"{settings.max_name_length} characters",
|
|
98
|
+
"code": "invalid",
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _busy(settings: BranchSettings) -> ProblemError:
|
|
106
|
+
return ProblemError(
|
|
107
|
+
BRANCH_BUSY,
|
|
108
|
+
"Another writer holds the branch; retry later.",
|
|
109
|
+
headers={"Retry-After": str(settings.lock_retry_after_s)},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def page(
|
|
114
|
+
store: StoreAccess, domain_id: str, kind: RefKind, *, limit: int, after: str | None
|
|
115
|
+
) -> RefPage:
|
|
116
|
+
"""Refs of one kind ordered by name, with the Domain's default branch name."""
|
|
117
|
+
async with store.sessions() as session, session.begin():
|
|
118
|
+
domain = await require_domain(session, domain_id)
|
|
119
|
+
refs = await RefRepository(session, domain_id).page(kind, limit=limit, after=after)
|
|
120
|
+
states = await BranchMaterializationRepository(session, domain_id).of(
|
|
121
|
+
[r.name for r in refs]
|
|
122
|
+
)
|
|
123
|
+
return RefPage(domain.default_branch, refs, states)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def get(store: StoreAccess, domain_id: str, name: str) -> BranchState:
|
|
127
|
+
"""A branch with ahead and behind counts against the default branch head."""
|
|
128
|
+
async with store.sessions() as session, session.begin():
|
|
129
|
+
domain = await require_domain(session, domain_id)
|
|
130
|
+
ref = await require_branch(session, domain_id, name)
|
|
131
|
+
default = await RefRepository(session, domain_id).get(domain.default_branch)
|
|
132
|
+
ahead = behind = 0
|
|
133
|
+
if default is not None and default.name != ref.name:
|
|
134
|
+
ahead, behind = await RevisionRepository(session, domain_id).ahead_behind(
|
|
135
|
+
ref.head, default.head
|
|
136
|
+
)
|
|
137
|
+
state = await BranchMaterializationRepository(session, domain_id).get(name)
|
|
138
|
+
return BranchState(
|
|
139
|
+
ref=ref,
|
|
140
|
+
default=ref.name == domain.default_branch,
|
|
141
|
+
ahead=ahead,
|
|
142
|
+
behind=behind,
|
|
143
|
+
materialization=state,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def create(
|
|
148
|
+
store: StoreAccess,
|
|
149
|
+
domain_id: str,
|
|
150
|
+
name: str,
|
|
151
|
+
principal: Principal,
|
|
152
|
+
settings: BranchSettings,
|
|
153
|
+
*,
|
|
154
|
+
source: str | None,
|
|
155
|
+
revision: bytes | None,
|
|
156
|
+
idempotency_key: str | None,
|
|
157
|
+
mode: BranchMode | None = None,
|
|
158
|
+
) -> CreatedRef:
|
|
159
|
+
"""Create a materialized branch at `revision` or at the head of `source`.
|
|
160
|
+
|
|
161
|
+
`source` is a branch or tag (default: the Domain's default branch). An overlay (`mode`,
|
|
162
|
+
default from `store.materialize`) reads the source's rows and stores only its changes;
|
|
163
|
+
otherwise rows are copied from the source branch when the new branch starts at its head,
|
|
164
|
+
rebuilt from the tree otherwise. A large copy starts as an overlay whose rows a background
|
|
165
|
+
job copies (`materialization` pending). Overlays do not count against
|
|
166
|
+
`max_materialized_per_domain`; copies in progress do.
|
|
167
|
+
"""
|
|
168
|
+
check_ref_name(name, settings)
|
|
169
|
+
try:
|
|
170
|
+
async with store.sessions() as session, session.begin():
|
|
171
|
+
domain = await require_domain(session, domain_id)
|
|
172
|
+
refs = RefRepository(session, domain_id)
|
|
173
|
+
existing = await refs.get(name)
|
|
174
|
+
if existing is not None:
|
|
175
|
+
replayed = _replay(
|
|
176
|
+
existing,
|
|
177
|
+
principal,
|
|
178
|
+
idempotency_key,
|
|
179
|
+
RefKind.BRANCH,
|
|
180
|
+
default=name == domain.default_branch,
|
|
181
|
+
)
|
|
182
|
+
state = await BranchMaterializationRepository(session, domain_id).get(name)
|
|
183
|
+
return replace(replayed, materialization=state)
|
|
184
|
+
source_name = source or domain.default_branch
|
|
185
|
+
found_source = await refs.get(source_name)
|
|
186
|
+
if found_source is None or found_source.kind is RefKind.SNAPSHOT:
|
|
187
|
+
raise ProblemError(NOT_FOUND, f"Branch or tag {source_name!r} not found.")
|
|
188
|
+
if revision is not None:
|
|
189
|
+
await require_revision(session, domain_id, revision)
|
|
190
|
+
materialized = await refs.count(RefKind.BRANCH, Materialization.FULL)
|
|
191
|
+
materialized += await BranchMaterializationRepository(session, domain_id).count_active()
|
|
192
|
+
if materialized >= settings.max_materialized_per_domain:
|
|
193
|
+
raise ProblemError(
|
|
194
|
+
LIMIT_EXCEEDED,
|
|
195
|
+
f"Domain {domain_id!r} already has {materialized} materialized branches "
|
|
196
|
+
f"(limit {settings.max_materialized_per_domain}); delete one first.",
|
|
197
|
+
)
|
|
198
|
+
created = await create_branch(
|
|
199
|
+
session,
|
|
200
|
+
domain_id,
|
|
201
|
+
name,
|
|
202
|
+
created_by=principal.id,
|
|
203
|
+
source=source_name,
|
|
204
|
+
start=revision,
|
|
205
|
+
mode=mode,
|
|
206
|
+
runtime=store.runtime,
|
|
207
|
+
clock=store.clock,
|
|
208
|
+
)
|
|
209
|
+
state = None
|
|
210
|
+
if created.pending:
|
|
211
|
+
state = await BranchMaterializationRepository(session, domain_id).get(name)
|
|
212
|
+
except BranchDeletingError:
|
|
213
|
+
raise ProblemError(
|
|
214
|
+
ALREADY_EXISTS,
|
|
215
|
+
f"Branch {name!r} is being deleted; its name is free again once its rows are gone.",
|
|
216
|
+
headers={"Retry-After": str(settings.lock_retry_after_s)},
|
|
217
|
+
) from None
|
|
218
|
+
except BranchExistsError:
|
|
219
|
+
async with store.sessions() as session, session.begin():
|
|
220
|
+
await require_domain(session, domain_id)
|
|
221
|
+
found = await RefRepository(session, domain_id).require(name)
|
|
222
|
+
return _replay(found, principal, idempotency_key, RefKind.BRANCH)
|
|
223
|
+
except LockTimeoutError:
|
|
224
|
+
raise _busy(settings) from None
|
|
225
|
+
return CreatedRef(ref=created.ref, mode=created.mode, materialization=state)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _replay(
|
|
229
|
+
existing: RefRow,
|
|
230
|
+
principal: Principal,
|
|
231
|
+
idempotency_key: str | None,
|
|
232
|
+
kind: RefKind,
|
|
233
|
+
*,
|
|
234
|
+
default: bool = False,
|
|
235
|
+
) -> CreatedRef:
|
|
236
|
+
"""An existing ref returned for a retried create, or 409."""
|
|
237
|
+
if idempotency_key is None or existing.created_by != principal.id or existing.kind is not kind:
|
|
238
|
+
raise ProblemError(ALREADY_EXISTS, f"A branch or tag named {existing.name!r} exists.")
|
|
239
|
+
return CreatedRef(ref=existing, replayed=True, default=default)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def delete(
|
|
243
|
+
store: StoreAccess, domain_id: str, name: str, settings: BranchSettings
|
|
244
|
+
) -> tuple[RefRow, BranchDeletion]:
|
|
245
|
+
"""Delete a branch and its materialized rows. The default and protected branches stay;
|
|
246
|
+
overlay branches based on it are promoted to full copies first. A large branch is hidden
|
|
247
|
+
at once and its rows are deleted by a background job (`BranchDeletion.SCHEDULED`), which
|
|
248
|
+
first waits for the background copies of the overlays based on it."""
|
|
249
|
+
try:
|
|
250
|
+
async with store.sessions() as session, session.begin():
|
|
251
|
+
domain = await require_domain(session, domain_id)
|
|
252
|
+
refs = RefRepository(session, domain_id)
|
|
253
|
+
await refs.lock(name, timeout_ms=store.runtime.settings.commit.lock_timeout_ms)
|
|
254
|
+
ref = await require_branch(session, domain_id, name)
|
|
255
|
+
if name == domain.default_branch:
|
|
256
|
+
raise ProblemError(
|
|
257
|
+
PROTECTED_BRANCH, f"{name!r} is the default branch of the Domain."
|
|
258
|
+
)
|
|
259
|
+
if ref.protected:
|
|
260
|
+
raise ProblemError(
|
|
261
|
+
PROTECTED_BRANCH, f"Branch {name!r} is protected; unprotect it first."
|
|
262
|
+
)
|
|
263
|
+
try:
|
|
264
|
+
deletion = await delete_branch(
|
|
265
|
+
session, domain_id, name, runtime=store.runtime, clock=store.clock
|
|
266
|
+
)
|
|
267
|
+
except RefNotFoundError:
|
|
268
|
+
raise ProblemError(NOT_FOUND, f"Branch {name!r} not found.") from None
|
|
269
|
+
except LockTimeoutError:
|
|
270
|
+
raise _busy(settings) from None
|
|
271
|
+
return ref, deletion
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def set_protected(
|
|
275
|
+
store: StoreAccess, domain_id: str, name: str, protected: bool
|
|
276
|
+
) -> BranchState:
|
|
277
|
+
"""Protect or unprotect a branch."""
|
|
278
|
+
async with store.sessions() as session, session.begin():
|
|
279
|
+
domain = await require_domain(session, domain_id)
|
|
280
|
+
await require_branch(session, domain_id, name)
|
|
281
|
+
ref = await RefRepository(session, domain_id).set_protected(name, protected)
|
|
282
|
+
assert ref is not None
|
|
283
|
+
return BranchState(ref=ref, default=name == domain.default_branch, ahead=0, behind=0)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def create_tag(
|
|
287
|
+
store: StoreAccess,
|
|
288
|
+
domain_id: str,
|
|
289
|
+
name: str,
|
|
290
|
+
principal: Principal,
|
|
291
|
+
settings: BranchSettings,
|
|
292
|
+
*,
|
|
293
|
+
source: str | None,
|
|
294
|
+
revision: bytes | None,
|
|
295
|
+
idempotency_key: str | None,
|
|
296
|
+
) -> CreatedRef:
|
|
297
|
+
"""Create an immutable tag at `revision` or at the head of `source` (default branch)."""
|
|
298
|
+
check_ref_name(name, settings)
|
|
299
|
+
try:
|
|
300
|
+
return await _create_tag(
|
|
301
|
+
store, domain_id, name, principal, source, revision, idempotency_key
|
|
302
|
+
)
|
|
303
|
+
except IntegrityError:
|
|
304
|
+
raise ProblemError(ALREADY_EXISTS, f"A branch or tag named {name!r} exists.") from None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def _create_tag(
|
|
308
|
+
store: StoreAccess,
|
|
309
|
+
domain_id: str,
|
|
310
|
+
name: str,
|
|
311
|
+
principal: Principal,
|
|
312
|
+
source: str | None,
|
|
313
|
+
revision: bytes | None,
|
|
314
|
+
idempotency_key: str | None,
|
|
315
|
+
) -> CreatedRef:
|
|
316
|
+
async with store.sessions() as session, session.begin():
|
|
317
|
+
domain = await require_domain(session, domain_id)
|
|
318
|
+
refs = RefRepository(session, domain_id)
|
|
319
|
+
existing = await refs.get(name)
|
|
320
|
+
if existing is not None:
|
|
321
|
+
return _replay(existing, principal, idempotency_key, RefKind.TAG)
|
|
322
|
+
if revision is None:
|
|
323
|
+
source_ref = await refs.get(source or domain.default_branch)
|
|
324
|
+
if source_ref is None or source_ref.kind is RefKind.SNAPSHOT:
|
|
325
|
+
raise ProblemError(NOT_FOUND, f"Branch or tag {source!r} not found.")
|
|
326
|
+
revision = source_ref.head
|
|
327
|
+
await require_revision(session, domain_id, revision)
|
|
328
|
+
ref = await refs.create(
|
|
329
|
+
name,
|
|
330
|
+
revision,
|
|
331
|
+
created_at=store.clock(),
|
|
332
|
+
created_by=principal.id,
|
|
333
|
+
kind=RefKind.TAG,
|
|
334
|
+
materialization=Materialization.NONE,
|
|
335
|
+
reason=TAG_CREATE_REASON,
|
|
336
|
+
)
|
|
337
|
+
return CreatedRef(ref=ref)
|