repoactive 0.2.5.dev1__tar.gz → 0.2.6__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 (26) hide show
  1. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/PKG-INFO +1 -1
  2. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/pyproject.toml +1 -1
  3. repoactive-0.2.6/src/repoactive/boxquote.py +32 -0
  4. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/cli.py +7 -8
  5. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/config.py +15 -21
  6. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/jj.py +5 -27
  7. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/platforms/github.py +4 -1
  8. repoactive-0.2.6/src/repoactive/platforms/gitlab.py +105 -0
  9. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/runner.py +341 -509
  10. repoactive-0.2.6/src/repoactive/selection.py +210 -0
  11. repoactive-0.2.6/src/repoactive/trailers.py +37 -0
  12. repoactive-0.2.5.dev1/src/repoactive/platforms/gitlab.py +0 -57
  13. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/.gitignore +0 -0
  14. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/LICENSE +0 -0
  15. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/README.md +0 -0
  16. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/__init__.py +0 -0
  17. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/constants.py +0 -0
  18. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/graph.py +0 -0
  19. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/jobtree.py +0 -0
  20. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/lock.py +0 -0
  21. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/platforms/__init__.py +0 -0
  22. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/platforms/base.py +0 -0
  23. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/progress.py +0 -0
  24. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/settings.py +0 -0
  25. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/ui.py +0 -0
  26. {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/updates.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.6
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.5.dev1"
13
+ version = "0.2.6"
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
 
@@ -278,8 +278,18 @@ def _validate_duration(value: str) -> str:
278
278
  return value
279
279
 
280
280
 
281
+ def _validate_single_line(value: str, info: ValidationInfo) -> str:
282
+ if "\n" in value:
283
+ assert info.field_name is not None
284
+ raise NewlineInTitleError(info.field_name, value)
285
+ return value
286
+
287
+
281
288
  _BranchPrefix = Annotated[str, AfterValidator(_validate_branch_prefix)]
282
289
  _Duration = Annotated[str, AfterValidator(_validate_duration)]
290
+ # Titles and title prefixes end up in commit subjects and MR titles, which are
291
+ # single-line by nature.
292
+ _SingleLine = Annotated[str, AfterValidator(_validate_single_line)]
283
293
  # json_schema_input_type keeps booleans valid in the published JSON schema.
284
294
  _CreateMR = Annotated[
285
295
  CreateMR, BeforeValidator(_coerce_create_mr, json_schema_input_type=bool | CreateMR)
@@ -290,22 +300,14 @@ class JobDefaults(BaseModel):
290
300
  model_config = ConfigDict(extra="forbid")
291
301
 
292
302
  branch_prefix: _BranchPrefix = "repoactive/"
293
- mr_title_prefix: str = "[repoactive] "
294
- commit_title_prefix: str = "[repoactive] "
303
+ mr_title_prefix: _SingleLine = "[repoactive] "
304
+ commit_title_prefix: _SingleLine = "[repoactive] "
295
305
  labels: list[str] = Field(default_factory=list)
296
306
  base_branch: str | None = None
297
307
  cooldown_period: _Duration | None = None
298
308
  timeout: _Duration | None = "2m"
299
309
  auto_merge: bool = False
300
310
 
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
308
-
309
311
 
310
312
  # Fields Job.resolve fills in from JobDefaults when the job does not set them
311
313
  # itself. labels is absent on purpose: it is merged, not replaced (see resolve).
@@ -325,7 +327,7 @@ class Job(BaseModel):
325
327
 
326
328
  name: str
327
329
  command: str
328
- title: str
330
+ title: _SingleLine
329
331
  description: str | None = None
330
332
  base_branch: str | None = None
331
333
  draft: bool = False
@@ -352,8 +354,8 @@ class Job(BaseModel):
352
354
 
353
355
  # the following fields will be resolved from the defaults
354
356
  branch_prefix: _BranchPrefix | None = None
355
- mr_title_prefix: str | None = None
356
- commit_title_prefix: str | None = None
357
+ mr_title_prefix: _SingleLine | None = None
358
+ commit_title_prefix: _SingleLine | None = None
357
359
  labels: list[str] = Field(default_factory=list)
358
360
  cooldown_period: _Duration | None = None
359
361
  timeout: _Duration | None = None
@@ -366,14 +368,6 @@ class Job(BaseModel):
366
368
  raise InvalidJobNameError(value)
367
369
  return value
368
370
 
369
- @field_validator("title", "commit_title_prefix")
370
- @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)
375
- return value
376
-
377
371
  @field_validator("tags")
378
372
  @classmethod
379
373
  def _check_tags(cls, value: list[str]) -> list[str]:
@@ -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.
@@ -60,5 +60,8 @@ class GitHubPlatform(Platform):
60
60
  if params.labels:
61
61
  pr.set_labels(*params.labels)
62
62
  if params.auto_merge:
63
- pr.enable_automerge()
63
+ try:
64
+ pr.enable_automerge()
65
+ except GithubException as e:
66
+ print(f" warning: could not enable auto-merge ({e})")
64
67
  return pr.html_url
@@ -0,0 +1,105 @@
1
+ """GitLab platform implementation using python-gitlab."""
2
+
3
+ import time
4
+
5
+ import gitlab
6
+ from gitlab.exceptions import GitlabError, GitlabMRClosedError
7
+ from gitlab.v4.objects import ProjectMergeRequest
8
+
9
+ from repoactive.platforms.base import MRParams, Platform, PlatformError
10
+
11
+ _DRAFT_PREFIX = "Draft: "
12
+
13
+ # detailed_merge_status / merge_status values that mean GitLab's async
14
+ # mergeability check hasn't finished yet. Setting auto-merge while the check is
15
+ # pending returns 422 "Branch cannot be merged".
16
+ _MERGE_CHECK_PENDING = frozenset({"unchecked", "checking", "preparing"})
17
+
18
+ # A freshly created MR is not immediately ready for auto-merge: GitLab runs the
19
+ # mergeability check and creates the MR pipeline in the background. Poll for
20
+ # that to settle, bounded by this timeout; the next run re-attempts if it never
21
+ # does.
22
+ _AUTO_MERGE_TIMEOUT = 60.0
23
+ _AUTO_MERGE_POLL_INTERVAL = 3.0
24
+
25
+
26
+ def _mr_title(title: str, *, draft: bool) -> str:
27
+ return f"{_DRAFT_PREFIX}{title}" if draft else title
28
+
29
+
30
+ def _ready_for_auto_merge(mr: ProjectMergeRequest) -> bool:
31
+ """Report whether GitLab can accept merge_when_pipeline_succeeds for ``mr``.
32
+
33
+ Two conditions must hold. The mergeability check must be done: setting the
34
+ flag while detailed_merge_status is still ``checking``/``unchecked``/
35
+ ``preparing`` returns 422 "Branch cannot be merged". And a pipeline must
36
+ exist: with none, merge_when_pipeline_succeeds has nothing to wait for and
37
+ GitLab merges immediately, defeating the point of auto-merge.
38
+ """
39
+ status = getattr(mr, "detailed_merge_status", None) or getattr(mr, "merge_status", None)
40
+ if status in _MERGE_CHECK_PENDING:
41
+ return False
42
+ return getattr(mr, "head_pipeline", None) is not None
43
+
44
+
45
+ class GitLabPlatform(Platform):
46
+ def __init__(self, *, url: str | None, token: str, repo: str) -> None:
47
+ self._gl = gitlab.Gitlab(url or "https://gitlab.com", private_token=token)
48
+ try:
49
+ self._project = self._gl.projects.get(repo)
50
+ except GitlabError as e:
51
+ raise PlatformError("GitLab", repo, e) from e
52
+
53
+ def default_branch(self) -> str:
54
+ return self._project.default_branch
55
+
56
+ def ensure_mr(self, params: MRParams) -> str:
57
+ title = _mr_title(params.title, draft=params.draft)
58
+ existing = self._project.mergerequests.list(
59
+ source_branch=params.source_branch,
60
+ state="opened",
61
+ iterator=False,
62
+ )
63
+ if existing:
64
+ mr = existing[0]
65
+ mr.title = title
66
+ mr.description = params.description
67
+ mr.labels = params.labels
68
+ # Retarget in case the job's base_branch changed since the MR was
69
+ # created.
70
+ mr.target_branch = params.target_branch
71
+ mr.save()
72
+ else:
73
+ mr = self._project.mergerequests.create(
74
+ {
75
+ "source_branch": params.source_branch,
76
+ "target_branch": params.target_branch,
77
+ "title": title,
78
+ "description": params.description,
79
+ "labels": params.labels,
80
+ }
81
+ )
82
+ web_url = mr.web_url
83
+ if params.auto_merge:
84
+ self._enable_auto_merge(mr)
85
+ return web_url
86
+
87
+ def _enable_auto_merge(self, mr: ProjectMergeRequest) -> None:
88
+ """Set merge_when_pipeline_succeeds once the MR is ready, else warn.
89
+
90
+ Re-fetches the MR (the list/create payloads omit head_pipeline) and
91
+ polls until it is ready for auto-merge or the timeout passes. A repo
92
+ with no CI never grows a pipeline; there the poll times out and the
93
+ final call merges immediately, the best available behavior.
94
+ """
95
+ iid = mr.iid
96
+ deadline = time.monotonic() + _AUTO_MERGE_TIMEOUT
97
+ while True:
98
+ mr = self._project.mergerequests.get(iid)
99
+ if _ready_for_auto_merge(mr) or time.monotonic() >= deadline:
100
+ break
101
+ time.sleep(_AUTO_MERGE_POLL_INTERVAL)
102
+ try:
103
+ mr.merge(merge_when_pipeline_succeeds=True)
104
+ except GitlabMRClosedError as e:
105
+ print(f" warning: could not enable auto-merge ({e})")