Skip to content

Commit dd3b7fb

Browse files
committed
Fail closed on GitHub API errors during the sync workflow.
1 parent 613f2cd commit dd3b7fb

4 files changed

Lines changed: 69 additions & 17 deletions

File tree

src/repo_sync/stack/gh_ops.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -291,18 +291,20 @@ def get_pr_for_commit(self, sha: str) -> PullRequest | None:
291291
lookup, avoiding false-positive text matches from gh pr list --search.
292292
Filters for merged PRs to avoid picking up open/closed PRs that happen
293293
to include the same commit.
294+
295+
Raises subprocess.CalledProcessError if the GitHub API call fails.
296+
Callers in non-critical paths should catch this and degrade gracefully;
297+
callers in safety-critical paths (e.g. loop detection) must let it
298+
propagate to abort the workflow.
294299
"""
295-
try:
296-
output = self._run(
297-
[
298-
"api",
299-
f"repos/{self.repo}/commits/{sha}/pulls",
300-
"--jq",
301-
'[.[] | select(.merged_at != null)] | .[0]',
302-
],
303-
)
304-
except subprocess.CalledProcessError:
305-
return None
300+
output = self._run(
301+
[
302+
"api",
303+
f"repos/{self.repo}/commits/{sha}/pulls",
304+
"--jq",
305+
'[.[] | select(.merged_at != null)] | .[0]',
306+
],
307+
)
306308
if not output or output == "null":
307309
return None
308310
pr = json.loads(output)

src/repo_sync/stack/loop_detection.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,15 @@ def is_sync_originated(
3939
return False
4040

4141
# Check 2: was the commit merged from a repo-sync/ branch?
42+
#
43+
# get_pr_for_commit raises on API failure, which intentionally aborts
44+
# the sync run. We must not fail-open here: if we cannot verify the
45+
# PR branch, we cannot safely determine whether this commit is
46+
# sync-originated.
4247
pr = gh.get_pr_for_commit(commit_sha)
4348
if pr is None:
44-
# No PR found -- this could be a direct push with a spoofed trailer.
49+
# API succeeded but no merged PR was found — this could be a
50+
# direct push with a spoofed trailer.
4551
return False
4652

4753
if not is_sync_branch(pr.head_branch):

src/repo_sync/workflows/sync.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from __future__ import annotations
1313

1414
import logging
15+
import subprocess
1516
from dataclasses import dataclass
1617

1718
from repo_sync.stack.branches import check_idempotency
@@ -94,9 +95,17 @@ def enumerate_unsynced_commits(
9495
# Filter out sync-originated commits.
9596
result = []
9697
for sha in all_commits:
97-
if is_sync_originated(source_git, source_gh, sha):
98-
logger.info("Skipping sync-originated commit %s.", sha[:12])
99-
continue
98+
try:
99+
if is_sync_originated(source_git, source_gh, sha):
100+
logger.info("Skipping sync-originated commit %s.", sha[:12])
101+
continue
102+
except subprocess.CalledProcessError:
103+
logger.error(
104+
"GitHub API failure during loop detection for commit %s. "
105+
"Aborting sync to avoid creating an infinite loop.",
106+
sha[:12],
107+
)
108+
raise
100109
result.append(sha)
101110

102111
return result
@@ -146,7 +155,15 @@ def build_public_to_private_description(
146155
direct pushes.
147156
"""
148157
source_repo_name = source_repo.split("/")[-1]
149-
source_pr = source_gh.get_pr_for_commit(source_sha)
158+
try:
159+
source_pr = source_gh.get_pr_for_commit(source_sha)
160+
except subprocess.CalledProcessError:
161+
logger.warning(
162+
"GitHub API error looking up PR for commit %s; "
163+
"falling back to commit-based description.",
164+
source_sha[:12],
165+
)
166+
source_pr = None
150167

151168
if source_pr is not None:
152169
return public_to_private_from_pr(
@@ -180,7 +197,15 @@ def determine_sync_reviewer(
180197
Tries the merger of the source PR first, then the commit author (via
181198
GitHub API), then the fallback team.
182199
"""
183-
source_pr = source_gh.get_pr_for_commit(source_sha)
200+
try:
201+
source_pr = source_gh.get_pr_for_commit(source_sha)
202+
except subprocess.CalledProcessError:
203+
logger.warning(
204+
"GitHub API error looking up PR for commit %s; "
205+
"falling back to commit author or team for reviewer.",
206+
source_sha[:12],
207+
)
208+
source_pr = None
184209
pr_number = source_pr.number if source_pr else None
185210

186211
# For direct pushes (no source PR), look up the commit author via the API.

tests/test_loop_detection.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99

1010
from __future__ import annotations
1111

12+
import subprocess
1213
from unittest.mock import MagicMock
1314

15+
import pytest
16+
1417
from repo_sync.stack.gh_ops import GhOps, PullRequest
1518
from repo_sync.stack.git_ops import GitOps
1619
from repo_sync.stack.loop_detection import is_sync_originated
@@ -99,6 +102,22 @@ def test_trailer_present_but_no_pr_found(self) -> None:
99102

100103
assert is_sync_originated(git, gh, "directpush") is False
101104

105+
def test_api_failure_propagates_when_trailer_present(self) -> None:
106+
"""An API failure during PR lookup aborts rather than failing-open."""
107+
git = MagicMock(spec=GitOps)
108+
git.commit_message.return_value = (
109+
"sync commit\n\n"
110+
"Repo-Sync-Origin: warpdotdev/warp-internal@abc123"
111+
)
112+
113+
gh = MagicMock(spec=GhOps)
114+
gh.get_pr_for_commit.side_effect = subprocess.CalledProcessError(
115+
1, ["gh", "api", "repos/org/repo/commits/abc123/pulls"]
116+
)
117+
118+
with pytest.raises(subprocess.CalledProcessError):
119+
is_sync_originated(git, gh, "abc123")
120+
102121
def test_public_to_private_sync_branch_recognized(self) -> None:
103122
"""Public-to-private sync branches are also recognized."""
104123
git = MagicMock(spec=GitOps)

0 commit comments

Comments
 (0)