Skip to content

Commit 0bfa0e9

Browse files
committed
🩹 fix: detect parent repo for fork preflight
1 parent 9fc32bb commit 0bfa0e9

3 files changed

Lines changed: 107 additions & 22 deletions

File tree

src/gh_llm/commands/repo.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ def render_repo_branch_protection(preflight: RepoPreflight) -> list[str]:
184184

185185
def render_repo_next_commands(preflight: RepoPreflight) -> list[str]:
186186
repo = f"{preflight.owner}/{preflight.name}"
187+
pr_target_repo = preflight.parent_repo if preflight.is_fork and preflight.parent_repo is not None else repo
187188
lines = [
188189
"## Next Commands",
189190
"These commands are inferred from the permission, onboarding-file, and default-branch protection signals above.",
@@ -213,14 +214,17 @@ def render_repo_next_commands(preflight: RepoPreflight) -> list[str]:
213214
)
214215
step += 1
215216

216-
lines.append(f"{step}. Open your PR against the default branch:")
217-
lines.append(f" ⏎ `gh pr create --repo {repo} --base {preflight.default_branch}`")
217+
if pr_target_repo != repo:
218+
lines.append(f"{step}. Open your PR against the parent repository:")
219+
else:
220+
lines.append(f"{step}. Open your PR against the default branch:")
221+
lines.append(f" ⏎ `gh pr create --repo {pr_target_repo} --base {preflight.default_branch}`")
218222
step += 1
219223

220224
protection = preflight.branch_protection
221225
if protection is not None and protection.requires_status_checks:
222226
lines.append(f"{step}. Check required CI after the PR exists:")
223-
lines.append(f" ⏎ `{display_command_with(f'pr checks --pr <pr_number> --repo {repo}')}`")
227+
lines.append(f" ⏎ `{display_command_with(f'pr checks --pr <pr_number> --repo {pr_target_repo}')}`")
224228

225229
lines.append("")
226230
return lines

src/gh_llm/github_api.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,8 @@ def resolve_repo_preflight(self, repo: str) -> RepoPreflight:
887887
parent_obj = _as_dict_optional(payload.get("parent"))
888888
viewer_permission = _normalized_optional_str(payload.get("viewerPermission"))
889889
can_push = viewer_permission in {"ADMIN", "MAINTAIN", "WRITE"}
890+
is_fork = bool(payload.get("isFork"))
891+
parent_repo = _extract_parent_repo_full_name(parent_obj)
890892
branch_protection = self._resolve_default_branch_protection(
891893
owner=owner,
892894
name=name,
@@ -904,8 +906,8 @@ def resolve_repo_preflight(self, repo: str) -> RepoPreflight:
904906
viewer_permission=viewer_permission,
905907
can_push=can_push,
906908
fork_recommended=(not can_push),
907-
is_fork=bool(payload.get("isFork")),
908-
parent_repo=_normalized_optional_str(parent_obj.get("nameWithOwner")) if parent_obj is not None else None,
909+
is_fork=is_fork,
910+
parent_repo=parent_repo,
909911
tree_truncated=tree_truncated,
910912
contributing_docs=_collect_repo_documents(tree_items, kind="contributing"),
911913
agents_docs=_collect_repo_documents(tree_items, kind="agents"),
@@ -3198,6 +3200,26 @@ def _normalized_optional_str(value: object) -> str | None:
31983200
return normalized or None
31993201

32003202

3203+
def _extract_parent_repo_full_name(parent_obj: dict[str, object] | None) -> str | None:
3204+
if parent_obj is None:
3205+
return None
3206+
3207+
direct = _normalized_optional_str(parent_obj.get("nameWithOwner"))
3208+
if direct is not None:
3209+
return direct
3210+
3211+
full_name = _normalized_optional_str(parent_obj.get("full_name"))
3212+
if full_name is not None:
3213+
return full_name
3214+
3215+
owner_obj = _as_dict_optional(parent_obj.get("owner"))
3216+
owner_login = _normalized_optional_str(owner_obj.get("login")) if owner_obj is not None else None
3217+
name = _normalized_optional_str(parent_obj.get("name"))
3218+
if owner_login is not None and name is not None:
3219+
return f"{owner_login}/{name}"
3220+
return None
3221+
3222+
32013223
def _collect_repo_documents(tree_items: list[object], *, kind: str) -> tuple[RepoDocument, ...]:
32023224
docs: list[RepoDocument] = []
32033225
seen: set[str] = set()

tests/test_cli.py

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -115,21 +115,8 @@ def run(self, cmd: list[str], *, check: bool, capture_output: bool, text: bool)
115115
)
116116

117117
if cmd[:3] == ["gh", "repo", "view"]:
118-
return FakeCompletedProcess(
119-
json.dumps(
120-
{
121-
"nameWithOwner": "PaddlePaddle/Paddle",
122-
"description": "PaddlePaddle core framework",
123-
"homepageUrl": "https://www.paddlepaddle.org.cn/",
124-
"isFork": False,
125-
"parent": None,
126-
"url": "https://github.com/PaddlePaddle/Paddle",
127-
"sshUrl": "git@github.com:PaddlePaddle/Paddle.git",
128-
"viewerPermission": "READ",
129-
"defaultBranchRef": {"name": "develop"},
130-
}
131-
)
132-
)
118+
repo = cmd[3] if len(cmd) > 3 else "PaddlePaddle/Paddle"
119+
return FakeCompletedProcess(json.dumps(_repo_view_payload(repo)))
133120

