mini-runbot 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mini_runbot/__init__.py +3 -0
- mini_runbot/adapters/git/cli.py +165 -0
- mini_runbot/adapters/persistence/sqlite.py +270 -0
- mini_runbot/adapters/runtime/compose.py +323 -0
- mini_runbot/adapters/runtime/local.py +30 -0
- mini_runbot/adapters/runtime/ports.py +49 -0
- mini_runbot/api/app.py +147 -0
- mini_runbot/api/schemas.py +73 -0
- mini_runbot/application/build_manager.py +390 -0
- mini_runbot/application/executor.py +41 -0
- mini_runbot/application/scheduler.py +60 -0
- mini_runbot/bootstrap.py +28 -0
- mini_runbot/cli/init_config.py +228 -0
- mini_runbot/cli/main.py +346 -0
- mini_runbot/cli/presentation.py +167 -0
- mini_runbot/config.py +129 -0
- mini_runbot/domain/enums.py +24 -0
- mini_runbot/domain/errors.py +45 -0
- mini_runbot/domain/models.py +72 -0
- mini_runbot/domain/transitions.py +43 -0
- mini_runbot/domain/validation.py +88 -0
- mini_runbot/ports/repositories.py +23 -0
- mini_runbot/ports/services.py +62 -0
- mini_runbot/resources/config.example.yaml +22 -0
- mini_runbot/templates/compose.yaml.j2 +49 -0
- mini_runbot/web/app.js +247 -0
- mini_runbot/web/index.html +94 -0
- mini_runbot/web/styles.css +148 -0
- mini_runbot-0.2.0.dist-info/METADATA +146 -0
- mini_runbot-0.2.0.dist-info/RECORD +33 -0
- mini_runbot-0.2.0.dist-info/WHEEL +5 -0
- mini_runbot-0.2.0.dist-info/entry_points.txt +2 -0
- mini_runbot-0.2.0.dist-info/top_level.txt +1 -0
mini_runbot/__init__.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from mini_runbot.config import Settings
|
|
9
|
+
from mini_runbot.domain.errors import GitOperationError, UnsafePathError
|
|
10
|
+
from mini_runbot.domain.models import RepositoryRevision
|
|
11
|
+
|
|
12
|
+
REMOTE_GIT_SCHEMES = {"https", "ssh"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_remote_git_url(value: str) -> bool:
|
|
16
|
+
"""Return whether a configured source is an allowed remote Git URL."""
|
|
17
|
+
parsed = urlsplit(value)
|
|
18
|
+
if parsed.scheme.lower() in REMOTE_GIT_SCHEMES:
|
|
19
|
+
return True
|
|
20
|
+
return value.startswith("git@") and ":" in value.partition("@")[2]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_remote_git_url(value: str) -> None:
|
|
24
|
+
parsed = urlsplit(value)
|
|
25
|
+
if parsed.scheme:
|
|
26
|
+
if parsed.scheme.lower() not in REMOTE_GIT_SCHEMES:
|
|
27
|
+
raise GitOperationError(
|
|
28
|
+
"Remote repository URL must use HTTPS or SSH"
|
|
29
|
+
)
|
|
30
|
+
if not parsed.hostname:
|
|
31
|
+
raise GitOperationError("Remote repository URL must include a host")
|
|
32
|
+
if parsed.scheme.lower() == "https" and (parsed.username or parsed.password):
|
|
33
|
+
raise GitOperationError(
|
|
34
|
+
"HTTPS repository URLs must not contain embedded credentials"
|
|
35
|
+
)
|
|
36
|
+
if parsed.password:
|
|
37
|
+
raise GitOperationError(
|
|
38
|
+
"SSH repository URLs must not contain an embedded password"
|
|
39
|
+
)
|
|
40
|
+
return
|
|
41
|
+
if value.startswith("git@") and ":" in value.partition("@")[2]:
|
|
42
|
+
return
|
|
43
|
+
raise GitOperationError("Remote repository URL must use HTTPS or SSH")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GitCliService:
|
|
47
|
+
def __init__(self, settings: Settings) -> None:
|
|
48
|
+
self.settings = settings
|
|
49
|
+
|
|
50
|
+
def checkout(
|
|
51
|
+
self,
|
|
52
|
+
revision: RepositoryRevision,
|
|
53
|
+
workspace_path: Path,
|
|
54
|
+
log_path: Path,
|
|
55
|
+
) -> RepositoryRevision:
|
|
56
|
+
config = self.settings.repository(revision.name, revision.requested_ref)
|
|
57
|
+
sources_root = (workspace_path / "sources").resolve()
|
|
58
|
+
target = (sources_root / config.target).resolve()
|
|
59
|
+
if target.parent != sources_root:
|
|
60
|
+
raise UnsafePathError(f"Checkout target escaped sources directory: {target}")
|
|
61
|
+
if target.exists():
|
|
62
|
+
raise GitOperationError(f"Checkout target already exists: {target}")
|
|
63
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
if is_remote_git_url(config.url) or "://" in config.url:
|
|
67
|
+
source = config.url
|
|
68
|
+
sha, output = self._checkout_remote(source, revision.requested_ref, target)
|
|
69
|
+
else:
|
|
70
|
+
source_path = Path(config.url).expanduser().resolve()
|
|
71
|
+
if not source_path.is_dir():
|
|
72
|
+
raise GitOperationError(
|
|
73
|
+
f"Configured repository does not exist: {source_path}"
|
|
74
|
+
)
|
|
75
|
+
source_check = self._run(
|
|
76
|
+
["git", "-C", str(source_path), "rev-parse", "--is-inside-work-tree"]
|
|
77
|
+
)
|
|
78
|
+
if source_check.strip() != "true":
|
|
79
|
+
raise GitOperationError(
|
|
80
|
+
f"Configured source is not a Git repository: {source_path}"
|
|
81
|
+
)
|
|
82
|
+
sha = self._run(
|
|
83
|
+
[
|
|
84
|
+
"git",
|
|
85
|
+
"-C",
|
|
86
|
+
str(source_path),
|
|
87
|
+
"rev-parse",
|
|
88
|
+
"--verify",
|
|
89
|
+
f"{revision.requested_ref}^{{commit}}",
|
|
90
|
+
]
|
|
91
|
+
).strip()
|
|
92
|
+
output = self._run(
|
|
93
|
+
["git", "clone", "--no-checkout", "--", str(source_path), str(target)]
|
|
94
|
+
)
|
|
95
|
+
output += self._run(
|
|
96
|
+
["git", "-C", str(target), "checkout", "--detach", sha]
|
|
97
|
+
)
|
|
98
|
+
source = str(source_path)
|
|
99
|
+
with log_path.open("a", encoding="utf-8") as log:
|
|
100
|
+
log.write(f"== {revision.name} @ {revision.requested_ref} ==\n")
|
|
101
|
+
log.write(output)
|
|
102
|
+
if output and not output.endswith("\n"):
|
|
103
|
+
log.write("\n")
|
|
104
|
+
addons_path = (target / config.addons_subpath).resolve()
|
|
105
|
+
if target not in {addons_path, *addons_path.parents} or not addons_path.is_dir():
|
|
106
|
+
raise GitOperationError(
|
|
107
|
+
f"Configured addons_subpath does not exist inside checkout: {config.addons_subpath}"
|
|
108
|
+
)
|
|
109
|
+
return replace(
|
|
110
|
+
revision,
|
|
111
|
+
source=source,
|
|
112
|
+
commit_sha=sha,
|
|
113
|
+
checkout_path=str(addons_path),
|
|
114
|
+
addons_priority=config.addons_priority,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _checkout_remote(
|
|
118
|
+
self, source: str, requested_ref: str, target: Path
|
|
119
|
+
) -> tuple[str, str]:
|
|
120
|
+
validate_remote_git_url(source)
|
|
121
|
+
target.mkdir()
|
|
122
|
+
output = self._run(["git", "init", str(target)])
|
|
123
|
+
output += self._run(
|
|
124
|
+
["git", "-C", str(target), "remote", "add", "origin", source]
|
|
125
|
+
)
|
|
126
|
+
output += self._run(
|
|
127
|
+
[
|
|
128
|
+
"git",
|
|
129
|
+
"-C",
|
|
130
|
+
str(target),
|
|
131
|
+
"fetch",
|
|
132
|
+
"--depth=1",
|
|
133
|
+
"--no-tags",
|
|
134
|
+
"origin",
|
|
135
|
+
requested_ref,
|
|
136
|
+
]
|
|
137
|
+
)
|
|
138
|
+
sha = self._run(
|
|
139
|
+
[
|
|
140
|
+
"git",
|
|
141
|
+
"-C",
|
|
142
|
+
str(target),
|
|
143
|
+
"rev-parse",
|
|
144
|
+
"--verify",
|
|
145
|
+
"FETCH_HEAD^{commit}",
|
|
146
|
+
]
|
|
147
|
+
).strip()
|
|
148
|
+
output += self._run(
|
|
149
|
+
["git", "-C", str(target), "checkout", "--detach", sha]
|
|
150
|
+
)
|
|
151
|
+
return sha, output
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _run(arguments: list[str]) -> str:
|
|
155
|
+
try:
|
|
156
|
+
result = subprocess.run(
|
|
157
|
+
arguments, capture_output=True, text=True, timeout=120, check=False
|
|
158
|
+
)
|
|
159
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
160
|
+
raise GitOperationError(f"Git command could not run: {exc}") from exc
|
|
161
|
+
output = f"{result.stdout}{result.stderr}"
|
|
162
|
+
if result.returncode != 0:
|
|
163
|
+
safe_command = " ".join(arguments[:3])
|
|
164
|
+
raise GitOperationError(f"Git command failed ({safe_command}): {output[-500:]}")
|
|
165
|
+
return output
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import (
|
|
7
|
+
JSON,
|
|
8
|
+
DateTime,
|
|
9
|
+
Integer,
|
|
10
|
+
String,
|
|
11
|
+
create_engine,
|
|
12
|
+
delete,
|
|
13
|
+
inspect,
|
|
14
|
+
select,
|
|
15
|
+
text,
|
|
16
|
+
update,
|
|
17
|
+
)
|
|
18
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
19
|
+
from sqlalchemy.exc import IntegrityError
|
|
20
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
|
|
21
|
+
|
|
22
|
+
from mini_runbot.domain.enums import BuildStatus, StageStatus
|
|
23
|
+
from mini_runbot.domain.errors import ConcurrentUpdateError
|
|
24
|
+
from mini_runbot.domain.models import Build, RepositoryRevision, StageResult
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Base(DeclarativeBase):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BuildRow(Base):
|
|
32
|
+
__tablename__ = "builds"
|
|
33
|
+
|
|
34
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
35
|
+
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
36
|
+
requested_ref: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
37
|
+
repositories: Mapped[list[dict[str, object]]] = mapped_column(JSON, nullable=False)
|
|
38
|
+
modules: Mapped[list[str]] = mapped_column(JSON, nullable=False)
|
|
39
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
40
|
+
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
41
|
+
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
42
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
43
|
+
host_port: Mapped[int | None] = mapped_column(Integer)
|
|
44
|
+
database_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
45
|
+
compose_project_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
46
|
+
workspace_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
47
|
+
preview_url: Mapped[str | None] = mapped_column(String(2048))
|
|
48
|
+
failure_stage: Mapped[str | None] = mapped_column(String(64))
|
|
49
|
+
failure_message: Mapped[str | None] = mapped_column(String(1000))
|
|
50
|
+
stages: Mapped[list[dict[str, object]]] = mapped_column(JSON, nullable=False, default=list)
|
|
51
|
+
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PortLeaseRow(Base):
|
|
55
|
+
__tablename__ = "port_leases"
|
|
56
|
+
|
|
57
|
+
port: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
58
|
+
build_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SqliteBuildRepository:
|
|
62
|
+
def __init__(self, database_url: str) -> None:
|
|
63
|
+
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
|
64
|
+
self.engine = create_engine(database_url, connect_args=connect_args)
|
|
65
|
+
self.sessions = sessionmaker(self.engine, expire_on_commit=False)
|
|
66
|
+
|
|
67
|
+
def create_schema(self) -> None:
|
|
68
|
+
Base.metadata.create_all(self.engine)
|
|
69
|
+
columns = {item["name"] for item in inspect(self.engine).get_columns("builds")}
|
|
70
|
+
if "stages" not in columns:
|
|
71
|
+
with self.engine.begin() as connection:
|
|
72
|
+
connection.execute(
|
|
73
|
+
text("ALTER TABLE builds ADD COLUMN stages JSON NOT NULL DEFAULT '[]'")
|
|
74
|
+
)
|
|
75
|
+
self._seed_port_leases()
|
|
76
|
+
|
|
77
|
+
def add(self, build: Build) -> None:
|
|
78
|
+
with self.sessions.begin() as session:
|
|
79
|
+
session.add(self._to_row(build))
|
|
80
|
+
|
|
81
|
+
def get(self, build_id: str) -> Build | None:
|
|
82
|
+
with self.sessions() as session:
|
|
83
|
+
row = session.get(BuildRow, build_id)
|
|
84
|
+
return self._to_domain(row) if row else None
|
|
85
|
+
|
|
86
|
+
def list(self) -> list[Build]:
|
|
87
|
+
with self.sessions() as session:
|
|
88
|
+
rows = session.scalars(select(BuildRow).order_by(BuildRow.created_at.desc())).all()
|
|
89
|
+
return [self._to_domain(row) for row in rows]
|
|
90
|
+
|
|
91
|
+
def update(self, build: Build) -> None:
|
|
92
|
+
previous_version = build.version
|
|
93
|
+
values = self._values(build)
|
|
94
|
+
values["version"] = previous_version + 1
|
|
95
|
+
with self.sessions.begin() as session:
|
|
96
|
+
result = session.execute(
|
|
97
|
+
update(BuildRow)
|
|
98
|
+
.where(BuildRow.id == build.id, BuildRow.version == previous_version)
|
|
99
|
+
.values(**values)
|
|
100
|
+
)
|
|
101
|
+
if result.rowcount != 1:
|
|
102
|
+
raise ConcurrentUpdateError(f"Build {build.id} changed concurrently")
|
|
103
|
+
build.version += 1
|
|
104
|
+
|
|
105
|
+
def delete(self, build_id: str) -> None:
|
|
106
|
+
with self.sessions.begin() as session:
|
|
107
|
+
session.execute(
|
|
108
|
+
delete(PortLeaseRow).where(PortLeaseRow.build_id == build_id)
|
|
109
|
+
)
|
|
110
|
+
session.execute(delete(BuildRow).where(BuildRow.id == build_id))
|
|
111
|
+
|
|
112
|
+
def try_acquire_port(self, build_id: str, port: int) -> bool:
|
|
113
|
+
try:
|
|
114
|
+
with self.sessions.begin() as session:
|
|
115
|
+
existing = session.scalar(
|
|
116
|
+
select(PortLeaseRow).where(PortLeaseRow.build_id == build_id)
|
|
117
|
+
)
|
|
118
|
+
if existing is not None:
|
|
119
|
+
return existing.port == port
|
|
120
|
+
session.add(PortLeaseRow(port=port, build_id=build_id))
|
|
121
|
+
session.flush()
|
|
122
|
+
except IntegrityError:
|
|
123
|
+
return False
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def release_port(self, build_id: str, port: int) -> None:
|
|
127
|
+
with self.sessions.begin() as session:
|
|
128
|
+
session.execute(
|
|
129
|
+
delete(PortLeaseRow).where(
|
|
130
|
+
PortLeaseRow.port == port, PortLeaseRow.build_id == build_id
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def reconcile_port_leases(self) -> list[int]:
|
|
135
|
+
terminal = {BuildStatus.DESTROYED.value}
|
|
136
|
+
released: list[int] = []
|
|
137
|
+
with self.sessions.begin() as session:
|
|
138
|
+
builds = {row.id: row for row in session.scalars(select(BuildRow)).all()}
|
|
139
|
+
leases = session.scalars(select(PortLeaseRow)).all()
|
|
140
|
+
for lease in leases:
|
|
141
|
+
build = builds.get(lease.build_id)
|
|
142
|
+
if (
|
|
143
|
+
build is None
|
|
144
|
+
or build.status in terminal
|
|
145
|
+
or build.host_port != lease.port
|
|
146
|
+
):
|
|
147
|
+
released.append(lease.port)
|
|
148
|
+
session.delete(lease)
|
|
149
|
+
self._insert_missing_port_leases(session, builds.values(), terminal)
|
|
150
|
+
return released
|
|
151
|
+
|
|
152
|
+
def _seed_port_leases(self) -> None:
|
|
153
|
+
terminal = {BuildStatus.DESTROYED.value}
|
|
154
|
+
with self.sessions.begin() as session:
|
|
155
|
+
self._insert_missing_port_leases(
|
|
156
|
+
session, session.scalars(select(BuildRow)).all(), terminal
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _insert_missing_port_leases(session, builds, terminal: set[str]) -> None:
|
|
161
|
+
for build in builds:
|
|
162
|
+
if build.host_port is None or build.status in terminal:
|
|
163
|
+
continue
|
|
164
|
+
statement = (
|
|
165
|
+
sqlite_insert(PortLeaseRow)
|
|
166
|
+
.values(port=build.host_port, build_id=build.id)
|
|
167
|
+
.on_conflict_do_nothing()
|
|
168
|
+
)
|
|
169
|
+
session.execute(statement)
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _repo_dict(repo: RepositoryRevision) -> dict[str, object]:
|
|
173
|
+
return {
|
|
174
|
+
"name": repo.name,
|
|
175
|
+
"source": repo.source,
|
|
176
|
+
"requested_ref": repo.requested_ref,
|
|
177
|
+
"commit_sha": repo.commit_sha,
|
|
178
|
+
"checkout_path": repo.checkout_path,
|
|
179
|
+
"addons_priority": repo.addons_priority,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def _values(self, build: Build) -> dict[str, object]:
|
|
183
|
+
return {
|
|
184
|
+
"status": build.status.value,
|
|
185
|
+
"requested_ref": build.requested_ref,
|
|
186
|
+
"repositories": [self._repo_dict(repo) for repo in build.repositories],
|
|
187
|
+
"modules": build.modules,
|
|
188
|
+
"created_at": build.created_at,
|
|
189
|
+
"started_at": build.started_at,
|
|
190
|
+
"finished_at": build.finished_at,
|
|
191
|
+
"expires_at": build.expires_at,
|
|
192
|
+
"host_port": build.host_port,
|
|
193
|
+
"database_name": build.database_name,
|
|
194
|
+
"compose_project_name": build.compose_project_name,
|
|
195
|
+
"workspace_path": str(build.workspace_path),
|
|
196
|
+
"preview_url": build.preview_url,
|
|
197
|
+
"failure_stage": build.failure_stage,
|
|
198
|
+
"failure_message": build.failure_message,
|
|
199
|
+
"stages": [self._stage_dict(stage) for stage in build.stages],
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _stage_dict(stage: StageResult) -> dict[str, object]:
|
|
204
|
+
return {
|
|
205
|
+
"name": stage.name,
|
|
206
|
+
"status": stage.status.value,
|
|
207
|
+
"started_at": stage.started_at.isoformat() if stage.started_at else None,
|
|
208
|
+
"finished_at": stage.finished_at.isoformat() if stage.finished_at else None,
|
|
209
|
+
"duration_seconds": stage.duration_seconds,
|
|
210
|
+
"exit_code": stage.exit_code,
|
|
211
|
+
"log_path": stage.log_path,
|
|
212
|
+
"summary": stage.summary,
|
|
213
|
+
"metadata": stage.metadata,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
def _to_row(self, build: Build) -> BuildRow:
|
|
217
|
+
return BuildRow(id=build.id, version=build.version, **self._values(build))
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _to_domain(row: BuildRow) -> Build:
|
|
221
|
+
return Build(
|
|
222
|
+
id=row.id,
|
|
223
|
+
status=BuildStatus(row.status),
|
|
224
|
+
requested_ref=row.requested_ref,
|
|
225
|
+
repositories=[RepositoryRevision(**item) for item in row.repositories],
|
|
226
|
+
modules=list(row.modules),
|
|
227
|
+
created_at=_as_utc(row.created_at),
|
|
228
|
+
started_at=_as_utc(row.started_at),
|
|
229
|
+
finished_at=_as_utc(row.finished_at),
|
|
230
|
+
expires_at=_as_utc(row.expires_at),
|
|
231
|
+
host_port=row.host_port,
|
|
232
|
+
database_name=row.database_name,
|
|
233
|
+
compose_project_name=row.compose_project_name,
|
|
234
|
+
workspace_path=Path(row.workspace_path),
|
|
235
|
+
preview_url=row.preview_url,
|
|
236
|
+
failure_stage=row.failure_stage,
|
|
237
|
+
failure_message=row.failure_message,
|
|
238
|
+
stages=[
|
|
239
|
+
StageResult(
|
|
240
|
+
name=item["name"],
|
|
241
|
+
status=StageStatus(item["status"]),
|
|
242
|
+
started_at=(
|
|
243
|
+
_as_utc(datetime.fromisoformat(item["started_at"]))
|
|
244
|
+
if item.get("started_at")
|
|
245
|
+
else None
|
|
246
|
+
),
|
|
247
|
+
finished_at=(
|
|
248
|
+
_as_utc(datetime.fromisoformat(item["finished_at"]))
|
|
249
|
+
if item.get("finished_at")
|
|
250
|
+
else None
|
|
251
|
+
),
|
|
252
|
+
duration_seconds=item.get("duration_seconds"),
|
|
253
|
+
exit_code=item.get("exit_code"),
|
|
254
|
+
log_path=item.get("log_path"),
|
|
255
|
+
summary=item.get("summary"),
|
|
256
|
+
metadata=item.get("metadata", {}),
|
|
257
|
+
)
|
|
258
|
+
for item in row.stages
|
|
259
|
+
],
|
|
260
|
+
version=row.version,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _as_utc(value: datetime | None) -> datetime | None:
|
|
265
|
+
"""Restore the UTC contract that SQLite's datetime adapter cannot retain."""
|
|
266
|
+
if value is None:
|
|
267
|
+
return None
|
|
268
|
+
if value.tzinfo is None:
|
|
269
|
+
return value.replace(tzinfo=UTC)
|
|
270
|
+
return value.astimezone(UTC)
|