repoactive 0.2.3.dev1__tar.gz → 0.2.4__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.3.dev1 → repoactive-0.2.4}/PKG-INFO +1 -1
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/pyproject.toml +1 -1
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/config.py +3 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/jj.py +36 -16
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/platforms/base.py +1 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/platforms/github.py +2 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/platforms/gitlab.py +6 -1
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/runner.py +43 -13
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/updates.py +1 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/.gitignore +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/LICENSE +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/README.md +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/__init__.py +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/cli.py +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/constants.py +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/graph.py +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/jobtree.py +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/lock.py +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/platforms/__init__.py +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/progress.py +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/settings.py +0 -0
- {repoactive-0.2.3.dev1 → repoactive-0.2.4}/src/repoactive/ui.py +0 -0
|
@@ -269,6 +269,7 @@ class JobDefaults(BaseModel):
|
|
|
269
269
|
base_branch: str | None = None
|
|
270
270
|
cooldown_period: _Duration | None = None
|
|
271
271
|
timeout: _Duration | None = "2m"
|
|
272
|
+
auto_merge: bool = False
|
|
272
273
|
|
|
273
274
|
@field_validator("mr_title_prefix", "commit_title_prefix")
|
|
274
275
|
@classmethod
|
|
@@ -288,6 +289,7 @@ _DEFAULTED_FIELDS = (
|
|
|
288
289
|
"base_branch",
|
|
289
290
|
"cooldown_period",
|
|
290
291
|
"timeout",
|
|
292
|
+
"auto_merge",
|
|
291
293
|
)
|
|
292
294
|
|
|
293
295
|
|
|
@@ -323,6 +325,7 @@ class Job(BaseModel):
|
|
|
323
325
|
labels: list[str] = Field(default_factory=list)
|
|
324
326
|
cooldown_period: _Duration | None = None
|
|
325
327
|
timeout: _Duration | None = None
|
|
328
|
+
auto_merge: bool | None = None
|
|
326
329
|
|
|
327
330
|
@field_validator("name")
|
|
328
331
|
@classmethod
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Wrapper around the jj (Jujutsu) CLI for managing colocated jj+git repositories."""
|
|
2
2
|
|
|
3
3
|
import contextlib
|
|
4
|
+
import enum
|
|
4
5
|
import logging
|
|
5
6
|
import shutil
|
|
6
7
|
import subprocess
|
|
@@ -8,13 +9,19 @@ import tempfile
|
|
|
8
9
|
import time
|
|
9
10
|
from collections.abc import Generator
|
|
10
11
|
from dataclasses import dataclass
|
|
11
|
-
from datetime import datetime
|
|
12
|
+
from datetime import UTC, datetime
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
14
15
|
from repoactive.constants import JOB_TRAILER_KEY
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
17
18
|
|
|
19
|
+
|
|
20
|
+
class Colocation(enum.Enum):
|
|
21
|
+
COLOCATED = "colocated"
|
|
22
|
+
PLAIN = "plain"
|
|
23
|
+
|
|
24
|
+
|
|
18
25
|
# Prefix for the temporary workspaces repoactive creates for each job. It is not
|
|
19
26
|
# configurable so that stale workspaces (left behind by a killed run) can always
|
|
20
27
|
# be recognised and reclaimed by name (see JJ.forget_stale_workspaces).
|
|
@@ -181,9 +188,9 @@ class JJ:
|
|
|
181
188
|
)
|
|
182
189
|
raise CommandFailedError(program, args, e.stderr) from e
|
|
183
190
|
logger.debug(
|
|
184
|
-
"%s %s -> %d bytes in %.3fs",
|
|
191
|
+
"%s %s ... -> %d bytes in %.3fs",
|
|
185
192
|
program,
|
|
186
|
-
" ".join(args),
|
|
193
|
+
" ".join(args[:1]),
|
|
187
194
|
len(result.stdout),
|
|
188
195
|
time.monotonic() - start,
|
|
189
196
|
)
|
|
@@ -215,8 +222,8 @@ class JJ:
|
|
|
215
222
|
def edit(self, revision: str) -> None:
|
|
216
223
|
self._run("edit", revision)
|
|
217
224
|
|
|
218
|
-
def restore(self,
|
|
219
|
-
self._run("restore", "--
|
|
225
|
+
def restore(self, *, source_rev: str, destination_rev: str) -> None:
|
|
226
|
+
self._run("restore", "--from", source_rev, "--into", destination_rev)
|
|
220
227
|
|
|
221
228
|
def rebase(self, *onto: str) -> None:
|
|
222
229
|
onto_args = [arg for parent in onto for arg in ("--onto", parent)]
|
|
@@ -400,8 +407,10 @@ class JJ:
|
|
|
400
407
|
if name.strip()
|
|
401
408
|
}
|
|
402
409
|
|
|
403
|
-
def
|
|
404
|
-
|
|
410
|
+
def last_job_commit_date(
|
|
411
|
+
self, *, job_name: str, base: str, since: datetime
|
|
412
|
+
) -> datetime | None:
|
|
413
|
+
"""Return the committer date of the most recent job commit on ``base``, or ``None``.
|
|
405
414
|
|
|
406
415
|
Matches commits that have a ``Repoactive-Job: <job_name>`` trailer and a
|
|
407
416
|
committer date at or after ``since``. Used to throttle jobs: a recent
|
|
@@ -410,16 +419,22 @@ class JJ:
|
|
|
410
419
|
The trailer is matched via jj's trailer parsing, which only considers the
|
|
411
420
|
final paragraph of the description, so a stray matching line in the body
|
|
412
421
|
is correctly ignored.
|
|
422
|
+
|
|
423
|
+
Returns the newest matching committer timestamp, or ``None`` if no match.
|
|
413
424
|
"""
|
|
414
425
|
revset = f'::{base} & committer_date(after:"{_jj_timestamp(since)}")'
|
|
415
426
|
template = f"""
|
|
416
427
|
if (trailers.any(|t| t.key() == "{JOB_TRAILER_KEY}" && t.value() == "{job_name}"),
|
|
417
|
-
"
|
|
428
|
+
committer.timestamp().utc().format("%Y-%m-%dT%H:%M:%S") ++ "\\n",
|
|
418
429
|
""
|
|
419
430
|
)
|
|
420
431
|
"""
|
|
421
432
|
output = self._run("log", "--no-graph", "-r", revset, "-T", template)
|
|
422
|
-
|
|
433
|
+
lines = [line.strip() for line in output.splitlines() if line.strip()]
|
|
434
|
+
if not lines:
|
|
435
|
+
return None
|
|
436
|
+
# jj log returns newest-first; take the first line.
|
|
437
|
+
return datetime.fromisoformat(lines[0]).replace(tzinfo=UTC)
|
|
423
438
|
|
|
424
439
|
def git_push_bookmarks(self, *bookmarks: str) -> None:
|
|
425
440
|
"""Push bookmarks to the remote.
|
|
@@ -464,7 +479,7 @@ class JJ:
|
|
|
464
479
|
"""Sync the colocated git checkout (HEAD and index) to the jj working copy.
|
|
465
480
|
|
|
466
481
|
jj only exports git HEAD in the default workspace; workspaces colocated
|
|
467
|
-
via
|
|
482
|
+
via _workspace_add() need this after the working copy moves (new, edit,
|
|
468
483
|
rebase). No-op if the workspace is not colocated.
|
|
469
484
|
"""
|
|
470
485
|
if not self.is_colocated():
|
|
@@ -500,9 +515,11 @@ class JJ:
|
|
|
500
515
|
if stale:
|
|
501
516
|
self.git_worktree_prune()
|
|
502
517
|
|
|
503
|
-
def
|
|
518
|
+
def _workspace_add(
|
|
519
|
+
self, name: str, path: Path, colocation: Colocation = Colocation.COLOCATED
|
|
520
|
+
) -> None:
|
|
504
521
|
self._run("workspace", "add", "--name", name, str(path))
|
|
505
|
-
if self.is_colocated():
|
|
522
|
+
if colocation is Colocation.COLOCATED and self.is_colocated():
|
|
506
523
|
self._colocate_workspace(name, path)
|
|
507
524
|
|
|
508
525
|
def _colocate_workspace(self, name: str, path: Path) -> None:
|
|
@@ -533,7 +550,9 @@ class JJ:
|
|
|
533
550
|
self._run("workspace", "forget", name)
|
|
534
551
|
|
|
535
552
|
@contextlib.contextmanager
|
|
536
|
-
def temp_workspace(
|
|
553
|
+
def temp_workspace(
|
|
554
|
+
self, name: str, colocation: Colocation = Colocation.COLOCATED
|
|
555
|
+
) -> Generator["JJ"]:
|
|
537
556
|
"""Create a workspace named ``name`` in a temp directory, cleaning up on exit.
|
|
538
557
|
|
|
539
558
|
Adds a jj workspace inside a fresh temp directory and yields a JJ bound
|
|
@@ -544,7 +563,7 @@ class JJ:
|
|
|
544
563
|
tmp_root = Path(tempfile.mkdtemp(prefix="repoactive-workspace-"))
|
|
545
564
|
workspace_path = tmp_root / "workspace"
|
|
546
565
|
logger.debug("adding workspace %s at %s", name, workspace_path)
|
|
547
|
-
self.
|
|
566
|
+
self._workspace_add(name, workspace_path, colocation)
|
|
548
567
|
try:
|
|
549
568
|
yield JJ(workspace_path)
|
|
550
569
|
finally:
|
|
@@ -552,5 +571,6 @@ class JJ:
|
|
|
552
571
|
with contextlib.suppress(JJError):
|
|
553
572
|
self.workspace_forget(name)
|
|
554
573
|
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
555
|
-
|
|
556
|
-
|
|
574
|
+
if colocation is Colocation.COLOCATED:
|
|
575
|
+
with contextlib.suppress(JJError):
|
|
576
|
+
self.git_worktree_prune()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""GitLab platform implementation using python-gitlab."""
|
|
2
2
|
|
|
3
3
|
import gitlab
|
|
4
|
-
from gitlab.exceptions import GitlabError
|
|
4
|
+
from gitlab.exceptions import GitlabError, GitlabMRClosedError
|
|
5
5
|
|
|
6
6
|
from repoactive.platforms.base import MRParams, Platform, PlatformError
|
|
7
7
|
|
|
@@ -49,4 +49,9 @@ class GitLabPlatform(Platform):
|
|
|
49
49
|
"labels": params.labels,
|
|
50
50
|
}
|
|
51
51
|
)
|
|
52
|
+
if params.auto_merge:
|
|
53
|
+
try:
|
|
54
|
+
mr.merge(merge_when_pipeline_succeeds=True)
|
|
55
|
+
except GitlabMRClosedError as e:
|
|
56
|
+
print(f" warning: could not enable auto-merge ({e})")
|
|
52
57
|
return mr.web_url
|
|
@@ -28,7 +28,7 @@ from repoactive.config import (
|
|
|
28
28
|
)
|
|
29
29
|
from repoactive.constants import JOB_TRAILER_KEY
|
|
30
30
|
from repoactive.graph import CircularDependencyError, detect_dependency_cycle, topological_sort
|
|
31
|
-
from repoactive.jj import JJ, workspace_name
|
|
31
|
+
from repoactive.jj import JJ, Colocation, workspace_name
|
|
32
32
|
from repoactive.jobtree import format_job_forest, print_job_table
|
|
33
33
|
from repoactive.lock import run_lock
|
|
34
34
|
from repoactive.platforms.base import MRParams, Platform
|
|
@@ -63,6 +63,7 @@ _INHERITED_FIELDS = (
|
|
|
63
63
|
"commit_title_prefix",
|
|
64
64
|
"draft",
|
|
65
65
|
"create_mr",
|
|
66
|
+
"auto_merge",
|
|
66
67
|
)
|
|
67
68
|
|
|
68
69
|
|
|
@@ -621,22 +622,43 @@ def _build_generated_jobs(
|
|
|
621
622
|
return emitted
|
|
622
623
|
|
|
623
624
|
|
|
624
|
-
def
|
|
625
|
-
"""
|
|
625
|
+
def _format_duration(seconds: float) -> str:
|
|
626
|
+
"""Format a duration in seconds as a human-readable string like '3d 2h' or '45m'."""
|
|
627
|
+
seconds = int(seconds)
|
|
628
|
+
days, seconds = divmod(seconds, 86400)
|
|
629
|
+
hours, seconds = divmod(seconds, 3600)
|
|
630
|
+
minutes, seconds = divmod(seconds, 60)
|
|
631
|
+
if days:
|
|
632
|
+
parts = [f"{days}d"]
|
|
633
|
+
if hours:
|
|
634
|
+
parts.append(f"{hours}h")
|
|
635
|
+
return " ".join(parts)
|
|
636
|
+
if hours:
|
|
637
|
+
parts = [f"{hours}h"]
|
|
638
|
+
if minutes:
|
|
639
|
+
parts.append(f"{minutes}m")
|
|
640
|
+
return " ".join(parts)
|
|
641
|
+
if minutes:
|
|
642
|
+
return f"{minutes}m"
|
|
643
|
+
return f"{seconds}s"
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _on_cooldown(job: Job, repo_path: Path) -> datetime | None:
|
|
647
|
+
"""Return the last-run timestamp if the job is still on cooldown, else None."""
|
|
626
648
|
delta = job.cooldown_timedelta()
|
|
627
649
|
if delta is None:
|
|
628
|
-
return
|
|
650
|
+
return None
|
|
629
651
|
base = job.base_branch or "trunk()"
|
|
630
652
|
since = datetime.now(UTC) - delta
|
|
631
|
-
|
|
653
|
+
last_run = JJ(repo_path).last_job_commit_date(job_name=job.name, base=base, since=since)
|
|
632
654
|
logger.debug(
|
|
633
|
-
"[%s] cooldown check: base=%s since=%s ->
|
|
655
|
+
"[%s] cooldown check: base=%s since=%s -> last_run=%s",
|
|
634
656
|
job.name,
|
|
635
657
|
base,
|
|
636
658
|
since.isoformat(),
|
|
637
|
-
|
|
659
|
+
last_run,
|
|
638
660
|
)
|
|
639
|
-
return
|
|
661
|
+
return last_run
|
|
640
662
|
|
|
641
663
|
|
|
642
664
|
def _validate_selection(
|
|
@@ -827,8 +849,13 @@ def _dispatch_job( # noqa: PLR0913
|
|
|
827
849
|
# emits nothing, throttling its whole fan-out as a unit (the dual trailer
|
|
828
850
|
# records a recent child landing as the generator's). See
|
|
829
851
|
# docs/adr/0004-job-generators.md.
|
|
830
|
-
if _on_cooldown(resolved_job, repo_path):
|
|
831
|
-
|
|
852
|
+
if last_run := _on_cooldown(resolved_job, repo_path):
|
|
853
|
+
elapsed = datetime.now(UTC) - last_run
|
|
854
|
+
elapsed_str = _format_duration(elapsed.total_seconds())
|
|
855
|
+
print(
|
|
856
|
+
f"==> [{job.name}] on cooldown ({resolved_job.cooldown_period}),"
|
|
857
|
+
f" last run {elapsed_str} ago, skipped"
|
|
858
|
+
)
|
|
832
859
|
summary.on_cooldown.add(job.name)
|
|
833
860
|
# Treat like a no-op run so dependents proceed on the base branch.
|
|
834
861
|
summary.results[job.name] = JobResult(
|
|
@@ -1003,9 +1030,10 @@ def _absorb_results(
|
|
|
1003
1030
|
)
|
|
1004
1031
|
if not content_unchanged:
|
|
1005
1032
|
if abs_ws is None:
|
|
1006
|
-
abs_ws = stack.enter_context(
|
|
1007
|
-
|
|
1008
|
-
|
|
1033
|
+
abs_ws = stack.enter_context(
|
|
1034
|
+
repo.temp_workspace("repoactive-absorb", Colocation.PLAIN)
|
|
1035
|
+
)
|
|
1036
|
+
abs_ws.restore(source_rev=new_cid, destination_rev=old_cid)
|
|
1009
1037
|
repo.describe_revision(old_cid, message)
|
|
1010
1038
|
elif _strip_boxquote_and_trailers(
|
|
1011
1039
|
repo.get_description(old_cid)
|
|
@@ -1033,6 +1061,7 @@ def _absorb_results(
|
|
|
1033
1061
|
command_output=result.command_output,
|
|
1034
1062
|
labels=result.job.labels,
|
|
1035
1063
|
draft=result.job.draft,
|
|
1064
|
+
auto_merge=result.job.auto_merge or False,
|
|
1036
1065
|
depends_on=list(result.job.depends_on),
|
|
1037
1066
|
)
|
|
1038
1067
|
plan.updates.append(
|
|
@@ -1287,6 +1316,7 @@ def _apply_plan_publish(
|
|
|
1287
1316
|
description=build_mr_description(update.mr, dependency_links),
|
|
1288
1317
|
labels=update.mr.labels,
|
|
1289
1318
|
draft=update.mr.draft,
|
|
1319
|
+
auto_merge=update.mr.auto_merge,
|
|
1290
1320
|
)
|
|
1291
1321
|
url = platform.ensure_mr(params)
|
|
1292
1322
|
except Exception as e:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|