repoactive 0.2.3.dev2__tar.gz → 0.2.4.dev2__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 (22) hide show
  1. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/PKG-INFO +1 -1
  2. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/pyproject.toml +1 -1
  3. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/jj.py +36 -16
  4. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/platforms/gitlab.py +5 -2
  5. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/runner.py +40 -13
  6. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/.gitignore +0 -0
  7. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/LICENSE +0 -0
  8. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/README.md +0 -0
  9. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/__init__.py +0 -0
  10. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/cli.py +0 -0
  11. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/config.py +0 -0
  12. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/constants.py +0 -0
  13. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/graph.py +0 -0
  14. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/jobtree.py +0 -0
  15. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/lock.py +0 -0
  16. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/platforms/__init__.py +0 -0
  17. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/platforms/base.py +0 -0
  18. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/platforms/github.py +0 -0
  19. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/progress.py +0 -0
  20. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/settings.py +0 -0
  21. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/ui.py +0 -0
  22. {repoactive-0.2.3.dev2 → repoactive-0.2.4.dev2}/src/repoactive/updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repoactive
3
- Version: 0.2.3.dev2
3
+ Version: 0.2.4.dev2
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
@@ -10,7 +10,7 @@ only-include = ["src"]
10
10
 
11
11
  [project]
12
12
  name = "repoactive"
13
- version = "0.2.3.dev2"
13
+ version = "0.2.4.dev2"
14
14
  description = "Script-driven code changes with automated merge requests"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -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, source: str) -> None:
219
- self._run("restore", "--changes-in", source)
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 has_recent_job_commit(self, *, job_name: str, base: str, since: datetime) -> bool:
404
- """Whether ``base`` already carries a recent commit from the given job.
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
- "x",
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
- return bool(output.strip())
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 workspace_add() need this after the working copy moves (new, edit,
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 workspace_add(self, name: str, path: Path) -> None:
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(self, name: str) -> Generator["JJ"]:
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.workspace_add(name, workspace_path)
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
- with contextlib.suppress(JJError):
556
- self.git_worktree_prune()
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
 
@@ -50,5 +50,8 @@ class GitLabPlatform(Platform):
50
50
  }
51
51
  )
52
52
  if params.auto_merge:
53
- mr.merge(merge_when_pipeline_succeeds=True)
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})")
54
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
@@ -622,22 +622,43 @@ def _build_generated_jobs(
622
622
  return emitted
623
623
 
624
624
 
625
- def _on_cooldown(job: Job, repo_path: Path) -> bool:
626
- """Whether the job landed on its base branch within its cooldown_period window."""
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."""
627
648
  delta = job.cooldown_timedelta()
628
649
  if delta is None:
629
- return False
650
+ return None
630
651
  base = job.base_branch or "trunk()"
631
652
  since = datetime.now(UTC) - delta
632
- on_cooldown = JJ(repo_path).has_recent_job_commit(job_name=job.name, base=base, since=since)
653
+ last_run = JJ(repo_path).last_job_commit_date(job_name=job.name, base=base, since=since)
633
654
  logger.debug(
634
- "[%s] cooldown check: base=%s since=%s -> on_cooldown=%s",
655
+ "[%s] cooldown check: base=%s since=%s -> last_run=%s",
635
656
  job.name,
636
657
  base,
637
658
  since.isoformat(),
638
- on_cooldown,
659
+ last_run,
639
660
  )
640
- return on_cooldown
661
+ return last_run
641
662
 
642
663
 
643
664
  def _validate_selection(
@@ -828,8 +849,13 @@ def _dispatch_job( # noqa: PLR0913
828
849
  # emits nothing, throttling its whole fan-out as a unit (the dual trailer
829
850
  # records a recent child landing as the generator's). See
830
851
  # docs/adr/0004-job-generators.md.
831
- if _on_cooldown(resolved_job, repo_path):
832
- print(f"==> [{job.name}] on cooldown ({resolved_job.cooldown_period}), skipped")
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
+ )
833
859
  summary.on_cooldown.add(job.name)
834
860
  # Treat like a no-op run so dependents proceed on the base branch.
835
861
  summary.results[job.name] = JobResult(
@@ -1004,9 +1030,10 @@ def _absorb_results(
1004
1030
  )
1005
1031
  if not content_unchanged:
1006
1032
  if abs_ws is None:
1007
- abs_ws = stack.enter_context(repo.temp_workspace("repoactive-absorb"))
1008
- abs_ws.edit(old_cid)
1009
- abs_ws.restore(new_cid)
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)
1010
1037
  repo.describe_revision(old_cid, message)
1011
1038
  elif _strip_boxquote_and_trailers(
1012
1039
  repo.get_description(old_cid)
File without changes