feat: add GitHub API client, diff-aware context, tiktoken token counting
- 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
This commit is contained in:
parent
0b3163d297
commit
973b8992e5
8 changed files with 1027 additions and 0 deletions
190
package-lock.json
generated
190
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
219
src/resources/extensions/gsd/diff-context.ts
Normal file
219
src/resources/extensions/gsd/diff-context.ts
Normal file
|
|
@ -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<string[]> {
|
||||
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<string>();
|
||||
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<ChangedFileInfo[]> {
|
||||
const dir = resolve(cwd);
|
||||
|
||||
try {
|
||||
const result: ChangedFileInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
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];
|
||||
}
|
||||
235
src/resources/extensions/gsd/github-client.ts
Normal file
235
src/resources/extensions/gsd/github-client.ts
Normal file
|
|
@ -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<RepoInfo | null> {
|
||||
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<PullRequestResult> {
|
||||
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<PR> {
|
||||
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<Review[]> {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
136
src/resources/extensions/gsd/tests/diff-context.test.ts
Normal file
136
src/resources/extensions/gsd/tests/diff-context.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
42
src/resources/extensions/gsd/token-counter.ts
Normal file
42
src/resources/extensions/gsd/token-counter.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { Tiktoken } from "tiktoken";
|
||||
|
||||
let encoder: Tiktoken | null = null;
|
||||
let encoderFailed = false;
|
||||
|
||||
async function getEncoder(): Promise<Tiktoken | null> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
const enc = await getEncoder();
|
||||
return enc !== null;
|
||||
}
|
||||
|
||||
export function isAccurateCountingAvailable(): boolean {
|
||||
return encoder !== null;
|
||||
}
|
||||
150
src/tests/github-client.test.ts
Normal file
150
src/tests/github-client.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
54
src/tests/token-counter.test.ts
Normal file
54
src/tests/token-counter.test.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue