|
| 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