Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions lib/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
const GITHUB_API_URL = "https://api.github.com";
const RETRY_DELAY_MS = 1000;
const RATE_LIMIT_PADDING_MS = 3000;

type GitHubRequestParams = Record<string, string | number | boolean | undefined>;

export interface GitHubRequestOptions extends RequestInit {
params?: GitHubRequestParams;
maxAttempts?: number;
retry?: boolean;
shouldRetry?: (context: {response: Response; attempt: number; maxAttempts: number; url: string}) => Promise<boolean> | boolean;
}

export async function callGitHubApi(path: string, options: GitHubRequestOptions = {}): Promise<Response> {
const token = getToken();
const {params, maxAttempts = 3, retry = true, shouldRetry, headers: customHeaders, ...fetchOptions} = options;

const url = buildGitHubUrl(path, params);
const headers = new Headers({
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
});

if (customHeaders) {
new Headers(customHeaders).forEach((value, key) => {
headers.set(key, value);
});
}

const attempts = retry ? maxAttempts : 1;

for (let attempt = 1; attempt <= attempts; attempt += 1) {
const response = await fetch(url, {
...fetchOptions,
headers,
});

if (isRateLimitStatus(response.status)) {
await handleRateLimit(response);
continue;
}

if (!response.ok) {
if (attempt >= attempts) {
throw new Error(`GitHub API request failed for ${url} (attempt ${attempt}/${attempts}) with status ${response.status}: ${response.statusText}`);
}

await wait(RETRY_DELAY_MS);
continue;
}

if (retry && shouldRetry && attempt < attempts) {
const shouldRetryResult = await shouldRetry({
response: response.clone(),
attempt,
maxAttempts,
url,
});

if (shouldRetryResult) {
await wait(RETRY_DELAY_MS);
continue;
}
}

return response;
}

throw new Error(`GitHub API request failed after ${attempts} attempts for ${url}`);
}

function getToken(): string {
const token = process.env.GITHUB_API_TOKEN;

if (!token) {
throw new Error("Missing GITHUB_API_TOKEN environment variable");
}

return token;
}

function buildGitHubUrl(path: string, params?: GitHubRequestParams): string {
const baseUrl = path.startsWith("http") ? new URL(path) : new URL(path.startsWith("/") ? path : `/${path}`, GITHUB_API_URL);

if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value === undefined) return;
baseUrl.searchParams.set(key, String(value));
});
}

return baseUrl.toString();
}

function isRateLimitStatus(status: number) {
return status === 403 || status === 429;
}

async function handleRateLimit(response: Response): Promise<void> {
const resetAt = getRateLimitReset(response);
if (resetAt) {
const waitTime = Math.max(resetAt - Date.now(), 0) + RATE_LIMIT_PADDING_MS;
console.warn(`Rate limited by GitHub (status ${response.status}). Waiting ${waitTime}ms until reset.`);
await wait(waitTime);
return;
}

const retryAfter = parseRetryAfter(response.headers.get("Retry-After"));
if (retryAfter !== null) {
console.warn(`Rate limited by GitHub (status ${response.status}). Waiting ${retryAfter}ms due to Retry-After header.`);
await wait(retryAfter);
return;
}

console.warn(`Rate limited by GitHub (status ${response.status}). Waiting ${RETRY_DELAY_MS}ms before retrying.`);
await wait(RETRY_DELAY_MS);
}

function getRateLimitReset(response: Response): number | null {
const headerValue = response.headers.get("X-RateLimit-Reset");
if (!headerValue) return null;

const epochSeconds = Number(headerValue);
if (Number.isNaN(epochSeconds)) {
return null;
}

return epochSeconds * 1000;
}

function parseRetryAfter(headerValue: string | null): number | null {
if (!headerValue) return null;

const numericValue = Number(headerValue);
if (!Number.isNaN(numericValue)) {
return numericValue * 1000;
}

const retryDate = new Date(headerValue);
const waitTime = retryDate.getTime() - Date.now();
return Number.isNaN(waitTime) ? null : Math.max(waitTime, 0);
}

function wait(duration: number) {
return new Promise((resolve) => setTimeout(resolve, duration));
}
27 changes: 27 additions & 0 deletions lib/snippets/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export const MAX_LINES = 11;
export const MIN_LINES = 3;

export const MAX_LINE_LENGTH = 72;

export const MAX_SNIPPET_LENGTH = 350;
export const MIN_SNIPPET_LENGTH = 75;

export const VALID_NODES = [
"function_declaration",
"function_definition",
"class_declaration",
"class_definition",
"function_item",
"method_declaration",
"method_definition",
"method_item",
"class_item",
"interface_declaration",
"interface_definition",
"interface_item",
"module",
"switch_statement",
"while_statement",
"for_statement",
"if_statement",
];
26 changes: 26 additions & 0 deletions lib/snippets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {extractSnippets} from "./parsing/parser";
import {filterSnippets} from "./processing/filter";
import {formatCode} from "./processing/process";
import {hashSnippet} from "./processing/utils";

interface ISnippetOutput {
sha: string;
content: string;
rawContent: string;
}

export function getSnippetsFromFile(fileContent: string, languageId: string): ISnippetOutput[] {
const extractedSnippets = extractSnippets(fileContent, languageId);

const formattedSnippets = extractedSnippets
.map((snippet) => ({
sha: hashSnippet(snippet),
content: formatCode(snippet),
rawContent: snippet,
}))
.filter((s) => s.content !== null) as ISnippetOutput[];

const filteredSnippets = formattedSnippets.filter((snippet) => filterSnippets(snippet.content));

return filteredSnippets;
}
46 changes: 46 additions & 0 deletions lib/snippets/parsing/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {getInitialIndentation} from "@lib/snippets/processing/indentation";
import {getTSParser} from "./parser-factory";
import IParser from "tree-sitter";
import {isValidNode} from "@lib/snippets/processing/filter";

export function findValidNodes(node: IParser.SyntaxNode): IParser.SyntaxNode[] {
let nodes: IParser.SyntaxNode[] = [];
if (isValidNode(node)) {
nodes.push(node);
}

node.children.forEach((child) => {
if (child === null) return;
nodes = nodes.concat(findValidNodes(child));
});

return nodes;
}

export function convertSnippetToText(node: IParser.SyntaxNode, sourceCode: string): string {
const initialIndentation = getInitialIndentation(node.startIndex, sourceCode);

return initialIndentation + getNodeText(node);
}

export function extractSnippets(fileContent: string, languageId: string): string[] {
const parser = getTSParser(languageId);

let parsedCode: IParser.Tree;

try {
parsedCode = parser.parse(fileContent);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
return [];
}

const validNodes = findValidNodes(parsedCode.rootNode);
const validSnippets = validNodes.map((node) => convertSnippetToText(node, fileContent));

return validSnippets;
}

export function getNodeText(node: IParser.SyntaxNode): string {
return node.text;
}
36 changes: 36 additions & 0 deletions lib/snippets/processing/filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import IParser from "tree-sitter";
import {MAX_LINE_LENGTH, MAX_LINES, MAX_SNIPPET_LENGTH, MIN_LINES, MIN_SNIPPET_LENGTH, VALID_NODES} from "../config";

function filterLinesNumber(snippet: string): boolean {
const numLines = snippet.split("\n").length;
return numLines <= MAX_LINES && numLines >= MIN_LINES;
}

function filterLineLength(snippet: string): boolean {
const lines = snippet.split("\n");
return lines.every((line) => line.trim().length <= MAX_LINE_LENGTH);
}

function filterSnippetLength(snippet: string): boolean {
return snippet.length >= MIN_SNIPPET_LENGTH && snippet.length <= MAX_SNIPPET_LENGTH;
}

function filterSnippetSpecialCharacters(snippet: string): boolean {
const regex = /^[\x00-\x7F]+$/;
const test = regex.test(snippet);

return test;
}
Comment on lines +18 to 23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

ASCII-only regex triggers Biome warning and could be modernized

filterSnippetSpecialCharacters uses /^[\x00-\x7F]+$/, which Biome flags (noControlCharactersInRegex) and enforces strict ASCII-only content. If you want “ASCII-only” semantics, you can avoid explicit control-character ranges and satisfy the linter with Unicode properties:

