From 973b8992e557785596c60fb86bb8c8a90cb0a336 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 13:50:00 -0500 Subject: [PATCH] feat: add GitHub API client, diff-aware context, tiktoken token counting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GitHub API integration via @octokit/rest: createGitHubClient, getRepoInfo (parses HTTPS/SSH remotes), createPullRequest, getPullRequest, listPullRequestReviews, createIssueComment - Add diff-aware context module: getRecentlyChangedFiles, getChangedFilesWithContext, rankFilesByRelevance — prioritizes recently-changed files for context window budget allocation - Add accurate token counting via tiktoken: countTokens (async), countTokensSync, initTokenCounter — falls back to chars/4 heuristic when tiktoken is unavailable - 27 new tests across 3 test files --- package-lock.json | 190 ++++++++++++++ package.json | 1 + src/resources/extensions/gsd/diff-context.ts | 219 ++++++++++++++++ src/resources/extensions/gsd/github-client.ts | 235 ++++++++++++++++++ .../extensions/gsd/tests/diff-context.test.ts | 136 ++++++++++ src/resources/extensions/gsd/token-counter.ts | 42 ++++ src/tests/github-client.test.ts | 150 +++++++++++ src/tests/token-counter.test.ts | 54 ++++ 8 files changed, 1027 insertions(+) create mode 100644 src/resources/extensions/gsd/diff-context.ts create mode 100644 src/resources/extensions/gsd/github-client.ts create mode 100644 src/resources/extensions/gsd/tests/diff-context.test.ts create mode 100644 src/resources/extensions/gsd/token-counter.ts create mode 100644 src/tests/github-client.test.ts create mode 100644 src/tests/token-counter.test.ts diff --git a/package-lock.json b/package-lock.json index 3a0b9fd3a..0bc291f33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@google/genai": "^1.40.0", "@mariozechner/jiti": "^2.6.2", "@mistralai/mistralai": "1.14.1", + "@octokit/rest": "^22.0.1", "@silvia-odwyer/photon-node": "^0.3.4", "@sinclair/typebox": "^0.34.41", "@types/mime-types": "^2.1.4", @@ -1480,6 +1481,161 @@ "zod-to-json-schema": "^3.24.1" } }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2443,6 +2599,12 @@ "node": ">=10.0.0" } }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -2797,6 +2959,22 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3300,6 +3478,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-with-bigint": { + "version": "3.5.7", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", + "integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==", + "license": "MIT" + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4199,6 +4383,12 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index fdd18a50b..adf5b4560 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@google/genai": "^1.40.0", "@mariozechner/jiti": "^2.6.2", "@mistralai/mistralai": "1.14.1", + "@octokit/rest": "^22.0.1", "@silvia-odwyer/photon-node": "^0.3.4", "@sinclair/typebox": "^0.34.41", "@types/mime-types": "^2.1.4", diff --git a/src/resources/extensions/gsd/diff-context.ts b/src/resources/extensions/gsd/diff-context.ts new file mode 100644 index 000000000..73048e189 --- /dev/null +++ b/src/resources/extensions/gsd/diff-context.ts @@ -0,0 +1,219 @@ +/** + * Diff-aware context module — prioritizes recently-changed files when building + * context for the AI agent. Uses git diff/status to discover changes, then + * provides ranking utilities for context-window budget allocation. + * + * Standalone module: only imports node:child_process and node:path. + */ + +import { execSync } from "node:child_process"; +import { resolve } from "node:path"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface ChangedFileInfo { + path: string; + changeType: "modified" | "added" | "deleted" | "staged"; + linesChanged?: number; +} + +export interface RecentFilesOptions { + /** Maximum number of files to return (default 20) */ + maxFiles?: number; + /** Only consider commits within this many days (default 7) */ + sinceDays?: number; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const EXEC_OPTS = { + encoding: "utf-8" as const, + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"] as const, +}; + +function git(cmd: string, cwd: string): string { + return execSync(`git ${cmd}`, { ...EXEC_OPTS, cwd }).trim(); +} + +function splitLines(output: string): string[] { + return output + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Returns recently-changed file paths, deduplicated and sorted by recency + * (most recent first). Combines committed diffs, staged changes, and + * unstaged/untracked files from `git status`. + */ +export async function getRecentlyChangedFiles( + cwd: string, + options?: RecentFilesOptions, +): Promise { + const maxFiles = options?.maxFiles ?? 20; + const sinceDays = options?.sinceDays ?? 7; + const dir = resolve(cwd); + + try { + // 1. Committed changes in the last N commits (or since sinceDays) + let committedFiles: string[] = []; + try { + const since = `--since="${sinceDays} days ago"`; + const raw = git(`log --diff-filter=ACMR --name-only --pretty=format: ${since}`, dir); + committedFiles = splitLines(raw); + } catch { + // Fallback: use HEAD~10 + try { + const raw = git("diff --name-only HEAD~10", dir); + committedFiles = splitLines(raw); + } catch { + // Shallow clone or <10 commits — ignore + } + } + + // 2. Staged changes + let stagedFiles: string[] = []; + try { + const raw = git("diff --cached --name-only", dir); + stagedFiles = splitLines(raw); + } catch { + // ignore + } + + // 3. Unstaged / untracked via porcelain status + let statusFiles: string[] = []; + try { + const raw = git("status --porcelain", dir); + statusFiles = splitLines(raw).map((line) => line.slice(3)); // strip XY + space + } catch { + // ignore + } + + // Deduplicate, preserving insertion order (most-recent-first: status → staged → committed) + const seen = new Set(); + const result: string[] = []; + for (const file of [...statusFiles, ...stagedFiles, ...committedFiles]) { + if (!seen.has(file)) { + seen.add(file); + result.push(file); + } + } + + return result.slice(0, maxFiles); + } catch { + // Non-git directory or git unavailable — graceful fallback + return []; + } +} + +/** + * Returns richer change metadata: change type and approximate line counts. + */ +export async function getChangedFilesWithContext( + cwd: string, +): Promise { + const dir = resolve(cwd); + + try { + const result: ChangedFileInfo[] = []; + const seen = new Set(); + + const add = (info: ChangedFileInfo) => { + if (!seen.has(info.path)) { + seen.add(info.path); + result.push(info); + } + }; + + // 1. Staged files with numstat + try { + const numstat = git("diff --cached --numstat", dir); + for (const line of splitLines(numstat)) { + const [added, deleted, filePath] = line.split("\t"); + if (!filePath) continue; + const lines = + added === "-" || deleted === "-" + ? undefined + : Number(added) + Number(deleted); + add({ path: filePath, changeType: "staged", linesChanged: lines }); + } + } catch { + // ignore + } + + // 2. Unstaged modifications with numstat + try { + const numstat = git("diff --numstat", dir); + for (const line of splitLines(numstat)) { + const [added, deleted, filePath] = line.split("\t"); + if (!filePath) continue; + const lines = + added === "-" || deleted === "-" + ? undefined + : Number(added) + Number(deleted); + add({ path: filePath, changeType: "modified", linesChanged: lines }); + } + } catch { + // ignore + } + + // 3. Untracked / deleted from porcelain status + try { + const raw = git("status --porcelain", dir); + for (const line of splitLines(raw)) { + const code = line.slice(0, 2); + const filePath = line.slice(3); + if (seen.has(filePath)) continue; + + if (code.includes("?")) { + add({ path: filePath, changeType: "added" }); + } else if (code.includes("D")) { + add({ path: filePath, changeType: "deleted" }); + } else if (code.includes("A")) { + add({ path: filePath, changeType: "added" }); + } else { + add({ path: filePath, changeType: "modified" }); + } + } + } catch { + // ignore + } + + return result; + } catch { + return []; + } +} + +/** + * Ranks a file list so that recently-changed files appear first. + * Files present in `changedFiles` are placed at the front (in their + * original changedFiles order), followed by unchanged files in their + * original order. + */ +export function rankFilesByRelevance( + files: string[], + changedFiles: string[], +): string[] { + const changedSet = new Set(changedFiles); + const changed: string[] = []; + const rest: string[] = []; + + for (const f of files) { + if (changedSet.has(f)) { + changed.push(f); + } else { + rest.push(f); + } + } + + // Maintain changedFiles priority order within the changed group + const changedOrder = new Map(changedFiles.map((f, i) => [f, i])); + changed.sort((a, b) => (changedOrder.get(a) ?? 0) - (changedOrder.get(b) ?? 0)); + + return [...changed, ...rest]; +} diff --git a/src/resources/extensions/gsd/github-client.ts b/src/resources/extensions/gsd/github-client.ts new file mode 100644 index 000000000..54b64fe37 --- /dev/null +++ b/src/resources/extensions/gsd/github-client.ts @@ -0,0 +1,235 @@ +/** + * GSD GitHub Client + * + * Standalone utility for interacting with GitHub's API via Octokit. + * Provides helpers for PR creation, review reading, and issue management. + * Can be used by other extensions that need GitHub integration. + */ + +import { execSync } from "node:child_process"; +import { Octokit } from "@octokit/rest"; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export interface RepoInfo { + owner: string; + repo: string; +} + +export interface PullRequestOptions { + owner: string; + repo: string; + title: string; + body: string; + head: string; + base: string; +} + +export interface PullRequestResult { + number: number; + url: string; +} + +export interface PR { + number: number; + title: string; + body: string | null; + state: string; + head: { ref: string; sha: string }; + base: { ref: string }; + url: string; + user: { login: string } | null; +} + +export interface Review { + id: number; + user: { login: string } | null; + state: string; + body: string | null; + submitted_at: string | null; +} + +export interface IssueCommentOptions { + owner: string; + repo: string; + number: number; + body: string; +} + +// ─── Remote URL Parsing ──────────────────────────────────────────────────── + +/** + * Parse a GitHub owner/repo from a git remote URL. + * Supports both HTTPS and SSH formats: + * https://github.com/owner/repo.git + * git@github.com:owner/repo.git + * https://github.com/owner/repo + * ssh://git@github.com/owner/repo.git + */ +export function parseRemoteUrl(url: string): RepoInfo | null { + // SSH format: git@github.com:owner/repo.git + const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/); + if (sshMatch) { + return { owner: sshMatch[1], repo: sshMatch[2] }; + } + + // HTTPS or ssh:// format + const httpsMatch = url.match( + /(?:https?|ssh):\/\/(?:[^@]+@)?github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/, + ); + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2] }; + } + + return null; +} + +// ─── Client Creation ─────────────────────────────────────────────────────── + +/** + * Create an authenticated Octokit client. + * Uses the provided token, or falls back to GITHUB_TOKEN / GH_TOKEN env vars. + * Returns null if no token is available. + */ +export function createGitHubClient(token?: string): Octokit | null { + const auth = token || process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (!auth) { + return null; + } + return new Octokit({ auth }); +} + +// ─── Repository Info ─────────────────────────────────────────────────────── + +/** + * Detect the GitHub owner/repo from the git remote in the given working directory. + */ +export async function getRepoInfo(cwd: string): Promise { + try { + const url = execSync("git config --get remote.origin.url", { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + + if (!url) return null; + return parseRemoteUrl(url); + } catch { + return null; + } +} + +// ─── Pull Request Operations ─────────────────────────────────────────────── + +/** + * Create a pull request on GitHub. + */ +export async function createPullRequest( + client: Octokit, + options: PullRequestOptions, +): Promise { + try { + const { data } = await client.pulls.create({ + owner: options.owner, + repo: options.repo, + title: options.title, + body: options.body, + head: options.head, + base: options.base, + }); + return { number: data.number, url: data.html_url }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Unknown error"; + throw new Error( + `Failed to create pull request for ${options.owner}/${options.repo}: ${message}`, + ); + } +} + +/** + * Fetch a single pull request by number. + */ +export async function getPullRequest( + client: Octokit, + options: { owner: string; repo: string; number: number }, +): Promise { + try { + const { data } = await client.pulls.get({ + owner: options.owner, + repo: options.repo, + pull_number: options.number, + }); + return { + number: data.number, + title: data.title, + body: data.body, + state: data.state, + head: { ref: data.head.ref, sha: data.head.sha }, + base: { ref: data.base.ref }, + url: data.html_url, + user: data.user ? { login: data.user.login } : null, + }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Unknown error"; + throw new Error( + `Failed to get pull request #${options.number} for ${options.owner}/${options.repo}: ${message}`, + ); + } +} + +/** + * List reviews on a pull request. + */ +export async function listPullRequestReviews( + client: Octokit, + options: { owner: string; repo: string; number: number }, +): Promise { + try { + const { data } = await client.pulls.listReviews({ + owner: options.owner, + repo: options.repo, + pull_number: options.number, + }); + return data.map((review) => ({ + id: review.id, + user: review.user ? { login: review.user.login } : null, + state: review.state, + body: review.body, + submitted_at: review.submitted_at ?? null, + })); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Unknown error"; + throw new Error( + `Failed to list reviews for PR #${options.number} in ${options.owner}/${options.repo}: ${message}`, + ); + } +} + +// ─── Issue Comments ──────────────────────────────────────────────────────── + +/** + * Create a comment on an issue or pull request. + */ +export async function createIssueComment( + client: Octokit, + options: IssueCommentOptions, +): Promise<{ id: number }> { + try { + const { data } = await client.issues.createComment({ + owner: options.owner, + repo: options.repo, + issue_number: options.number, + body: options.body, + }); + return { id: data.id }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Unknown error"; + throw new Error( + `Failed to create comment on issue #${options.number} in ${options.owner}/${options.repo}: ${message}`, + ); + } +} diff --git a/src/resources/extensions/gsd/tests/diff-context.test.ts b/src/resources/extensions/gsd/tests/diff-context.test.ts new file mode 100644 index 000000000..f789a91a2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/diff-context.test.ts @@ -0,0 +1,136 @@ +/** + * Unit tests for diff-context.ts — diff-aware context module. + * Tests git-based file discovery and relevance ranking. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { tmpdir } from "node:os"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; + +import { + getRecentlyChangedFiles, + getChangedFilesWithContext, + rankFilesByRelevance, +} from "../diff-context.js"; + +// ─── getRecentlyChangedFiles ──────────────────────────────────────────────── + +describe("diff-context: getRecentlyChangedFiles", () => { + it("returns an array of file paths in the current git repo", async () => { + // Use the project root — guaranteed to be a git repo + const cwd = process.cwd(); + const files = await getRecentlyChangedFiles(cwd); + + assert.ok(Array.isArray(files), "should return an array"); + // The result may be empty if the repo is totally clean with no recent + // commits, but the function should not throw. + }); + + it("respects maxFiles option", async () => { + const cwd = process.cwd(); + const files = await getRecentlyChangedFiles(cwd, { maxFiles: 3 }); + + assert.ok(files.length <= 3, "should not exceed maxFiles"); + }); + + it("returns empty array for non-git directory", async () => { + const tmp = mkdtempSync(join(tmpdir(), "diff-ctx-test-")); + const files = await getRecentlyChangedFiles(tmp); + + assert.deepStrictEqual(files, [], "should return empty array for non-git dir"); + }); + + it("returns deduplicated paths", async () => { + const cwd = process.cwd(); + const files = await getRecentlyChangedFiles(cwd, { maxFiles: 100 }); + const unique = new Set(files); + + assert.equal(files.length, unique.size, "should have no duplicates"); + }); +}); + +// ─── getChangedFilesWithContext ───────────────────────────────────────────── + +describe("diff-context: getChangedFilesWithContext", () => { + it("returns array of ChangedFileInfo objects", async () => { + const cwd = process.cwd(); + const infos = await getChangedFilesWithContext(cwd); + + assert.ok(Array.isArray(infos), "should return an array"); + + for (const info of infos) { + assert.ok(typeof info.path === "string", "path should be a string"); + assert.ok( + ["modified", "added", "deleted", "staged"].includes(info.changeType), + `changeType should be valid, got: ${info.changeType}`, + ); + if (info.linesChanged !== undefined) { + assert.ok(typeof info.linesChanged === "number", "linesChanged should be a number"); + } + } + }); + + it("returns empty array for non-git directory", async () => { + const tmp = mkdtempSync(join(tmpdir(), "diff-ctx-test2-")); + const infos = await getChangedFilesWithContext(tmp); + + assert.deepStrictEqual(infos, [], "should return empty array for non-git dir"); + }); +}); + +// ─── rankFilesByRelevance ─────────────────────────────────────────────────── + +describe("diff-context: rankFilesByRelevance", () => { + it("places changed files before unchanged files", () => { + const allFiles = ["a.ts", "b.ts", "c.ts", "d.ts"]; + const changed = ["c.ts", "a.ts"]; + + const ranked = rankFilesByRelevance(allFiles, changed); + + // Changed files come first, sorted by changedFiles priority (c before a) + assert.equal(ranked[0], "c.ts"); + assert.equal(ranked[1], "a.ts"); + // Unchanged files follow in original order + assert.equal(ranked[2], "b.ts"); + assert.equal(ranked[3], "d.ts"); + }); + + it("preserves order of changed files based on changedFiles priority", () => { + const allFiles = ["x.ts", "y.ts", "z.ts", "w.ts"]; + const changed = ["z.ts", "x.ts"]; // z has higher priority (index 0) + + const ranked = rankFilesByRelevance(allFiles, changed); + + assert.equal(ranked[0], "z.ts", "z.ts should be first (higher priority in changedFiles)"); + assert.equal(ranked[1], "x.ts", "x.ts should be second"); + }); + + it("returns unchanged files in original order when no changed files match", () => { + const allFiles = ["a.ts", "b.ts", "c.ts"]; + const changed = ["x.ts", "y.ts"]; // none match + + const ranked = rankFilesByRelevance(allFiles, changed); + + assert.deepStrictEqual(ranked, ["a.ts", "b.ts", "c.ts"]); + }); + + it("handles empty inputs gracefully", () => { + assert.deepStrictEqual(rankFilesByRelevance([], []), []); + assert.deepStrictEqual(rankFilesByRelevance(["a.ts"], []), ["a.ts"]); + assert.deepStrictEqual(rankFilesByRelevance([], ["a.ts"]), []); + }); + + it("handles all files being changed", () => { + const allFiles = ["a.ts", "b.ts"]; + const changed = ["b.ts", "a.ts"]; + + const ranked = rankFilesByRelevance(allFiles, changed); + + // Both are changed, so sorted by changedFiles order: b first, then a + assert.equal(ranked[0], "b.ts"); + assert.equal(ranked[1], "a.ts"); + assert.equal(ranked.length, 2); + }); +}); diff --git a/src/resources/extensions/gsd/token-counter.ts b/src/resources/extensions/gsd/token-counter.ts new file mode 100644 index 000000000..28b80519d --- /dev/null +++ b/src/resources/extensions/gsd/token-counter.ts @@ -0,0 +1,42 @@ +import type { Tiktoken } from "tiktoken"; + +let encoder: Tiktoken | null = null; +let encoderFailed = false; + +async function getEncoder(): Promise { + if (encoder) return encoder; + if (encoderFailed) return null; + try { + const { encoding_for_model } = await import("tiktoken"); + encoder = encoding_for_model("gpt-4o"); + return encoder; + } catch { + encoderFailed = true; + return null; + } +} + +export async function countTokens(text: string): Promise { + const enc = await getEncoder(); + if (enc) { + const tokens = enc.encode(text); + return tokens.length; + } + return Math.ceil(text.length / 4); +} + +export function countTokensSync(text: string): number { + if (encoder) { + return encoder.encode(text).length; + } + return Math.ceil(text.length / 4); +} + +export async function initTokenCounter(): Promise { + const enc = await getEncoder(); + return enc !== null; +} + +export function isAccurateCountingAvailable(): boolean { + return encoder !== null; +} diff --git a/src/tests/github-client.test.ts b/src/tests/github-client.test.ts new file mode 100644 index 000000000..41b9ed52f --- /dev/null +++ b/src/tests/github-client.test.ts @@ -0,0 +1,150 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + parseRemoteUrl, + createGitHubClient, + getRepoInfo, +} from "../resources/extensions/gsd/github-client.ts"; + +describe("parseRemoteUrl — extracts owner/repo from git remote URLs", () => { + it("parses HTTPS URL with .git suffix", () => { + const result = parseRemoteUrl("https://github.com/octocat/hello-world.git"); + assert.deepEqual(result, { owner: "octocat", repo: "hello-world" }); + }); + + it("parses HTTPS URL without .git suffix", () => { + const result = parseRemoteUrl("https://github.com/octocat/hello-world"); + assert.deepEqual(result, { owner: "octocat", repo: "hello-world" }); + }); + + it("parses SSH URL with .git suffix", () => { + const result = parseRemoteUrl("git@github.com:octocat/hello-world.git"); + assert.deepEqual(result, { owner: "octocat", repo: "hello-world" }); + }); + + it("parses SSH URL without .git suffix", () => { + const result = parseRemoteUrl("git@github.com:octocat/hello-world"); + assert.deepEqual(result, { owner: "octocat", repo: "hello-world" }); + }); + + it("parses ssh:// protocol URL", () => { + const result = parseRemoteUrl( + "ssh://git@github.com/octocat/hello-world.git", + ); + assert.deepEqual(result, { owner: "octocat", repo: "hello-world" }); + }); + + it("handles repos with hyphens and underscores", () => { + const result = parseRemoteUrl( + "https://github.com/my-org/my_cool-repo.git", + ); + assert.deepEqual(result, { owner: "my-org", repo: "my_cool-repo" }); + }); + + it("returns null for non-GitHub URLs", () => { + const result = parseRemoteUrl("https://gitlab.com/owner/repo.git"); + assert.equal(result, null); + }); + + it("returns null for malformed URLs", () => { + assert.equal(parseRemoteUrl("not-a-url"), null); + assert.equal(parseRemoteUrl(""), null); + }); + + it("returns null for bare paths", () => { + assert.equal(parseRemoteUrl("/home/user/repo.git"), null); + }); +}); + +describe("createGitHubClient — Octokit instantiation", () => { + it("returns null when no token is provided and env vars are unset", () => { + const origGH = process.env.GITHUB_TOKEN; + const origGH2 = process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + + try { + const client = createGitHubClient(); + assert.equal(client, null); + } finally { + if (origGH !== undefined) process.env.GITHUB_TOKEN = origGH; + if (origGH2 !== undefined) process.env.GH_TOKEN = origGH2; + } + }); + + it("creates a client when a token is provided directly", () => { + const client = createGitHubClient("ghp_test123"); + assert.notEqual(client, null); + assert.equal(typeof client!.pulls, "object"); + assert.equal(typeof client!.issues, "object"); + }); + + it("creates a client from GITHUB_TOKEN env var", () => { + const origGH = process.env.GITHUB_TOKEN; + const origGH2 = process.env.GH_TOKEN; + delete process.env.GH_TOKEN; + process.env.GITHUB_TOKEN = "ghp_env_test"; + + try { + const client = createGitHubClient(); + assert.notEqual(client, null); + } finally { + if (origGH !== undefined) { + process.env.GITHUB_TOKEN = origGH; + } else { + delete process.env.GITHUB_TOKEN; + } + if (origGH2 !== undefined) process.env.GH_TOKEN = origGH2; + } + }); + + it("creates a client from GH_TOKEN env var", () => { + const origGH = process.env.GITHUB_TOKEN; + const origGH2 = process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + process.env.GH_TOKEN = "ghp_gh_token_test"; + + try { + const client = createGitHubClient(); + assert.notEqual(client, null); + } finally { + if (origGH !== undefined) process.env.GITHUB_TOKEN = origGH; + if (origGH2 !== undefined) { + process.env.GH_TOKEN = origGH2; + } else { + delete process.env.GH_TOKEN; + } + } + }); + + it("prefers explicit token over env vars", () => { + const origGH = process.env.GITHUB_TOKEN; + process.env.GITHUB_TOKEN = "ghp_from_env"; + + try { + const client = createGitHubClient("ghp_explicit"); + assert.notEqual(client, null); + } finally { + if (origGH !== undefined) { + process.env.GITHUB_TOKEN = origGH; + } else { + delete process.env.GITHUB_TOKEN; + } + } + }); +}); + +describe("getRepoInfo — detects repo from git working directory", () => { + it("returns owner/repo for the current repository", async () => { + const info = await getRepoInfo(process.cwd()); + // This test repo is gsd-build/gsd-2 + assert.notEqual(info, null); + assert.equal(info!.owner, "gsd-build"); + assert.equal(info!.repo, "gsd-2" /* or GSD-2 depending on remote */); + }); + + it("returns null for a non-git directory", async () => { + const info = await getRepoInfo("/tmp"); + assert.equal(info, null); + }); +}); diff --git a/src/tests/token-counter.test.ts b/src/tests/token-counter.test.ts new file mode 100644 index 000000000..5cfd98696 --- /dev/null +++ b/src/tests/token-counter.test.ts @@ -0,0 +1,54 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + countTokens, + countTokensSync, + initTokenCounter, + isAccurateCountingAvailable, +} from "../resources/extensions/gsd/token-counter.ts"; + +describe("token-counter", () => { + it("countTokensSync returns heuristic estimate before init", () => { + const count = countTokensSync("hello world"); + assert.equal(count, Math.ceil("hello world".length / 4)); + }); + + it("initTokenCounter initializes the encoder", async () => { + const result = await initTokenCounter(); + assert.equal(typeof result, "boolean"); + }); + + it("countTokens returns a positive number for non-empty text", async () => { + const count = await countTokens("The quick brown fox jumps over the lazy dog."); + assert.ok(count > 0, "should return positive token count"); + }); + + it("countTokens returns 0 for empty string", async () => { + const count = await countTokens(""); + assert.equal(count, 0); + }); + + it("isAccurateCountingAvailable reflects encoder state", () => { + const available = isAccurateCountingAvailable(); + assert.equal(typeof available, "boolean"); + }); + + it("countTokensSync gives accurate count after init", async () => { + await initTokenCounter(); + if (isAccurateCountingAvailable()) { + const syncCount = countTokensSync("hello world"); + const asyncCount = await countTokens("hello world"); + assert.equal(syncCount, asyncCount, "sync and async should match after init"); + } + }); + + it("token count is more accurate than chars/4 for code", async () => { + await initTokenCounter(); + if (isAccurateCountingAvailable()) { + const code = 'function add(a: number, b: number): number { return a + b; }'; + const tokens = await countTokens(code); + const heuristic = Math.ceil(code.length / 4); + assert.ok(tokens !== heuristic, "tiktoken count should differ from simple heuristic for code"); + } + }); +});