github-context-tools 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ """
2
+ github-context-tools
3
+ ====================
4
+ A collection of GitHub API tools for fetching context for LLM-based workflows.
5
+
6
+ Quickstart::
7
+
8
+ from github_context_tools import make_tools
9
+
10
+ tools = make_tools(token="ghp_...") # or set GH_TOKEN env var
11
+
12
+ # Pass the list to your agent framework's tool registry
13
+ agent.run(tools=tools)
14
+ """
15
+
16
+ from importlib.metadata import version as _version
17
+
18
+ __version__ = _version("github-context-tools")
19
+
20
+ from github_context_tools.tools.factory import make_tools
21
+ from github_context_tools.models import (
22
+ PRMetadata,
23
+ PRDiff,
24
+ PRDescription,
25
+ PRComment,
26
+ FileContent,
27
+ SearchResult,
28
+ TextMatch,
29
+ CommitSummary,
30
+ CommitDiff,
31
+ BlameEntry,
32
+ Issue,
33
+ IssueComment,
34
+ RepoConventions,
35
+ )
36
+ from github_context_tools.exceptions import (
37
+ GitHubAPIError,
38
+ NotFoundError,
39
+ AuthenticationError,
40
+ RateLimitError,
41
+ )
42
+
43
+ __all__ = [
44
+ # Factory
45
+ "make_tools",
46
+ # PR entry points
47
+ "PRMetadata",
48
+ "PRDiff",
49
+ # PR intent
50
+ "PRDescription",
51
+ "PRComment",
52
+ # Code understanding
53
+ "FileContent",
54
+ "SearchResult",
55
+ "TextMatch",
56
+ # History
57
+ "CommitSummary",
58
+ "CommitDiff",
59
+ "BlameEntry",
60
+ # Issues
61
+ "Issue",
62
+ "IssueComment",
63
+ # Conventions
64
+ "RepoConventions",
65
+ # Exceptions
66
+ "GitHubAPIError",
67
+ "NotFoundError",
68
+ "AuthenticationError",
69
+ "RateLimitError",
70
+ ]
@@ -0,0 +1,106 @@
1
+ import os
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+ from github_context_tools.exceptions import (
7
+ AuthenticationError,
8
+ GitHubAPIError,
9
+ NotFoundError,
10
+ RateLimitError,
11
+ )
12
+
13
+ GITHUB_API_BASE = "https://api.github.com"
14
+
15
+ _HTTP_ERRORS: dict[int, tuple[type[GitHubAPIError], str]] = {
16
+ 401: (AuthenticationError, "Authentication failed — check your token"),
17
+ 403: (AuthenticationError, "Permission denied — token may lack required scopes"),
18
+ 404: (NotFoundError, "Resource not found"),
19
+ 429: (RateLimitError, "GitHub API rate limit exceeded"),
20
+ }
21
+
22
+
23
+ def _resolve_token(token: str | None) -> str:
24
+ resolved = token or os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
25
+ if not resolved:
26
+ raise ValueError(
27
+ "No GitHub token provided. Pass token= to make_tools() or set the "
28
+ "GH_TOKEN / GITHUB_TOKEN environment variable."
29
+ )
30
+ return resolved
31
+
32
+
33
+ def _raise_for_status(response: httpx.Response) -> None:
34
+ if response.is_success:
35
+ return
36
+ try:
37
+ detail = response.json().get("message", "")
38
+ except Exception:
39
+ detail = ""
40
+ msg = f"{detail} ({response.url})" if detail else str(response.url)
41
+ exc_class, prefix = _HTTP_ERRORS.get(
42
+ response.status_code,
43
+ (GitHubAPIError, f"GitHub API error {response.status_code}"),
44
+ )
45
+ raise exc_class(f"{prefix}: {msg}", status_code=response.status_code)
46
+
47
+
48
+ def make_requester(token: str | None = None, http_client: httpx.Client | None = None):
49
+ """
50
+ Returns a (get_json, get_text, post_json) triple of callables that share a
51
+ resolved token and a single Client per make_requester call.
52
+
53
+ The Client is thread-safe; concurrent tool calls from a thread pool work
54
+ correctly without any additional synchronisation.
55
+
56
+ :param token: GitHub personal access token. If omitted, reads from the
57
+ GH_TOKEN or GITHUB_TOKEN environment variable.
58
+ :param http_client: Optional httpx.Client instance. Use this to configure
59
+ SSL settings, proxies, timeouts, etc. If omitted, a new
60
+ client is created.
61
+ """
62
+ resolved = _resolve_token(token)
63
+ if http_client is not None:
64
+ client = http_client
65
+ else:
66
+ client = httpx.Client()
67
+
68
+ default_headers = {
69
+ "Authorization": f"Bearer {resolved}",
70
+ "Accept": "application/vnd.github+json",
71
+ "X-GitHub-Api-Version": "2022-11-28",
72
+ }
73
+
74
+ def _url(path: str) -> str:
75
+ return path if path.startswith("https://") else f"{GITHUB_API_BASE}{path}"
76
+
77
+ def _get(path: str, *, headers: dict[str, str] | None = None) -> httpx.Response:
78
+ merged = {**default_headers, **(headers or {})}
79
+ response = client.get(_url(path), headers=merged)
80
+ _raise_for_status(response)
81
+ return response
82
+
83
+ def _post(
84
+ path: str, body: dict[str, Any], *, headers: dict[str, str] | None = None
85
+ ) -> httpx.Response:
86
+ merged = {
87
+ **default_headers,
88
+ "Content-Type": "application/json",
89
+ **(headers or {}),
90
+ }
91
+ response = client.post(_url(path), json=body, headers=merged)
92
+ _raise_for_status(response)
93
+ return response
94
+
95
+ def get_json(path: str, *, headers: dict[str, str] | None = None) -> Any:
96
+ return _get(path, headers=headers).json()
97
+
98
+ def get_text(path: str, *, headers: dict[str, str] | None = None) -> str:
99
+ return _get(path, headers=headers).text
100
+
101
+ def post_json(
102
+ path: str, body: dict[str, Any], *, headers: dict[str, str] | None = None
103
+ ) -> Any:
104
+ return _post(path, body, headers=headers).json()
105
+
106
+ return get_json, get_text, post_json
@@ -0,0 +1,25 @@
1
+ import re
2
+
3
+
4
+ _PR_URL_RE = re.compile(
5
+ r"https://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/pull/(?P<number>\d+)"
6
+ )
7
+
8
+
9
+ def parse_pr_url(pr_url: str) -> tuple[str, str, int]:
10
+ """Return (owner, repo, pr_number) from a GitHub PR URL."""
11
+ m = _PR_URL_RE.match(pr_url.rstrip("/"))
12
+ if not m:
13
+ raise ValueError(
14
+ f"Invalid GitHub PR URL: {pr_url!r}. "
15
+ "Expected format: https://github.com/owner/repo/pull/123"
16
+ )
17
+ return m.group("owner"), m.group("repo"), int(m.group("number"))
18
+
19
+
20
+ def validate_repo(repo: str) -> None:
21
+ """Raise ValueError if repo is not in 'owner/name' format."""
22
+ if "/" not in repo or repo.startswith("/") or repo.endswith("/"):
23
+ raise ValueError(
24
+ f"Invalid repo format: {repo!r}. Expected 'owner/name', e.g. 'acme/backend'"
25
+ )
@@ -0,0 +1,18 @@
1
+ class GitHubAPIError(Exception):
2
+ """Raised when the GitHub API returns an unexpected error."""
3
+
4
+ def __init__(self, message: str, status_code: int | None = None):
5
+ super().__init__(message)
6
+ self.status_code = status_code
7
+
8
+
9
+ class NotFoundError(GitHubAPIError):
10
+ """Raised when the requested resource does not exist (HTTP 404)."""
11
+
12
+
13
+ class AuthenticationError(GitHubAPIError):
14
+ """Raised when the token is missing, invalid, or lacks required scopes (HTTP 401/403)."""
15
+
16
+
17
+ class RateLimitError(GitHubAPIError):
18
+ """Raised when the GitHub API rate limit is exceeded (HTTP 429)."""
@@ -0,0 +1,157 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+ from git_unified_diff_parse import ChangedFile
5
+
6
+
7
+ # ── PR entry-point results ────────────────────────────────────────────────────
8
+
9
+ @dataclass(frozen=True)
10
+ class PRMetadata:
11
+ repo: str # "owner/name"
12
+ pr_number: int
13
+ title: str
14
+ description: str
15
+ author: str # GitHub login
16
+ base_branch: str
17
+ head_branch: str
18
+ base_sha: str
19
+ head_sha: str
20
+ html_url: str
21
+ state: str # "open" or "closed"
22
+ commits: int
23
+ changed_files: int
24
+ additions: int
25
+ deletions: int
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PRDiff:
30
+ repo: str # "owner/name"
31
+ diff: tuple[ChangedFile, ...]
32
+
33
+
34
+ # ── Group 1: Code understanding ───────────────────────────────────────────────
35
+
36
+ @dataclass(frozen=True)
37
+ class FileContent:
38
+ # Decoded file content. None when the file exceeds GitHub's 1MB inline limit.
39
+ content: Optional[str]
40
+ # Direct download URL. Present when the file is too large for inline delivery.
41
+ download_url: Optional[str]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class TextMatch:
46
+ fragment: str
47
+ matches: tuple[tuple[str, tuple[int, int]], ...] # (matched_text, (start, end))
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class SearchResult:
52
+ path: str
53
+ filename: str
54
+ html_url: str
55
+ sha: str
56
+ language: Optional[str]
57
+ score: float
58
+ line_numbers: tuple[str, ...]
59
+ text_matches: tuple[TextMatch, ...]
60
+ # Kept for backwards compatibility — first fragment from text_matches, or empty string
61
+ snippet: str
62
+
63
+
64
+ # ── Group 2: History ──────────────────────────────────────────────────────────
65
+
66
+ @dataclass(frozen=True)
67
+ class CommitSummary:
68
+ sha: str
69
+ message: str
70
+ author: str
71
+ date: str
72
+ html_url: str
73
+ parents: tuple[str, ...] # parent SHAs; len > 1 means merge commit
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class CommitDiff:
78
+ sha: str
79
+ diff: tuple[ChangedFile, ...]
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class BlameEntry:
84
+ start_line: int
85
+ end_line: int
86
+ sha: str
87
+ message: str
88
+ author: str
89
+ date: str
90
+ commit_url: str
91
+ age: int # 1 (newest) to 10 (oldest) relative to other changes in the file
92
+
93
+
94
+ # ── Group 3: PR intent ────────────────────────────────────────────────────────
95
+
96
+ @dataclass(frozen=True)
97
+ class PRDescription:
98
+ title: str
99
+ body: str
100
+ labels: tuple[str, ...]
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class PRComment:
105
+ author: str
106
+ body: str
107
+ created_at: str
108
+ path: Optional[str]
109
+ # Line number on the side of the diff specified by `side`.
110
+ line: Optional[int]
111
+ # "RIGHT" = new file (addition side), "LEFT" = old file (deletion side).
112
+ # None for non-review (conversation) comments.
113
+ side: Optional[str]
114
+ # Line number in the file before the PR's changes (base commit).
115
+ original_line: Optional[int]
116
+ original_side: Optional[str]
117
+ # For multi-line comments: the first line and its side.
118
+ start_line: Optional[int]
119
+ start_side: Optional[str]
120
+ # Surrounding diff context at the point the comment was made.
121
+ diff_hunk: Optional[str]
122
+ # SHA of the commit the comment was made on — None if made on a stale commit.
123
+ commit_id: Optional[str]
124
+ # ID of the parent comment; set for replies in a thread, None for top-level.
125
+ in_reply_to_id: Optional[int]
126
+ html_url: Optional[str]
127
+ is_review_comment: bool
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class IssueComment:
132
+ author: str
133
+ body: str
134
+ created_at: str
135
+ html_url: str
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class Issue:
140
+ number: int
141
+ title: str
142
+ body: str
143
+ author: str
144
+ state: str
145
+ state_reason: Optional[str] # "completed", "not_planned", "reopened", or None
146
+ labels: tuple[str, ...]
147
+ created_at: str
148
+ closed_at: Optional[str]
149
+ comments: tuple[IssueComment, ...]
150
+
151
+
152
+ # ── Group 4: Conventions ──────────────────────────────────────────────────────
153
+
154
+ @dataclass(frozen=True)
155
+ class RepoConventions:
156
+ """Keys are filenames (e.g. 'CONTRIBUTING.md'), values are file contents."""
157
+ files: tuple[tuple[str, str], ...]
File without changes
@@ -0,0 +1,3 @@
1
+ from github_context_tools.tools.factory import make_tools
2
+
3
+ __all__ = ["make_tools"]
@@ -0,0 +1,211 @@
1
+ from base64 import b64decode
2
+ from collections.abc import Callable
3
+ from typing import Annotated
4
+
5
+ from github_context_tools._utils import validate_repo
6
+ from github_context_tools.models import FileContent, SearchResult, TextMatch
7
+
8
+
9
+ def _is_sibling(item_path: str, item_type: str, directory: str, exclude: str) -> bool:
10
+ if item_type != "blob" or item_path == exclude:
11
+ return False
12
+ if directory:
13
+ prefix = directory + "/"
14
+ return item_path.startswith(prefix) and "/" not in item_path[len(prefix):]
15
+ return "/" not in item_path
16
+
17
+
18
+ def _build_tree(items: list[dict], path_prefix: str) -> dict:
19
+ tree: dict = {}
20
+ for item in items:
21
+ if path_prefix and not item["path"].startswith(path_prefix):
22
+ continue
23
+ parts = item["path"].split("/")
24
+ node = tree
25
+ for part in parts[:-1]:
26
+ node = node.setdefault(part, {})
27
+ if item["type"] == "blob":
28
+ node[parts[-1]] = "blob"
29
+ return tree
30
+
31
+
32
+ def _build_search_query(repo: str, path: str | None, filename: str | None, symbol: str | None, content: str | None) -> str:
33
+ if not any([path, filename, symbol, content]):
34
+ raise ValueError("At least one of path, filename, symbol, or content must be provided")
35
+ # The classic GitHub code search API uses free-text terms for content/symbol matching.
36
+ # `filename:` and `path:` are supported qualifiers; `content:` and `symbol:` are not —
37
+ # they must be passed as plain search terms instead.
38
+ qualifiers = [f"repo:{repo}"]
39
+ terms = []
40
+ if path:
41
+ qualifiers.append(f"path:{path}")
42
+ if filename:
43
+ qualifiers.append(f"filename:{filename}")
44
+ if symbol:
45
+ terms.append(symbol)
46
+ if content:
47
+ terms.append(content)
48
+ return "+".join(terms + qualifiers)
49
+
50
+
51
+ def _parse_text_matches(raw: list[dict]) -> tuple[TextMatch, ...]:
52
+ return tuple(
53
+ TextMatch(
54
+ fragment=m.get("fragment", ""),
55
+ matches=tuple(
56
+ (hit["text"], (hit["indices"][0], hit["indices"][1]))
57
+ for hit in m.get("matches", [])
58
+ ),
59
+ )
60
+ for m in raw
61
+ )
62
+
63
+
64
+ def _search_results_from_response(data: dict) -> list[SearchResult]:
65
+ results = []
66
+ for item in data.get("items", []):
67
+ text_matches = _parse_text_matches(item.get("text_matches", []))
68
+ results.append(
69
+ SearchResult(
70
+ path=item["path"],
71
+ filename=item["name"],
72
+ html_url=item["html_url"],
73
+ sha=item["sha"],
74
+ language=item.get("language"),
75
+ score=item.get("score", 0.0),
76
+ line_numbers=tuple(item.get("line_numbers") or []),
77
+ text_matches=text_matches,
78
+ snippet=text_matches[0].fragment if text_matches else "",
79
+ )
80
+ )
81
+ return results
82
+
83
+
84
+ def make_code_tools(get_json, _get_text, _post_json) -> list[Callable]:
85
+ def get_file_at_ref(
86
+ repo: Annotated[str, "Repository in 'owner/name' format, e.g. 'acme/backend'"],
87
+ path: Annotated[
88
+ str, "File path relative to the repo root, e.g. 'src/auth/oauth.py'"
89
+ ],
90
+ ref: Annotated[str, "Any git ref — branch name, tag, or commit SHA"],
91
+ ) -> Annotated[
92
+ FileContent,
93
+ (
94
+ "File content with fields: content (str with decoded file text, None if file exceeds GitHub's 1MB inline limit),"
95
+ " download_url (direct download URL, present when content is None for large files, otherwise may also be set)."
96
+ " Always check content first; fall back to download_url if content is None."
97
+ ),
98
+ ]:
99
+ """Fetch the content of a file at any git ref.
100
+
101
+ For files under 1MB, content is returned inline.
102
+ For larger files, content is None and download_url is provided instead.
103
+ """
104
+ validate_repo(repo)
105
+ data = get_json(f"/repos/{repo}/contents/{path}?ref={ref}")
106
+ if "content" in data:
107
+ return FileContent(
108
+ content=b64decode(data["content"]).decode("utf-8"),
109
+ download_url=data.get("download_url"),
110
+ )
111
+ if data.get("download_url"):
112
+ return FileContent(content=None, download_url=data["download_url"])
113
+ raise ValueError(f"Unable to fetch content for {path!r} at ref {ref!r}")
114
+
115
+ def get_directory_tree(
116
+ repo: Annotated[str, "Repository in 'owner/name' format, e.g. 'acme/backend'"],
117
+ ref: Annotated[str, "Any git ref — branch name, tag, or commit SHA"],
118
+ path: Annotated[
119
+ str,
120
+ "Directory path to filter by, e.g. 'src/auth'. Pass empty string for the full repo tree",
121
+ ] = "",
122
+ ) -> Annotated[
123
+ dict,
124
+ (
125
+ "Nested dict representing the directory tree rooted at the given path."
126
+ " Directories are dicts mapping entry names to their children."
127
+ " Files are the string 'blob'."
128
+ " Example: {'src': {'auth': {'oauth.py': 'blob', 'token.py': 'blob'}, 'utils.py': 'blob'}}."
129
+ " Pass an empty string for path to get the full repo tree."
130
+ ),
131
+ ]:
132
+ """List the file tree for a directory (or the whole repo) at a given ref.
133
+
134
+ Returns a nested dict where each directory is a dict of its children and
135
+ each file is the string 'blob'. Example:
136
+ {'src': {'auth': {'oauth.py': 'blob', 'token.py': 'blob'}, 'utils.py': 'blob'}}
137
+ """
138
+ validate_repo(repo)
139
+ data = get_json(f"/repos/{repo}/git/trees/{ref}?recursive=1")
140
+ return _build_tree(data["tree"], path)
141
+
142
+ def search_codebase(
143
+ repo: Annotated[str, "Repository in 'owner/name' format, e.g. 'acme/backend'"],
144
+ path: Annotated[
145
+ str | None,
146
+ "Filter by file path or glob pattern, e.g. 'src/auth' or '*.py'. Matches anywhere in the file path",
147
+ ] = None,
148
+ filename: Annotated[
149
+ str | None,
150
+ "Filter by exact filename, e.g. 'conftest.py' or 'README.md'",
151
+ ] = None,
152
+ symbol: Annotated[
153
+ str | None,
154
+ "Search term to find a function, method, or class by name, e.g. 'WithContext' or 'OAuthHandler'. Used as a free-text search term.",
155
+ ] = None,
156
+ content: Annotated[
157
+ str | None,
158
+ "Search term to find files containing this string, e.g. a variable name, string literal, or import. Used as a free-text search term.",
159
+ ] = None,
160
+ ) -> Annotated[
161
+ list[SearchResult],
162
+ (
163
+ "List of SearchResult objects, each with fields:"
164
+ " path (file path relative to repo root),"
165
+ " filename (just the file name, e.g. 'oauth.py'),"
166
+ " html_url (GitHub web URL to view the file),"
167
+ " sha (git blob SHA of the file),"
168
+ " language (detected programming language, or None),"
169
+ " score (relevance score from GitHub),"
170
+ " line_numbers (tuple of line number strings where matches occur),"
171
+ " text_matches (tuple of TextMatch objects, each with: fragment (code snippet),"
172
+ " matches (tuple of (matched_text, (start_index, end_index)) pairs)),"
173
+ " snippet (first fragment from text_matches for convenience, or empty string)."
174
+ ),
175
+ ]:
176
+ """Search the repository by path, filename, symbol, or content.
177
+ At least one of path, filename, symbol, or content must be provided.
178
+ Rate-limited to 10 requests per minute for authenticated users.
179
+ """
180
+ validate_repo(repo)
181
+ q = _build_search_query(repo, path, filename, symbol, content)
182
+ return _search_results_from_response(
183
+ get_json(
184
+ f"/search/code?q={q}",
185
+ headers={"Accept": "application/vnd.github.text-match+json"},
186
+ )
187
+ )
188
+
189
+ def get_sibling_files(
190
+ repo: Annotated[str, "Repository in 'owner/name' format, e.g. 'acme/backend'"],
191
+ path: Annotated[str, "Path of the changed file, e.g. 'src/auth/oauth.py'"],
192
+ ref: Annotated[str, "Any git ref — branch name, tag, or commit SHA"],
193
+ ) -> Annotated[
194
+ list[str],
195
+ (
196
+ "List of file paths (strings) for other files in the same directory as the given path."
197
+ " The given file itself is excluded. Paths are relative to the repo root,"
198
+ " e.g. ['src/auth/token.py', 'src/auth/utils.py']."
199
+ ),
200
+ ]:
201
+ """List sibling files in the same directory as the given file."""
202
+ validate_repo(repo)
203
+ directory = path.rsplit("/", 1)[0] if "/" in path else ""
204
+ data = get_json(f"/repos/{repo}/git/trees/{ref}?recursive=1")
205
+ return [
206
+ item["path"]
207
+ for item in data["tree"]
208
+ if _is_sibling(item["path"], item["type"], directory, path)
209
+ ]
210
+
211
+ return [get_file_at_ref, get_directory_tree, search_codebase, get_sibling_files]
@@ -0,0 +1,53 @@
1
+ from base64 import b64decode
2
+ from collections.abc import Callable
3
+ from typing import Annotated
4
+
5
+ from github_context_tools._utils import validate_repo
6
+ from github_context_tools.models import RepoConventions
7
+
8
+ _CONVENTION_CANDIDATES = [
9
+ "CONTRIBUTING.md",
10
+ "CLAUDE.md",
11
+ "AGENTS.md",
12
+ ".cursor/rules",
13
+ ".cursorrules",
14
+ ".github/copilot-instructions.md",
15
+ "docs/architecture.md",
16
+ "docs/ARCHITECTURE.md",
17
+ "docs/development.md",
18
+ "DEVELOPMENT.md",
19
+ ]
20
+
21
+
22
+ def _fetch_convention_files(get_json, repo: str) -> list[tuple[str, str]]:
23
+ found = []
24
+ for candidate in _CONVENTION_CANDIDATES:
25
+ try:
26
+ content = b64decode(
27
+ get_json(f"/repos/{repo}/contents/{candidate}")["content"]
28
+ ).decode("utf-8")
29
+ found.append((candidate, content))
30
+ except Exception:
31
+ pass
32
+ return found
33
+
34
+
35
+ def make_conventions_tools(get_json, _get_text, _post_json) -> list[Callable]:
36
+ def get_repo_conventions(
37
+ repo: Annotated[str, "Repository in 'owner/name' format, e.g. 'acme/backend'"],
38
+ ) -> Annotated[
39
+ RepoConventions,
40
+ (
41
+ "Contents of well-known convention and context files found in the repo's default branch. "
42
+ "Files that do not exist are silently skipped. "
43
+ "Returns a tuple of (filename, content) pairs for any of: "
44
+ "CONTRIBUTING.md, CLAUDE.md, AGENTS.md, .cursor/rules, .cursorrules, "
45
+ ".github/copilot-instructions.md, docs/architecture.md, docs/ARCHITECTURE.md, "
46
+ "docs/development.md, DEVELOPMENT.md."
47
+ ),
48
+ ]:
49
+ """Fetch well-known convention and context files from the repo's default branch."""
50
+ validate_repo(repo)
51
+ return RepoConventions(files=tuple(_fetch_convention_files(get_json, repo)))
52
+
53
+ return [get_repo_conventions]