github.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import { tool } from "ai";
  2. import { z } from "zod";
  3. // ---------------------------------------------------------------------------
  4. // Shared helpers
  5. // ---------------------------------------------------------------------------
  6. const ghHeaders = { Accept: "application/vnd.github.v3+json" };
  7. function handleGitHubError(res: Response, context: string) {
  8. if (res.status === 404) return { error: `Not found: ${context}` };
  9. if (res.status === 403)
  10. return { error: "GitHub API rate limit exceeded. Try again later." };
  11. return { error: `Failed to fetch ${context}: ${res.statusText}` };
  12. }
  13. // ---------------------------------------------------------------------------
  14. // getGitHubRepo
  15. // ---------------------------------------------------------------------------
  16. /**
  17. * Get public GitHub repository information.
  18. * Uses the public GitHub REST API (no auth, 60 req/hr rate limit).
  19. */
  20. export const getGitHubRepo = tool({
  21. description:
  22. "Get information about a public GitHub repository including stars, forks, open issues, description, language, and recent activity.",
  23. inputSchema: z.object({
  24. owner: z.string().describe("Repository owner (e.g., 'vercel')"),
  25. repo: z.string().describe("Repository name (e.g., 'next.js')"),
  26. }),
  27. execute: async ({ owner, repo }) => {
  28. const repoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
  29. const [repoRes, languagesRes] = await Promise.all([
  30. fetch(repoUrl, { headers: ghHeaders }),
  31. fetch(`${repoUrl}/languages`, { headers: ghHeaders }),
  32. ]);
  33. if (!repoRes.ok) {
  34. return handleGitHubError(repoRes, `${owner}/${repo}`);
  35. }
  36. const repoData = (await repoRes.json()) as {
  37. full_name: string;
  38. description: string | null;
  39. html_url: string;
  40. stargazers_count: number;
  41. forks_count: number;
  42. open_issues_count: number;
  43. watchers_count: number;
  44. language: string | null;
  45. license: { spdx_id: string } | null;
  46. created_at: string;
  47. updated_at: string;
  48. pushed_at: string;
  49. topics: string[];
  50. size: number;
  51. default_branch: string;
  52. archived: boolean;
  53. fork: boolean;
  54. };
  55. const languages: Record<string, number> = languagesRes.ok
  56. ? ((await languagesRes.json()) as Record<string, number>)
  57. : {};
  58. const totalBytes = Object.values(languages).reduce((a, b) => a + b, 0);
  59. const languageBreakdown = Object.entries(languages)
  60. .map(([lang, bytes]) => ({
  61. language: lang,
  62. percentage: Math.round((bytes / totalBytes) * 100),
  63. bytes,
  64. }))
  65. .sort((a, b) => b.bytes - a.bytes)
  66. .slice(0, 8);
  67. return {
  68. name: repoData.full_name,
  69. description: repoData.description,
  70. url: repoData.html_url,
  71. stars: repoData.stargazers_count,
  72. forks: repoData.forks_count,
  73. openIssues: repoData.open_issues_count,
  74. watchers: repoData.watchers_count,
  75. primaryLanguage: repoData.language,
  76. license: repoData.license?.spdx_id ?? "None",
  77. createdAt: repoData.created_at,
  78. updatedAt: repoData.updated_at,
  79. lastPush: repoData.pushed_at,
  80. topics: repoData.topics,
  81. defaultBranch: repoData.default_branch,
  82. archived: repoData.archived,
  83. isFork: repoData.fork,
  84. languages: languageBreakdown,
  85. };
  86. },
  87. });
  88. // ---------------------------------------------------------------------------
  89. // getGitHubPullRequests
  90. // ---------------------------------------------------------------------------
  91. type GitHubPR = {
  92. number: number;
  93. title: string;
  94. state: string;
  95. html_url: string;
  96. user: { login: string } | null;
  97. created_at: string;
  98. updated_at: string;
  99. merged_at: string | null;
  100. comments: number;
  101. labels: Array<{ name: string }>;
  102. draft: boolean;
  103. };
  104. type GitHubPRReview = {
  105. id: number;
  106. };
  107. type GitHubPRReaction = {
  108. total_count: number;
  109. };
  110. /**
  111. * Get pull requests from a public GitHub repository.
  112. * Supports filtering by state and sorting by various criteria.
  113. * Fetches comment counts and reactions for ranking "most popular" PRs.
  114. */
  115. export const getGitHubPullRequests = tool({
  116. description:
  117. "Get pull requests from a public GitHub repository. Returns titles, authors, state, comment counts, and reactions. Use sort='popularity' to find the most discussed / reacted PRs.",
  118. inputSchema: z.object({
  119. owner: z.string().describe("Repository owner (e.g., 'vercel')"),
  120. repo: z.string().describe("Repository name (e.g., 'next.js')"),
  121. state: z
  122. .enum(["open", "closed", "all"])
  123. .nullable()
  124. .describe("Filter by state. Defaults to 'open'."),
  125. sort: z
  126. .enum(["created", "updated", "popularity", "long-running"])
  127. .nullable()
  128. .describe(
  129. "Sort order. 'popularity' sorts by reactions+comments, 'long-running' sorts by age. Defaults to 'created'.",
  130. ),
  131. perPage: z
  132. .number()
  133. .int()
  134. .min(1)
  135. .max(30)
  136. .nullable()
  137. .describe("Number of PRs to return (1-30). Defaults to 10."),
  138. }),
  139. execute: async ({ owner, repo, state, sort, perPage }) => {
  140. const count = perPage ?? 10;
  141. const prState = state ?? "open";
  142. // GitHub API sort param: 'popularity' and 'long-running' are API-native
  143. const apiSort =
  144. sort === "popularity"
  145. ? "popularity"
  146. : sort === "long-running"
  147. ? "long-running"
  148. : sort === "updated"
  149. ? "updated"
  150. : "created";
  151. const url = new URL(
  152. `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`,
  153. );
  154. url.searchParams.set("state", prState);
  155. url.searchParams.set("sort", apiSort);
  156. url.searchParams.set("direction", "desc");
  157. url.searchParams.set("per_page", String(count));
  158. const res = await fetch(url.toString(), { headers: ghHeaders });
  159. if (!res.ok) {
  160. return handleGitHubError(res, `${owner}/${repo} pull requests`);
  161. }
  162. const prs = (await res.json()) as GitHubPR[];
  163. // Fetch review + reaction counts in parallel for richer data
  164. const enriched = await Promise.all(
  165. prs.map(async (pr) => {
  166. const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${pr.number}`;
  167. const [reviewsRes, reactionsRes] = await Promise.all([
  168. fetch(`${base}/reviews?per_page=100`, { headers: ghHeaders }),
  169. fetch(
  170. `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${pr.number}/reactions`,
  171. {
  172. headers: {
  173. ...ghHeaders,
  174. Accept: "application/vnd.github.squirrel-girl-preview+json",
  175. },
  176. },
  177. ),
  178. ]);
  179. const reviews: GitHubPRReview[] = reviewsRes.ok
  180. ? ((await reviewsRes.json()) as GitHubPRReview[])
  181. : [];
  182. let reactionCount = 0;
  183. if (reactionsRes.ok) {
  184. const reactions = (await reactionsRes.json()) as GitHubPRReaction[];
  185. reactionCount = reactions.length;
  186. }
  187. return {
  188. number: pr.number,
  189. title: pr.title,
  190. state: pr.merged_at ? "merged" : pr.state,
  191. author: pr.user?.login ?? "unknown",
  192. url: pr.html_url,
  193. createdAt: pr.created_at,
  194. updatedAt: pr.updated_at,
  195. comments: pr.comments,
  196. reviews: reviews.length,
  197. reactions: reactionCount,
  198. labels: pr.labels.map((l) => l.name),
  199. draft: pr.draft,
  200. };
  201. }),
  202. );
  203. return {
  204. repository: `${owner}/${repo}`,
  205. state: prState,
  206. count: enriched.length,
  207. pullRequests: enriched,
  208. };
  209. },
  210. });