-function filterSnippetSpecialCharacters(snippet: string): boolean {
-  const regex = /^[\x00-\x7F]+$/;
-  const test = regex.test(snippet);
-
-  return test;
-}
+function filterSnippetSpecialCharacters(snippet: string): boolean {
+  // Allow only ASCII characters.
+  const regex = /^[\p{ASCII}]+$/u;
+  return regex.test(snippet);
+}

Also double‑check that rejecting any non‑ASCII characters (identifiers, comments, emojis, etc.) is intentional, as this will exclude many valid snippets.










🏁 Script executed:

#!/bin/bash
# Verify file exists and inspect the function
cat lib/snippets/processing/filter.ts

Length of output: 1255


🏁 Script executed:

#!/bin/bash
# Search for Biome configuration files
find . -name ".biomeignore" -o -name "biome.json" -o -name "biome.jsonc" 2>/dev/null | head -20

Length of output: 12


🏁 Script executed:

#!/bin/bash
# Find usages of filterSnippetSpecialCharacters to understand purpose
rg -n "filterSnippetSpecialCharacters" --type ts --type js

Length of output: 637


🏁 Script executed:

#!/bin/bash
# Check TypeScript configuration for target/lib settings
cat tsconfig.json 2>/dev/null || cat tsconfig.*.json 2>/dev/null | head -50

Length of output: 746


🏁 Script executed:

#!/bin/bash
# Look for any linting output or CI config that might show Biome warnings
find . -name ".github" -type d | xargs -I {} find {} -name "*.yml" -o -name "*.yaml" 2>/dev/null | xargs cat 2>/dev/null | grep -i biome -A 5 -B 5 | head -50

Length of output: 0


🌐 Web query:

Does TypeScript/JavaScript support \p{ASCII} Unicode property in regex?

💡 Result:

Yes — JavaScript (and therefore TypeScript at runtime) supports the Unicode binary property ASCII via Unicode property escapes. Use the u (Unicode) flag, e.g. /\p{ASCII}/u. [1][2]

Sources:
[1] MDN — Unicode character class escape (\p{...}, \P{...}).
[2] MDN (Chinese) — notes listing binary properties including ASCII.


🌐 Web query:

What does Biome noControlCharactersInRegex rule flag?

💡 Result:

The Biome rule noControlCharactersInRegex flags control (non-printable) ASCII characters in regular expressions — i.e. characters U+0000–U+001F — when they appear as:

  • hex escapes (\x00–\x1F)
  • Unicode escapes (\u0000–\u001F)
  • Unicode code point escapes (\u{0}–\u{1F})
  • unescaped raw characters U+0000–U+001F

It allows common control escape sequences like \t and \n. [1][2]



---

🏁 Script executed:

