repoactive 0.2.5.dev1__tar.gz → 0.2.7__tar.gz
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.
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/PKG-INFO +16 -2
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/README.md +15 -1
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/pyproject.toml +1 -1
- repoactive-0.2.7/src/repoactive/boxquote.py +32 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/cli.py +7 -8
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/config.py +68 -93
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/jj.py +5 -27
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/platforms/base.py +1 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/platforms/github.py +22 -1
- repoactive-0.2.7/src/repoactive/platforms/gitlab.py +112 -0
- repoactive-0.2.7/src/repoactive/progress.py +153 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/runner.py +383 -532
- repoactive-0.2.7/src/repoactive/selection.py +210 -0
- repoactive-0.2.7/src/repoactive/trailers.py +37 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/ui.py +12 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/updates.py +1 -0
- repoactive-0.2.5.dev1/src/repoactive/platforms/gitlab.py +0 -57
- repoactive-0.2.5.dev1/src/repoactive/progress.py +0 -66
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/.gitignore +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/LICENSE +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/__init__.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/constants.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/graph.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/jobtree.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/lock.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/platforms/__init__.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/settings.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: repoactive
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Script-driven code changes with automated merge requests
|
|
5
5
|
License-Expression: GPL-3.0-or-later
|
|
6
6
|
License-File: LICENSE
|
|
@@ -458,6 +458,11 @@ block.
|
|
|
458
458
|
every MR/PR so it merges automatically once its pipeline passes. On
|
|
459
459
|
GitHub, the repository must have "Allow auto-merge" enabled in its
|
|
460
460
|
settings.
|
|
461
|
+
- **`required_approvals`** (default: none) - Minimum number of approvals
|
|
462
|
+
required before the MR can be merged. **GitLab only** - this sets the
|
|
463
|
+
per-MR approval requirement. GitHub has no per-PR equivalent (required
|
|
464
|
+
approvals are a repository-wide branch protection setting), so a job that
|
|
465
|
+
sets `required_approvals` against a GitHub repository fails with an error.
|
|
461
466
|
|
|
462
467
|
**Branch and commit options:**
|
|
463
468
|
|
|
@@ -506,6 +511,9 @@ underscores.
|
|
|
506
511
|
already contains this job's changes - see [Stacking MRs](#stacking-mrs)).
|
|
507
512
|
- **`auto_merge`** (default: inherited from `job-defaults`) - When `true`,
|
|
508
513
|
enable auto-merge on this job's MR/PR.
|
|
514
|
+
- **`required_approvals`** (default: inherited from `job-defaults`) -
|
|
515
|
+
Minimum number of approvals required before this job's MR can be merged.
|
|
516
|
+
GitLab only; see `job-defaults.required_approvals`.
|
|
509
517
|
|
|
510
518
|
**Branch and commit options:**
|
|
511
519
|
|
|
@@ -907,7 +915,9 @@ cooldown_on = ["full-lock"]
|
|
|
907
915
|
inherited from `[job-defaults]`); without a window there is nothing to
|
|
908
916
|
throttle against, so a config that sets one without the other is rejected.
|
|
909
917
|
The relationship is one-directional and needs no change to the broad job -
|
|
910
|
-
it still writes only its own trailer.
|
|
918
|
+
it still writes only its own trailer. The listed names need not match a job
|
|
919
|
+
in the current config: they match trailers already landed on the base
|
|
920
|
+
branch, which may come from jobs that have since been removed or renamed.
|
|
911
921
|
|
|
912
922
|
This only suppresses _starting_ a redundant run. A narrow job that already
|
|
913
923
|
has an open, unmerged branch is always refreshed regardless of cooldown (see
|
|
@@ -974,6 +984,10 @@ command = "uv lock --upgrade --package pkg-a"
|
|
|
974
984
|
title = "build: upgrade pkg-a"
|
|
975
985
|
```
|
|
976
986
|
|
|
987
|
+
Fragments may only contain `[job.<name>]` tables; anything else (e.g.
|
|
988
|
+
`[job-defaults]`) fails the generator. To set defaults for the emitted jobs,
|
|
989
|
+
set them on the generator itself — they are inherited (see below).
|
|
990
|
+
|
|
977
991
|
Key points:
|
|
978
992
|
|
|
979
993
|
- **Selection is unchanged.** A generator is selected like any job (by the
|
|
@@ -431,6 +431,11 @@ block.
|
|
|
431
431
|
every MR/PR so it merges automatically once its pipeline passes. On
|
|
432
432
|
GitHub, the repository must have "Allow auto-merge" enabled in its
|
|
433
433
|
settings.
|
|
434
|
+
- **`required_approvals`** (default: none) - Minimum number of approvals
|
|
435
|
+
required before the MR can be merged. **GitLab only** - this sets the
|
|
436
|
+
per-MR approval requirement. GitHub has no per-PR equivalent (required
|
|
437
|
+
approvals are a repository-wide branch protection setting), so a job that
|
|
438
|
+
sets `required_approvals` against a GitHub repository fails with an error.
|
|
434
439
|
|
|
435
440
|
**Branch and commit options:**
|
|
436
441
|
|
|
@@ -479,6 +484,9 @@ underscores.
|
|
|
479
484
|
already contains this job's changes - see [Stacking MRs](#stacking-mrs)).
|
|
480
485
|
- **`auto_merge`** (default: inherited from `job-defaults`) - When `true`,
|
|
481
486
|
enable auto-merge on this job's MR/PR.
|
|
487
|
+
- **`required_approvals`** (default: inherited from `job-defaults`) -
|
|
488
|
+
Minimum number of approvals required before this job's MR can be merged.
|
|
489
|
+
GitLab only; see `job-defaults.required_approvals`.
|
|
482
490
|
|
|
483
491
|
**Branch and commit options:**
|
|
484
492
|
|
|
@@ -880,7 +888,9 @@ cooldown_on = ["full-lock"]
|
|
|
880
888
|
inherited from `[job-defaults]`); without a window there is nothing to
|
|
881
889
|
throttle against, so a config that sets one without the other is rejected.
|
|
882
890
|
The relationship is one-directional and needs no change to the broad job -
|
|
883
|
-
it still writes only its own trailer.
|
|
891
|
+
it still writes only its own trailer. The listed names need not match a job
|
|
892
|
+
in the current config: they match trailers already landed on the base
|
|
893
|
+
branch, which may come from jobs that have since been removed or renamed.
|
|
884
894
|
|
|
885
895
|
This only suppresses _starting_ a redundant run. A narrow job that already
|
|
886
896
|
has an open, unmerged branch is always refreshed regardless of cooldown (see
|
|
@@ -947,6 +957,10 @@ command = "uv lock --upgrade --package pkg-a"
|
|
|
947
957
|
title = "build: upgrade pkg-a"
|
|
948
958
|
```
|
|
949
959
|
|
|
960
|
+
Fragments may only contain `[job.<name>]` tables; anything else (e.g.
|
|
961
|
+
`[job-defaults]`) fails the generator. To set defaults for the emitted jobs,
|
|
962
|
+
set them on the generator itself — they are inherited (see below).
|
|
963
|
+
|
|
950
964
|
Key points:
|
|
951
965
|
|
|
952
966
|
- **Selection is unchanged.** A generator is selected like any job (by the
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Render and strip boxquote.el-style boxes used to embed command output in messages."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# A boxquote block: a ``,----`` opening line through its closing ``` `---- ``` line.
|
|
6
|
+
# DOTALL lets ``.*?`` span the body lines; MULTILINE anchors both delimiters to a
|
|
7
|
+
# line of their own. Non-greedy so each block stops at its own closing line.
|
|
8
|
+
_BOXQUOTE_BLOCK = re.compile(r"^,----.*?^`----[ \t]*$", re.MULTILINE | re.DOTALL)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def boxquote(msg: str, title: str = "") -> str:
|
|
12
|
+
"""Render ``msg`` inside a boxquote.el-style box.
|
|
13
|
+
|
|
14
|
+
The first line is ``,----[ title ]`` (or just ``,----`` when ``title`` is
|
|
15
|
+
empty), each line of ``msg`` is prefixed with ``| ``, and the box closes
|
|
16
|
+
with ``` `---- ```.
|
|
17
|
+
"""
|
|
18
|
+
top = f",----[ {title} ]" if title else ",----"
|
|
19
|
+
body = "\n".join(f"| {line}" for line in msg.splitlines())
|
|
20
|
+
return f"{top}\n{body}\n`----"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def strip_boxquotes(text: str) -> str:
|
|
24
|
+
"""Remove every boxquote block (see :func:`boxquote`) from ``text``.
|
|
25
|
+
|
|
26
|
+
A block runs from a ``,----`` opening line to its closing ``` `---- ``` line;
|
|
27
|
+
the surrounding text is kept. Blank lines exposed by a removed block are
|
|
28
|
+
collapsed so paragraphs stay separated by a single blank line, and leading
|
|
29
|
+
and trailing blank lines are trimmed.
|
|
30
|
+
"""
|
|
31
|
+
without = _BOXQUOTE_BLOCK.sub("", text)
|
|
32
|
+
return re.sub(r"\n{3,}", "\n\n", without).strip("\n")
|
|
@@ -42,10 +42,9 @@ from repoactive.platforms import (
|
|
|
42
42
|
from repoactive.platforms.base import PlatformError
|
|
43
43
|
from repoactive.runner import (
|
|
44
44
|
RunMode,
|
|
45
|
-
UnknownJobsError,
|
|
46
|
-
UnknownTagsError,
|
|
47
45
|
run_all,
|
|
48
46
|
)
|
|
47
|
+
from repoactive.selection import UnknownJobsError, UnknownTagsError
|
|
49
48
|
from repoactive.settings import SettingsError, load_settings
|
|
50
49
|
from repoactive.ui import err_console, print_undo_hint
|
|
51
50
|
|
|
@@ -241,8 +240,8 @@ def run( # noqa: PLR0913
|
|
|
241
240
|
config=cfg,
|
|
242
241
|
repo_path=repo,
|
|
243
242
|
platform=platform,
|
|
244
|
-
requested_names=jobs or
|
|
245
|
-
requested_tags=tags or
|
|
243
|
+
requested_names=frozenset(jobs or []),
|
|
244
|
+
requested_tags=frozenset(tags or []),
|
|
246
245
|
mode=mode,
|
|
247
246
|
)
|
|
248
247
|
except RunLockHeldError as e:
|
|
@@ -361,14 +360,14 @@ def dump_schema(
|
|
|
361
360
|
def _print_commit_table(commits: list[JobCommit]) -> None:
|
|
362
361
|
"""Print ``commits`` as columns padded to their widest value."""
|
|
363
362
|
names_column = [",".join(sorted(c.job_names)) for c in commits]
|
|
364
|
-
|
|
363
|
+
commit_width = max(len(c.commit_id) for c in commits)
|
|
365
364
|
change_width = max(len(c.change_id) for c in commits)
|
|
366
|
-
|
|
365
|
+
names_width = max(len(names) for names in names_column)
|
|
367
366
|
age_width = max(len(c.relative_age) for c in commits)
|
|
368
367
|
for c, names in zip(commits, names_column, strict=True):
|
|
369
368
|
typer.echo(
|
|
370
|
-
f"{c.commit_id:<{
|
|
371
|
-
f"{names:<{
|
|
369
|
+
f"{c.commit_id:<{commit_width}} {c.change_id:<{change_width}} "
|
|
370
|
+
f"{names:<{names_width}} {c.relative_age:<{age_width}} {c.subject}"
|
|
372
371
|
)
|
|
373
372
|
|
|
374
373
|
|
|
@@ -11,7 +11,7 @@ from dataclasses import dataclass
|
|
|
11
11
|
from datetime import timedelta
|
|
12
12
|
from enum import StrEnum
|
|
13
13
|
from pathlib import Path
|
|
14
|
-
from typing import Annotated, Literal
|
|
14
|
+
from typing import Annotated, Literal, cast
|
|
15
15
|
|
|
16
16
|
from pydantic import (
|
|
17
17
|
AfterValidator,
|
|
@@ -112,15 +112,6 @@ class UnknownRunOnlyIfChangedError(ValueError):
|
|
|
112
112
|
)
|
|
113
113
|
|
|
114
114
|
|
|
115
|
-
class UnknownCooldownOnError(ValueError):
|
|
116
|
-
"""Raised when cooldown_on references a job that does not exist."""
|
|
117
|
-
|
|
118
|
-
def __init__(self, name: str, unknown: list[str]) -> None:
|
|
119
|
-
super().__init__(
|
|
120
|
-
f"job '{name}' cooldown_on references unknown job(s): {', '.join(unknown)}"
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
|
|
124
115
|
class SelfCooldownOnError(ValueError):
|
|
125
116
|
"""Raised when a job lists itself in cooldown_on."""
|
|
126
117
|
|
|
@@ -166,40 +157,6 @@ class GeneratedByInBodyError(ValueError):
|
|
|
166
157
|
)
|
|
167
158
|
|
|
168
159
|
|
|
169
|
-
class JobNotTableError(ValueError):
|
|
170
|
-
"""Raised when a [job.<name>] entry is not a table."""
|
|
171
|
-
|
|
172
|
-
def __init__(self, name: str) -> None:
|
|
173
|
-
super().__init__(f"job {name!r} must be a table ([job.{name}])")
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
class JobsNotTableError(ValueError):
|
|
177
|
-
"""Raised when 'job' is not a table keyed by name (e.g. the old array form)."""
|
|
178
|
-
|
|
179
|
-
def __init__(self) -> None:
|
|
180
|
-
super().__init__(
|
|
181
|
-
"jobs must be a table keyed by name ([job.<name>]); the [[job]] array form "
|
|
182
|
-
"with a 'name' field is no longer supported"
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
class PlatformNotTableError(ValueError):
|
|
187
|
-
"""Raised when a [platform.<name>] entry is not a table."""
|
|
188
|
-
|
|
189
|
-
def __init__(self, name: str) -> None:
|
|
190
|
-
super().__init__(f"platform {name!r} must be a table ([platform.{name}])")
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
class PlatformsNotTableError(ValueError):
|
|
194
|
-
"""Raised when 'platform' is not a table keyed by name (e.g. the old array form)."""
|
|
195
|
-
|
|
196
|
-
def __init__(self) -> None:
|
|
197
|
-
super().__init__(
|
|
198
|
-
"platforms must be a table keyed by name ([platform.<name>]); the [[platform]] "
|
|
199
|
-
"array form is no longer supported"
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
|
|
203
160
|
class DuplicatePlatformHostError(ValueError):
|
|
204
161
|
"""Raised when two platforms resolve to the same host.
|
|
205
162
|
|
|
@@ -278,8 +235,18 @@ def _validate_duration(value: str) -> str:
|
|
|
278
235
|
return value
|
|
279
236
|
|
|
280
237
|
|
|
238
|
+
def _validate_single_line(value: str, info: ValidationInfo) -> str:
|
|
239
|
+
if "\n" in value:
|
|
240
|
+
assert info.field_name is not None
|
|
241
|
+
raise NewlineInTitleError(info.field_name, value)
|
|
242
|
+
return value
|
|
243
|
+
|
|
244
|
+
|
|
281
245
|
_BranchPrefix = Annotated[str, AfterValidator(_validate_branch_prefix)]
|
|
282
246
|
_Duration = Annotated[str, AfterValidator(_validate_duration)]
|
|
247
|
+
# Titles and title prefixes end up in commit subjects and MR titles, which are
|
|
248
|
+
# single-line by nature.
|
|
249
|
+
_SingleLine = Annotated[str, AfterValidator(_validate_single_line)]
|
|
283
250
|
# json_schema_input_type keeps booleans valid in the published JSON schema.
|
|
284
251
|
_CreateMR = Annotated[
|
|
285
252
|
CreateMR, BeforeValidator(_coerce_create_mr, json_schema_input_type=bool | CreateMR)
|
|
@@ -290,21 +257,14 @@ class JobDefaults(BaseModel):
|
|
|
290
257
|
model_config = ConfigDict(extra="forbid")
|
|
291
258
|
|
|
292
259
|
branch_prefix: _BranchPrefix = "repoactive/"
|
|
293
|
-
mr_title_prefix:
|
|
294
|
-
commit_title_prefix:
|
|
260
|
+
mr_title_prefix: _SingleLine = "[repoactive] "
|
|
261
|
+
commit_title_prefix: _SingleLine = "[repoactive] "
|
|
295
262
|
labels: list[str] = Field(default_factory=list)
|
|
296
263
|
base_branch: str | None = None
|
|
297
264
|
cooldown_period: _Duration | None = None
|
|
298
265
|
timeout: _Duration | None = "2m"
|
|
299
266
|
auto_merge: bool = False
|
|
300
|
-
|
|
301
|
-
@field_validator("mr_title_prefix", "commit_title_prefix")
|
|
302
|
-
@classmethod
|
|
303
|
-
def _check_no_newline(cls, value: str, info: ValidationInfo) -> str:
|
|
304
|
-
if "\n" in value:
|
|
305
|
-
assert info.field_name is not None
|
|
306
|
-
raise NewlineInTitleError(info.field_name, value)
|
|
307
|
-
return value
|
|
267
|
+
required_approvals: int | None = Field(default=None, ge=0)
|
|
308
268
|
|
|
309
269
|
|
|
310
270
|
# Fields Job.resolve fills in from JobDefaults when the job does not set them
|
|
@@ -317,6 +277,7 @@ _DEFAULTED_FIELDS = (
|
|
|
317
277
|
"cooldown_period",
|
|
318
278
|
"timeout",
|
|
319
279
|
"auto_merge",
|
|
280
|
+
"required_approvals",
|
|
320
281
|
)
|
|
321
282
|
|
|
322
283
|
|
|
@@ -325,7 +286,7 @@ class Job(BaseModel):
|
|
|
325
286
|
|
|
326
287
|
name: str
|
|
327
288
|
command: str
|
|
328
|
-
title:
|
|
289
|
+
title: _SingleLine
|
|
329
290
|
description: str | None = None
|
|
330
291
|
base_branch: str | None = None
|
|
331
292
|
draft: bool = False
|
|
@@ -352,12 +313,13 @@ class Job(BaseModel):
|
|
|
352
313
|
|
|
353
314
|
# the following fields will be resolved from the defaults
|
|
354
315
|
branch_prefix: _BranchPrefix | None = None
|
|
355
|
-
mr_title_prefix:
|
|
356
|
-
commit_title_prefix:
|
|
316
|
+
mr_title_prefix: _SingleLine | None = None
|
|
317
|
+
commit_title_prefix: _SingleLine | None = None
|
|
357
318
|
labels: list[str] = Field(default_factory=list)
|
|
358
319
|
cooldown_period: _Duration | None = None
|
|
359
320
|
timeout: _Duration | None = None
|
|
360
321
|
auto_merge: bool | None = None
|
|
322
|
+
required_approvals: int | None = Field(default=None, ge=0)
|
|
361
323
|
|
|
362
324
|
@field_validator("name")
|
|
363
325
|
@classmethod
|
|
@@ -366,12 +328,15 @@ class Job(BaseModel):
|
|
|
366
328
|
raise InvalidJobNameError(value)
|
|
367
329
|
return value
|
|
368
330
|
|
|
369
|
-
@field_validator("
|
|
331
|
+
@field_validator("cooldown_on")
|
|
370
332
|
@classmethod
|
|
371
|
-
def
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
333
|
+
def _check_cooldown_on(cls, value: list[str]) -> list[str]:
|
|
334
|
+
# The names need not match a configured job (the trailers they match may
|
|
335
|
+
# come from jobs since removed from the config), but they are interpolated
|
|
336
|
+
# into the cooldown revset, so they must stay within the job-name alphabet.
|
|
337
|
+
for name in value:
|
|
338
|
+
if not _JOB_NAME_RE.match(name):
|
|
339
|
+
raise InvalidJobNameError(name)
|
|
375
340
|
return value
|
|
376
341
|
|
|
377
342
|
@field_validator("tags")
|
|
@@ -453,14 +418,16 @@ class Config(BaseModel):
|
|
|
453
418
|
TOML stores jobs as a table keyed by name; the name comes from the key
|
|
454
419
|
and is injected into each job. A non-mapping value (e.g. an already-built
|
|
455
420
|
``list[Job]`` passed programmatically) passes through unchanged.
|
|
421
|
+
|
|
422
|
+
Each entry is assumed to be a table: ``load_config`` validates every
|
|
423
|
+
source against ``ConfigShape`` first, and programmatic callers must
|
|
424
|
+
pass tables as well.
|
|
456
425
|
"""
|
|
457
426
|
if not isinstance(value, dict):
|
|
458
427
|
return value
|
|
459
428
|
jobs: list[dict] = []
|
|
460
|
-
for key, body in value.items():
|
|
429
|
+
for key, body in cast("dict[str, dict]", value).items():
|
|
461
430
|
name = str(key)
|
|
462
|
-
if not isinstance(body, dict):
|
|
463
|
-
raise JobNotTableError(name)
|
|
464
431
|
if "name" in body:
|
|
465
432
|
raise JobNameInBodyError(name)
|
|
466
433
|
if "generated_by" in body:
|
|
@@ -477,15 +444,14 @@ class Config(BaseModel):
|
|
|
477
444
|
(platforms are matched by ``url``), so it is dropped here. A non-mapping
|
|
478
445
|
value (e.g. an already-built ``list`` passed programmatically) passes
|
|
479
446
|
through unchanged.
|
|
447
|
+
|
|
448
|
+
Each entry is assumed to be a table: ``load_config`` validates every
|
|
449
|
+
source against ``ConfigShape`` first, and programmatic callers must
|
|
450
|
+
pass tables as well.
|
|
480
451
|
"""
|
|
481
452
|
if not isinstance(value, dict):
|
|
482
453
|
return value
|
|
483
|
-
|
|
484
|
-
for key, body in value.items():
|
|
485
|
-
if not isinstance(body, dict):
|
|
486
|
-
raise PlatformNotTableError(str(key))
|
|
487
|
-
platforms.append(body)
|
|
488
|
-
return platforms
|
|
454
|
+
return list(value.values())
|
|
489
455
|
|
|
490
456
|
@model_validator(mode="after")
|
|
491
457
|
def validate_unique_platform_hosts(self) -> Config:
|
|
@@ -523,13 +489,12 @@ class Config(BaseModel):
|
|
|
523
489
|
|
|
524
490
|
@model_validator(mode="after")
|
|
525
491
|
def validate_cooldown_on(self) -> Config:
|
|
526
|
-
names
|
|
492
|
+
# cooldown_on names are deliberately not checked against the configured
|
|
493
|
+
# jobs: they match trailers already landed on the base branch, which may
|
|
494
|
+
# come from jobs since removed or renamed from the config.
|
|
527
495
|
for job in self.jobs:
|
|
528
496
|
if not job.cooldown_on:
|
|
529
497
|
continue
|
|
530
|
-
unknown = sorted(set(job.cooldown_on) - names)
|
|
531
|
-
if unknown:
|
|
532
|
-
raise UnknownCooldownOnError(job.name, unknown)
|
|
533
498
|
if job.name in job.cooldown_on:
|
|
534
499
|
raise SelfCooldownOnError(job.name)
|
|
535
500
|
# cooldown_on only throttles against a cooldown window; without one
|
|
@@ -565,28 +530,35 @@ class Config(BaseModel):
|
|
|
565
530
|
return {p.token_env for p in self.platforms}
|
|
566
531
|
|
|
567
532
|
|
|
568
|
-
|
|
569
|
-
"""
|
|
533
|
+
class ConfigShape(BaseModel):
|
|
534
|
+
"""Structural shape of a raw config source, validated before merging.
|
|
570
535
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
536
|
+
Merging digs into the ``job`` and ``platform`` tables, so each must be a
|
|
537
|
+
table of tables, and ``job-defaults`` must be a table; an odd shape (e.g.
|
|
538
|
+
``job.foo = "hello"``) would otherwise crash the merge with an unhelpful
|
|
539
|
+
error. Only the shape is checked here — field contents and any other keys
|
|
540
|
+
are validated by ``Config`` after the merge.
|
|
574
541
|
"""
|
|
575
|
-
if not isinstance(value, dict):
|
|
576
|
-
raise JobsNotTableError
|
|
577
|
-
return value
|
|
578
542
|
|
|
543
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
544
|
+
|
|
545
|
+
job: dict[str, dict] = Field(default_factory=dict)
|
|
546
|
+
platform: dict[str, dict] = Field(default_factory=dict)
|
|
547
|
+
job_defaults: dict = Field(alias="job-defaults", default_factory=dict)
|
|
579
548
|
|
|
580
|
-
def platforms_table(value: object) -> dict:
|
|
581
|
-
"""Return ``value`` as a platform table, rejecting the old ``[[platform]]`` array.
|
|
582
549
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
550
|
+
class FragmentShape(BaseModel):
|
|
551
|
+
"""Structural shape of a generator-emitted job fragment.
|
|
552
|
+
|
|
553
|
+
Generators may only emit ``[job.<name>]`` tables: the generator job itself
|
|
554
|
+
acts as the scoped job-defaults for its emitted jobs (ADR 0004), so a
|
|
555
|
+
``[job-defaults]`` or ``[platform]`` in a fragment would never apply and
|
|
556
|
+
is rejected instead of silently ignored.
|
|
586
557
|
"""
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
558
|
+
|
|
559
|
+
model_config = ConfigDict(extra="forbid")
|
|
560
|
+
|
|
561
|
+
job: dict[str, dict] = Field(default_factory=dict)
|
|
590
562
|
|
|
591
563
|
|
|
592
564
|
def _deep_merge(*, base: dict, override: dict) -> dict:
|
|
@@ -741,10 +713,13 @@ def _merge_config(sources: Iterable[_ConfigSource]) -> Config:
|
|
|
741
713
|
for source in sources:
|
|
742
714
|
data = source.data
|
|
743
715
|
try:
|
|
744
|
-
|
|
716
|
+
# Reject odd shapes (e.g. job.foo = "hello") up front; the merge
|
|
717
|
+
# helpers assume tables of tables and would fail cryptically.
|
|
718
|
+
shape = ConfigShape.model_validate(data)
|
|
719
|
+
jobs = merge_jobs(base=merged.get("job", {}), override=shape.job)
|
|
745
720
|
platforms = merge_platforms(
|
|
746
721
|
base=merged.get("platform", {}),
|
|
747
|
-
override=
|
|
722
|
+
override=shape.platform,
|
|
748
723
|
)
|
|
749
724
|
merged = _deep_merge(base=merged, override=data)
|
|
750
725
|
merged["job"] = jobs
|
|
@@ -219,9 +219,6 @@ class JJ:
|
|
|
219
219
|
def new(self, *parents: str) -> None:
|
|
220
220
|
self._run("new", *parents)
|
|
221
221
|
|
|
222
|
-
def edit(self, revision: str) -> None:
|
|
223
|
-
self._run("edit", revision)
|
|
224
|
-
|
|
225
222
|
def restore(self, *, source_rev: str, destination_rev: str) -> None:
|
|
226
223
|
self._run("restore", "--from", source_rev, "--into", destination_rev)
|
|
227
224
|
|
|
@@ -354,27 +351,8 @@ class JJ:
|
|
|
354
351
|
)
|
|
355
352
|
return result
|
|
356
353
|
|
|
357
|
-
def
|
|
358
|
-
"""Job names that appear
|
|
359
|
-
|
|
360
|
-
Without ``revset``: scans all commits not yet landed in trunk —
|
|
361
|
-
used by the default run to refresh stale branches (ADR 0003). A commit
|
|
362
|
-
may carry more than one trailer (a generated job records both its own
|
|
363
|
-
name and the generator's, see ADR 0004), so comma-joined values are
|
|
364
|
-
split; job names never contain commas.
|
|
365
|
-
|
|
366
|
-
With ``revset``: scans all descendants of ``revset`` that are not
|
|
367
|
-
in trunk — used by successor expansion to pull in jobs whose commits
|
|
368
|
-
sit anywhere in the stack above a selected job's bookmark (ADR 0012).
|
|
369
|
-
Uses ``present()`` semantics implicitly via the caller-supplied revset,
|
|
370
|
-
so non-existent bookmarks are silently skipped.
|
|
371
|
-
"""
|
|
372
|
-
if revset is not None:
|
|
373
|
-
if not revset:
|
|
374
|
-
return set()
|
|
375
|
-
target = f"descendants({revset}) & ~({revset}) & ~(::trunk())"
|
|
376
|
-
else:
|
|
377
|
-
target = "~(::trunk())"
|
|
354
|
+
def job_names_in_revset(self, revset: str) -> set[str]:
|
|
355
|
+
"""Job names that appear in revset."""
|
|
378
356
|
template = f"""
|
|
379
357
|
if(trailers.contains_key("{JOB_TRAILER_KEY}"),
|
|
380
358
|
trailers.filter(|t| t.key() == "{JOB_TRAILER_KEY}").map(|t| t.value()).join(",")
|
|
@@ -382,7 +360,7 @@ class JJ:
|
|
|
382
360
|
""
|
|
383
361
|
)
|
|
384
362
|
"""
|
|
385
|
-
output = self._run("log", "--no-graph", "-r",
|
|
363
|
+
output = self._run("log", "--no-graph", "-r", revset, "-T", template)
|
|
386
364
|
return {
|
|
387
365
|
name.strip()
|
|
388
366
|
for line in output.splitlines()
|
|
@@ -421,8 +399,8 @@ class JJ:
|
|
|
421
399
|
lines = [line.strip() for line in output.splitlines() if line.strip()]
|
|
422
400
|
if not lines:
|
|
423
401
|
return None
|
|
424
|
-
#
|
|
425
|
-
return datetime.fromisoformat(lines
|
|
402
|
+
# ISO-8601 strings are lexicographically ordered, so max() gives the newest.
|
|
403
|
+
return datetime.fromisoformat(max(lines)).replace(tzinfo=UTC)
|
|
426
404
|
|
|
427
405
|
def git_push_bookmarks(self, *bookmarks: str) -> None:
|
|
428
406
|
"""Push bookmarks to the remote.
|
|
@@ -8,6 +8,22 @@ _DEFAULT_API_URL = "https://api.github.com"
|
|
|
8
8
|
_PUBLIC_GITHUB_URL = "https://github.com"
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
class RequiredApprovalsNotSupportedError(Exception):
|
|
12
|
+
"""Raised when a job sets required_approvals but its platform is GitHub.
|
|
13
|
+
|
|
14
|
+
GitHub has no per-PR approval requirement: the number of required
|
|
15
|
+
approvals is a repository-wide branch protection setting, so repoactive
|
|
16
|
+
cannot honor a per-job value there. required_approvals is GitLab-only.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
super().__init__(
|
|
21
|
+
"required_approvals is not supported on GitHub: the number of required "
|
|
22
|
+
"approvals is a repository-wide branch protection setting, not a per-PR "
|
|
23
|
+
"value. Remove required_approvals from the job (it is supported on GitLab only)."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
11
27
|
class GitHubPlatform(Platform):
|
|
12
28
|
def __init__(self, *, url: str | None, token: str, repo: str) -> None:
|
|
13
29
|
normalized = (url or "").rstrip("/")
|
|
@@ -26,6 +42,8 @@ class GitHubPlatform(Platform):
|
|
|
26
42
|
return self._repo.default_branch
|
|
27
43
|
|
|
28
44
|
def ensure_mr(self, params: MRParams) -> str:
|
|
45
|
+
if params.required_approvals is not None:
|
|
46
|
+
raise RequiredApprovalsNotSupportedError
|
|
29
47
|
owner = self._repo.owner.login
|
|
30
48
|
# Look up by head only: filtering on base would miss the PR after the
|
|
31
49
|
# job's base_branch changed, and a new PR would be opened next to the
|
|
@@ -60,5 +78,8 @@ class GitHubPlatform(Platform):
|
|
|
60
78
|
if params.labels:
|
|
61
79
|
pr.set_labels(*params.labels)
|
|
62
80
|
if params.auto_merge:
|
|
63
|
-
|
|
81
|
+
try:
|
|
82
|
+
pr.enable_automerge()
|
|
83
|
+
except GithubException as e:
|
|
84
|
+
print(f" warning: could not enable auto-merge ({e})")
|
|
64
85
|
return pr.html_url
|