gitd-client 0.14.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.
gitd/models.py ADDED
@@ -0,0 +1,369 @@
1
+ """What gitd answers, as plain dataclasses, each built from the message gitd sends. The
2
+ messages are made from the contract at run time, so their fields are typed here as Any."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from enum import StrEnum
9
+ from typing import Any
10
+
11
+ from . import messages
12
+ from .publishing_settings import OpenedPullRequest, Provenance
13
+ from .timestamps import Timestamp
14
+
15
+ STRATEGY_PREFIX = "STRATEGY_"
16
+ ACTION_PREFIX = "ACTION_"
17
+ ACCESS_PREFIX = "ACCESS_"
18
+ REASON_PREFIX = "REASON_"
19
+
20
+
21
+ class Strategy(StrEnum):
22
+ """How a workspace is made: a layer on a shared template, a block clone, or every file written."""
23
+
24
+ OVERLAY = "overlay"
25
+ REFLINK = "reflink"
26
+ COPY = "copy"
27
+
28
+ @classmethod
29
+ def from_message(cls, number: int) -> Strategy:
30
+ name = messages.STRATEGY.name(number).removeprefix(STRATEGY_PREFIX).lower()
31
+ return cls(name) if name in {member.value for member in cls} else cls.COPY
32
+
33
+ @classmethod
34
+ def preference(cls, strategy: Strategy | None) -> int:
35
+ """The number a request carries: the strategy asked for, or none, for gitd to choose."""
36
+ name = strategy.value.upper() if strategy else "UNSPECIFIED"
37
+ return messages.STRATEGY.number(STRATEGY_PREFIX + name)
38
+
39
+
40
+ class Access(StrEnum):
41
+ """Whether a repository can be read: by anyone, with a credential gitd keeps, or not at all."""
42
+
43
+ PUBLIC = "public"
44
+ REACHABLE = "reachable"
45
+ DENIED = "denied"
46
+
47
+ @classmethod
48
+ def from_message(cls, number: int) -> Access:
49
+ name = messages.ACCESS.name(number).removeprefix(ACCESS_PREFIX).lower()
50
+ return cls(name) if name in {member.value for member in cls} else cls.DENIED
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Progress:
55
+ """How far a fetch or checkout has gone; repository is the submodule's URL, empty for the
56
+ workspace's own repository."""
57
+
58
+ phase: str
59
+ percent: int
60
+ repository: str
61
+
62
+ @classmethod
63
+ def from_message(cls, progress: Any) -> Progress:
64
+ return cls(phase=progress.phase, percent=progress.percent, repository=progress.repository)
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Layers:
69
+ """An overlay workspace's template (lower), its own layer (upper, work), and the warm
70
+ layer between them, when it stands on one."""
71
+
72
+ lower: str
73
+ upper: str
74
+ work: str
75
+ warm: str = ""
76
+
77
+ @classmethod
78
+ def from_message(cls, layers: Any) -> Layers:
79
+ return cls(lower=layers.lower, upper=layers.upper, work=layers.work, warm=layers.warm)
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class PendingSubmodule:
84
+ """A submodule that could not be checked out, and what git said."""
85
+
86
+ path: str
87
+ url: str
88
+ reason: str
89
+
90
+ @classmethod
91
+ def from_message(cls, submodule: Any) -> PendingSubmodule:
92
+ return cls(path=submodule.path, url=submodule.url, reason=submodule.failure.git_said)
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Workspace:
97
+ """Where a workspace ended up. branch is empty when it is detached at a commit;
98
+ lfs_patterns are the files the repository keeps in git LFS, which are pointers here, as
99
+ gitd does not download them."""
100
+
101
+ path: str
102
+ commit: str
103
+ branch: str
104
+ strategy: Strategy
105
+ layers: Layers | None = None
106
+ pending_submodules: list[PendingSubmodule] = field(default_factory=list)
107
+ lfs_patterns: list[str] = field(default_factory=list)
108
+
109
+ @classmethod
110
+ def from_message(cls, path: str, result: Any) -> Workspace:
111
+ return cls(
112
+ path=path,
113
+ commit=result.commit,
114
+ branch=result.branch,
115
+ strategy=Strategy.from_message(result.strategy),
116
+ layers=Layers.from_message(result.layers) if result.HasField("layers") else None,
117
+ pending_submodules=[
118
+ PendingSubmodule.from_message(pending) for pending in result.pending_submodules
119
+ ],
120
+ lfs_patterns=list(result.lfs_patterns),
121
+ )
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class Submodule:
126
+ path: str
127
+ url: str
128
+ commit: str
129
+ checked_out: bool
130
+
131
+ @classmethod
132
+ def from_message(cls, submodule: Any) -> Submodule:
133
+ return cls(
134
+ path=submodule.path,
135
+ url=submodule.url,
136
+ commit=submodule.commit,
137
+ checked_out=submodule.checked_out,
138
+ )
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class Description:
143
+ """What a workspace is on disk. modified says whether it has uncommitted changes;
144
+ lfs_patterns are the files kept in git LFS, which are pointers here."""
145
+
146
+ url: str
147
+ commit: str
148
+ branch: str
149
+ modified: bool
150
+ strategy: Strategy
151
+ submodules: list[Submodule] = field(default_factory=list)
152
+ lfs_patterns: list[str] = field(default_factory=list)
153
+
154
+ @classmethod
155
+ def from_message(cls, response: Any) -> Description:
156
+ return cls(
157
+ url=response.url,
158
+ commit=response.commit,
159
+ branch=response.branch,
160
+ modified=response.modified,
161
+ strategy=Strategy.from_message(response.strategy),
162
+ submodules=[Submodule.from_message(submodule) for submodule in response.submodules],
163
+ lfs_patterns=list(response.lfs_patterns),
164
+ )
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class PublishedRepository:
169
+ """A repository a publish pushed. path is the submodule's, empty for the workspace's own
170
+ repository; subjects are the new commits' subjects, newest first."""
171
+
172
+ url: str
173
+ path: str
174
+ commit: str
175
+ subjects: list[str]
176
+
177
+ @classmethod
178
+ def from_message(cls, repository: Any) -> PublishedRepository:
179
+ return cls(
180
+ url=repository.url,
181
+ path=repository.path,
182
+ commit=repository.commit,
183
+ subjects=list(repository.subjects),
184
+ )
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class Refusal:
189
+ """Why gitd refused a call: its reason (BRANCH_REFUSED, AUTH_REQUIRED, …) and what git said."""
190
+
191
+ reason: str
192
+ said: str
193
+
194
+ @classmethod
195
+ def from_message(cls, failure: Any) -> Refusal:
196
+ return cls(reason=cls.reason_of(failure), said=failure.git_said)
197
+
198
+ @staticmethod
199
+ def reason_of(failure: Any) -> str:
200
+ return messages.REASON.name(failure.reason).removeprefix(REASON_PREFIX)
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class SubmodulePullRequest:
205
+ """The pull request of one submodule, at path in the workspace and of the repository at
206
+ url, or why it could not be opened."""
207
+
208
+ path: str
209
+ url: str
210
+ pull_request: OpenedPullRequest | None = None
211
+ refused: Refusal | None = None
212
+
213
+ @classmethod
214
+ def from_message(cls, submodule: Any) -> SubmodulePullRequest:
215
+ return cls(
216
+ path=submodule.path,
217
+ url=submodule.url,
218
+ pull_request=(
219
+ OpenedPullRequest.from_message(submodule.pull_request)
220
+ if submodule.HasField("pull_request")
221
+ else None
222
+ ),
223
+ refused=Refusal.from_message(submodule.failure) if submodule.HasField("failure") else None,
224
+ )
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class Published:
229
+ """What a publish pushed: the workspace's repository first, then each submodule; the
230
+ pull request it opened or updated, or why it could not, the branch being published
231
+ either way; and each submodule's, when asked for. An update that brought nothing new
232
+ lists the workspace's repository with no subjects."""
233
+
234
+ branch: str
235
+ repositories: list[PublishedRepository]
236
+ pull_request: OpenedPullRequest | None = None
237
+ pull_request_refused: Refusal | None = None
238
+ submodule_pull_requests: list[SubmodulePullRequest] = field(default_factory=list)
239
+
240
+ @classmethod
241
+ def from_message(cls, response: Any) -> Published:
242
+ pushed = [response.repository, *response.submodules] if response.HasField("repository") else []
243
+ return cls(
244
+ branch=response.branch,
245
+ repositories=[PublishedRepository.from_message(repository) for repository in pushed],
246
+ pull_request=(
247
+ OpenedPullRequest.from_message(response.pull_request)
248
+ if response.HasField("pull_request")
249
+ else None
250
+ ),
251
+ pull_request_refused=(
252
+ Refusal.from_message(response.pull_request_failure)
253
+ if response.HasField("pull_request_failure")
254
+ else None
255
+ ),
256
+ submodule_pull_requests=[
257
+ SubmodulePullRequest.from_message(submodule) for submodule in response.submodule_pull_requests
258
+ ],
259
+ )
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class Info:
264
+ """Which gitd answers, and how it makes workspaces on its machine and why."""
265
+
266
+ version: str
267
+ git_version: str
268
+ pools: int
269
+ pools_bytes: int
270
+ templates: int
271
+ templates_bytes: int
272
+ strategy: Strategy
273
+ strategy_reason: str
274
+
275
+ @classmethod
276
+ def from_message(cls, response: Any) -> Info:
277
+ return cls(
278
+ version=response.version,
279
+ git_version=response.git_version,
280
+ pools=response.pools,
281
+ pools_bytes=response.pools_bytes,
282
+ templates=response.templates,
283
+ templates_bytes=response.templates_bytes,
284
+ strategy=Strategy.from_message(response.strategy),
285
+ strategy_reason=response.strategy_reason,
286
+ )
287
+
288
+
289
+ @dataclass(frozen=True)
290
+ class Probe:
291
+ """Whether a repository can be read, its default branch, and each branch's commit.
292
+ denied says why it cannot be read, when it cannot."""
293
+
294
+ access: Access
295
+ default_branch: str
296
+ branches: dict[str, str]
297
+ denied: str | None = None
298
+
299
+ @classmethod
300
+ def from_message(cls, response: Any) -> Probe:
301
+ return cls(
302
+ access=Access.from_message(response.access),
303
+ default_branch=response.default_branch,
304
+ branches=dict(response.commits),
305
+ denied=response.failure.git_said if response.HasField("failure") else None,
306
+ )
307
+
308
+
309
+ @dataclass(frozen=True)
310
+ class ConfigurationChange:
311
+ """A change to the credentials or publishing settings: connection_added,
312
+ connection_removed, settings_set or settings_removed; subject is the credential's id or
313
+ the settings' prefix."""
314
+
315
+ action: str
316
+ subject: str
317
+ url_prefix: str
318
+
319
+ @classmethod
320
+ def from_message(cls, change: Any) -> ConfigurationChange:
321
+ action = messages.CHANGE_ACTION.name(change.action).removeprefix(ACTION_PREFIX).lower()
322
+ return cls(action=action, subject=change.subject, url_prefix=change.url_prefix)
323
+
324
+
325
+ @dataclass(frozen=True)
326
+ class AuditEntry:
327
+ """One publish, done or refused, or one change to credentials or settings, as the audit
328
+ log keeps it. url is the workspace's repository, empty when the workspace could not be
329
+ opened; repositories is what was pushed, the workspace's repository first, empty when
330
+ refused; requested_by and the pull request are as the publish said and did."""
331
+
332
+ time: datetime
333
+ workspace: str
334
+ url: str
335
+ branch: str
336
+ repositories: list[PublishedRepository] = field(default_factory=list)
337
+ refused: Refusal | None = None
338
+ requested_by: str = ""
339
+ pull_request: OpenedPullRequest | None = None
340
+ pull_request_refused: Refusal | None = None
341
+ change: ConfigurationChange | None = None
342
+ provenance: Provenance | None = None
343
+ submodule_pull_requests: list[SubmodulePullRequest] = field(default_factory=list)
344
+
345
+ @classmethod
346
+ def from_message(cls, entry: Any) -> AuditEntry:
347
+ pushed = [entry.repository, *entry.submodules] if entry.HasField("repository") else []
348
+ return cls(
349
+ time=Timestamp.to_datetime(entry.time),
350
+ workspace=entry.workspace,
351
+ url=entry.url,
352
+ branch=entry.branch,
353
+ repositories=[PublishedRepository.from_message(repository) for repository in pushed],
354
+ refused=Refusal.from_message(entry.refusal) if entry.HasField("refusal") else None,
355
+ requested_by=entry.requested_by,
356
+ pull_request=OpenedPullRequest.from_message(entry.pull_request)
357
+ if entry.HasField("pull_request")
358
+ else None,
359
+ pull_request_refused=(
360
+ Refusal.from_message(entry.pull_request_failure)
361
+ if entry.HasField("pull_request_failure")
362
+ else None
363
+ ),
364
+ change=ConfigurationChange.from_message(entry.change) if entry.HasField("change") else None,
365
+ provenance=Provenance.from_message(entry.provenance) if entry.HasField("provenance") else None,
366
+ submodule_pull_requests=[
367
+ SubmodulePullRequest.from_message(submodule) for submodule in entry.submodule_pull_requests
368
+ ],
369
+ )
@@ -0,0 +1,47 @@
1
+ """The calls that read and set the rules publishing follows: for a platform's own pages,
2
+ never for an agent."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from . import messages, methods
7
+ from .connection import Connection
8
+ from .publishing_settings import Preset, PublishSettings
9
+
10
+
11
+ class Publishing:
12
+ """The publishing settings, per repository prefix, as gitd.publishing.
13
+
14
+ gitd.publishing.set(PublishSettings(url_prefix="https://github.com/acme", preset="recommended",
15
+ branch_prefix="agents/"))
16
+ """
17
+
18
+ def __init__(self, connection: Connection) -> None:
19
+ self.connection = connection
20
+
21
+ def settings(self) -> list[PublishSettings]:
22
+ """Every setting, gitd.toml's included."""
23
+ response = self.connection.call(methods.GET_SETTINGS, messages.GetSettingsRequest())
24
+ return [PublishSettings.from_message(settings) for settings in response.settings]
25
+
26
+ def set(self, settings: PublishSettings) -> PublishSettings:
27
+ """Keeps the settings of a prefix, replacing those it had, as gitd checked them."""
28
+ request = messages.SetSettingsRequest(settings=settings.to_message())
29
+ return PublishSettings.from_message(self.connection.call(methods.SET_SETTINGS, request).settings)
30
+
31
+ def remove(self, url_prefix: str) -> None:
32
+ """Forgets the settings of a prefix set through the API."""
33
+ self.connection.call(methods.REMOVE_SETTINGS, messages.RemoveSettingsRequest(url_prefix=url_prefix))
34
+
35
+ def presets(self) -> list[Preset]:
36
+ """Every preset as it applies: gitd's own, gitd.toml's and those set through the API."""
37
+ response = self.connection.call(methods.LIST_PRESETS, messages.ListPresetsRequest())
38
+ return [Preset.from_message(preset) for preset in response.presets]
39
+
40
+ def set_preset(self, preset: Preset) -> Preset:
41
+ """Keeps a preset, replacing one of the same name; "recommended" replaces gitd's own."""
42
+ request = messages.SetPresetRequest(preset=preset.to_message())
43
+ return Preset.from_message(self.connection.call(methods.SET_PRESET, request).preset)
44
+
45
+ def remove_preset(self, name: str) -> None:
46
+ """Forgets a preset set through the API; refused while settings take it."""
47
+ self.connection.call(methods.REMOVE_PRESET, messages.RemovePresetRequest(name=name))
@@ -0,0 +1,216 @@
1
+ """The rules publishing follows, and what a publish says of its pull request, as plain
2
+ dataclasses that write the messages gitd takes and read those it answers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from enum import StrEnum
8
+ from typing import Any
9
+
10
+ from . import messages
11
+
12
+ OPENING_PREFIX = "OPEN_"
13
+ ORIGIN_PREFIX = "ORIGIN_"
14
+
15
+
16
+ class Opening(StrEnum):
17
+ """When publishes open pull requests: only when a publish asks, or always."""
18
+
19
+ WHEN_ASKED = "when_asked"
20
+ ALWAYS = "always"
21
+
22
+ @classmethod
23
+ def from_message(cls, number: int) -> Opening:
24
+ name = messages.OPENING.name(number).removeprefix(OPENING_PREFIX).lower()
25
+ return cls(name) if name in {member.value for member in cls} else cls.WHEN_ASKED
26
+
27
+ def to_message(self) -> int:
28
+ return messages.OPENING.number(OPENING_PREFIX + self.value.upper())
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PullRequestSettings:
33
+ """How publishes open pull requests: when, as drafts or not, with which labels, and
34
+ whether the person a publish names as requester is asked to review."""
35
+
36
+ opening: Opening = Opening.WHEN_ASKED
37
+ draft: bool = False
38
+ labels: list[str] = field(default_factory=list)
39
+ request_review_from_requester: bool = False
40
+
41
+ @classmethod
42
+ def from_message(cls, settings: Any) -> PullRequestSettings:
43
+ return cls(
44
+ opening=Opening.from_message(settings.open),
45
+ draft=settings.draft,
46
+ labels=list(settings.labels),
47
+ request_review_from_requester=settings.request_review_from_requester,
48
+ )
49
+
50
+ def to_message(self) -> Any:
51
+ return messages.PullRequestSettings(
52
+ open=self.opening.to_message(),
53
+ draft=self.draft,
54
+ labels=self.labels,
55
+ request_review_from_requester=self.request_review_from_requester,
56
+ )
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class PublishSettings:
61
+ """The rules of the repositories whose address starts with url_prefix (empty for every
62
+ repository): the name of a preset whose rules they take ("recommended" is gitd's own),
63
+ paths a publish may not change and paths it may not delete, as git's glob pathspecs,
64
+ paths those rules leave free, at most how many files it may change, the branch prefix,
65
+ and how pull requests are opened. from_file says gitd.toml declares them."""
66
+
67
+ url_prefix: str = ""
68
+ preset: str = ""
69
+ protected_paths: list[str] = field(default_factory=list)
70
+ kept_paths: list[str] = field(default_factory=list)
71
+ allowed_paths: list[str] = field(default_factory=list)
72
+ max_changed_files: int | None = None
73
+ branch_prefix: str = ""
74
+ pull_requests: PullRequestSettings | None = None
75
+ from_file: bool = False
76
+
77
+ @classmethod
78
+ def from_message(cls, settings: Any) -> PublishSettings:
79
+ return cls(
80
+ url_prefix=settings.url_prefix,
81
+ preset=settings.preset,
82
+ protected_paths=list(settings.protected_paths),
83
+ kept_paths=list(settings.kept_paths),
84
+ allowed_paths=list(settings.allowed_paths),
85
+ max_changed_files=settings.max_changed_files if settings.HasField("max_changed_files") else None,
86
+ branch_prefix=settings.branch_prefix,
87
+ pull_requests=(
88
+ PullRequestSettings.from_message(settings.pull_requests)
89
+ if settings.HasField("pull_requests")
90
+ else None
91
+ ),
92
+ from_file=settings.from_file,
93
+ )
94
+
95
+ def to_message(self) -> Any:
96
+ message: Any = messages.PublishSettings(
97
+ url_prefix=self.url_prefix,
98
+ preset=self.preset,
99
+ protected_paths=self.protected_paths,
100
+ kept_paths=self.kept_paths,
101
+ allowed_paths=self.allowed_paths,
102
+ branch_prefix=self.branch_prefix,
103
+ )
104
+ if self.max_changed_files is not None:
105
+ message.max_changed_files = self.max_changed_files
106
+ if self.pull_requests is not None:
107
+ message.pull_requests.CopyFrom(self.pull_requests.to_message())
108
+ return message
109
+
110
+
111
+ class PresetOrigin(StrEnum):
112
+ """Where a preset comes from: gitd's own, gitd.toml, or the API."""
113
+
114
+ BUILT_IN = "built_in"
115
+ FILE = "file"
116
+ API = "api"
117
+
118
+ @classmethod
119
+ def from_message(cls, number: int) -> PresetOrigin:
120
+ name = messages.PRESET_ORIGIN.name(number).removeprefix(ORIGIN_PREFIX).lower()
121
+ return cls(name) if name in {member.value for member in cls} else cls.API
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class Preset:
126
+ """A named set of rules settings take: paths a publish may not change, paths it may not
127
+ delete, and at most how many files it may change. One named "recommended" replaces
128
+ gitd's own. origin is read from gitd, never sent."""
129
+
130
+ name: str
131
+ protected_paths: list[str] = field(default_factory=list)
132
+ kept_paths: list[str] = field(default_factory=list)
133
+ max_changed_files: int | None = None
134
+ origin: PresetOrigin = PresetOrigin.API
135
+
136
+ @classmethod
137
+ def from_message(cls, preset: Any) -> Preset:
138
+ return cls(
139
+ name=preset.name,
140
+ protected_paths=list(preset.protected_paths),
141
+ kept_paths=list(preset.kept_paths),
142
+ max_changed_files=preset.max_changed_files if preset.HasField("max_changed_files") else None,
143
+ origin=PresetOrigin.from_message(preset.origin),
144
+ )
145
+
146
+ def to_message(self) -> Any:
147
+ message: Any = messages.Preset(
148
+ name=self.name, protected_paths=self.protected_paths, kept_paths=self.kept_paths
149
+ )
150
+ if self.max_changed_files is not None:
151
+ message.max_changed_files = self.max_changed_files
152
+ return message
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class PullRequestOptions:
157
+ """A publish's own say on its pull request: open one even when the settings open them
158
+ only when asked, its title (the newest commit's subject when empty), the text before
159
+ what gitd checked, the branch it merges into, and whether it is a draft. submodules
160
+ opens one too in each submodule's repository, with the same title, into the branch
161
+ .gitmodules names for it or its default branch: to be merged first, with a merge commit."""
162
+
163
+ open: bool = False
164
+ title: str = ""
165
+ body: str = ""
166
+ base: str = ""
167
+ draft: bool | None = None
168
+ submodules: bool = False
169
+
170
+ def to_message(self) -> Any:
171
+ message: Any = messages.PullRequestOptions(
172
+ open=self.open, title=self.title, body=self.body, base=self.base, submodules=self.submodules
173
+ )
174
+ if self.draft is not None:
175
+ message.draft = self.draft
176
+ return message
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class Provenance:
181
+ """Where a publish's work came from, as the platform says: gitd records it as told and
182
+ never writes it into a commit. Each field is one line of at most 200 characters;
183
+ task_url is an https address."""
184
+
185
+ agent: str = ""
186
+ model: str = ""
187
+ session: str = ""
188
+ task_url: str = ""
189
+
190
+ @classmethod
191
+ def from_message(cls, provenance: Any) -> Provenance:
192
+ return cls(
193
+ agent=provenance.agent,
194
+ model=provenance.model,
195
+ session=provenance.session,
196
+ task_url=provenance.task_url,
197
+ )
198
+
199
+ def to_message(self) -> Any:
200
+ return messages.Provenance(
201
+ agent=self.agent, model=self.model, session=self.session, task_url=self.task_url
202
+ )
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class OpenedPullRequest:
207
+ """A pull request gitd opened; updated says it was already open from the branch, and
208
+ gitd updated its title and body."""
209
+
210
+ url: str
211
+ number: int
212
+ updated: bool = False
213
+
214
+ @classmethod
215
+ def from_message(cls, opened: Any) -> OpenedPullRequest:
216
+ return cls(url=opened.url, number=opened.number, updated=opened.updated)
gitd/timestamps.py ADDED
@@ -0,0 +1,22 @@
1
+ """Between the contract's google.protobuf.Timestamp and Python's datetime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ NANOSECONDS_PER_SECOND = 1_000_000_000
9
+
10
+
11
+ class Timestamp:
12
+ """A Timestamp message read as a datetime, or written from one, always in UTC."""
13
+
14
+ @staticmethod
15
+ def to_datetime(message: Any) -> datetime:
16
+ return datetime.fromtimestamp(message.seconds + message.nanos / NANOSECONDS_PER_SECOND, tz=UTC)
17
+
18
+ @staticmethod
19
+ def fill(message: Any, moment: datetime) -> None:
20
+ seconds = moment.timestamp()
21
+ message.seconds = int(seconds // 1)
22
+ message.nanos = int(round((seconds % 1) * NANOSECONDS_PER_SECOND))
gitd/warm_layers.py ADDED
@@ -0,0 +1,34 @@
1
+ """Warm layers: what installing a repository's dependencies wrote, sealed from one
2
+ workspace for the next ones to stand on."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class WarmLayer:
13
+ """A layer sealed from a workspace. Workspaces stand on it at commit only; changed_files
14
+ are the files it changes that git does not ignore, and users the workspaces on it now."""
15
+
16
+ url: str
17
+ name: str
18
+ commit: str
19
+ sealed_at: datetime
20
+ sealed_from: str
21
+ changed_files: int
22
+ users: int
23
+
24
+ @classmethod
25
+ def from_message(cls, warm: Any) -> WarmLayer:
26
+ return cls(
27
+ url=warm.url,
28
+ name=warm.name,
29
+ commit=warm.commit,
30
+ sealed_at=datetime.fromtimestamp(warm.sealed_at, tz=UTC),
31
+ sealed_from=warm.sealed_from,
32
+ changed_files=warm.changed_files,
33
+ users=warm.users,
34
+ )