134121
if cmd[:3] == ["gh", "api", "user"]:
135122
return FakeCompletedProcess(json.dumps({"login": "ShigureNyako"}))
@@ -138,7 +125,7 @@ def run(self, cmd: list[str], *, check: bool, capture_output: bool, text: bool)
138125
return FakeCompletedProcess(json.dumps(_pull_files_payload(cmd[2])))
139126

140127
if cmd[:2] == ["gh", "api"] and len(cmd) >= 3 and "/git/trees/" in cmd[2]:
141-
return FakeCompletedProcess(json.dumps(_repo_tree_payload()))
128+
return FakeCompletedProcess(json.dumps(_repo_tree_payload(cmd[2])))
142129

143130
if cmd[:2] == ["gh", "api"] and len(cmd) >= 3 and "/branches/" in cmd[2]:
144131
payload = _repo_branch_payload(cmd[2])
@@ -2345,6 +2332,27 @@ def run_with_truncated_tree(
23452332
assert "gh browse -R PaddlePaddle/Paddle --branch develop 'CONTRIBUTING_GUIDE.md'" in out
23462333

23472334

2335+
def test_repo_preflight_surfaces_parent_repo_and_targets_upstream_pr_for_forks(
2336+
monkeypatch: pytest.MonkeyPatch,
2337+
tmp_path: Path,
2338+
capsys: pytest.CaptureFixture[str],
2339+
) -> None:
2340+
responder = GhResponder()
2341+
monkeypatch.setattr(github_api.subprocess, "run", responder.run)
2342+
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
2343+
2344+
code = cli.run(["repo", "preflight", "--repo", "ShigureNyako/gh-llm"])
2345+
assert code == 0
2346+
2347+
out = capsys.readouterr().out
2348+
assert "repo: ShigureNyako/gh-llm" in out
2349+
assert "is_fork: true" in out
2350+
assert "parent_repo: ShigureLab/gh-llm" in out
2351+
assert "Parent repo: `ShigureLab/gh-llm`" in out
2352+
assert "gh pr create --repo ShigureLab/gh-llm --base main" in out
2353+
assert "gh pr create --repo ShigureNyako/gh-llm --base main" not in out
2354+
2355+
23482356
def _branch_protection_rules_payload(after: str | None) -> dict[str, Any]:
23492357
del after
23502358
return {
@@ -2378,7 +2386,45 @@ def _branch_protection_rules_payload(after: str | None) -> dict[str, Any]:
23782386
}
23792387

23802388

2381-
def _repo_tree_payload() -> dict[str, Any]:
2389+
def _repo_view_payload(repo: str) -> dict[str, Any]:
2390+
if repo == "ShigureNyako/gh-llm":
2391+
return {
2392+
"nameWithOwner": "ShigureNyako/gh-llm",
2393+
"description": "Forked gh-llm workspace",
2394+
"homepageUrl": "",
2395+
"isFork": True,
2396+
"parent": {
2397+
"name": "gh-llm",
2398+
"owner": {"login": "ShigureLab"},
2399+
},
2400+
"url": "https://github.com/ShigureNyako/gh-llm",
2401+
"sshUrl": "git@github.com:ShigureNyako/gh-llm.git",
2402+
"viewerPermission": "ADMIN",
2403+
"defaultBranchRef": {"name": "main"},
2404+
}
2405+
return {
2406+
"nameWithOwner": "PaddlePaddle/Paddle",
2407+
"description": "PaddlePaddle core framework",
2408+
"homepageUrl": "https://www.paddlepaddle.org.cn/",
2409+
"isFork": False,
2410+
"parent": None,
2411+
"url": "https://github.com/PaddlePaddle/Paddle",
2412+
"sshUrl": "git@github.com:PaddlePaddle/Paddle.git",
2413+
"viewerPermission": "READ",
2414+
"defaultBranchRef": {"name": "develop"},
2415+
}
2416+
2417+
2418+
def _repo_tree_payload(path: str) -> dict[str, Any]:
2419+
if "repos/ShigureNyako/gh-llm/" in path:
2420+
return {
2421+
"sha": "mock-tree-sha",
2422+
"truncated": False,
2423+
"tree": [
2424+
{"path": "README.md", "type": "blob"},
2425+
{"path": "skills/github-conversation/SKILL.md", "type": "blob"},
2426+
],
2427+
}
23822428
return {
23832429
"sha": "mock-tree-sha",
23842430
"truncated": False,
@@ -2396,6 +2442,19 @@ def _repo_tree_payload() -> dict[str, Any]:
23962442
def _repo_branch_payload(path: str) -> dict[str, Any] | None:
23972443
if "/branches/" not in path:
23982444
return None
2445+
if "repos/ShigureNyako/gh-llm/branches/main" in path:
2446+
return {
2447+
"name": "main",
2448+
"protected": False,
2449+
"protection": {
2450+
"enabled": False,
2451+
"required_status_checks": {
2452+
"enforcement_level": "off",
2453+
"contexts": [],
2454+
"checks": [],
2455+
},
2456+
},
2457+
}
23992458
return {
24002459
"name": "develop",
24012460
"protected": True,

0 commit comments

Comments
 (0)