Skip to content

Commit dd853f2

Browse files
vorporealoz-agent
andauthored
Use sync conflict-handling behavior in restack flow. (#21)
## Description When the restack workflow hits a rebase conflict, it previously just aborted and hoped the approve workflow would handle it later. This was racy and unreliable. This PR mirrors the sync workflow's conflict-handling approach: accept the conflict markers, invoke the conflict-resolution agent, push the result, and add labels/trailers to prevent auto-approval. Key changes: - **Shared conflict helpers** (`conflict.py`): extracted `run_agent_with_manifest()`, `add_conflict_label()`, and `assign_conflict_reviewer()` so both sync and restack use the same code paths for conflict handling. - **Restack conflict path**: on rebase conflict, detects modify/delete conflicts while paused, accepts markers via `git add -A` + `git rebase --continue`, runs the conflict-resolution agent on committed markers, pushes, labels the PR, appends a `Repo-Sync-Conflict: rebase` trailer (blocking auto-approval), and assigns a reviewer. - **Sync refactored**: both `_sync_private_to_public` and `_sync_public_to_private` now call the shared helpers instead of inline manifest/agent/label/reviewer code. - **Workflow YAML**: added Docker image build step and optional `warp_api_key` secret to `restack.yml`, plus `--escalate-to` CLI passthrough. ## Testing - All 243 existing tests pass (3 pre-existing failures in `test_fixup_script.py` are unrelated to this change). - Verified all imports resolve correctly. - Manually tested the rebase conflict flow on the warp-external repo to confirm the `git rebase --onto` + conflict resolution sequence works end-to-end. Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent f894fa3 commit dd853f2

7 files changed

Lines changed: 316 additions & 125 deletions

File tree

.github/workflows/restack.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ on:
3737
app_private_key:
3838
description: "GitHub App private key."
3939
required: true
40+
warp_api_key:
41+
description: "Warp API key for the conflict-resolution agent."
42+
required: false
4043
# workflow_dispatch is used for stuck-stack recovery.
4144
workflow_dispatch:
4245
inputs:
@@ -119,6 +122,9 @@ jobs:
119122
- name: Install repo-sync tools
120123
run: pip install -e .repo-sync
121124

125+
- name: Build conflict resolution agent image
126+
run: docker build -f .repo-sync/docker/conflict-resolution/Dockerfile -t repo-sync-conflict-resolution .repo-sync
127+
122128
- name: Configure git identity
123129
run: |
124130
git config --global user.name "warp-repo-sync[bot]"
@@ -137,4 +143,7 @@ jobs:
137143
--public-repo "${PUBLIC_REPO}" \
138144
--private-repo "${PRIVATE_REPO}" \
139145
--default-branch "${{ github.event.repository.default_branch }}" \
146+
--escalate-to "${ESCALATE_TO}" \
140147
--repo-dir .
148+
env:
149+
WARP_API_KEY: ${{ secrets.warp_api_key }}

src/repo_sync/stack/git_ops.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,27 @@ def rebase_onto(
110110
["rebase", "--onto", new_base, old_base, branch], check=False
111111
)
112112

113+
def rebase_continue(self) -> CommandResult:
114+
"""Continue an in-progress rebase after conflicts have been staged.
115+
116+
Sets GIT_EDITOR=true to prevent an interactive editor from opening
117+
in non-interactive environments (CI). The original commit message
118+
is preserved.
119+
"""
120+
env = {**os.environ, **self._env_additions, "GIT_EDITOR": "true"}
121+
result = subprocess.run(
122+
["git", "rebase", "--continue"],
123+
cwd=self.repo_dir,
124+
capture_output=True,
125+
text=True,
126+
env=env,
127+
)
128+
return CommandResult(
129+
returncode=result.returncode,
130+
stdout=result.stdout.strip(),
131+
stderr=result.stderr.strip(),
132+
)
133+
113134
def rebase_abort(self) -> None:
114135
"""Abort an in-progress rebase."""
115136
self._run(["rebase", "--abort"], check=False)

src/repo_sync/stack/trailers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,9 @@ def parse_conflict(text: str) -> bool:
137137
return False
138138

139139

140-
def format_conflict_trailer() -> str:
140+
def format_conflict_trailer(conflict_type: str = "cherry-pick") -> str:
141141
"""Format a Repo-Sync-Conflict trailer line."""
142-
return f"{_CONFLICT_PREFIX} cherry-pick"
142+
return f"{_CONFLICT_PREFIX} {conflict_type}"
143143

144144

145145
def format_assigned_trailer(username: str, timestamp: datetime) -> str:

src/repo_sync/workflows/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ def cmd_restack_pr(args: argparse.Namespace) -> None:
218218
public_repo=args.public_repo,
219219
private_repo=args.private_repo,
220220
default_branch=args.default_branch,
221+
escalate_to=args.escalate_to,
221222
)
222223
except RestackError as e:
223224
logging.error("%s", e)
@@ -396,6 +397,7 @@ def main() -> None:
396397
p.add_argument("--private-repo", required=True)
397398
p.add_argument("--default-branch", required=True)
398399
p.add_argument("--repo-dir", required=True)
400+
p.add_argument("--escalate-to", default="@oncall-client-primary")
399401
p.set_defaults(func=cmd_restack_pr)
400402

