path-sync 0.6.2__tar.gz → 0.7.0__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.
- {path_sync-0.6.2 → path_sync-0.7.0}/PKG-INFO +1 -1
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/__init__.py +1 -1
- path_sync-0.7.0/path_sync/_internal/auto_merge.py +168 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/cmd_copy.py +55 -27
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/cmd_dep_update.py +17 -4
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/git_ops.py +0 -14
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/models.py +14 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/models_dep.py +2 -1
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/config.py +4 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/pyproject.toml +1 -1
- {path_sync-0.6.2 → path_sync-0.7.0}/.gitignore +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/LICENSE +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/README.md +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/__main__.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/__init__.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/cmd_boot.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/cmd_options.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/cmd_validate.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/file_utils.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/header.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/log_capture.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/prompt_utils.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/typer_app.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/validation.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/verify.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/yaml_utils.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/copy.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/dep_update.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/sections.py +0 -0
- {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/validate_no_changes.py +0 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import NamedTuple
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from path_sync._internal.models import AutoMergeConfig
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PRState(StrEnum):
|
|
19
|
+
MERGED = "MERGED"
|
|
20
|
+
OPEN = "OPEN"
|
|
21
|
+
CLOSED = "CLOSED"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
FAILED_CHECK_STATES: frozenset[str] = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"FAILURE",
|
|
27
|
+
"ERROR",
|
|
28
|
+
"TIMED_OUT",
|
|
29
|
+
"STARTUP_FAILURE",
|
|
30
|
+
"STALE",
|
|
31
|
+
"ACTION_REQUIRED",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
COMPLETED_CHECK_STATES: frozenset[str] = frozenset(FAILED_CHECK_STATES | {"SUCCESS", "NEUTRAL", "SKIPPED", "CANCELLED"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CheckRun(BaseModel):
|
|
39
|
+
name: str
|
|
40
|
+
state: str
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def failed(self) -> bool:
|
|
44
|
+
return self.state in FAILED_CHECK_STATES
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def pending(self) -> bool:
|
|
48
|
+
return self.state not in COMPLETED_CHECK_STATES
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PRRef(NamedTuple):
|
|
52
|
+
dest_name: str
|
|
53
|
+
repo_path: Path
|
|
54
|
+
branch_or_url: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PRMergeResult(BaseModel):
|
|
58
|
+
dest_name: str
|
|
59
|
+
pr_url: str
|
|
60
|
+
branch: str
|
|
61
|
+
state: PRState
|
|
62
|
+
checks: list[CheckRun] = Field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def failed_checks(self) -> list[CheckRun]:
|
|
66
|
+
return [c for c in self.checks if c.failed]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def pending_checks(self) -> list[CheckRun]:
|
|
70
|
+
return [c for c in self.checks if c.pending]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def enable_auto_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig) -> None:
|
|
74
|
+
cmd = ["gh", "pr", "merge", "--auto", f"--{config.method}", pr_ref]
|
|
75
|
+
if config.delete_branch:
|
|
76
|
+
cmd.append("--delete-branch")
|
|
77
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
78
|
+
if result.returncode != 0:
|
|
79
|
+
logger.warning(f"Auto-merge enable failed for {pr_ref}: {result.stderr.strip()}")
|
|
80
|
+
else:
|
|
81
|
+
logger.info(f"Enabled auto-merge ({config.method}) for {pr_ref}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_pr_checks(repo_path: Path, pr_ref: str) -> list[CheckRun]:
|
|
85
|
+
cmd = ["gh", "pr", "checks", pr_ref, "--json", "name,state"]
|
|
86
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
87
|
+
if result.returncode != 0:
|
|
88
|
+
logger.warning(f"Failed to get checks for {pr_ref}: {result.stderr.strip()}")
|
|
89
|
+
return []
|
|
90
|
+
return [CheckRun.model_validate(c) for c in json.loads(result.stdout)]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_pr_state(repo_path: Path, pr_ref: str) -> PRState:
|
|
94
|
+
cmd = ["gh", "pr", "view", pr_ref, "--json", "state", "-q", ".state"]
|
|
95
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
96
|
+
if result.returncode != 0:
|
|
97
|
+
logger.warning(f"Failed to get PR state for {pr_ref}: {result.stderr.strip()}")
|
|
98
|
+
return PRState.OPEN
|
|
99
|
+
return PRState(result.stdout.strip())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_pr_url(repo_path: Path, pr_ref: str) -> str:
|
|
103
|
+
cmd = ["gh", "pr", "view", pr_ref, "--json", "url", "-q", ".url"]
|
|
104
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
105
|
+
if result.returncode != 0:
|
|
106
|
+
return pr_ref
|
|
107
|
+
return result.stdout.strip() or pr_ref
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def wait_for_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig, dest_name: str = "") -> PRMergeResult:
|
|
111
|
+
pr_url = get_pr_url(repo_path, pr_ref)
|
|
112
|
+
deadline = time.monotonic() + config.timeout_seconds
|
|
113
|
+
|
|
114
|
+
while time.monotonic() < deadline:
|
|
115
|
+
state = get_pr_state(repo_path, pr_ref)
|
|
116
|
+
if state == PRState.MERGED:
|
|
117
|
+
return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.MERGED)
|
|
118
|
+
if state == PRState.CLOSED:
|
|
119
|
+
checks = get_pr_checks(repo_path, pr_ref)
|
|
120
|
+
return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.CLOSED, checks=checks)
|
|
121
|
+
time.sleep(config.poll_interval_seconds)
|
|
122
|
+
|
|
123
|
+
checks = get_pr_checks(repo_path, pr_ref)
|
|
124
|
+
logger.warning(f"Timeout waiting for {pr_ref} after {config.timeout_seconds}s")
|
|
125
|
+
return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.OPEN, checks=checks)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def handle_auto_merge(
|
|
129
|
+
pr_refs: list[PRRef],
|
|
130
|
+
config: AutoMergeConfig,
|
|
131
|
+
no_wait: bool = False,
|
|
132
|
+
) -> list[PRMergeResult]:
|
|
133
|
+
if not pr_refs:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
pending_refs: list[PRRef] = []
|
|
137
|
+
for ref in pr_refs:
|
|
138
|
+
state = get_pr_state(ref.repo_path, ref.branch_or_url)
|
|
139
|
+
if state == PRState.MERGED:
|
|
140
|
+
logger.info(f"{ref.dest_name}: already merged")
|
|
141
|
+
continue
|
|
142
|
+
enable_auto_merge(ref.repo_path, ref.branch_or_url, config)
|
|
143
|
+
pending_refs.append(ref)
|
|
144
|
+
|
|
145
|
+
if no_wait:
|
|
146
|
+
logger.info("--no-wait: skipping merge polling")
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
results: list[PRMergeResult] = []
|
|
150
|
+
for ref in pending_refs:
|
|
151
|
+
logger.info(f"Waiting for {ref.dest_name} to merge...")
|
|
152
|
+
result = wait_for_merge(ref.repo_path, ref.branch_or_url, config, dest_name=ref.dest_name)
|
|
153
|
+
results.append(result)
|
|
154
|
+
|
|
155
|
+
_log_summary(results)
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _log_summary(results: list[PRMergeResult]) -> None:
|
|
160
|
+
if not results:
|
|
161
|
+
return
|
|
162
|
+
max_name = max(len(r.dest_name) for r in results)
|
|
163
|
+
header = f"{'Repo':<{max_name}} State Failed Checks"
|
|
164
|
+
logger.info(header)
|
|
165
|
+
logger.info("-" * len(header))
|
|
166
|
+
for r in results:
|
|
167
|
+
failed = ", ".join(c.name for c in r.failed_checks)
|
|
168
|
+
logger.info(f"{r.dest_name:<{max_name}} {r.state:<8} {failed}")
|
|
@@ -11,6 +11,7 @@ from pydantic import BaseModel
|
|
|
11
11
|
|
|
12
12
|
from path_sync import sections
|
|
13
13
|
from path_sync._internal import cmd_options, git_ops, header, prompt_utils, verify
|
|
14
|
+
from path_sync._internal.auto_merge import PRRef, handle_auto_merge
|
|
14
15
|
from path_sync._internal.file_utils import ensure_parents_write_text
|
|
15
16
|
from path_sync._internal.log_capture import capture_log
|
|
16
17
|
from path_sync._internal.models import (
|
|
@@ -53,6 +54,8 @@ class CopyOptions(BaseModel):
|
|
|
53
54
|
no_pr: bool = False
|
|
54
55
|
skip_orphan_cleanup: bool = False
|
|
55
56
|
skip_verify: bool = False
|
|
57
|
+
no_wait: bool = False
|
|
58
|
+
no_auto_merge: bool = False
|
|
56
59
|
pr_title: str = ""
|
|
57
60
|
labels: list[str] | None = None
|
|
58
61
|
reviewers: list[str] | None = None
|
|
@@ -130,6 +133,16 @@ def copy(
|
|
|
130
133
|
"--skip-verify",
|
|
131
134
|
help="Skip verification steps after syncing",
|
|
132
135
|
),
|
|
136
|
+
no_wait: bool = typer.Option(
|
|
137
|
+
False,
|
|
138
|
+
"--no-wait",
|
|
139
|
+
help="Enable auto-merge but skip polling for merge completion",
|
|
140
|
+
),
|
|
141
|
+
no_auto_merge: bool = typer.Option(
|
|
142
|
+
False,
|
|
143
|
+
"--no-auto-merge",
|
|
144
|
+
help="Skip auto-merge even when configured",
|
|
145
|
+
),
|
|
133
146
|
) -> None:
|
|
134
147
|
"""Copy files from SRC to DEST repositories."""
|
|
135
148
|
if name and config_path_opt:
|
|
@@ -147,9 +160,6 @@ def copy(
|
|
|
147
160
|
raise typer.Exit(EXIT_ERROR if detailed_exit_code else 1)
|
|
148
161
|
|
|
149
162
|
config = load_yaml_model(config_path, SrcConfig)
|
|
150
|
-
src_repo = git_ops.get_repo(src_root)
|
|
151
|
-
current_sha = git_ops.get_current_sha(src_repo)
|
|
152
|
-
src_repo_url = git_ops.get_remote_url(src_repo, config.git_remote)
|
|
153
163
|
|
|
154
164
|
opts = CopyOptions(
|
|
155
165
|
dry_run=dry_run,
|
|
@@ -161,31 +171,49 @@ def copy(
|
|
|
161
171
|
no_pr=no_pr,
|
|
162
172
|
skip_orphan_cleanup=skip_orphan_cleanup,
|
|
163
173
|
skip_verify=skip_verify,
|
|
174
|
+
no_wait=no_wait,
|
|
175
|
+
no_auto_merge=no_auto_merge,
|
|
164
176
|
pr_title=pr_title or config.pr_defaults.title,
|
|
165
177
|
labels=cmd_options.split_csv(pr_labels) or config.pr_defaults.labels,
|
|
166
178
|
reviewers=cmd_options.split_csv(pr_reviewers) or config.pr_defaults.reviewers,
|
|
167
179
|
assignees=cmd_options.split_csv(pr_assignees) or config.pr_defaults.assignees,
|
|
168
180
|
)
|
|
169
181
|
|
|
182
|
+
try:
|
|
183
|
+
total_changes = _run_copy(config, src_root, dest_filter, opts)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
if detailed_exit_code:
|
|
186
|
+
logger.error(f"Copy failed: {e}")
|
|
187
|
+
raise typer.Exit(EXIT_ERROR)
|
|
188
|
+
raise
|
|
189
|
+
|
|
190
|
+
if detailed_exit_code:
|
|
191
|
+
raise typer.Exit(EXIT_CHANGES if total_changes > 0 else EXIT_NO_CHANGES)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _run_copy(config: SrcConfig, src_root: Path, dest_filter: str, opts: CopyOptions) -> int:
|
|
195
|
+
src_repo = git_ops.get_repo(src_root)
|
|
196
|
+
current_sha = git_ops.get_current_sha(src_repo)
|
|
197
|
+
src_repo_url = git_ops.get_remote_url(src_repo, config.git_remote)
|
|
198
|
+
|
|
170
199
|
destinations = config.destinations
|
|
171
200
|
if dest_filter:
|
|
172
201
|
filter_names = [n.strip() for n in dest_filter.split(",")]
|
|
173
202
|
destinations = [d for d in destinations if d.name in filter_names]
|
|
174
203
|
|
|
175
204
|
total_changes = 0
|
|
205
|
+
pr_refs: list[PRRef] = []
|
|
176
206
|
for dest in destinations:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
logger.error(f"Failed to sync {dest.name}: {e}")
|
|
183
|
-
if detailed_exit_code:
|
|
184
|
-
raise typer.Exit(EXIT_ERROR)
|
|
185
|
-
raise
|
|
207
|
+
with capture_log(dest.name) as read_log:
|
|
208
|
+
changes, pr_ref = _sync_destination(config, dest, src_root, current_sha, src_repo_url, opts, read_log)
|
|
209
|
+
total_changes += changes
|
|
210
|
+
if pr_ref:
|
|
211
|
+
pr_refs.append(pr_ref)
|
|
186
212
|
|
|
187
|
-
if
|
|
188
|
-
|
|
213
|
+
if config.auto_merge and pr_refs and not opts.no_auto_merge:
|
|
214
|
+
handle_auto_merge(pr_refs, config.auto_merge, no_wait=opts.no_wait)
|
|
215
|
+
|
|
216
|
+
return total_changes
|
|
189
217
|
|
|
190
218
|
|
|
191
219
|
def _sync_destination(
|
|
@@ -196,7 +224,7 @@ def _sync_destination(
|
|
|
196
224
|
src_repo_url: str,
|
|
197
225
|
opts: CopyOptions,
|
|
198
226
|
read_log: Callable[[], str],
|
|
199
|
-
) -> int:
|
|
227
|
+
) -> tuple[int, PRRef | None]:
|
|
200
228
|
dest_root = (src_root / dest.dest_path_relative).resolve()
|
|
201
229
|
|
|
202
230
|
if opts.dry_run and not dest_root.exists():
|
|
@@ -205,7 +233,6 @@ def _sync_destination(
|
|
|
205
233
|
dest_repo = _ensure_dest_repo(dest, dest_root, opts.dry_run)
|
|
206
234
|
copy_branch = dest.resolved_copy_branch(config.name)
|
|
207
235
|
|
|
208
|
-
# --no-checkout skips branch switching (assumes already on correct branch)
|
|
209
236
|
if not opts.no_checkout and prompt_utils.prompt_confirm(f"Switch {dest.name} to {copy_branch}?", opts.no_prompt):
|
|
210
237
|
git_ops.prepare_copy_branch(
|
|
211
238
|
repo=dest_repo,
|
|
@@ -218,9 +245,8 @@ def _sync_destination(
|
|
|
218
245
|
|
|
219
246
|
if result.total == 0:
|
|
220
247
|
logger.info(f"{dest.name}: No changes")
|
|
221
|
-
return 0
|
|
248
|
+
return 0, None
|
|
222
249
|
|
|
223
|
-
# --skip-commit and --dry-run skip commit; otherwise prompt
|
|
224
250
|
should_skip_commit = opts.skip_commit or opts.dry_run
|
|
225
251
|
if not should_skip_commit and prompt_utils.prompt_confirm(f"Commit changes to {dest.name}?", opts.no_prompt):
|
|
226
252
|
sync_commit_msg = f"chore: sync {config.name} from {current_sha[:8]}"
|
|
@@ -240,10 +266,10 @@ def _sync_destination(
|
|
|
240
266
|
|
|
241
267
|
if verify_result.status == VerifyStatus.SKIPPED:
|
|
242
268
|
logger.warning(f"{dest.name}: Verification skipped due to failure")
|
|
243
|
-
return result.total
|
|
269
|
+
return result.total, None
|
|
244
270
|
|
|
245
|
-
_push_and_pr(config, dest_repo, dest_root, dest, current_sha, src_repo_url, opts, read_log, verify_result)
|
|
246
|
-
return result.total
|
|
271
|
+
pr_ref = _push_and_pr(config, dest_repo, dest_root, dest, current_sha, src_repo_url, opts, read_log, verify_result)
|
|
272
|
+
return result.total, pr_ref
|
|
247
273
|
|
|
248
274
|
|
|
249
275
|
def _print_sync_summary(dest: Destination, result: SyncResult) -> None:
|
|
@@ -526,29 +552,28 @@ def _push_and_pr(
|
|
|
526
552
|
opts: CopyOptions,
|
|
527
553
|
read_log: Callable[[], str],
|
|
528
554
|
verify_result: VerifyResult,
|
|
529
|
-
) -> None:
|
|
555
|
+
) -> PRRef | None:
|
|
530
556
|
if opts.skip_commit or opts.dry_run:
|
|
531
557
|
logger.info("Skipping push/PR (--skip-commit or --dry-run)")
|
|
532
|
-
return
|
|
558
|
+
return None
|
|
533
559
|
|
|
534
560
|
copy_branch = dest.resolved_copy_branch(config.name)
|
|
535
561
|
|
|
536
|
-
# Commit any remaining changes from verify steps without their own commit config
|
|
537
562
|
if git_ops.has_changes(repo):
|
|
538
563
|
if not prompt_utils.prompt_confirm(f"Commit remaining changes to {dest.name}?", opts.no_prompt):
|
|
539
|
-
return
|
|
564
|
+
return None
|
|
540
565
|
commit_msg = f"chore: post-sync changes for {config.name}"
|
|
541
566
|
git_ops.commit_changes(repo, commit_msg)
|
|
542
567
|
typer.echo(f" Committed: {commit_msg}", err=True)
|
|
543
568
|
|
|
544
569
|
if not prompt_utils.prompt_confirm(f"Push {dest.name} to origin?", opts.no_prompt):
|
|
545
|
-
return
|
|
570
|
+
return None
|
|
546
571
|
|
|
547
572
|
git_ops.push_branch(repo, copy_branch, force=True)
|
|
548
573
|
typer.echo(f" Pushed: {copy_branch} (force)", err=True)
|
|
549
574
|
|
|
550
575
|
if opts.no_pr or not prompt_utils.prompt_confirm(f"Create PR for {dest.name}?", opts.no_prompt):
|
|
551
|
-
return
|
|
576
|
+
return None
|
|
552
577
|
|
|
553
578
|
sync_log = read_log()
|
|
554
579
|
pr_body = config.pr_defaults.format_body(
|
|
@@ -574,6 +599,9 @@ def _push_and_pr(
|
|
|
574
599
|
if pr_url:
|
|
575
600
|
typer.echo(f" Created PR: {pr_url}", err=True)
|
|
576
601
|
|
|
602
|
+
branch_or_url = pr_url or copy_branch
|
|
603
|
+
return PRRef(dest_name=dest.name, repo_path=dest_root, branch_or_url=branch_or_url)
|
|
604
|
+
|
|
577
605
|
|
|
578
606
|
def _append_verify_warnings(body: str, failures: list[StepFailure]) -> str:
|
|
579
607
|
body += "\n\n---\n## Verification Warnings\n"
|
|
@@ -11,6 +11,7 @@ import typer
|
|
|
11
11
|
from git import Repo
|
|
12
12
|
|
|
13
13
|
from path_sync._internal import cmd_options, git_ops, prompt_utils, verify
|
|
14
|
+
from path_sync._internal.auto_merge import PRRef, handle_auto_merge
|
|
14
15
|
from path_sync._internal.log_capture import capture_log
|
|
15
16
|
from path_sync._internal.models import Destination, OnFailStrategy, find_repo_root
|
|
16
17
|
from path_sync._internal.models_dep import (
|
|
@@ -50,6 +51,8 @@ class RepoResult:
|
|
|
50
51
|
class DepUpdateOptions:
|
|
51
52
|
dry_run: bool = False
|
|
52
53
|
skip_verify: bool = False
|
|
54
|
+
no_wait: bool = False
|
|
55
|
+
no_auto_merge: bool = False
|
|
53
56
|
reviewers: list[str] | None = None
|
|
54
57
|
assignees: list[str] | None = None
|
|
55
58
|
|
|
@@ -61,6 +64,8 @@ def dep_update(
|
|
|
61
64
|
work_dir: str = typer.Option("", "--work-dir", help="Clone repos here (overrides dest_path_relative)"),
|
|
62
65
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without creating PRs"),
|
|
63
66
|
skip_verify: bool = typer.Option(False, "--skip-verify", help="Skip verification steps"),
|
|
67
|
+
no_wait: bool = typer.Option(False, "--no-wait", help="Enable auto-merge but skip polling for merge completion"),
|
|
68
|
+
no_auto_merge: bool = typer.Option(False, "--no-auto-merge", help="Skip auto-merge even when configured"),
|
|
64
69
|
src_root_opt: str = typer.Option("", "--src-root", help="Source repo root"),
|
|
65
70
|
pr_reviewers: str = cmd_options.pr_reviewers_option(),
|
|
66
71
|
pr_assignees: str = cmd_options.pr_assignees_option(),
|
|
@@ -82,12 +87,17 @@ def dep_update(
|
|
|
82
87
|
opts = DepUpdateOptions(
|
|
83
88
|
dry_run=dry_run,
|
|
84
89
|
skip_verify=skip_verify,
|
|
90
|
+
no_wait=no_wait,
|
|
91
|
+
no_auto_merge=no_auto_merge,
|
|
85
92
|
reviewers=cmd_options.split_csv(pr_reviewers) or config.pr.reviewers,
|
|
86
93
|
assignees=cmd_options.split_csv(pr_assignees) or config.pr.assignees,
|
|
87
94
|
)
|
|
88
95
|
|
|
89
96
|
results = _update_and_validate(config, destinations, src_root, work_dir, opts)
|
|
90
|
-
_create_prs(config, results, opts)
|
|
97
|
+
pr_refs = _create_prs(config, results, opts)
|
|
98
|
+
|
|
99
|
+
if config.auto_merge and pr_refs and not opts.no_auto_merge:
|
|
100
|
+
handle_auto_merge(pr_refs, config.auto_merge, no_wait=opts.no_wait)
|
|
91
101
|
|
|
92
102
|
if any(r.status == Status.SKIPPED for r in results):
|
|
93
103
|
raise typer.Exit(1)
|
|
@@ -172,7 +182,8 @@ def _verify_repo(repo: Repo, repo_path: Path, fallback_verify: verify.VerifyConf
|
|
|
172
182
|
return RepoResult(dest=dest, repo_path=repo_path, status=status, failures=result.failures)
|
|
173
183
|
|
|
174
184
|
|
|
175
|
-
def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOptions) ->
|
|
185
|
+
def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOptions) -> list[PRRef]:
|
|
186
|
+
pr_refs: list[PRRef] = []
|
|
176
187
|
for result in results:
|
|
177
188
|
if result.status == Status.SKIPPED:
|
|
178
189
|
continue
|
|
@@ -185,7 +196,7 @@ def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOpt
|
|
|
185
196
|
git_ops.push_branch(repo, config.pr.branch, force=True)
|
|
186
197
|
|
|
187
198
|
body = _build_pr_body(result.log_content, result.failures)
|
|
188
|
-
git_ops.create_or_update_pr(
|
|
199
|
+
pr_url = git_ops.create_or_update_pr(
|
|
189
200
|
result.repo_path,
|
|
190
201
|
config.pr.branch,
|
|
191
202
|
config.pr.title,
|
|
@@ -193,9 +204,11 @@ def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOpt
|
|
|
193
204
|
config.pr.labels or None,
|
|
194
205
|
reviewers=opts.reviewers,
|
|
195
206
|
assignees=opts.assignees,
|
|
196
|
-
auto_merge=config.pr.auto_merge,
|
|
197
207
|
)
|
|
198
208
|
logger.info(f"{result.dest.name}: PR created/updated")
|
|
209
|
+
branch_or_url = pr_url or config.pr.branch
|
|
210
|
+
pr_refs.append(PRRef(dest_name=result.dest.name, repo_path=result.repo_path, branch_or_url=branch_or_url))
|
|
211
|
+
return pr_refs
|
|
199
212
|
|
|
200
213
|
|
|
201
214
|
def _resolve_repo_path(dest: Destination, src_root: Path, work_dir: str) -> Path:
|
|
@@ -210,7 +210,6 @@ def create_or_update_pr(
|
|
|
210
210
|
labels: list[str] | None = None,
|
|
211
211
|
reviewers: list[str] | None = None,
|
|
212
212
|
assignees: list[str] | None = None,
|
|
213
|
-
auto_merge: bool = False,
|
|
214
213
|
) -> str:
|
|
215
214
|
cmd = ["gh", "pr", "create", "--head", branch, "--title", title]
|
|
216
215
|
cmd.extend(["--body", body or ""])
|
|
@@ -226,26 +225,13 @@ def create_or_update_pr(
|
|
|
226
225
|
if "already exists" in result.stderr:
|
|
227
226
|
logger.info("PR already exists, updating body")
|
|
228
227
|
update_pr_body(repo_path, branch, body)
|
|
229
|
-
if auto_merge:
|
|
230
|
-
_enable_auto_merge(repo_path, branch)
|
|
231
228
|
return ""
|
|
232
229
|
raise RuntimeError(f"Failed to create PR: {result.stderr}")
|
|
233
230
|
pr_url = result.stdout.strip()
|
|
234
231
|
logger.info(f"Created PR: {pr_url}")
|
|
235
|
-
if auto_merge:
|
|
236
|
-
_enable_auto_merge(repo_path, pr_url)
|
|
237
232
|
return pr_url
|
|
238
233
|
|
|
239
234
|
|
|
240
|
-
def _enable_auto_merge(repo_path: Path, pr_ref: str) -> None:
|
|
241
|
-
cmd = ["gh", "pr", "merge", "--auto", "--squash", pr_ref]
|
|
242
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
243
|
-
if result.returncode != 0:
|
|
244
|
-
logger.warning(f"Auto-merge failed (branch protection may not be configured): {result.stderr}")
|
|
245
|
-
else:
|
|
246
|
-
logger.info(f"Enabled auto-merge for {pr_ref}")
|
|
247
|
-
|
|
248
|
-
|
|
249
235
|
def file_has_git_changes(repo: Repo, file_path: Path, base_ref: str = "HEAD") -> bool:
|
|
250
236
|
rel_path = str(file_path.relative_to(repo.working_dir))
|
|
251
237
|
diff = repo.git.diff("--name-only", base_ref, "--", rel_path)
|
|
@@ -29,6 +29,19 @@ def _default_exclude_dirs() -> set[str]:
|
|
|
29
29
|
return set(DEFAULT_EXCLUDE_DIRS)
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
class MergeMethod(StrEnum):
|
|
33
|
+
SQUASH = "squash"
|
|
34
|
+
MERGE = "merge"
|
|
35
|
+
REBASE = "rebase"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AutoMergeConfig(BaseModel):
|
|
39
|
+
method: MergeMethod = MergeMethod.MERGE
|
|
40
|
+
delete_branch: bool = True
|
|
41
|
+
poll_interval_seconds: int = 30
|
|
42
|
+
timeout_seconds: int = 900
|
|
43
|
+
|
|
44
|
+
|
|
32
45
|
class SyncMode(StrEnum):
|
|
33
46
|
SYNC = "sync"
|
|
34
47
|
REPLACE = "replace"
|
|
@@ -178,6 +191,7 @@ class SrcConfig(BaseModel):
|
|
|
178
191
|
destinations: list[Destination] = Field(default_factory=list)
|
|
179
192
|
verify: VerifyConfig | None = None
|
|
180
193
|
wrap_synced_files: bool = False
|
|
194
|
+
auto_merge: AutoMergeConfig | None = None
|
|
181
195
|
|
|
182
196
|
def find_destination(self, name: str) -> Destination:
|
|
183
197
|
for dest in self.destinations:
|
|
@@ -6,6 +6,7 @@ from typing import ClassVar
|
|
|
6
6
|
from pydantic import BaseModel, Field
|
|
7
7
|
|
|
8
8
|
from path_sync._internal.models import (
|
|
9
|
+
AutoMergeConfig,
|
|
9
10
|
Destination,
|
|
10
11
|
PRFieldsBase,
|
|
11
12
|
SrcConfig,
|
|
@@ -23,7 +24,6 @@ class UpdateEntry(BaseModel):
|
|
|
23
24
|
class PRConfig(PRFieldsBase):
|
|
24
25
|
branch: str
|
|
25
26
|
title: str
|
|
26
|
-
auto_merge: bool = False
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
class DepConfig(BaseModel):
|
|
@@ -36,6 +36,7 @@ class DepConfig(BaseModel):
|
|
|
36
36
|
updates: list[UpdateEntry]
|
|
37
37
|
verify: VerifyConfig = Field(default_factory=VerifyConfig)
|
|
38
38
|
pr: PRConfig
|
|
39
|
+
auto_merge: AutoMergeConfig | None = None
|
|
39
40
|
|
|
40
41
|
def load_destinations(self, repo_root: Path) -> list[Destination]:
|
|
41
42
|
src_config_path = resolve_config_path(repo_root, self.from_config)
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
# Generated by pkg-ext
|
|
2
|
+
from path_sync._internal.models import AutoMergeConfig as _AutoMergeConfig
|
|
2
3
|
from path_sync._internal.models import Destination as _Destination
|
|
3
4
|
from path_sync._internal.models import HeaderConfig as _HeaderConfig
|
|
5
|
+
from path_sync._internal.models import MergeMethod as _MergeMethod
|
|
4
6
|
from path_sync._internal.models import PathMapping as _PathMapping
|
|
5
7
|
from path_sync._internal.models import PRDefaults as _PRDefaults
|
|
6
8
|
from path_sync._internal.models import SrcConfig as _SrcConfig
|
|
7
9
|
from path_sync._internal.models import SyncMode as _SyncMode
|
|
8
10
|
|
|
11
|
+
AutoMergeConfig = _AutoMergeConfig
|
|
9
12
|
Destination = _Destination
|
|
10
13
|
HeaderConfig = _HeaderConfig
|
|
14
|
+
MergeMethod = _MergeMethod
|
|
11
15
|
PRDefaults = _PRDefaults
|
|
12
16
|
PathMapping = _PathMapping
|
|
13
17
|
SrcConfig = _SrcConfig
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|