repoactive 0.2.1__tar.gz → 0.2.3.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.1 → repoactive-0.2.3.dev1}/PKG-INFO +53 -2
  2. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/README.md +52 -1
  3. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/pyproject.toml +1 -1
  4. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/config.py +58 -2
  5. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/jj.py +7 -3
  6. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/runner.py +43 -13
  7. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/.gitignore +0 -0
  8. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/LICENSE +0 -0
  9. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/__init__.py +0 -0
  10. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/cli.py +0 -0
  11. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/constants.py +0 -0
  12. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/graph.py +0 -0
  13. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/jobtree.py +0 -0
  14. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/lock.py +0 -0
  15. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/platforms/__init__.py +0 -0
  16. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/platforms/base.py +0 -0
  17. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/platforms/github.py +0 -0
  18. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/platforms/gitlab.py +0 -0
  19. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/progress.py +0 -0
  20. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/settings.py +0 -0
  21. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/ui.py +0 -0
  22. {repoactive-0.2.1 → repoactive-0.2.3.dev1}/src/repoactive/updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repoactive
3
- Version: 0.2.1
3
+ Version: 0.2.3.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
@@ -50,6 +50,7 @@ and (with `--mode publish`) the full MR lifecycle.
50
50
  - [Configuration](#configuration)
51
51
  - [Selecting jobs with tags](#selecting-jobs-with-tags)
52
52
  - [Disabling jobs](#disabling-jobs)
53
+ - [Gating jobs with `run_only_if_changed`](#gating-jobs-with-run_only_if_changed)
53
54
  - [Throttling jobs with `cooldown_period`](#throttling-jobs-with-cooldown_period)
54
55
  - [Limiting job runtime with `timeout`](#limiting-job-runtime-with-timeout)
55
56
  - [Generating jobs dynamically](#generating-jobs-dynamically)
@@ -145,7 +146,13 @@ need to write is the script that produces the change.
145
146
  2. It runs the job's script against the working tree.
146
147
  3. If the script produced a diff, it records the change. With `--mode push`
147
148
  or `--mode publish`, it pushes the branch; with `--mode publish`, it also
148
- creates or updates the merge request.
149
+ creates or updates the merge request. On a re-run, if the diff matches
150
+ what is already on the branch, the commit and branch are left untouched —
151
+ avoiding an unnecessary push that would re-trigger CI pipelines. The
152
+ commit is updated (and the branch pushed) only when the diff itself
153
+ changes or when `title`/`commit_title_prefix` changed. Command output
154
+ alone changing does not update the commit. The MR title and description
155
+ are always brought up to date, including any changed command output.
149
156
  4. If the script produced no diff, the empty commit is discarded and no MR
150
157
  is opened. A branch left over from an earlier run that did produce a diff
151
158
  is now stale, so it is deleted; with `--mode push` or `--mode publish`,
@@ -507,6 +514,11 @@ disabled = false
507
514
  # Optional: override branch_prefix/mr_title_prefix/commit_title_prefix from
508
515
  # job-defaults for this job only.
509
516
  mr_title_prefix = "[api] "
517
+ # Optional: only run this job if at least one of the named jobs produced a
518
+ # diff in the current run. If none of them changed anything, this job is
519
+ # skipped (with a no-op result so dependents still run). Names need not be
520
+ # in depends_on; any job that ran before this one can be listed.
521
+ # run_only_if_changed = ["other-job"]
510
522
  # Optional: minimum time between landed changes for this job. If a commit
511
523
  # from this job landed on the base branch within this window, the job is
512
524
  # skipped. Format: <number><unit>, unit one of s, m, h, d, w (e.g. "7d").
@@ -751,6 +763,45 @@ The lock is an advisory `flock` on `.jj/repoactive.lock`; the OS releases it
751
763
  automatically if a run is killed, so a crashed run never leaves the
752
764
  repository locked.
753
765
 
766
+ ## Gating jobs with `run_only_if_changed`
767
+
768
+ `run_only_if_changed` lets a job declare that it should only run when
769
+ specific upstream jobs actually produced a diff. If none of the named jobs
770
+ changed anything in the current run, the job is skipped.
771
+
772
+ ```toml
773
+ [job.prek-autoupdate]
774
+ command = "./scripts/prek.sh autoupdate"
775
+ title = "ci: update pre-commit hooks"
776
+ tags = ["weekly"]
777
+
778
+ [job.prek-run-all]
779
+ command = "sh -c './scripts/prek.sh run -a; true'"
780
+ title = "ci: apply pre-commit fixes"
781
+ depends_on = ["prek-autoupdate"]
782
+ run_only_if_changed = ["prek-autoupdate"]
783
+ tags = ["weekly"]
784
+ ```
785
+
786
+ Here `prek-run-all` is only useful when `prek-autoupdate` changed something:
787
+ if `prek-autoupdate` found no hooks to update, running `prek-run-all` would
788
+ produce an empty diff anyway. With `run_only_if_changed`, the skip is
789
+ explicit and immediate — `prek-run-all` never even starts.
790
+
791
+ Key behaviour:
792
+
793
+ - **At least one must have changed.** The job runs if any named job produced
794
+ a diff; it is skipped only when _all_ of them were empty.
795
+ - **Missing results count as no diff.** If a named job failed or was itself
796
+ skipped, it is treated as having produced no diff.
797
+ - **Dependents are unaffected.** A skipped job records a no-op result (like
798
+ a job on cooldown), so any job that `depends_on` it still runs on the base
799
+ branch — the skip does not cascade.
800
+ - **No ordering constraint.** Names in `run_only_if_changed` do not have to
801
+ appear in `depends_on`. Any job that runs before this one in topological
802
+ order can be listed; in practice most usages name a direct dependency, as
803
+ in the example above.
804
+
754
805
  ## Throttling jobs with `cooldown_period`
755
806
 
756
807
  Every commit `repoactive` creates carries a `Repoactive-Job: <name>` trailer
@@ -23,6 +23,7 @@ and (with `--mode publish`) the full MR lifecycle.
23
23
  - [Configuration](#configuration)
24
24
  - [Selecting jobs with tags](#selecting-jobs-with-tags)
25
25
  - [Disabling jobs](#disabling-jobs)
26
+ - [Gating jobs with `run_only_if_changed`](#gating-jobs-with-run_only_if_changed)
26
27
  - [Throttling jobs with `cooldown_period`](#throttling-jobs-with-cooldown_period)
27
28
  - [Limiting job runtime with `timeout`](#limiting-job-runtime-with-timeout)
28
29
  - [Generating jobs dynamically](#generating-jobs-dynamically)
@@ -118,7 +119,13 @@ need to write is the script that produces the change.
118
119
  2. It runs the job's script against the working tree.
119
120
  3. If the script produced a diff, it records the change. With `--mode push`
120
121
  or `--mode publish`, it pushes the branch; with `--mode publish`, it also
121
- creates or updates the merge request.
122
+ creates or updates the merge request. On a re-run, if the diff matches
123
+ what is already on the branch, the commit and branch are left untouched —
124
+ avoiding an unnecessary push that would re-trigger CI pipelines. The
125
+ commit is updated (and the branch pushed) only when the diff itself
126
+ changes or when `title`/`commit_title_prefix` changed. Command output
127
+ alone changing does not update the commit. The MR title and description
128
+ are always brought up to date, including any changed command output.
122
129
  4. If the script produced no diff, the empty commit is discarded and no MR
123
130
  is opened. A branch left over from an earlier run that did produce a diff
124
131
  is now stale, so it is deleted; with `--mode push` or `--mode publish`,
@@ -480,6 +487,11 @@ disabled = false
480
487
  # Optional: override branch_prefix/mr_title_prefix/commit_title_prefix from
481
488
  # job-defaults for this job only.
482
489
  mr_title_prefix = "[api] "
490
+ # Optional: only run this job if at least one of the named jobs produced a
491
+ # diff in the current run. If none of them changed anything, this job is
492
+ # skipped (with a no-op result so dependents still run). Names need not be
493
+ # in depends_on; any job that ran before this one can be listed.
494
+ # run_only_if_changed = ["other-job"]
483
495
  # Optional: minimum time between landed changes for this job. If a commit
484
496
  # from this job landed on the base branch within this window, the job is
485
497
  # skipped. Format: <number><unit>, unit one of s, m, h, d, w (e.g. "7d").
@@ -724,6 +736,45 @@ The lock is an advisory `flock` on `.jj/repoactive.lock`; the OS releases it
724
736
  automatically if a run is killed, so a crashed run never leaves the
725
737
  repository locked.
726
738
 
739
+ ## Gating jobs with `run_only_if_changed`
740
+
741
+ `run_only_if_changed` lets a job declare that it should only run when
742
+ specific upstream jobs actually produced a diff. If none of the named jobs
743
+ changed anything in the current run, the job is skipped.
744
+
745
+ ```toml
746
+ [job.prek-autoupdate]
747
+ command = "./scripts/prek.sh autoupdate"
748
+ title = "ci: update pre-commit hooks"
749
+ tags = ["weekly"]
750
+
751
+ [job.prek-run-all]
752
+ command = "sh -c './scripts/prek.sh run -a; true'"
753
+ title = "ci: apply pre-commit fixes"
754
+ depends_on = ["prek-autoupdate"]
755
+ run_only_if_changed = ["prek-autoupdate"]
756
+ tags = ["weekly"]
757
+ ```
758
+
759
+ Here `prek-run-all` is only useful when `prek-autoupdate` changed something:
760
+ if `prek-autoupdate` found no hooks to update, running `prek-run-all` would
761
+ produce an empty diff anyway. With `run_only_if_changed`, the skip is
762
+ explicit and immediate — `prek-run-all` never even starts.
763
+
764
+ Key behaviour:
765
+
766
+ - **At least one must have changed.** The job runs if any named job produced
767
+ a diff; it is skipped only when _all_ of them were empty.
768
+ - **Missing results count as no diff.** If a named job failed or was itself
769
+ skipped, it is treated as having produced no diff.
770
+ - **Dependents are unaffected.** A skipped job records a no-op result (like
771
+ a job on cooldown), so any job that `depends_on` it still runs on the base
772
+ branch — the skip does not cascade.
773
+ - **No ordering constraint.** Names in `run_only_if_changed` do not have to
774
+ appear in `depends_on`. Any job that runs before this one in topological
775
+ order can be listed; in practice most usages name a direct dependency, as
776
+ in the example above.
777
+
727
778
  ## Throttling jobs with `cooldown_period`
728
779
 
729
780
  Every commit `repoactive` creates carries a `Repoactive-Job: <name>` trailer
@@ -10,7 +10,7 @@ only-include = ["src"]
10
10
 
11
11
  [project]
12
12
  name = "repoactive"
13
- version = "0.2.1"
13
+ version = "0.2.3.dev1"
14
14
  description = "Script-driven code changes with automated merge requests"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -20,6 +20,7 @@ from pydantic import (
20
20
  ConfigDict,
21
21
  Field,
22
22
  ValidationError,
23
+ ValidationInfo,
23
24
  field_validator,
24
25
  model_validator,
25
26
  )
@@ -78,6 +79,13 @@ class InvalidTagError(ValueError):
78
79
  super().__init__(f"invalid tag {tag!r}: only letters, digits, '-', and '_' are allowed")
79
80
 
80
81
 
82
+ class NewlineInTitleError(ValueError):
83
+ """Raised when a title or prefix field contains a newline character."""
84
+
85
+ def __init__(self, field: str, value: str) -> None:
86
+ super().__init__(f"{field} must not contain newline characters, got {value!r}")
87
+
88
+
81
89
  class DisabledAndTagsError(ValueError):
82
90
  """Raised when a job sets both 'disabled' and 'tags'."""
83
91
 
@@ -95,6 +103,15 @@ class UnknownDependencyError(ValueError):
95
103
  super().__init__(f"job '{name}' depends_on unknown jobs: {unknown}")
96
104
 
97
105
 
106
+ class UnknownRunOnlyIfChangedError(ValueError):
107
+ """Raised when run_only_if_changed references a job that does not exist."""
108
+
109
+ def __init__(self, name: str, unknown: list[str]) -> None:
110
+ super().__init__(
111
+ f"job '{name}' run_only_if_changed references unknown job(s): {', '.join(unknown)}"
112
+ )
113
+
114
+
98
115
  class JobNameInBodyError(ValueError):
99
116
  """Raised when a [job.<name>] table also sets a 'name' field.
100
117
 
@@ -253,6 +270,14 @@ class JobDefaults(BaseModel):
253
270
  cooldown_period: _Duration | None = None
254
271
  timeout: _Duration | None = "2m"
255
272
 
273
+ @field_validator("mr_title_prefix", "commit_title_prefix")
274
+ @classmethod
275
+ def _check_no_newline(cls, value: str, info: ValidationInfo) -> str:
276
+ if "\n" in value:
277
+ assert info.field_name is not None
278
+ raise NewlineInTitleError(info.field_name, value)
279
+ return value
280
+
256
281
 
257
282
  # Fields Job.resolve fills in from JobDefaults when the job does not set them
258
283
  # itself. labels is absent on purpose: it is merged, not replaced (see resolve).
@@ -279,6 +304,7 @@ class Job(BaseModel):
279
304
  disabled: bool = False
280
305
  tags: list[str] = Field(default_factory=list)
281
306
  depends_on: list[str] = Field(default_factory=list)
307
+ run_only_if_changed: list[str] = Field(default_factory=list)
282
308
  output_in_commit: bool = True
283
309
  # When true, the command does not produce a diff to commit; instead it writes
284
310
  # one or more *.toml job fragments into the directory named by the
@@ -305,6 +331,14 @@ class Job(BaseModel):
305
331
  raise InvalidJobNameError(value)
306
332
  return value
307
333
 
334
+ @field_validator("title", "commit_title_prefix")
335
+ @classmethod
336
+ def _check_no_newline(cls, value: str, info: ValidationInfo) -> str:
337
+ if "\n" in value:
338
+ assert info.field_name is not None
339
+ raise NewlineInTitleError(info.field_name, value)
340
+ return value
341
+
308
342
  @field_validator("tags")
309
343
  @classmethod
310
344
  def _check_tags(cls, value: list[str]) -> list[str]:
@@ -443,6 +477,15 @@ class Config(BaseModel):
443
477
  detect_dependency_cycle(self.jobs)
444
478
  return self
445
479
 
480
+ @model_validator(mode="after")
481
+ def validate_run_only_if_changed(self) -> Config:
482
+ names = {j.name for j in self.jobs}
483
+ for job in self.jobs:
484
+ unknown = sorted(set(job.run_only_if_changed) - names)
485
+ if unknown:
486
+ raise UnknownRunOnlyIfChangedError(job.name, unknown)
487
+ return self
488
+
446
489
  def _resolved_jobs(self) -> list[Job]:
447
490
  return [job.resolve(self.job_defaults) for job in self.jobs]
448
491
 
@@ -521,8 +564,21 @@ def _merge_named_tables(*, base: dict, override: dict) -> dict:
521
564
 
522
565
 
523
566
  def merge_jobs(*, base: dict, override: dict) -> dict:
524
- """Merge two job tables keyed by name (see ``_merge_named_tables``)."""
525
- return _merge_named_tables(base=base, override=override)
567
+ """Merge two job tables keyed by name (see ``_merge_named_tables``).
568
+
569
+ When the override sets ``disabled = true``, any ``tags`` carried over from
570
+ the base are removed, and when the override sets ``tags``, any ``disabled``
571
+ carried over from the base is removed. This keeps the mutual-exclusion
572
+ invariant intact across multi-source merges.
573
+ """
574
+ result = _merge_named_tables(base=base, override=override)
575
+ for name, override_body in override.items():
576
+ merged_body = result[name]
577
+ if override_body.get("disabled") and "tags" not in override_body:
578
+ merged_body.pop("tags", None)
579
+ elif override_body.get("tags") and "disabled" not in override_body:
580
+ merged_body.pop("disabled", None)
581
+ return result
526
582
 
527
583
 
528
584
  def merge_platforms(*, base: dict, override: dict) -> dict:
@@ -273,9 +273,9 @@ class JJ:
273
273
  """Abandon a specific revision (not the working copy)."""
274
274
  self._run("abandon", revision)
275
275
 
276
- def diff_content(self, revision: str = "@") -> str:
277
- """Return the full diff of ``revision`` relative to its parents."""
278
- return self._run("diff", "-r", revision)
276
+ def same_content(self, rev1: str, rev2: str) -> bool:
277
+ """Return True if ``rev1`` and ``rev2`` have identical tree contents."""
278
+ return not self._run("diff", "--git", "--from", rev1, "--to", rev2).strip()
279
279
 
280
280
  def bookmark_change_id(self, name: str) -> str | None:
281
281
  """Return the change-id of the local bookmark ``name``, or None if absent."""
@@ -290,6 +290,10 @@ class JJ:
290
290
  """Set the commit message of a specific revision without touching ``@``."""
291
291
  self._run("describe", "-r", revision, "--message", message)
292
292
 
293
+ def get_description(self, revision: str) -> str:
294
+ """Return the commit message of a specific revision."""
295
+ return self._run("log", "--no-graph", "-r", revision, "-T", "description")
296
+
293
297
  def children_job_names(self, bookmarks: list[str]) -> set[str]:
294
298
  """Job names from unmerged commits that are direct children of any of ``bookmarks``.
295
299
 
@@ -26,6 +26,7 @@ from repoactive.config import (
26
26
  jobs_table,
27
27
  merge_jobs,
28
28
  )
29
+ from repoactive.constants import JOB_TRAILER_KEY
29
30
  from repoactive.graph import CircularDependencyError, detect_dependency_cycle, topological_sort
30
31
  from repoactive.jj import JJ, workspace_name
31
32
  from repoactive.jobtree import format_job_forest, print_job_table
@@ -374,6 +375,19 @@ def _boxquote(msg: str, title: str = "") -> str:
374
375
  return f"{top}\n{body}\n`----"
375
376
 
376
377
 
378
+ def _strip_boxquote_and_trailers(message: str) -> str:
379
+ """Strip the boxquote section and trailers paragraph from a commit message.
380
+
381
+ Returns only the title line and description field.
382
+ Note: inaccurate when the description itself contains a ,---- boxquote (ignored edge case).
383
+ """
384
+ if "\n\n,----" in message:
385
+ return message[: message.index("\n\n,----")]
386
+ if f"\n\n{JOB_TRAILER_KEY}" in message:
387
+ return message[: message.index(f"\n\n{JOB_TRAILER_KEY}")]
388
+ return message
389
+
390
+
377
391
  def _build_commit_message(job: Job, command_result: CommandResult) -> str:
378
392
  """Build the commit message recorded for a job's change.
379
393
 
@@ -788,6 +802,24 @@ def _dispatch_job( # noqa: PLR0913
788
802
  blocked.add(job.name)
789
803
  return []
790
804
 
805
+ if job.run_only_if_changed:
806
+ any_changed = any(
807
+ r.produced_diff
808
+ for d in job.run_only_if_changed
809
+ if (r := summary.results.get(d)) is not None
810
+ )
811
+ if not any_changed:
812
+ resolved_job = job.resolve(config.job_defaults)
813
+ parents = _compute_parents(resolved_job, summary.results)
814
+ print(
815
+ f"==> [{job.name}] skipped"
816
+ f" (run_only_if_changed: none of {job.run_only_if_changed} produced changes)"
817
+ )
818
+ summary.results[job.name] = JobResult(
819
+ job=resolved_job, effective_revsets=parents, produced_diff=False
820
+ )
821
+ return []
822
+
791
823
  resolved_job = job.resolve(config.job_defaults)
792
824
  parents = _compute_parents(resolved_job, summary.results)
793
825
  logger.debug("[%s] computed parents: %s", job.name, parents)
@@ -921,12 +953,6 @@ def _absorb_results(
921
953
  with contextlib.ExitStack() as stack:
922
954
  abs_ws: JJ | None = None
923
955
 
924
- def _get_abs_ws() -> JJ:
925
- nonlocal abs_ws
926
- if abs_ws is None:
927
- abs_ws = stack.enter_context(repo.temp_workspace("repoactive-absorb"))
928
- return abs_ws
929
-
930
956
  # Maps each phase-1 new_change_id to the canonical change-id post-absorb so
931
957
  # dependent jobs are rebased onto the right commit, not the abandoned fresh one.
932
958
  absorbed: dict[str, str] = {}
@@ -967,19 +993,23 @@ def _absorb_results(
967
993
  if result.old_change_id:
968
994
  old_cid = result.old_change_id
969
995
  repo.rebase_revision(old_cid, *canonical_parents)
970
- old_diff = repo.diff_content(old_cid)
971
- new_diff = repo.diff_content(new_cid)
996
+ content_unchanged = repo.same_content(old_cid, new_cid)
972
997
  logger.debug(
973
998
  "absorb: [%s] rebased %s onto %s, content %s",
974
999
  job.name,
975
1000
  old_cid,
976
1001
  canonical_parents,
977
- "unchanged" if old_diff == new_diff else "differs, restoring",
1002
+ "unchanged" if content_unchanged else "differs, restoring",
978
1003
  )
979
- if old_diff != new_diff:
980
- ws = _get_abs_ws()
981
- ws.edit(old_cid)
982
- ws.restore(new_cid)
1004
+ if not content_unchanged:
1005
+ if abs_ws is None:
1006
+ abs_ws = stack.enter_context(repo.temp_workspace("repoactive-absorb"))
1007
+ abs_ws.edit(old_cid)
1008
+ abs_ws.restore(new_cid)
1009
+ repo.describe_revision(old_cid, message)
1010
+ elif _strip_boxquote_and_trailers(
1011
+ repo.get_description(old_cid)
1012
+ ) != _strip_boxquote_and_trailers(message):
983
1013
  repo.describe_revision(old_cid, message)
984
1014
  repo.abandon_revision(new_cid)
985
1015
  # No bookmark_set needed: the bookmark follows old_cid through
File without changes
File without changes