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.
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/PKG-INFO +1 -1
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/pyproject.toml +1 -1
- repoactive-0.2.6/src/repoactive/boxquote.py +32 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/cli.py +7 -8
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/config.py +15 -21
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/jj.py +5 -27
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/platforms/github.py +4 -1
- repoactive-0.2.6/src/repoactive/platforms/gitlab.py +105 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/runner.py +341 -509
- repoactive-0.2.6/src/repoactive/selection.py +210 -0
- repoactive-0.2.6/src/repoactive/trailers.py +37 -0
- repoactive-0.2.5.dev1/src/repoactive/platforms/gitlab.py +0 -57
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/.gitignore +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/LICENSE +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/README.md +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/__init__.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/constants.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/graph.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/jobtree.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/lock.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/platforms/__init__.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/platforms/base.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/progress.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/settings.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/ui.py +0 -0
- {repoactive-0.2.5.dev1 → repoactive-0.2.6}/src/repoactive/updates.py +0 -0
|
@@ -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
|
|
245
|
-
requested_tags=tags or
|
|
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
|
-
|
|
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
|
-
|
|
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:<{
|
|
371
|
-
f"{names:<{
|
|
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:
|
|
294
|
-
commit_title_prefix:
|
|
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:
|
|
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:
|
|
356
|
-
commit_title_prefix:
|
|
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
|
|
358
|
-
"""Job names that appear
|
|
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",
|
|
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
|
-
#
|
|
425
|
-
return datetime.fromisoformat(lines
|
|
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
|
-
|
|
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})")
|