path-sync 0.2.0__py3-none-any.whl → 0.3.0__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.
- path_sync/__init__.py +10 -1
- path_sync/__main__.py +3 -3
- path_sync/config.py +14 -0
- path_sync/copy.py +4 -0
- path_sync/sections.py +61 -104
- {path_sync-0.2.0.dist-info → path_sync-0.3.0.dist-info}/METADATA +11 -5
- path_sync-0.3.0.dist-info/RECORD +10 -0
- path_sync-0.3.0.dist-info/licenses/LICENSE +21 -0
- path_sync/cmd_boot.py +0 -89
- path_sync/cmd_copy.py +0 -522
- path_sync/cmd_copy_test.py +0 -159
- path_sync/cmd_validate.py +0 -51
- path_sync/conftest.py +0 -15
- path_sync/file_utils.py +0 -7
- path_sync/git_ops.py +0 -188
- path_sync/header.py +0 -80
- path_sync/header_test.py +0 -41
- path_sync/models.py +0 -142
- path_sync/models_test.py +0 -69
- path_sync/sections_test.py +0 -128
- path_sync/typer_app.py +0 -8
- path_sync/validation.py +0 -84
- path_sync/validation_test.py +0 -114
- path_sync/yaml_utils.py +0 -19
- path_sync-0.2.0.dist-info/RECORD +0 -23
- {path_sync-0.2.0.dist-info → path_sync-0.3.0.dist-info}/WHEEL +0 -0
- {path_sync-0.2.0.dist-info → path_sync-0.3.0.dist-info}/entry_points.txt +0 -0
path_sync/cmd_validate.py
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
import typer
|
|
7
|
-
|
|
8
|
-
from path_sync import git_ops
|
|
9
|
-
from path_sync.models import find_repo_root
|
|
10
|
-
from path_sync.typer_app import app
|
|
11
|
-
from path_sync.validation import parse_skip_sections, validate_no_unauthorized_changes
|
|
12
|
-
|
|
13
|
-
logger = logging.getLogger(__name__)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@app.command("validate-no-changes")
|
|
17
|
-
def validate_no_changes(
|
|
18
|
-
branch: str = typer.Option(
|
|
19
|
-
"main", "-b", "--branch", help="Default branch to compare against"
|
|
20
|
-
),
|
|
21
|
-
skip_sections_opt: str = typer.Option(
|
|
22
|
-
"",
|
|
23
|
-
"--skip-sections",
|
|
24
|
-
help="Comma-separated path:section_id pairs to skip (e.g., 'justfile:coverage,pyproject.toml:default')",
|
|
25
|
-
),
|
|
26
|
-
) -> None:
|
|
27
|
-
"""Validate no unauthorized changes to synced files."""
|
|
28
|
-
repo_root = find_repo_root(Path.cwd())
|
|
29
|
-
repo = git_ops.get_repo(repo_root)
|
|
30
|
-
|
|
31
|
-
current_branch = repo.active_branch.name
|
|
32
|
-
if current_branch.startswith("sync/"):
|
|
33
|
-
logger.info(f"On sync branch {current_branch}, validation skipped")
|
|
34
|
-
return
|
|
35
|
-
if current_branch == branch:
|
|
36
|
-
logger.info(f"On default branch {branch}, validation skipped")
|
|
37
|
-
return
|
|
38
|
-
|
|
39
|
-
skip_sections = (
|
|
40
|
-
parse_skip_sections(skip_sections_opt) if skip_sections_opt else None
|
|
41
|
-
)
|
|
42
|
-
unauthorized = validate_no_unauthorized_changes(repo_root, branch, skip_sections)
|
|
43
|
-
|
|
44
|
-
if unauthorized:
|
|
45
|
-
files_list = "\n ".join(unauthorized)
|
|
46
|
-
logger.error(
|
|
47
|
-
f"Unauthorized changes in {len(unauthorized)} files:\n {files_list}"
|
|
48
|
-
)
|
|
49
|
-
raise typer.Exit(1)
|
|
50
|
-
|
|
51
|
-
logger.info("Validation passed: no unauthorized changes")
|
path_sync/conftest.py
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from git import Repo
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
@pytest.fixture
|
|
6
|
-
def tmp_repo(tmp_path):
|
|
7
|
-
"""Create a temporary git repo."""
|
|
8
|
-
|
|
9
|
-
repo_path = tmp_path / "repo"
|
|
10
|
-
repo_path.mkdir()
|
|
11
|
-
repo = Repo.init(repo_path)
|
|
12
|
-
(repo_path / ".gitkeep").write_text("")
|
|
13
|
-
repo.index.add([".gitkeep"])
|
|
14
|
-
repo.index.commit("Initial commit")
|
|
15
|
-
return repo_path
|
path_sync/file_utils.py
DELETED
path_sync/git_ops.py
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
import os
|
|
5
|
-
import subprocess
|
|
6
|
-
from contextlib import suppress
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError, Repo
|
|
10
|
-
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _auth_url(url: str) -> str:
|
|
15
|
-
"""Inject GH_TOKEN into HTTPS URL for authentication."""
|
|
16
|
-
token = os.environ.get("GH_TOKEN", "")
|
|
17
|
-
if not token:
|
|
18
|
-
return url
|
|
19
|
-
if url.startswith("https://github.com/"):
|
|
20
|
-
return url.replace("https://", f"https://x-access-token:{token}@")
|
|
21
|
-
return url
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def get_repo(path: Path) -> Repo:
|
|
25
|
-
try:
|
|
26
|
-
return Repo(path)
|
|
27
|
-
except InvalidGitRepositoryError as e:
|
|
28
|
-
raise ValueError(f"Not a git repository: {path}") from e
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def is_git_repo(path: Path) -> bool:
|
|
32
|
-
with suppress(InvalidGitRepositoryError, NoSuchPathError):
|
|
33
|
-
Repo(path)
|
|
34
|
-
return True
|
|
35
|
-
return False
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def get_default_branch(repo: Repo) -> str:
|
|
39
|
-
with suppress(GitCommandError):
|
|
40
|
-
return repo.git.symbolic_ref("refs/remotes/origin/HEAD", short=True).replace(
|
|
41
|
-
"origin/", ""
|
|
42
|
-
)
|
|
43
|
-
return "main"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def clone_repo(url: str, dest: Path) -> Repo:
|
|
47
|
-
logger.info(f"Cloning {url} to {dest}")
|
|
48
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
-
return Repo.clone_from(_auth_url(url), str(dest))
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def checkout_branch(repo: Repo, branch: str) -> None:
|
|
53
|
-
current = repo.active_branch.name
|
|
54
|
-
if current == branch:
|
|
55
|
-
return
|
|
56
|
-
logger.info(f"Checking out {branch}")
|
|
57
|
-
try:
|
|
58
|
-
repo.git.checkout(branch)
|
|
59
|
-
except GitCommandError:
|
|
60
|
-
repo.git.checkout("-b", branch)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def prepare_copy_branch(
|
|
64
|
-
repo: Repo, default_branch: str, copy_branch: str, from_default: bool = False
|
|
65
|
-
) -> None:
|
|
66
|
-
"""Prepare copy_branch for syncing.
|
|
67
|
-
|
|
68
|
-
Args:
|
|
69
|
-
from_default: If True, fetch origin and reset to origin/default_branch
|
|
70
|
-
before creating copy_branch. Use in CI for clean state.
|
|
71
|
-
If False, just switch to or create copy_branch from current HEAD.
|
|
72
|
-
"""
|
|
73
|
-
if from_default:
|
|
74
|
-
logger.info("Fetching origin")
|
|
75
|
-
repo.git.fetch("origin")
|
|
76
|
-
|
|
77
|
-
logger.info(f"Checking out {default_branch}")
|
|
78
|
-
repo.git.checkout(default_branch)
|
|
79
|
-
repo.git.reset("--hard", f"origin/{default_branch}")
|
|
80
|
-
|
|
81
|
-
with suppress(GitCommandError):
|
|
82
|
-
repo.git.branch("-D", copy_branch)
|
|
83
|
-
logger.info(f"Deleted existing local branch: {copy_branch}")
|
|
84
|
-
|
|
85
|
-
logger.info(f"Creating fresh branch: {copy_branch}")
|
|
86
|
-
repo.git.checkout("-b", copy_branch)
|
|
87
|
-
else:
|
|
88
|
-
checkout_branch(repo, copy_branch)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def get_current_sha(repo: Repo) -> str:
|
|
92
|
-
return repo.head.commit.hexsha
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def get_remote_url(repo: Repo, remote_name: str = "origin") -> str:
|
|
96
|
-
try:
|
|
97
|
-
return repo.remote(remote_name).url
|
|
98
|
-
except ValueError:
|
|
99
|
-
return ""
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def has_changes(repo: Repo) -> bool:
|
|
103
|
-
return repo.is_dirty() or len(repo.untracked_files) > 0
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def commit_changes(repo: Repo, message: str) -> None:
|
|
107
|
-
repo.git.add("-A")
|
|
108
|
-
if repo.is_dirty():
|
|
109
|
-
_ensure_git_user(repo)
|
|
110
|
-
repo.git.commit("-m", message)
|
|
111
|
-
logger.info(f"Committed: {message}")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _ensure_git_user(repo: Repo) -> None:
|
|
115
|
-
"""Configure git user if not already set."""
|
|
116
|
-
try:
|
|
117
|
-
repo.config_reader().get_value("user", "name")
|
|
118
|
-
except Exception:
|
|
119
|
-
repo.config_writer().set_value("user", "name", "path-sync[bot]").release()
|
|
120
|
-
repo.config_writer().set_value(
|
|
121
|
-
"user", "email", "path-sync[bot]@users.noreply.github.com"
|
|
122
|
-
).release()
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def push_branch(repo: Repo, branch: str, force: bool = True) -> None:
|
|
126
|
-
logger.info(f"Pushing {branch}" + (" (force)" if force else ""))
|
|
127
|
-
args = ["--force", "-u", "origin", branch] if force else ["-u", "origin", branch]
|
|
128
|
-
repo.git.push(*args)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def update_pr_body(repo_path: Path, branch: str, body: str) -> bool:
|
|
132
|
-
cmd = ["gh", "pr", "edit", branch, "--body", body]
|
|
133
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
134
|
-
if result.returncode != 0:
|
|
135
|
-
logger.warning(f"Failed to update PR body: {result.stderr}")
|
|
136
|
-
return False
|
|
137
|
-
logger.info("Updated PR body")
|
|
138
|
-
return True
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def create_or_update_pr(
|
|
142
|
-
repo_path: Path,
|
|
143
|
-
branch: str,
|
|
144
|
-
title: str,
|
|
145
|
-
body: str = "",
|
|
146
|
-
labels: list[str] | None = None,
|
|
147
|
-
reviewers: list[str] | None = None,
|
|
148
|
-
assignees: list[str] | None = None,
|
|
149
|
-
) -> str:
|
|
150
|
-
cmd = ["gh", "pr", "create", "--head", branch, "--title", title]
|
|
151
|
-
cmd.extend(["--body", body or ""])
|
|
152
|
-
if labels:
|
|
153
|
-
cmd.extend(["--label", ",".join(labels)])
|
|
154
|
-
if reviewers:
|
|
155
|
-
cmd.extend(["--reviewer", ",".join(reviewers)])
|
|
156
|
-
if assignees:
|
|
157
|
-
cmd.extend(["--assignee", ",".join(assignees)])
|
|
158
|
-
|
|
159
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
160
|
-
if result.returncode != 0:
|
|
161
|
-
if "already exists" in result.stderr:
|
|
162
|
-
logger.info("PR already exists, updating body")
|
|
163
|
-
update_pr_body(repo_path, branch, body)
|
|
164
|
-
return ""
|
|
165
|
-
raise RuntimeError(f"Failed to create PR: {result.stderr}")
|
|
166
|
-
logger.info(f"Created PR: {result.stdout.strip()}")
|
|
167
|
-
return result.stdout.strip()
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def file_has_git_changes(repo: Repo, file_path: Path, base_ref: str = "HEAD") -> bool:
|
|
171
|
-
rel_path = str(file_path.relative_to(repo.working_dir))
|
|
172
|
-
diff = repo.git.diff("--name-only", base_ref, "--", rel_path)
|
|
173
|
-
return bool(diff.strip())
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def get_changed_files(repo: Repo, base_ref: str = "HEAD") -> list[Path]:
|
|
177
|
-
diff = repo.git.diff("--name-only", base_ref)
|
|
178
|
-
if not diff.strip():
|
|
179
|
-
return []
|
|
180
|
-
repo_root = Path(repo.working_dir)
|
|
181
|
-
return [repo_root / p for p in diff.strip().split("\n")]
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def get_file_content_at_ref(repo: Repo, file_path: Path, ref: str) -> str | None:
|
|
185
|
-
rel_path = str(file_path.relative_to(repo.working_dir))
|
|
186
|
-
with suppress(GitCommandError):
|
|
187
|
-
return repo.git.show(f"{ref}:{rel_path}")
|
|
188
|
-
return None
|
path_sync/header.py
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
from path_sync.models import (
|
|
7
|
-
DEFAULT_COMMENT_PREFIXES,
|
|
8
|
-
DEFAULT_COMMENT_SUFFIXES,
|
|
9
|
-
HEADER_TEMPLATE,
|
|
10
|
-
HeaderConfig,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
COMMENT_PREFIXES = DEFAULT_COMMENT_PREFIXES
|
|
14
|
-
|
|
15
|
-
HEADER_PATTERN = re.compile(r"path-sync copy -n (?P<config_name>[\w-]+)")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def get_header_line(
|
|
19
|
-
extension: str,
|
|
20
|
-
config_name: str,
|
|
21
|
-
config: HeaderConfig | None = None,
|
|
22
|
-
) -> str:
|
|
23
|
-
if config:
|
|
24
|
-
prefix = config.comment_prefixes.get(extension, "")
|
|
25
|
-
suffix = config.comment_suffixes.get(extension, "")
|
|
26
|
-
else:
|
|
27
|
-
prefix = DEFAULT_COMMENT_PREFIXES.get(extension, "")
|
|
28
|
-
suffix = DEFAULT_COMMENT_SUFFIXES.get(extension, "")
|
|
29
|
-
|
|
30
|
-
if not prefix:
|
|
31
|
-
raise ValueError(f"No comment prefix found for extension: {extension}")
|
|
32
|
-
header_text = HEADER_TEMPLATE.format(config_name=config_name)
|
|
33
|
-
return f"{prefix} {header_text}{suffix}"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def has_header(content: str) -> bool:
|
|
37
|
-
first_line = content.split("\n", 1)[0] if content else ""
|
|
38
|
-
return bool(HEADER_PATTERN.search(first_line))
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def get_config_name(content: str) -> str | None:
|
|
42
|
-
first_line = content.split("\n", 1)[0] if content else ""
|
|
43
|
-
if match := HEADER_PATTERN.search(first_line):
|
|
44
|
-
return match.group("config_name")
|
|
45
|
-
return None
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def add_header(
|
|
49
|
-
content: str,
|
|
50
|
-
extension: str,
|
|
51
|
-
config_name: str,
|
|
52
|
-
config: HeaderConfig | None = None,
|
|
53
|
-
) -> str:
|
|
54
|
-
header = get_header_line(extension, config_name, config)
|
|
55
|
-
return f"{header}\n{content}"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def remove_header(content: str) -> str:
|
|
59
|
-
if not has_header(content):
|
|
60
|
-
return content
|
|
61
|
-
lines = content.split("\n", 1)
|
|
62
|
-
return lines[1] if len(lines) > 1 else ""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def file_get_config_name(path: Path) -> str | None:
|
|
66
|
-
"""Read first line and extract config name if present."""
|
|
67
|
-
if not path.exists() or path.suffix not in DEFAULT_COMMENT_PREFIXES:
|
|
68
|
-
return None
|
|
69
|
-
try:
|
|
70
|
-
with path.open() as f:
|
|
71
|
-
first_line = f.readline()
|
|
72
|
-
except (UnicodeDecodeError, OSError):
|
|
73
|
-
return None
|
|
74
|
-
return get_config_name(first_line)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def file_has_header(path: Path, config: HeaderConfig | None = None) -> bool:
|
|
78
|
-
if config and path.suffix not in config.comment_prefixes:
|
|
79
|
-
return False
|
|
80
|
-
return file_get_config_name(path) is not None
|
path_sync/header_test.py
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
from path_sync import header
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def test_header_generation():
|
|
5
|
-
assert header.get_header_line(".py", "my-config") == "# path-sync copy -n my-config"
|
|
6
|
-
assert (
|
|
7
|
-
header.get_header_line(".go", "my-config") == "// path-sync copy -n my-config"
|
|
8
|
-
)
|
|
9
|
-
assert (
|
|
10
|
-
header.get_header_line(".md", "my-config")
|
|
11
|
-
== "<!-- path-sync copy -n my-config -->"
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_has_header_matches_any_config_name():
|
|
16
|
-
assert header.has_header("# path-sync copy -n my-config\ncode")
|
|
17
|
-
assert header.has_header("# path-sync copy -n other_name\ncode")
|
|
18
|
-
assert not header.has_header("# DO NOT EDIT: path-sync destination file\ncode")
|
|
19
|
-
assert not header.has_header("print('hello')")
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_add_remove_header():
|
|
23
|
-
content = "print('hello')"
|
|
24
|
-
with_header = header.add_header(content, ".py", "test-config")
|
|
25
|
-
assert header.has_header(with_header)
|
|
26
|
-
without = header.remove_header(with_header)
|
|
27
|
-
assert without == content
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_file_has_header(tmp_path):
|
|
31
|
-
py_file = tmp_path / "test.py"
|
|
32
|
-
py_file.write_text(header.add_header("content", ".py", "my-config"))
|
|
33
|
-
assert header.file_has_header(py_file)
|
|
34
|
-
|
|
35
|
-
no_header = tmp_path / "plain.py"
|
|
36
|
-
no_header.write_text("content")
|
|
37
|
-
assert not header.file_has_header(no_header)
|
|
38
|
-
|
|
39
|
-
unsupported = tmp_path / "data.whl"
|
|
40
|
-
unsupported.write_bytes(b"\x00\x01\x02\x03")
|
|
41
|
-
assert not header.file_has_header(unsupported)
|
path_sync/models.py
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import glob as glob_mod
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import ClassVar
|
|
6
|
-
|
|
7
|
-
from pydantic import BaseModel, Field
|
|
8
|
-
|
|
9
|
-
LOG_FORMAT = "%(asctime)s %(levelname)s %(message)s"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class PathMapping(BaseModel):
|
|
13
|
-
src_path: str
|
|
14
|
-
dest_path: str = ""
|
|
15
|
-
|
|
16
|
-
def resolved_dest_path(self) -> str:
|
|
17
|
-
return self.dest_path or self.src_path
|
|
18
|
-
|
|
19
|
-
def expand_dest_paths(self, repo_root: Path) -> list[Path]:
|
|
20
|
-
dest_path = self.resolved_dest_path()
|
|
21
|
-
pattern = repo_root / dest_path
|
|
22
|
-
|
|
23
|
-
if "*" in dest_path:
|
|
24
|
-
return [Path(p) for p in glob_mod.glob(str(pattern), recursive=True)]
|
|
25
|
-
if pattern.is_dir():
|
|
26
|
-
return [p for p in pattern.rglob("*") if p.is_file()]
|
|
27
|
-
if pattern.exists():
|
|
28
|
-
return [pattern]
|
|
29
|
-
return []
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
HEADER_TEMPLATE = "path-sync copy -n {config_name}"
|
|
33
|
-
DEFAULT_COMMENT_PREFIXES: dict[str, str] = {
|
|
34
|
-
".py": "#",
|
|
35
|
-
".sh": "#",
|
|
36
|
-
".yaml": "#",
|
|
37
|
-
".yml": "#",
|
|
38
|
-
".go": "//",
|
|
39
|
-
".js": "//",
|
|
40
|
-
".ts": "//",
|
|
41
|
-
".md": "<!--",
|
|
42
|
-
".mdc": "<!--",
|
|
43
|
-
".html": "<!--",
|
|
44
|
-
}
|
|
45
|
-
DEFAULT_COMMENT_SUFFIXES: dict[str, str] = {
|
|
46
|
-
".md": " -->",
|
|
47
|
-
".mdc": " -->",
|
|
48
|
-
".html": " -->",
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
class HeaderConfig(BaseModel):
|
|
53
|
-
comment_prefixes: dict[str, str] = Field(
|
|
54
|
-
default_factory=DEFAULT_COMMENT_PREFIXES.copy
|
|
55
|
-
)
|
|
56
|
-
comment_suffixes: dict[str, str] = Field(
|
|
57
|
-
default_factory=DEFAULT_COMMENT_SUFFIXES.copy
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
DEFAULT_BODY_TEMPLATE = """\
|
|
62
|
-
Synced from [{src_repo_name}]({src_repo_url}) @ `{src_sha_short}`
|
|
63
|
-
|
|
64
|
-
<details>
|
|
65
|
-
<summary>Sync Log</summary>
|
|
66
|
-
|
|
67
|
-
```
|
|
68
|
-
{sync_log}
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
</details>
|
|
72
|
-
"""
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
class PRDefaults(BaseModel):
|
|
76
|
-
title: str = "chore: sync {name} files"
|
|
77
|
-
body_template: str = DEFAULT_BODY_TEMPLATE
|
|
78
|
-
body_suffix: str = ""
|
|
79
|
-
labels: list[str] = Field(default_factory=list)
|
|
80
|
-
reviewers: list[str] = Field(default_factory=list)
|
|
81
|
-
assignees: list[str] = Field(default_factory=list)
|
|
82
|
-
|
|
83
|
-
def format_body(
|
|
84
|
-
self,
|
|
85
|
-
src_repo_url: str,
|
|
86
|
-
src_sha: str,
|
|
87
|
-
sync_log: str,
|
|
88
|
-
dest_name: str,
|
|
89
|
-
) -> str:
|
|
90
|
-
src_repo_name = src_repo_url.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git")
|
|
91
|
-
body = self.body_template.format(
|
|
92
|
-
src_repo_url=src_repo_url,
|
|
93
|
-
src_repo_name=src_repo_name,
|
|
94
|
-
src_sha=src_sha,
|
|
95
|
-
src_sha_short=src_sha[:8],
|
|
96
|
-
sync_log=sync_log,
|
|
97
|
-
dest_name=dest_name,
|
|
98
|
-
)
|
|
99
|
-
if self.body_suffix:
|
|
100
|
-
body = f"{body}\n---\n{self.body_suffix}"
|
|
101
|
-
return body
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
class Destination(BaseModel):
|
|
105
|
-
name: str
|
|
106
|
-
repo_url: str = ""
|
|
107
|
-
dest_path_relative: str
|
|
108
|
-
copy_branch: str = "sync/path-sync"
|
|
109
|
-
default_branch: str = "main"
|
|
110
|
-
skip_sections: dict[str, list[str]] = Field(default_factory=dict)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
class SrcConfig(BaseModel):
|
|
114
|
-
CONFIG_EXT: ClassVar[str] = ".src.yaml"
|
|
115
|
-
|
|
116
|
-
name: str
|
|
117
|
-
git_remote: str = "origin"
|
|
118
|
-
src_repo_url: str = ""
|
|
119
|
-
schedule: str = "0 6 * * *"
|
|
120
|
-
header_config: HeaderConfig = Field(default_factory=HeaderConfig)
|
|
121
|
-
pr_defaults: PRDefaults = Field(default_factory=PRDefaults)
|
|
122
|
-
paths: list[PathMapping] = Field(default_factory=list)
|
|
123
|
-
destinations: list[Destination] = Field(default_factory=list)
|
|
124
|
-
|
|
125
|
-
def find_destination(self, name: str) -> Destination:
|
|
126
|
-
for dest in self.destinations:
|
|
127
|
-
if dest.name == name:
|
|
128
|
-
return dest
|
|
129
|
-
raise ValueError(f"Destination not found: {name}")
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def resolve_config_path(repo_root: Path, name: str) -> Path:
|
|
133
|
-
return repo_root / ".github" / f"{name}{SrcConfig.CONFIG_EXT}"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def find_repo_root(start_path: Path) -> Path:
|
|
137
|
-
current = start_path.resolve()
|
|
138
|
-
while current != current.parent:
|
|
139
|
-
if (current / ".git").exists():
|
|
140
|
-
return current
|
|
141
|
-
current = current.parent
|
|
142
|
-
raise ValueError(f"No git repository found from {start_path}")
|
path_sync/models_test.py
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
from path_sync.models import (
|
|
2
|
-
Destination,
|
|
3
|
-
PathMapping,
|
|
4
|
-
PRDefaults,
|
|
5
|
-
SrcConfig,
|
|
6
|
-
find_repo_root,
|
|
7
|
-
resolve_config_path,
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def test_path_mapping_resolved():
|
|
12
|
-
m1 = PathMapping(src_path="src/file.py")
|
|
13
|
-
assert m1.resolved_dest_path() == "src/file.py"
|
|
14
|
-
|
|
15
|
-
m2 = PathMapping(src_path="src/file.py", dest_path="dest/file.py")
|
|
16
|
-
assert m2.resolved_dest_path() == "dest/file.py"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_resolve_config_path(tmp_path):
|
|
20
|
-
src_path = resolve_config_path(tmp_path, "sdlc")
|
|
21
|
-
assert src_path == tmp_path / ".github" / "sdlc.src.yaml"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_find_repo_root(tmp_repo):
|
|
25
|
-
subdir = tmp_repo / "a" / "b"
|
|
26
|
-
subdir.mkdir(parents=True)
|
|
27
|
-
found = find_repo_root(subdir)
|
|
28
|
-
assert found == tmp_repo
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_src_config_find_destination():
|
|
32
|
-
config = SrcConfig(
|
|
33
|
-
name="test",
|
|
34
|
-
destinations=[
|
|
35
|
-
Destination(name="repo1", dest_path_relative="../repo1"),
|
|
36
|
-
Destination(name="repo2", dest_path_relative="../repo2"),
|
|
37
|
-
],
|
|
38
|
-
)
|
|
39
|
-
dest = config.find_destination("repo1")
|
|
40
|
-
assert dest.name == "repo1"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_destination_skip_sections():
|
|
44
|
-
dest = Destination(
|
|
45
|
-
name="test",
|
|
46
|
-
dest_path_relative="../test",
|
|
47
|
-
skip_sections={"justfile": ["pkg-ext"], "pyproject.toml": ["coverage"]},
|
|
48
|
-
)
|
|
49
|
-
assert dest.skip_sections["justfile"] == ["pkg-ext"]
|
|
50
|
-
assert dest.skip_sections.get("unknown", []) == []
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def test_pr_defaults_format_body():
|
|
54
|
-
pr = PRDefaults()
|
|
55
|
-
body = pr.format_body(
|
|
56
|
-
src_repo_url="https://github.com/user/my-repo",
|
|
57
|
-
src_sha="abc12345def67890",
|
|
58
|
-
sync_log="INFO Wrote: file.py",
|
|
59
|
-
dest_name="dest1",
|
|
60
|
-
)
|
|
61
|
-
assert "[my-repo](https://github.com/user/my-repo)" in body
|
|
62
|
-
assert "`abc12345`" in body
|
|
63
|
-
assert "INFO Wrote: file.py" in body
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def test_pr_defaults_format_body_extracts_repo_name():
|
|
67
|
-
pr = PRDefaults(body_template="{src_repo_name}")
|
|
68
|
-
assert pr.format_body("https://github.com/u/repo", "sha", "", "") == "repo"
|
|
69
|
-
assert pr.format_body("https://github.com/u/repo.git", "sha", "", "") == "repo"
|