401403
# approve-pr.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Shared conflict handling helpers for sync and restack workflows.
2+
3+
Provides the common logic that both the sync PR creation and restack
4+
workflows use when a cherry-pick or rebase produces conflicts:
5+
6+
- Writing the modify/delete manifest and invoking the conflict-resolution
7+
agent (``run_agent_with_manifest``).
8+
- Adding the ``repo-sync:conflict`` label (``add_conflict_label``).
9+
- Determining and assigning a reviewer with the ``Repo-Sync-Assigned``
10+
trailer (``assign_conflict_reviewer``).
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
import logging
17+
import os
18+
from datetime import datetime, timezone
19+
20+
from repo_sync.stack.gh_ops import GhOps
21+
from repo_sync.stack.git_ops import GitOps
22+
from repo_sync.stack.trailers import (
23+
append_trailer,
24+
format_assigned_trailer,
25+
)
26+
from repo_sync.workflows.sync import determine_sync_reviewer
27+
28+
logger = logging.getLogger(__name__)
29+
30+
31+
def run_agent_with_manifest(
32+
git: GitOps,
33+
md_conflicts: list[dict[str, str]],
34+
manifest_context: str,
35+
agent_context: str,
36+
) -> bool:
37+
"""Write the modify/delete manifest, invoke the agent, and clean up.
38+
39+
The manifest (``.repo-sync-conflicts.json``) is a transient file
40+
consumed by the conflict-resolution agent. It is never committed.
41+
42+
Must be called *after* the conflict markers have been committed (so
43+
the agent sees committed markers, not a paused git operation).
44+
45+
Returns True if the agent resolved the conflicts, False otherwise.
46+
"""
47+
manifest_path = os.path.join(git.repo_dir, ".repo-sync-conflicts.json")
48+
49+
if md_conflicts:
50+
logger.info(
51+
"Detected %d modify/delete conflict(s): %s",
52+
len(md_conflicts),
53+
[c["path"] for c in md_conflicts],
54+
)
55+
manifest = {
56+
"context": manifest_context,
57+
"modify_delete_conflicts": md_conflicts,
58+
}
59+
with open(manifest_path, "w") as f:
60+
json.dump(manifest, f, indent=2)
61+
f.write("\n")
62+
63+
from repo_sync.workflows.agent import run_conflict_resolution_agent
64+
65+
agent_succeeded = run_conflict_resolution_agent(
66+
repo_dir=git.repo_dir,
67+
context=agent_context,
68+
)
69+
70+
# Clean up the manifest if the agent left it behind.
71+
if os.path.exists(manifest_path):
72+
os.remove(manifest_path)
73+
74+
if agent_succeeded:
75+
logger.info("Conflict-resolution agent succeeded.")
76+
else:
77+
logger.warning(
78+
"Conflict-resolution agent did not resolve conflicts. "
79+
"PR will contain raw conflict markers."
80+
)
81+
82+
return agent_succeeded
83+
84+
85+
def add_conflict_label(gh: GhOps, pr_number: int) -> None:
86+
"""Create (if needed) and apply the ``repo-sync:conflict`` label."""
87+
gh._run(
88+
["label", "create", "repo-sync:conflict",
89+
"--color", "D93F0B",
90+
"--description", "Sync PR has merge conflicts",
91+
"--repo", gh.repo],
92+
check=False,
93+
)
94+
gh._run(
95+
["pr", "edit", str(pr_number), "--repo", gh.repo,
96+
"--add-label", "repo-sync:conflict"],
97+
)
98+
99+
100+
def assign_conflict_reviewer(
101+
gh: GhOps,
102+
pr_number: int,
103+
source_repo: str | None,
104+
source_sha: str | None,
105+
escalate_to: str,
106+
) -> str:
107+
"""Determine a reviewer, assign them, and append the Assigned trailer.
108+
109+
Looks up the original commit author via ``determine_sync_reviewer``
110+
when *source_repo* and *source_sha* are provided; falls back to
111+
*escalate_to* otherwise.
112+
113+
Returns the reviewer login or team slug.
114+
"""
115+
if source_repo and source_sha:
116+
source_gh = GhOps(source_repo, token=os.environ.get("GH_TOKEN"))
117+
reviewer = determine_sync_reviewer(
118+
source_gh=source_gh,
119+
source_sha=source_sha,
120+
fallback_team=escalate_to,
121+
)
122+
else:
123+
reviewer = escalate_to
124+
125+
# Request review.
126+
gh._run(
127+
["pr", "edit", str(pr_number), "--repo", gh.repo,
128+
"--add-reviewer", reviewer],
129+
check=False,
130+
)
131+
132+
# Append Repo-Sync-Assigned trailer to the PR body.
133+
assigned_trailer = format_assigned_trailer(
134+
reviewer, datetime.now(timezone.utc),
135+
)
136+
current_body = gh._run(
137+
["pr", "view", str(pr_number), "--repo", gh.repo,
138+
"--json", "body", "--jq", ".body"],
139+
check=False,
140+
) or ""
141+
updated_body = append_trailer(current_body, assigned_trailer)
142+
gh.update_pr_body(pr_number, updated_body)
143+
144+
logger.info("Assigned reviewer %s on PR #%d.", reviewer, pr_number)
145+
return reviewer

0 commit comments

Comments
 (0)