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 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