Skip to content

Commit a90ac2e

Browse files
committed
Extract GitHub tool group into agent-tools/github.ts
Moves github_list_repositories/repository_tree/read_file and the shared token/API helpers out of agent-tools.ts. agent-tools.ts is now a thin barrel (~196 lines) that re-exports each category module and hosts executeTool's unchanged dispatch switch.
1 parent 5886641 commit a90ac2e

2 files changed

Lines changed: 82 additions & 80 deletions

File tree

‎app/src/agent-tools.ts‎

Lines changed: 2 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { getAccountToken } from "./accounts";
21
import { capturePageScreenshot } from "./browser-capture";
32
import * as settingsStore from "./settings-store";
43
import * as terminalManager from "./terminal-manager";
54
import { AGENT_TOOLS } from "./agent-tools/schema";
65
import {
7-
MAX_READ_CHARS,
86
readFile,
97
writeFile,
108
replaceInFile,
@@ -37,6 +35,7 @@ import {
3735
import { gitStatus, gitDiff, gitLog, gitCommit } from "./agent-tools/git";
3836
import { NETWORK_TOOLS, fetchUrl, httpRequest, webSearch } from "./agent-tools/network";
3937
import type { WebSearchResult } from "./agent-tools/network";
38+
import { githubListRepositories, githubRepositoryTree, githubReadFile } from "./agent-tools/github";
4039

4140
export { AGENT_TOOLS };
4241
export {
@@ -72,84 +71,7 @@ export {
7271
export { gitStatus, gitDiff, gitLog, gitCommit };
7372
export { fetchUrl, httpRequest, webSearch };
7473
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 };
15375

15476
export async function executeTool(workspaceRoot: string, name: string, args: Record<string, unknown>): Promise<unknown> {
15577
if (NETWORK_TOOLS.has(name) && settingsStore.getSettings().networkToolsEnabled === false) {

‎app/src/agent-tools/github.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { getAccountToken } from "../accounts";
2+
import { MAX_READ_CHARS } from "./filesystem";
3+
4+
function requireGitHubToken(): string {
5+
const token = getAccountToken("github");
6+
if (!token) throw new Error("Link a GitHub account in Settings → Integrations before using GitHub repository tools.");
7+
return token;
8+
}
9+
10+
function normalizeGitHubRepository(repository: string): string {
11+
const value = repository.trim();
12+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) {
13+
throw new Error('repository must use the "owner/name" format.');
14+
}
15+
return value;
16+
}
17+
18+
async function githubApi<T>(endpoint: string): Promise<T> {
19+
const response = await fetch(`https://api.github.com${endpoint}`, {
20+
headers: {
21+
Authorization: `Bearer ${requireGitHubToken()}`,
22+
Accept: "application/vnd.github+json",
23+
"X-GitHub-Api-Version": "2026-03-10",
24+
"User-Agent": "Modelforge",
25+
},
26+
});
27+
if (response.status === 401) throw new Error("The linked GitHub token is invalid or expired. Reconnect it in Settings.");
28+
if (response.status === 404) throw new Error("The repository, ref, or file was not found, or the linked account cannot access it.");
29+
if (!response.ok) throw new Error(`GitHub API error (HTTP ${response.status}).`);
30+
return await response.json() as T;
31+
}
32+
33+
export async function githubListRepositories(visibility = "all", limit = 30): Promise<unknown[]> {
34+
const safeVisibility = ["all", "public", "private"].includes(visibility) ? visibility : "all";
35+
const safeLimit = Math.max(1, Math.min(Math.floor(limit), 100));
36+
const repos = await githubApi<Array<Record<string, unknown>>>(
37+
`/user/repos?visibility=${safeVisibility}&affiliation=owner,collaborator,organization_member&sort=updated&per_page=${safeLimit}`
38+
);
39+
return repos.map((repo) => ({
40+
fullName: repo.full_name,
41+
private: repo.private,
42+
description: repo.description,
43+
defaultBranch: repo.default_branch,
44+
language: repo.language,
45+
updatedAt: repo.updated_at,
46+
url: repo.html_url,
47+
}));
48+
}
49+
50+
export async function githubRepositoryTree(repository: string, ref?: string): Promise<{ ref: string; truncated: boolean; files: unknown[] }> {
51+
const repo = normalizeGitHubRepository(repository);
52+
let resolvedRef = ref?.trim();
53+
if (!resolvedRef) {
54+
const metadata = await githubApi<{ default_branch: string }>(`/repos/${repo}`);
55+
resolvedRef = metadata.default_branch;
56+
}
57+
const tree = await githubApi<{ truncated: boolean; tree: Array<{ path: string; type: string; size?: number; sha: string }> }>(
58+
`/repos/${repo}/git/trees/${encodeURIComponent(resolvedRef)}?recursive=1`
59+
);
60+
return {
61+
ref: resolvedRef,
62+
truncated: tree.truncated,
63+
files: tree.tree.filter((item) => item.type === "blob").slice(0, 2_000).map((item) => ({ path: item.path, sizeBytes: item.size ?? null, sha: item.sha })),
64+
};
65+
}
66+
67+
export async function githubReadFile(repository: string, filePath: string, ref?: string): Promise<string> {
68+
const repo = normalizeGitHubRepository(repository);
69+
const cleanPath = filePath.replace(/^\/+/, "");
70+
if (!cleanPath || cleanPath.split("/").some((segment) => segment === ".." || segment === "." || !segment)) {
71+
throw new Error("Invalid repository file path.");
72+
}
73+
const encodedPath = cleanPath.split("/").map(encodeURIComponent).join("/");
74+
const query = ref?.trim() ? `?ref=${encodeURIComponent(ref.trim())}` : "";
75+
const file = await githubApi<{ type: string; size: number; encoding?: string; content?: string }>(`/repos/${repo}/contents/${encodedPath}${query}`);
76+
if (file.type !== "file" || file.encoding !== "base64" || !file.content) throw new Error("The requested GitHub path is not a readable file.");
77+
if (file.size > MAX_READ_CHARS * 4) throw new Error(`The GitHub file is too large to analyze directly (${file.size} bytes).`);
78+
const content = Buffer.from(file.content.replace(/\s/g, ""), "base64").toString("utf-8");
79+
return content.length > MAX_READ_CHARS ? `${content.slice(0, MAX_READ_CHARS)}\n\n[truncated]` : content;
80+
}

0 commit comments

Comments
 (0)