repoactive 0.0.1.dev0__py3-none-any.whl
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/__init__.py +0 -0
- repoactive/cli.py +83 -0
- repoactive/config.py +120 -0
- repoactive/jj.py +67 -0
- repoactive/platforms/__init__.py +27 -0
- repoactive/platforms/base.py +32 -0
- repoactive/platforms/github.py +46 -0
- repoactive/platforms/gitlab.py +43 -0
- repoactive/runner.py +342 -0
- repoactive-0.0.1.dev0.dist-info/METADATA +176 -0
- repoactive-0.0.1.dev0.dist-info/RECORD +14 -0
- repoactive-0.0.1.dev0.dist-info/WHEEL +4 -0
- repoactive-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- repoactive-0.0.1.dev0.dist-info/licenses/LICENSE +674 -0
repoactive/__init__.py
ADDED
|
File without changes
|
repoactive/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from repoactive.config import load_config
|
|
9
|
+
from repoactive.platforms import get_platform
|
|
10
|
+
from repoactive.runner import run_all
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
_DEFAULT_CONFIG = Path(".repoactive.toml")
|
|
15
|
+
_DEFAULT_REPO = Path()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _version_callback(value: bool) -> None:
|
|
19
|
+
if value:
|
|
20
|
+
typer.echo(version("repoactive"))
|
|
21
|
+
raise typer.Exit()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback()
|
|
25
|
+
def callback(
|
|
26
|
+
_version: Annotated[
|
|
27
|
+
bool,
|
|
28
|
+
typer.Option(
|
|
29
|
+
"--version", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
30
|
+
),
|
|
31
|
+
] = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Script-driven code changes with automated merge requests."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def run(
|
|
38
|
+
config: Annotated[
|
|
39
|
+
list[Path] | None,
|
|
40
|
+
typer.Option("--config", "-c", help="Config file; repeat to merge, later files win."),
|
|
41
|
+
] = None,
|
|
42
|
+
repo: Annotated[
|
|
43
|
+
Path, typer.Option("--repo", "-r", help="Path to the jj repository.")
|
|
44
|
+
] = _DEFAULT_REPO,
|
|
45
|
+
local: Annotated[
|
|
46
|
+
bool, typer.Option("--local", help="Skip pushing branches and MR creation/update.")
|
|
47
|
+
] = False,
|
|
48
|
+
debug: Annotated[bool, typer.Option("--debug", "-d", help="Enable debug logging.")] = False,
|
|
49
|
+
jobs: Annotated[
|
|
50
|
+
list[str] | None,
|
|
51
|
+
typer.Argument(help="Jobs to run (default: all); dependencies are auto-included."),
|
|
52
|
+
] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Apply jobs and create or update merge requests."""
|
|
55
|
+
if debug:
|
|
56
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
57
|
+
cfg = load_config(config or [_DEFAULT_CONFIG])
|
|
58
|
+
platform = None if local else get_platform(cfg.platform, repo)
|
|
59
|
+
summary = run_all(
|
|
60
|
+
config=cfg, repo_path=repo, platform=platform, requested_jobs=jobs or None, local=local
|
|
61
|
+
)
|
|
62
|
+
if not summary.ok:
|
|
63
|
+
raise typer.Exit(code=1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command("validate-config")
|
|
67
|
+
def validate_config(
|
|
68
|
+
config: Annotated[
|
|
69
|
+
list[Path] | None,
|
|
70
|
+
typer.Option("--config", "-c", help="Config file; repeat to merge, later files win."),
|
|
71
|
+
] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Validate configuration and exit."""
|
|
74
|
+
try:
|
|
75
|
+
cfg = load_config(config or [_DEFAULT_CONFIG])
|
|
76
|
+
except Exception as e:
|
|
77
|
+
typer.echo(f"Invalid config: {e}", err=True)
|
|
78
|
+
raise typer.Exit(code=1) from e
|
|
79
|
+
typer.echo(f"Config OK: {len(cfg.jobs)} job(s) defined.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main() -> None:
|
|
83
|
+
app()
|
repoactive/config.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PlatformConfig(BaseModel):
|
|
14
|
+
model_config = ConfigDict(extra="forbid")
|
|
15
|
+
|
|
16
|
+
type: Literal["gitlab", "github"]
|
|
17
|
+
url: str | None = None
|
|
18
|
+
token_env: str
|
|
19
|
+
# Project path ("namespace/repo"). Auto-detected from remote URL if omitted.
|
|
20
|
+
repo: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Defaults(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="forbid")
|
|
25
|
+
|
|
26
|
+
branch_prefix: str = "repoactive/"
|
|
27
|
+
mr_title_prefix: str = "[repoactive] "
|
|
28
|
+
commit_title_prefix: str = "[repoactive] "
|
|
29
|
+
labels: list[str] = Field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Job(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
command: str
|
|
37
|
+
title: str
|
|
38
|
+
description: str | None = None
|
|
39
|
+
labels: list[str] = Field(default_factory=list)
|
|
40
|
+
base_branch: str | None = None
|
|
41
|
+
draft: bool = False
|
|
42
|
+
create_mr: bool = True
|
|
43
|
+
disabled: bool = False
|
|
44
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
45
|
+
output_in_commit: bool = True
|
|
46
|
+
|
|
47
|
+
def branch_name(self, prefix: str) -> str:
|
|
48
|
+
return f"{prefix}{self.name}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Config(BaseModel):
|
|
52
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
53
|
+
|
|
54
|
+
platform: PlatformConfig
|
|
55
|
+
defaults: Defaults = Field(default_factory=Defaults)
|
|
56
|
+
jobs: list[Job] = Field(default_factory=list, alias="job")
|
|
57
|
+
|
|
58
|
+
@model_validator(mode="after")
|
|
59
|
+
def validate_depends_on(self) -> Config:
|
|
60
|
+
names = {j.name for j in self.jobs}
|
|
61
|
+
for job in self.jobs:
|
|
62
|
+
unknown = set(job.depends_on) - names
|
|
63
|
+
if unknown:
|
|
64
|
+
raise ValueError(f"Job '{job.name}' depends_on unknown jobs: {sorted(unknown)}")
|
|
65
|
+
by_name = {j.name: j for j in self.jobs}
|
|
66
|
+
visiting: set[str] = set()
|
|
67
|
+
visited: set[str] = set()
|
|
68
|
+
|
|
69
|
+
def detect_cycle(name: str) -> None:
|
|
70
|
+
if name in visiting:
|
|
71
|
+
raise ValueError(f"Circular dependency involving '{name}'")
|
|
72
|
+
if name in visited:
|
|
73
|
+
return
|
|
74
|
+
visiting.add(name)
|
|
75
|
+
for dep in by_name[name].depends_on:
|
|
76
|
+
detect_cycle(dep)
|
|
77
|
+
visiting.discard(name)
|
|
78
|
+
visited.add(name)
|
|
79
|
+
|
|
80
|
+
for job in self.jobs:
|
|
81
|
+
detect_cycle(job.name)
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
86
|
+
result = dict(base)
|
|
87
|
+
for key, value in override.items():
|
|
88
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
89
|
+
result[key] = _deep_merge(result[key], value)
|
|
90
|
+
else:
|
|
91
|
+
result[key] = value
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _merge_jobs(base: list[dict], override: list[dict]) -> list[dict]:
|
|
96
|
+
"""Merge two job lists by name: order is preserved, override fields win, new names appended."""
|
|
97
|
+
job_by_name: dict[str, dict] = {}
|
|
98
|
+
result: list[dict] = []
|
|
99
|
+
for job in base + override:
|
|
100
|
+
name = job["name"]
|
|
101
|
+
if name in job_by_name:
|
|
102
|
+
job_by_name[name].update(job)
|
|
103
|
+
else:
|
|
104
|
+
job_by_name[name] = dict(job)
|
|
105
|
+
result.append(job_by_name[name])
|
|
106
|
+
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_config(paths: list[Path]) -> Config:
|
|
111
|
+
assert paths
|
|
112
|
+
configs = [tomllib.loads(path.read_text()) for path in paths]
|
|
113
|
+
merged = {}
|
|
114
|
+
for data in configs:
|
|
115
|
+
jobs = _merge_jobs(merged.get("job", []), data.get("job", []))
|
|
116
|
+
merged = _deep_merge(merged, data)
|
|
117
|
+
merged["job"] = jobs
|
|
118
|
+
config = Config.model_validate(merged) # ensure it's valid after each merge
|
|
119
|
+
logger.debug("loaded config: %s", config.model_dump_json(indent=2))
|
|
120
|
+
return config
|
repoactive/jj.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JJError(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _run(*args: str, cwd: Path | None = None) -> str:
|
|
13
|
+
try:
|
|
14
|
+
result = subprocess.run(
|
|
15
|
+
["jj", "--no-pager", "--color=never", *args],
|
|
16
|
+
cwd=cwd,
|
|
17
|
+
capture_output=True,
|
|
18
|
+
text=True,
|
|
19
|
+
check=True,
|
|
20
|
+
)
|
|
21
|
+
return result.stdout
|
|
22
|
+
except subprocess.CalledProcessError as e:
|
|
23
|
+
raise JJError(f"jj {' '.join(args)} failed:\n{e.stderr.strip()}") from e
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def new(*parents: str, cwd: Path | None = None) -> None:
|
|
27
|
+
_run("new", *parents, cwd=cwd)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def bookmark_set(name: str, revision: str = "@", cwd: Path | None = None) -> None:
|
|
31
|
+
_run("bookmark", "set", name, "--revision", revision, "--allow-backwards", cwd=cwd)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def bookmark_exists(name: str, cwd: Path | None = None) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
_run("bookmark", "list", name, cwd=cwd)
|
|
37
|
+
return True
|
|
38
|
+
except JJError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_empty(cwd: Path | None = None) -> bool:
|
|
43
|
+
output = _run("log", "-r", "@", "--no-graph", "--template", "json(self.empty())", cwd=cwd)
|
|
44
|
+
result = output.strip() == "true"
|
|
45
|
+
logger.debug("is_empty: jj output=%r result=%r", output.strip(), result)
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def abandon(cwd: Path | None = None) -> None:
|
|
50
|
+
_run("abandon", "@", cwd=cwd)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def describe(message: str, cwd: Path | None = None) -> None:
|
|
54
|
+
_run("describe", "--message", message, cwd=cwd)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def git_push(bookmark: str, cwd: Path | None = None) -> None:
|
|
58
|
+
_run("git", "push", "--bookmark", bookmark, cwd=cwd)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_remote_url(remote: str = "origin", cwd: Path | None = None) -> str:
|
|
62
|
+
output = _run("git", "remote", "list", cwd=cwd)
|
|
63
|
+
for line in output.splitlines():
|
|
64
|
+
parts = line.split()
|
|
65
|
+
if parts and parts[0] == remote:
|
|
66
|
+
return parts[1]
|
|
67
|
+
raise JJError(f"Remote '{remote}' not found")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from repoactive import jj
|
|
5
|
+
from repoactive.config import PlatformConfig
|
|
6
|
+
from repoactive.platforms.base import Platform, parse_repo_from_url
|
|
7
|
+
from repoactive.platforms.github import GitHubPlatform
|
|
8
|
+
from repoactive.platforms.gitlab import GitLabPlatform
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_platform(config: PlatformConfig, repo_path: Path) -> Platform:
|
|
12
|
+
token = os.environ.get(config.token_env)
|
|
13
|
+
if not token:
|
|
14
|
+
raise RuntimeError(
|
|
15
|
+
f"Platform token not set: environment variable '{config.token_env}' is empty"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
repo = config.repo
|
|
19
|
+
if not repo:
|
|
20
|
+
remote_url = jj.get_remote_url(cwd=repo_path)
|
|
21
|
+
repo = parse_repo_from_url(remote_url)
|
|
22
|
+
|
|
23
|
+
if config.type == "gitlab":
|
|
24
|
+
return GitLabPlatform(url=config.url, token=token, repo=repo)
|
|
25
|
+
if config.type == "github":
|
|
26
|
+
return GitHubPlatform(url=config.url, token=token, repo=repo)
|
|
27
|
+
raise RuntimeError(f"Unsupported platform type: {config.type!r}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class MRParams:
|
|
8
|
+
source_branch: str
|
|
9
|
+
target_branch: str
|
|
10
|
+
title: str
|
|
11
|
+
description: str
|
|
12
|
+
labels: list[str]
|
|
13
|
+
draft: bool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Platform(ABC):
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def default_branch(self) -> str:
|
|
19
|
+
"""Return the repository's default branch name."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def ensure_mr(self, params: MRParams) -> str:
|
|
23
|
+
"""Create or update an MR/PR. Returns the MR/PR URL."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_repo_from_url(url: str) -> str:
|
|
27
|
+
"""Extract 'namespace/repo' from an HTTPS or SSH git remote URL."""
|
|
28
|
+
ssh = re.match(r"git@[^:]+:(.+?)(?:\.git)?$", url)
|
|
29
|
+
if ssh:
|
|
30
|
+
return ssh.group(1)
|
|
31
|
+
# HTTPS
|
|
32
|
+
return re.sub(r"https?://[^/]+/", "", url).removesuffix(".git")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from github import Github
|
|
2
|
+
|
|
3
|
+
from repoactive.platforms.base import MRParams, Platform
|
|
4
|
+
|
|
5
|
+
_DEFAULT_API_URL = "https://api.github.com"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GitHubPlatform(Platform):
|
|
9
|
+
def __init__(self, url: str | None, token: str, repo: str) -> None:
|
|
10
|
+
base_url = (url.rstrip("/") + "/api/v3") if url else _DEFAULT_API_URL
|
|
11
|
+
self._gh = Github(token, base_url=base_url)
|
|
12
|
+
self._repo = self._gh.get_repo(repo)
|
|
13
|
+
|
|
14
|
+
def default_branch(self) -> str:
|
|
15
|
+
return self._repo.default_branch
|
|
16
|
+
|
|
17
|
+
def ensure_mr(self, params: MRParams) -> str:
|
|
18
|
+
owner = self._repo.owner.login
|
|
19
|
+
existing = list(
|
|
20
|
+
self._repo.get_pulls(
|
|
21
|
+
state="open",
|
|
22
|
+
head=f"{owner}:{params.source_branch}",
|
|
23
|
+
base=params.target_branch,
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
if existing:
|
|
27
|
+
pr = existing[0]
|
|
28
|
+
pr.edit(
|
|
29
|
+
title=params.title,
|
|
30
|
+
body=params.description,
|
|
31
|
+
# PyGithub does not support converting to/from draft via edit;
|
|
32
|
+
# draft state can only be set at creation time.
|
|
33
|
+
)
|
|
34
|
+
# Labels are set separately
|
|
35
|
+
pr.set_labels(*params.labels)
|
|
36
|
+
else:
|
|
37
|
+
pr = self._repo.create_pull(
|
|
38
|
+
title=params.title,
|
|
39
|
+
body=params.description,
|
|
40
|
+
head=params.source_branch,
|
|
41
|
+
base=params.target_branch,
|
|
42
|
+
draft=params.draft,
|
|
43
|
+
)
|
|
44
|
+
if params.labels:
|
|
45
|
+
pr.set_labels(*params.labels)
|
|
46
|
+
return pr.html_url
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import gitlab
|
|
2
|
+
|
|
3
|
+
from repoactive.platforms.base import MRParams, Platform
|
|
4
|
+
|
|
5
|
+
_DRAFT_PREFIX = "Draft: "
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _mr_title(title: str, *, draft: bool) -> str:
|
|
9
|
+
return f"{_DRAFT_PREFIX}{title}" if draft else title
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitLabPlatform(Platform):
|
|
13
|
+
def __init__(self, url: str | None, token: str, repo: str) -> None:
|
|
14
|
+
self._gl = gitlab.Gitlab(url or "https://gitlab.com", private_token=token)
|
|
15
|
+
self._project = self._gl.projects.get(repo)
|
|
16
|
+
|
|
17
|
+
def default_branch(self) -> str:
|
|
18
|
+
return self._project.default_branch
|
|
19
|
+
|
|
20
|
+
def ensure_mr(self, params: MRParams) -> str:
|
|
21
|
+
title = _mr_title(params.title, draft=params.draft)
|
|
22
|
+
existing = self._project.mergerequests.list(
|
|
23
|
+
source_branch=params.source_branch,
|
|
24
|
+
state="opened",
|
|
25
|
+
iterator=False,
|
|
26
|
+
)
|
|
27
|
+
if existing:
|
|
28
|
+
mr = existing[0]
|
|
29
|
+
mr.title = title
|
|
30
|
+
mr.description = params.description
|
|
31
|
+
mr.labels = params.labels
|
|
32
|
+
mr.save()
|
|
33
|
+
else:
|
|
34
|
+
mr = self._project.mergerequests.create(
|
|
35
|
+
{
|
|
36
|
+
"source_branch": params.source_branch,
|
|
37
|
+
"target_branch": params.target_branch,
|
|
38
|
+
"title": title,
|
|
39
|
+
"description": params.description,
|
|
40
|
+
"labels": params.labels,
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
return mr.web_url
|