repoactive 0.2.3.dev1__tar.gz → 0.2.4.dev1__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.dev1 → repoactive-0.2.4.dev1}/PKG-INFO +1 -1
  2. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/pyproject.toml +1 -1
  3. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/config.py +3 -0
  4. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/jj.py +15 -7
  5. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/platforms/base.py +1 -0
  6. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/platforms/github.py +2 -0
  7. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/platforms/gitlab.py +2 -0
  8. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/runner.py +39 -11
  9. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/updates.py +1 -0
  10. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/.gitignore +0 -0
  11. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/LICENSE +0 -0
  12. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/README.md +0 -0
  13. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/__init__.py +0 -0
  14. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/cli.py +0 -0
  15. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/constants.py +0 -0
  16. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/graph.py +0 -0
  17. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/jobtree.py +0 -0
  18. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/lock.py +0 -0
  19. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/platforms/__init__.py +0 -0
  20. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/progress.py +0 -0
  21. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/settings.py +0 -0
  22. {repoactive-0.2.3.dev1 → repoactive-0.2.4.dev1}/src/repoactive/ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repoactive
3
- Version: 0.2.3.dev1
3
+ Version: 0.2.4.dev1
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.dev1"
13
+ version = "0.2.4.dev1"
14
14
  description = "Script-driven code changes with automated merge requests"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -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
@@ -8,7 +8,7 @@ import tempfile
8
8
  import time
9
9
  from collections.abc import Generator
10
10
  from dataclasses import dataclass
11
- from datetime import datetime
11
+ from datetime import UTC, datetime
12
12
  from pathlib import Path
13
13
 
14
14
  from repoactive.constants import JOB_TRAILER_KEY
@@ -215,8 +215,8 @@ class JJ:
215
215
  def edit(self, revision: str) -> None:
216
216
  self._run("edit", revision)
217
217
 
218
- def restore(self, source: str) -> None:
219
- self._run("restore", "--changes-in", source)
218
+ def restore(self, *, source_rev: str, destination_rev: str) -> None:
219
+ self._run("restore", "--from", source_rev, "--into", destination_rev)
220
220
 
221
221
  def rebase(self, *onto: str) -> None:
222
222
  onto_args = [arg for parent in onto for arg in ("--onto", parent)]
@@ -400,8 +400,10 @@ class JJ:
400
400
  if name.strip()
401
401
  }
402
402
 
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.
403
+ def last_job_commit_date(
404
+ self, *, job_name: str, base: str, since: datetime
405
+ ) -> datetime | None:
406
+ """Return the committer date of the most recent job commit on ``base``, or ``None``.
405
407
 
406
408
  Matches commits that have a ``Repoactive-Job: <job_name>`` trailer and a
407
409
  committer date at or after ``since``. Used to throttle jobs: a recent
@@ -410,16 +412,22 @@ class JJ:
410
412
  The trailer is matched via jj's trailer parsing, which only considers the
411
413
  final paragraph of the description, so a stray matching line in the body
412
414
  is correctly ignored.
415
+
416
+ Returns the newest matching committer timestamp, or ``None`` if no match.
413
417
  """
414
418
  revset = f'::{base} & committer_date(after:"{_jj_timestamp(since)}")'
415
419
  template = f"""
416
420
  if (trailers.any(|t| t.key() == "{JOB_TRAILER_KEY}" && t.value() == "{job_name}"),
417
- "x",
421
+ committer.timestamp().utc().format("%Y-%m-%dT%H:%M:%S") ++ "\\n",
418
422
  ""
419
423
  )
420
424
  """
421
425
  output = self._run("log", "--no-graph", "-r", revset, "-T", template)
422
- return bool(output.strip())
426
+ lines = [line.strip() for line in output.splitlines() if line.strip()]
427
+ if not lines:
428
+ return None
429
+ # jj log returns newest-first; take the first line.
430
+ return datetime.fromisoformat(lines[0]).replace(tzinfo=UTC)
423
431
 
424
432
  def git_push_bookmarks(self, *bookmarks: str) -> None:
425
433
  """Push bookmarks to the remote.
@@ -25,6 +25,7 @@ class MRParams:
25
25
  description: str
26
26
  labels: list[str]
27
27
  draft: bool
28
+ auto_merge: bool = False
28
29
 
29
30
 
30
31
  class Platform(ABC):
@@ -59,4 +59,6 @@ class GitHubPlatform(Platform):
59
59
  )
60
60
  if params.labels:
61
61
  pr.set_labels(*params.labels)
62
+ if params.auto_merge:
63
+ pr.enable_automerge()
62
64
  return pr.html_url
@@ -49,4 +49,6 @@ class GitLabPlatform(Platform):
49
49
  "labels": params.labels,
50
50
  }
51
51
  )
52
+ if params.auto_merge:
53
+ mr.merge(merge_when_pipeline_succeeds=True)
52
54
  return mr.web_url
@@ -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 _on_cooldown(job: Job, repo_path: Path) -> bool:
625
- """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."""
626
648
  delta = job.cooldown_timedelta()
627
649
  if delta is None:
628
- return False
650
+ return None
629
651
  base = job.base_branch or "trunk()"
630
652
  since = datetime.now(UTC) - delta
631
- 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)
632
654
  logger.debug(
633
- "[%s] cooldown check: base=%s since=%s -> on_cooldown=%s",
655
+ "[%s] cooldown check: base=%s since=%s -> last_run=%s",
634
656
  job.name,
635
657
  base,
636
658
  since.isoformat(),
637
- on_cooldown,
659
+ last_run,
638
660
  )
639
- return on_cooldown
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
- 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
+ )
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(
@@ -1004,8 +1031,7 @@ def _absorb_results(
1004
1031
  if not content_unchanged:
1005
1032
  if abs_ws is None:
1006
1033
  abs_ws = stack.enter_context(repo.temp_workspace("repoactive-absorb"))
1007
- abs_ws.edit(old_cid)
1008
- abs_ws.restore(new_cid)
1034
+ abs_ws.restore(source_rev=new_cid, destination_rev=old_cid)
1009
1035
  repo.describe_revision(old_cid, message)
1010
1036
  elif _strip_boxquote_and_trailers(
1011
1037
  repo.get_description(old_cid)
@@ -1033,6 +1059,7 @@ def _absorb_results(
1033
1059
  command_output=result.command_output,
1034
1060
  labels=result.job.labels,
1035
1061
  draft=result.job.draft,
1062
+ auto_merge=result.job.auto_merge or False,
1036
1063
  depends_on=list(result.job.depends_on),
1037
1064
  )
1038
1065
  plan.updates.append(
@@ -1287,6 +1314,7 @@ def _apply_plan_publish(
1287
1314
  description=build_mr_description(update.mr, dependency_links),
1288
1315
  labels=update.mr.labels,
1289
1316
  draft=update.mr.draft,
1317
+ auto_merge=update.mr.auto_merge,
1290
1318
  )
1291
1319
  url = platform.ensure_mr(params)
1292
1320
  except Exception as e:
@@ -46,6 +46,7 @@ class MRUpdate(BaseModel):
46
46
  command_output: str
47
47
  labels: list[str]
48
48
  draft: bool
49
+ auto_merge: bool = False
49
50
  depends_on: list[str] = Field(default_factory=list)
50
51
 
51
52
 
File without changes