|
1 | | -import { getAccountToken } from "./accounts"; |
2 | 1 | import { capturePageScreenshot } from "./browser-capture"; |
3 | 2 | import * as settingsStore from "./settings-store"; |
4 | 3 | import * as terminalManager from "./terminal-manager"; |
5 | 4 | import { AGENT_TOOLS } from "./agent-tools/schema"; |
6 | 5 | import { |
7 | | - MAX_READ_CHARS, |
8 | 6 | readFile, |
9 | 7 | writeFile, |
10 | 8 | replaceInFile, |
@@ -37,6 +35,7 @@ import { |
37 | 35 | import { gitStatus, gitDiff, gitLog, gitCommit } from "./agent-tools/git"; |
38 | 36 | import { NETWORK_TOOLS, fetchUrl, httpRequest, webSearch } from "./agent-tools/network"; |
39 | 37 | import type { WebSearchResult } from "./agent-tools/network"; |
| 38 | +import { githubListRepositories, githubRepositoryTree, githubReadFile } from "./agent-tools/github"; |
40 | 39 |
|
41 | 40 | export { AGENT_TOOLS }; |
42 | 41 | export { |
@@ -72,84 +71,7 @@ export { |
72 | 71 | export { gitStatus, gitDiff, gitLog, gitCommit }; |
73 | 72 | export { fetchUrl, httpRequest, webSearch }; |
74 | 73 | export type { WebSearchResult }; |
75 | | - |
76 | | -function requireGitHubToken(): string { |
77 | | - const token = getAccountToken("github"); |
78 | | - if (!token) throw new Error("Link a GitHub account in Settings → Integrations before using GitHub repository tools."); |
79 | | - return token; |
80 | | -} |
81 | | - |
82 | | -function normalizeGitHubRepository(repository: string): string { |
83 | | - const value = repository.trim(); |
84 | | - if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) { |
85 | | - throw new Error('repository must use the "owner/name" format.'); |
86 | | - } |
87 | | - return value; |
88 | | -} |
89 | | - |
90 | | -async function githubApi<T>(endpoint: string): Promise<T> { |
91 | | - const response = await fetch(`https://api.github.com${endpoint}`, { |
92 | | - headers: { |
93 | | - Authorization: `Bearer ${requireGitHubToken()}`, |
94 | | - Accept: "application/vnd.github+json", |
95 | | - "X-GitHub-Api-Version": "2026-03-10", |
96 | | - "User-Agent": "Modelforge", |
97 | | - }, |
98 | | - }); |
99 | | - if (response.status === 401) throw new Error("The linked GitHub token is invalid or expired. Reconnect it in Settings."); |
100 | | - if (response.status === 404) throw new Error("The repository, ref, or file was not found, or the linked account cannot access it."); |
101 | | - if (!response.ok) throw new Error(`GitHub API error (HTTP ${response.status}).`); |
102 | | - return await response.json() as T; |
103 | | -} |
104 | | - |
105 | | -export async function githubListRepositories(visibility = "all", limit = 30): Promise<unknown[]> { |
106 | | - const safeVisibility = ["all", "public", "private"].includes(visibility) ? visibility : "all"; |
107 | | - const safeLimit = Math.max(1, Math.min(Math.floor(limit), 100)); |
108 | | - const repos = await githubApi<Array<Record<string, unknown>>>( |
109 | | - `/user/repos?visibility=${safeVisibility}&affiliation=owner,collaborator,organization_member&sort=updated&per_page=${safeLimit}` |
110 | | - ); |
111 | | - return repos.map((repo) => ({ |
112 | | - fullName: repo.full_name, |
113 | | - private: repo.private, |
114 | | - description: repo.description, |
115 | | - defaultBranch: repo.default_branch, |
116 | | - language: repo.language, |
117 | | - updatedAt: repo.updated_at, |
118 | | - url: repo.html_url, |
119 | | - })); |
120 | | -} |
121 | | - |
122 | | -export async function githubRepositoryTree(repository: string, ref?: string): Promise<{ ref: string; truncated: boolean; files: unknown[] }> { |
123 | | - const repo = normalizeGitHubRepository(repository); |
124 | | - let resolvedRef = ref?.trim(); |
125 | | - if (!resolvedRef) { |
126 | | - const metadata = await githubApi<{ default_branch: string }>(`/repos/${repo}`); |
127 | | - resolvedRef = metadata.default_branch; |
128 | | - } |
129 | | - const tree = await githubApi<{ truncated: boolean; tree: Array<{ path: string; type: string; size?: number; sha: string }> }>( |
130 | | - `/repos/${repo}/git/trees/${encodeURIComponent(resolvedRef)}?recursive=1` |
131 | | - ); |
132 | | - return { |
133 | | - ref: resolvedRef, |
134 | | - truncated: tree.truncated, |
135 | | - files: tree.tree.filter((item) => item.type === "blob").slice(0, 2_000).map((item) => ({ path: item.path, sizeBytes: item.size ?? null, sha: item.sha })), |
136 | | - }; |
137 | | -} |
138 | | - |
139 | | -export async function githubReadFile(repository: string, filePath: string, ref?: string): Promise<string> { |
140 | | - const repo = normalizeGitHubRepository(repository); |
141 | | - const cleanPath = filePath.replace(/^\/+/, ""); |
142 | | - if (!cleanPath || cleanPath.split("/").some((segment) => segment === ".." || segment === "." || !segment)) { |
143 | | - throw new Error("Invalid repository file path."); |
144 | | - } |
145 | | - const encodedPath = cleanPath.split("/").map(encodeURIComponent).join("/"); |
146 | | - const query = ref?.trim() ? `?ref=${encodeURIComponent(ref.trim())}` : ""; |
147 | | - const file = await githubApi<{ type: string; size: number; encoding?: string; content?: string }>(`/repos/${repo}/contents/${encodedPath}${query}`); |
148 | | - if (file.type !== "file" || file.encoding !== "base64" || !file.content) throw new Error("The requested GitHub path is not a readable file."); |
149 | | - if (file.size > MAX_READ_CHARS * 4) throw new Error(`The GitHub file is too large to analyze directly (${file.size} bytes).`); |
150 | | - const content = Buffer.from(file.content.replace(/\s/g, ""), "base64").toString("utf-8"); |
151 | | - return content.length > MAX_READ_CHARS ? `${content.slice(0, MAX_READ_CHARS)}\n\n[truncated]` : content; |
152 | | -} |
| 74 | +export { githubListRepositories, githubRepositoryTree, githubReadFile }; |
153 | 75 |
|
154 | 76 | export async function executeTool(workspaceRoot: string, name: string, args: Record<string, unknown>): Promise<unknown> { |
155 | 77 | if (NETWORK_TOOLS.has(name) && settingsStore.getSettings().networkToolsEnabled === false) { |
|
0 commit comments