Skip to content

Commit 5bea943

Browse files
✨ feat: add repo preflight onboarding command (#32)
Co-authored-by: SigureMo <38436475+SigureMo@users.noreply.github.com>
1 parent 0dc7548 commit 5bea943

6 files changed

Lines changed: 967 additions & 0 deletions

File tree

skills/github-conversation/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ gh llm doctor
6161

6262
## Fast start
6363

64+
### Preflight an unfamiliar repo
65+
66+
```bash
67+
gh-llm repo preflight --repo <owner/repo>
68+
```
69+
70+
Use this before forking or opening a PR when you need the default branch, onboarding docs (`CONTRIBUTING*`, `AGENTS.md`, PR template, `CODEOWNERS`), branch-protection summary, and likely next commands in one place.
71+
6472
### Read a PR
6573

6674
```bash

src/gh_llm/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
parse_review_ids as _parse_review_ids,
1313
register_pr_parser,
1414
)
15+
from gh_llm.commands.repo import register_repo_parser
1516
from gh_llm.diagnostics import GhCommandError, format_command_error
1617
from gh_llm.invocation import detect_prog_name
1718

@@ -60,6 +61,7 @@ def _build_parser() -> argparse.ArgumentParser:
6061
subparsers = parser.add_subparsers(dest="command")
6162
register_pr_parser(subparsers)
6263
register_issue_parser(subparsers)
64+
register_repo_parser(subparsers)
6365
register_doctor_parser(subparsers)
6466

6567
return parser

src/gh_llm/commands/repo.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Any
4+
5+
from gh_llm.github_api import GitHubClient
6+
from gh_llm.invocation import display_command_with
7+
8+
if TYPE_CHECKING:
9+
from gh_llm.models import RepoDocument, RepoPreflight
10+
11+
12+
def register_repo_parser(subparsers: Any) -> None:
13+
repo_parser = subparsers.add_parser("repo", help="Repository-level onboarding commands")
14+
repo_subparsers = repo_parser.add_subparsers(dest="repo_command")
15+
16+
preflight_parser = repo_subparsers.add_parser(
17+
"preflight",
18+
help="show repo-level preflight summary",
19+
)
20+
preflight_parser.add_argument("--repo", required=True, help="repository in OWNER/REPO format")
21+
preflight_parser.set_defaults(handler=cmd_repo_preflight)
22+
23+
24+
def cmd_repo_preflight(args: Any) -> int:
25+
client = GitHubClient()
26+
preflight = client.resolve_repo_preflight(str(args.repo))
27+
for line in render_repo_preflight(preflight):
28+
print(line)
29+
return 0
30+
31+
32+
def render_repo_preflight(preflight: RepoPreflight) -> list[str]:
33+
return [
34+
*render_repo_frontmatter(preflight),
35+
"",
36+
*render_repo_summary(preflight),
37+
*render_repo_onboarding_files(preflight),
38+
*render_repo_branch_protection(preflight),
39+
*render_repo_next_commands(preflight),
40+
]
41+
42+
43+
def render_repo_frontmatter(preflight: RepoPreflight) -> list[str]:
44+
lines = [
45+
"---",
46+
f"repo: {preflight.owner}/{preflight.name}",
47+
f"url: {preflight.url}",
48+
f"default_branch: {preflight.default_branch}",
49+
f"viewer_permission: {preflight.viewer_permission or 'UNKNOWN'}",
50+
f"can_push: {str(preflight.can_push).lower()}",
51+
f"fork_recommended: {str(preflight.fork_recommended).lower()}",
52+
f"is_fork: {str(preflight.is_fork).lower()}",
53+
f"tree_truncated: {str(preflight.tree_truncated).lower()}",
54+
]
55+
if preflight.parent_repo:
56+
lines.append(f"parent_repo: {preflight.parent_repo}")
57+
if preflight.branch_protection is not None:
58+
lines.append(f"branch_protection_pattern: {preflight.branch_protection.pattern}")
59+
lines.append("---")
60+
return lines
61+
62+
63+
def render_repo_summary(preflight: RepoPreflight) -> list[str]:
64+
permission = preflight.viewer_permission or "UNKNOWN"
65+
https_clone_url = (
66+
preflight.url + ".git" if preflight.url else f"https://github.com/{preflight.owner}/{preflight.name}.git"
67+
)
68+
lines = [
69+
"## Repository",
70+
f"Description: {preflight.description or '(no description)'}",
71+
f"Default branch: `{preflight.default_branch}`",
72+
f"Homepage: {preflight.homepage_url or '(none)'}",
73+
f"HTTPS clone: `{https_clone_url}`",
74+
f"SSH clone: `{preflight.ssh_url or '(not available)'}`",
75+
f"Push access: {'yes' if preflight.can_push else 'no'} (viewer_permission: {permission})",
76+
f"Fork before push: {'yes' if preflight.fork_recommended else 'no'}",
77+
]
78+
if preflight.is_fork and preflight.parent_repo:
79+
lines.append(f"Parent repo: `{preflight.parent_repo}`")
80+
if preflight.tree_truncated:
81+
lines.append(
82+
"Warning: recursive repository tree output was truncated; onboarding file detection used common-path fallback and may still be incomplete."
83+
)
84+
lines.append("")
85+
return lines
86+
87+
88+
def render_repo_onboarding_files(preflight: RepoPreflight) -> list[str]:
89+
repo = f"{preflight.owner}/{preflight.name}"
90+
return [
91+
"## Onboarding Files",
92+
*render_repo_document_group(
93+
title="CONTRIBUTING",
94+
docs=preflight.contributing_docs,
95+
repo=repo,
96+
default_branch=preflight.default_branch,
97+
),
98+
*render_repo_document_group(
99+
title="AGENTS",
100+
docs=preflight.agents_docs,
101+
repo=repo,
102+
default_branch=preflight.default_branch,
103+
),
104+
*render_repo_document_group(
105+
title="PR Templates",
106+
docs=preflight.pr_templates,
107+
repo=repo,
108+
default_branch=preflight.default_branch,
109+
),
110+
*render_repo_document_group(
111+
title="CODEOWNERS",
112+
docs=preflight.codeowners_files,
113+
repo=repo,
114+
default_branch=preflight.default_branch,
115+
),
116+
"",
117+
]
118+
119+
120+
def render_repo_document_group(
121+
*,
122+
title: str,
123+
docs: tuple[RepoDocument, ...],
124+
repo: str,
125+
default_branch: str,
126+
) -> list[str]:
127+
lines = [f"### {title}"]
128+
if not docs:
129+
lines.append("(not found)")
130+
return lines
131+
132+
for doc in docs:
133+
browse_cmd = _gh_browse_command(repo=repo, default_branch=default_branch, path=doc.path)
134+
lines.append(f"- `{doc.path}`")
135+
lines.append(f" ⏎ open: `{browse_cmd}`")
136+
return lines
137+
138+
139+
def render_repo_branch_protection(preflight: RepoPreflight) -> list[str]:
140+
lines = ["## Branch Protection"]
141+
protection = preflight.branch_protection
142+
if protection is None:
143+
lines.append(f"Default branch `{preflight.default_branch}` is not protected.")
144+
lines.append("")
145+
return lines
146+
147+
if protection.source == "graphql":
148+
lines.append(f"Matched rule: `{protection.pattern}`")
149+
else:
150+
lines.append(f"Protected branch: `{protection.pattern}`")
151+
lines.append("Review-related rule details were not available from branch rule queries.")
152+
if protection.requires_status_checks:
153+
if protection.required_status_check_contexts:
154+
lines.append(
155+
"Required checks: " + ", ".join(f"`{name}`" for name in protection.required_status_check_contexts)
156+
)
157+
else:
158+
lines.append("Required checks: enabled, but GitHub did not return named contexts.")
159+
else:
160+
lines.append("Required checks: not enabled")
161+
162+
if protection.requires_approving_reviews is None:
163+
lines.append("Approving reviews: unknown")
164+
elif protection.requires_approving_reviews:
165+
required = protection.required_approving_review_count or 1
166+
lines.append(f"Approving reviews: required ({required})")
167+
else:
168+
lines.append("Approving reviews: not required")
169+
170+
if protection.requires_code_owner_reviews is None:
171+
lines.append("Code owner reviews: unknown")
172+
else:
173+
lines.append(
174+
"Code owner reviews: " + ("required" if protection.requires_code_owner_reviews else "not required")
175+
)
176+
177+
if protection.is_admin_enforced is None:
178+
lines.append("Admin enforcement: unknown")
179+
else:
180+
lines.append("Admin enforcement: " + ("enabled" if protection.is_admin_enforced else "disabled"))
181+
lines.append("")
182+
return lines
183+
184+
185+
def render_repo_next_commands(preflight: RepoPreflight) -> list[str]:
186+
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
188+
lines = [
189+
"## Next Commands",
190+
"These commands are inferred from the permission, onboarding-file, and default-branch protection signals above.",
191+
]
192+
193+
step = 1
194+
if preflight.fork_recommended:
195+
lines.append(f"{step}. Create your fork before pushing:")
196+
lines.append(f" ⏎ `gh repo fork {repo} --clone`")
197+
else:
198+
lines.append(f"{step}. Clone the repository:")
199+
lines.append(f" ⏎ `gh repo clone {repo}`")
200+
step += 1
201+
202+
doc_steps = [
203+
("Read contribution guidance", preflight.contributing_docs),
204+
("Read AGENTS instructions", preflight.agents_docs),
205+
("Inspect the PR template before opening a pull request", preflight.pr_templates),
206+
("Check CODEOWNERS to see who may review your changes", preflight.codeowners_files),
207+
]
208+
for title, docs in doc_steps:
209+
if not docs:
210+
continue
211+
lines.append(f"{step}. {title}:")
212+
lines.append(
213+
f" ⏎ `{_gh_browse_command(repo=repo, default_branch=preflight.default_branch, path=docs[0].path)}`"
214+
)
215+
step += 1
216+
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}`")
222+
step += 1
223+
224+
protection = preflight.branch_protection
225+
if protection is not None and protection.requires_status_checks:
226+
lines.append(f"{step}. Check required CI after the PR exists:")
227+
lines.append(f" ⏎ `{display_command_with(f'pr checks --pr <pr_number> --repo {pr_target_repo}')}`")
228+
229+
lines.append("")
230+
return lines
231+
232+
233+
def _gh_browse_command(*, repo: str, default_branch: str, path: str) -> str:
234+
return f"gh browse -R {repo} --branch {default_branch} {_shell_single_quote(path)}"
235+
236+
237+
def _shell_single_quote(value: str) -> str:
238+
return "'" + value.replace("'", "'\"'\"'") + "'"

0 commit comments

Comments
 (0)