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.
Files changed (27) hide show
  1. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/PKG-INFO +16 -2
  2. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/README.md +15 -1
  3. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/pyproject.toml +1 -1
  4. repoactive-0.2.7/src/repoactive/boxquote.py +32 -0
  5. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/cli.py +7 -8
  6. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/config.py +68 -93
  7. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/jj.py +5 -27
  8. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/platforms/base.py +1 -0
  9. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/platforms/github.py +22 -1
  10. repoactive-0.2.7/src/repoactive/platforms/gitlab.py +112 -0
  11. repoactive-0.2.7/src/repoactive/progress.py +153 -0
  12. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/runner.py +383 -532
  13. repoactive-0.2.7/src/repoactive/selection.py +210 -0
  14. repoactive-0.2.7/src/repoactive/trailers.py +37 -0
  15. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/ui.py +12 -0
  16. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/updates.py +1 -0
  17. repoactive-0.2.5.dev1/src/repoactive/platforms/gitlab.py +0 -57
  18. repoactive-0.2.5.dev1/src/repoactive/progress.py +0 -66
  19. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/.gitignore +0 -0
  20. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/LICENSE +0 -0
  21. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/__init__.py +0 -0
  22. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/constants.py +0 -0
  23. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/graph.py +0 -0
  24. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/jobtree.py +0 -0
  25. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/lock.py +0 -0
  26. {repoactive-0.2.5.dev1 → repoactive-0.2.7}/src/repoactive/platforms/__init__.py +0 -0
  27. {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.5.dev1
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
@@ -10,7 +10,7 @@ only-include = ["src"]
10
10
 
11
11
  [project]
12
12
  name = "repoactive"
13
- version = "0.2.5.dev1"
13
+ version = "0.2.7"
14
14
  description = "Script-driven code changes with automated merge requests"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -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 None,
245
- requested_tags=tags or None,
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
- id_width = max(len(c.commit_id) for c in commits)
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
- name_width = max(len(names) for names in names_column)
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:<{id_width}} {c.change_id:<{change_width}} "
371
- f"{names:<{name_width}} {c.relative_age:<{age_width}} {c.subject}"
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: str = "[repoactive] "
294
- commit_title_prefix: str = "[repoactive] "
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: str
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: str | None = None
356
- commit_title_prefix: str | None = None
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("title", "commit_title_prefix")
331
+ @field_validator("cooldown_on")
370
332
  @classmethod
371
- def _check_no_newline(cls, value: str, info: ValidationInfo) -> str:
372
- if "\n" in value:
373
- assert info.field_name is not None
374
- raise NewlineInTitleError(info.field_name, value)
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
- platforms: list[dict] = []
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 = {j.name for j in self.jobs}
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
- def jobs_table(value: object) -> dict:
569
- """Return ``value`` as a job table, rejecting the old ``[[job]]`` array form.
533
+ class ConfigShape(BaseModel):
534
+ """Structural shape of a raw config source, validated before merging.
570
535
 
571
- TOML parses ``[job.<name>]`` tables into a dict keyed by name; an array of
572
- tables (the format used before) parses into a list, which is no longer
573
- accepted.
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
- TOML parses ``[platform.<name>]`` tables into a dict keyed by name; an array
584
- of tables (the format used before) parses into a list, which is no longer
585
- accepted.
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
- if not isinstance(value, dict):
588
- raise PlatformsNotTableError
589
- return value
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
- jobs = merge_jobs(base=merged.get("job", {}), override=jobs_table(data.get("job", {})))
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=platforms_table(data.get("platform", {})),
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 pending_job_names(self, revset: str | None = None) -> set[str]:
358
- """Job names that appear on unmerged commits reachable via ``revset``.
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", target, "-T", template)
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
- # jj log returns newest-first; take the first line.
425
- return datetime.fromisoformat(lines[0]).replace(tzinfo=UTC)
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.
@@ -26,6 +26,7 @@ class MRParams:
26
26
  labels: list[str]
27
27
  draft: bool
28
28
  auto_merge: bool = False
29
+ required_approvals: int | None = None
29
30
 
30
31
 
31
32
  class Platform(ABC):
@@ -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
- pr.enable_automerge()
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