```shell
#!/bin/bash
# Inspect Biome configuration
cat biome.json

Length of output: 1774


🏁 Script executed:

#!/bin/bash
# Check if Next.js project uses custom Babel config or build tools
ls -la | grep -i babel
cat .babelrc* 2>/dev/null || cat babel.config.* 2>/dev/null

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for context around snippet filtering — comments or docs explaining ASCII requirement
rg -n "ASCII|control|character|\\\\x00" --type ts --type js lib/snippets src/features/snippets -B 2 -A 2

Length of output: 3868


🏁 Script executed:

#!/bin/bash
# Check if project has a next.config or similar that might affect regex compilation
cat next.config.* 2>/dev/null | head -100

Length of output: 594


The suggested Unicode property syntax may not work with your ES2017 TypeScript target without additional transpilation config

The Biome warning (noControlCharactersInRegex) is real and correctly triggered by /^[\x00-\x7F]+$/. However, the suggested fix /^[\p{ASCII}]+$/u uses Unicode properties, which were added in ES2018—your tsconfig targets ES2017.

To satisfy Biome without risking transpilation issues, simplify the regex to avoid explicit control-character ranges:

function filterSnippetSpecialCharacters(snippet: string): boolean {
-  const regex = /^[\x00-\x7F]+$/;
-  const test = regex.test(snippet);
-
-  return test;
+  // Allow only printable ASCII characters (0x20–0x7E).
+  const regex = /^[ -~]*$/;
+  return regex.test(snippet);
}

This avoids control characters entirely and satisfies Biome without transpilation concerns. Alternatively, confirm your build system handles Unicode property transpilation, then the /^\p{ASCII}+$/u approach works.

Also verify that rejecting all non-ASCII content (identifiers, comments, emojis, etc.) is intentional—this filter may be too restrictive for valid snippets.

🧰 Tools
🪛 Biome (2.1.2)

[error] 19-19: Unexpected control character in a regular expression.

Control characters are unusual and potentially incorrect inputs, so they are disallowed.

(lint/suspicious/noControlCharactersInRegex)

🤖 Prompt for AI Agents
In lib/snippets/processing/filter.ts around lines 18-23, the current regex
/^[\x00-\x7F]+$/ triggers Biome's noControlCharactersInRegex and the suggested
Unicode property /\p{ASCII}/u requires ES2018+, so either replace the regex with
a printable-ASCII-only pattern (e.g. use the visible ASCII range to exclude
control characters, such as /^[ -~]+$/) to satisfy Biome without changing
transpilation, or if you truly need full ASCII control characters support,
update the project target to ES2018+ (or enable Unicode property transpilation)
and switch to /^\p{ASCII}+$/u; also confirm that rejecting all non-ASCII input
is intentional before applying the change.


function filterTabsInBetween(snippet: string): boolean {
const regex = /^.+\\t.+$/;
return !regex.test(snippet);
}

export function isValidNode(node: IParser.SyntaxNode) {
return VALID_NODES.includes(node.type);
}

export function filterSnippets(content: string): boolean {
return filterSnippetLength(content) && filterLinesNumber(content) && filterLineLength(content) && filterSnippetSpecialCharacters(content) && filterTabsInBetween(content);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {countInitialWhitespaces, removeInitialCharacters} from "./text.server";
import {countInitialWhitespaces, removeInitialCharacters} from "./utils";

type IIndentationStyle = {type: "none" | "mixed" | "tab"} | {type: "space"; value: number[]};
export function detectIndentationStyle(snippet: string): IIndentationStyle {
Expand Down
54 changes: 54 additions & 0 deletions lib/snippets/processing/process.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {adjustIndentationOffset, applyIndentationToEmptyLines, detectIndentationStyle} from "./indentation";
import {countInitialWhitespaces} from "./utils";

function cleanText(text: string): string {
// Remove whitespaces from empty lines
text = text.replace(/(?<=\n)[ \t]+(?=\n)/g, "");

return text;
}

function hasProhibitedCharacters(input: string): boolean {
const regex = /((?:(?![ \t\n])\s)|\\)/;
return regex.test(input);
}

export function formatCode(snippet: string): string | null {
if (hasProhibitedCharacters(snippet)) {
return null;
}

// Clean raw snippet
snippet = cleanText(snippet);

const indentationStyle = detectIndentationStyle(snippet);

if (indentationStyle.type === "mixed") {
return null;
}

if (indentationStyle.type === "none") {
return snippet;
}

// Convert raw snippet indentation style with standardized '\t' indentation
let codeLines = snippet.split("\n").map((line) => {
const initialWhitespaces = countInitialWhitespaces(line);

const trimmedLine = line.trim();

if (indentationStyle.type === "space") {
return "\t".repeat(indentationStyle.value.indexOf(initialWhitespaces)) + trimmedLine;
}

return "\t".repeat(initialWhitespaces) + trimmedLine;
});

// Adjust indentation offset on every line
codeLines = adjustIndentationOffset(codeLines);

// Applies indentation to empty lines (the minimum indentation of the surrounding lines)
codeLines = applyIndentationToEmptyLines(codeLines);

return codeLines.join("\n");
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {createHash} from "crypto";

export function countInitialWhitespaces(text: string): number {
let count = 0;
for (let i = 0; i < text.length; i++) {
Expand All @@ -21,3 +23,10 @@ export function removeInitialCharacters(lines: string[], quantity: number): stri

return newLines;
}

export function hashSnippet(content: string): string {
// Remove all whitespaces
const newContent = content.replace(/\s+/g, "").trim();

return createHash("sha256").update(newContent).digest("hex");
}
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading