utils.ts•4.9 kB
import { getUserAgent } from "universal-user-agent";
import { createGitHubError, GitHubTimeoutError, GitHubNetworkError, GitHubError, createEnhancedGitHubError } from "./errors.js";
import { VERSION } from "./version.js";
type RequestOptions = {
  method?: string;
  body?: unknown;
  headers?: Record<string, string>;
}
async function parseResponseBody(response: Response): Promise<unknown> {
  const contentType = response.headers.get("content-type");
  if (contentType?.includes("application/json")) {
    try {
      return await response.json();
    } catch (error) {
      console.error("Error parsing JSON response:", error);
      throw new Error(`Error parsing JSON response: ${error}`);
    }
  }
  return response.text();
}
export function buildUrl(baseUrl: string, params: Record<string, string | number | undefined>): string {
  const url = new URL(baseUrl);
  Object.entries(params).forEach(([key, value]) => {
    if (value !== undefined) {
      url.searchParams.append(key, value.toString());
    }
  });
  return url.toString();
}
const USER_AGENT = `github-actions-mcp/v${VERSION} ${getUserAgent()}`;
// Default timeout for GitHub API requests (30 seconds)
const DEFAULT_TIMEOUT = 30000;
// Rate limiting constants
const MAX_REQUESTS_PER_MINUTE = 60; // GitHub API rate limit is typically 5000/hour for authenticated requests
let requestCount = 0;
let requestCountResetTime = Date.now() + 60000;
/**
 * Make a request to the GitHub API with security enhancements
 * 
 * @param url The URL to send the request to
 * @param options Request options including method, body, headers, and timeout
 * @returns The response body
 */
export async function githubRequest(
  url: string,
  options: RequestOptions & { timeout?: number } = {}
): Promise<unknown> {
  // Implement basic rate limiting
  if (Date.now() > requestCountResetTime) {
    requestCount = 0;
    requestCountResetTime = Date.now() + 60000;
  }
  
  if (requestCount >= MAX_REQUESTS_PER_MINUTE) {
    const waitTime = requestCountResetTime - Date.now();
    throw new Error(`Rate limit exceeded. Please try again in ${Math.ceil(waitTime / 1000)} seconds.`);
  }
  requestCount++;
  // Validate URL to ensure it's a GitHub API URL (security measure)
  if (!url.startsWith('https://api.github.com/')) {
    throw new Error('Invalid GitHub API URL. Only https://api.github.com/ URLs are allowed.');
  }
  const headers: Record<string, string> = {
    "Accept": "application/vnd.github.v3+json",
    "Content-Type": "application/json",
    "User-Agent": USER_AGENT,
    ...options.headers,
  };
  if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) {
    headers["Authorization"] = `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`;
  }
  // Set up request timeout
  const timeout = options.timeout || DEFAULT_TIMEOUT;
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  try {
    const response = await fetch(url, {
      method: options.method || "GET",
      headers,
      body: options.body ? JSON.stringify(options.body) : undefined,
      signal: controller.signal
    });
    const responseBody = await parseResponseBody(response);
    if (!response.ok) {
      throw createGitHubError(response.status, responseBody);
    }
    return responseBody;
  } catch (error: unknown) {
    if ((error as Error).name === 'AbortError') {
      throw new GitHubTimeoutError(`Request timeout after ${timeout}ms`, timeout);
    }
    if ((error as { cause?: { code: string } }).cause?.code === 'ENOTFOUND' || 
        (error as { cause?: { code: string } }).cause?.code === 'ECONNREFUSED') {
      throw new GitHubNetworkError(`Unable to connect to GitHub API`, 
        (error as { cause?: { code: string } }).cause!.code);
    }
    if (!(error instanceof GitHubError)) {
      throw createEnhancedGitHubError(error as Error & { cause?: { code: string } });
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}
export function validateRepositoryName(name: string): string {
  const sanitized = name.trim().toLowerCase();
  if (!sanitized) {
    throw new Error("Repository name cannot be empty");
  }
  if (!/^[a-z0-9_.-]+$/.test(sanitized)) {
    throw new Error(
      "Repository name can only contain lowercase letters, numbers, hyphens, periods, and underscores"
    );
  }
  if (sanitized.startsWith(".") || sanitized.endsWith(".")) {
    throw new Error("Repository name cannot start or end with a period");
  }
  return sanitized;
}
export function validateOwnerName(owner: string): string {
  const sanitized = owner.trim().toLowerCase();
  if (!sanitized) {
    throw new Error("Owner name cannot be empty");
  }
  if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(sanitized)) {
    throw new Error(
      "Owner name must start with a letter or number and can contain up to 39 characters"
    );
  }
  return sanitized;
}