app/lib/githubService.ts (496 lines of code) (raw):
// GitHub Issue Types
export interface GitHubIssue {
id: number;
number: number;
title: string;
body: string;
state: "open" | "closed";
user: {
login: string;
avatar_url: string;
};
labels: Array<{
name: string;
color: string;
}>;
created_at: string;
updated_at: string;
}
export interface GitHubUser {
login: string;
id: number;
avatar_url: string;
name: string;
email?: string;
bio?: string;
company?: string;
location?: string;
public_repos: number;
followers: number;
following: number;
}
export interface IssueMention {
number: number;
startIndex: number;
endIndex: number;
}
export interface AuthenticationStatus {
isAuthenticated: boolean;
user?: GitHubUser;
scopes?: string[];
rateLimit?: {
limit: number;
remaining: number;
reset: number;
};
}
// GitHub API Service
export class GitHubService {
private static issueCache = new Map<string, GitHubIssue>();
private static repositoryIssuesCache = new Map<
string,
{ data: GitHubIssue[]; timestamp: number }
>();
private static pendingRequests = new Map<string, Promise<any>>();
private static failedRequests = new Map<
string,
{ count: number; lastAttempt: number }
>();
private static readonly BASE_URL = "https://api.github.com";
private static readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
private static readonly MAX_RETRY_ATTEMPTS = 3;
private static readonly RETRY_BACKOFF_TIME = 10 * 60 * 1000; // 10 minutes
// Authentication properties
private static authToken: string | null = null;
private static authenticatedUser: GitHubUser | null = null;
private static authScopes: string[] = [];
// Authentication methods
static setAuthToken(token: string): void {
this.authToken = token;
// Clear caches when authentication changes as rate limits and access may differ
this.clearAllCaches();
}
static clearAuth(): void {
this.authToken = null;
this.authenticatedUser = null;
this.authScopes = [];
// Clear caches when authentication is removed
this.clearAllCaches();
}
static isAuthenticated(): boolean {
return this.authToken !== null;
}
static getAuthToken(): string | null {
return this.authToken;
}
static async getAuthenticatedUser(): Promise<GitHubUser | null> {
if (!this.isAuthenticated()) {
return null;
}
// Return cached user if available
if (this.authenticatedUser) {
return this.authenticatedUser;
}
try {
const response = await this.makeAuthenticatedRequest("/user");
if (response.ok) {
const user: GitHubUser = await response.json();
this.authenticatedUser = user;
return user;
}
} catch (error) {
console.warn("Failed to fetch authenticated user:", error);
}
return null;
}
static async getAuthenticationStatus(): Promise<AuthenticationStatus> {
if (!this.isAuthenticated()) {
return { isAuthenticated: false };
}
try {
const user = await this.getAuthenticatedUser();
const rateLimit = await this.getRateLimit();
return {
isAuthenticated: true,
user: user || undefined,
scopes: this.authScopes.length > 0 ? this.authScopes : undefined,
rateLimit,
};
} catch (error) {
console.warn("Failed to get authentication status:", error);
return { isAuthenticated: true }; // Token exists but might have limited info
}
}
static async getRateLimit(): Promise<
{ limit: number; remaining: number; reset: number } | undefined
> {
try {
const response = await this.makeRequest("/rate_limit");
if (response.ok) {
const data = await response.json();
return {
limit: data.rate.limit,
remaining: data.rate.remaining,
reset: data.rate.reset,
};
}
} catch (error) {
console.warn("Failed to fetch rate limit:", error);
}
return undefined;
}
// Enhanced request methods with authentication
private static async makeRequest(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
const url = endpoint.startsWith("http")
? endpoint
: `${this.BASE_URL}${endpoint}`;
const headers: HeadersInit = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
...options.headers,
};
// Add authentication header if token is available
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
return fetch(url, {
...options,
headers,
});
}
private static async makeAuthenticatedRequest(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
if (!this.isAuthenticated()) {
throw new Error("Authentication required for this request");
}
return this.makeRequest(endpoint, options);
}
// Enhanced issue fetching with authentication
static async getIssue(
repoUrl: string,
issueNumber: number
): Promise<GitHubIssue | null> {
const cacheKey = `${repoUrl}#${issueNumber}`;
// Check cache first
if (this.issueCache.has(cacheKey)) {
return this.issueCache.get(cacheKey)!;
}
// Check if this request has failed too many times
if (this.shouldSkipFailedRequest(cacheKey)) {
return null;
}
// Check if request is already pending to avoid duplicate requests
if (this.pendingRequests.has(cacheKey)) {
try {
return await this.pendingRequests.get(cacheKey);
} catch (error) {
return null;
}
}
// Create and store the promise
const requestPromise = this.fetchIssue(repoUrl, issueNumber);
this.pendingRequests.set(cacheKey, requestPromise);
try {
const issue = await requestPromise;
if (issue) {
this.issueCache.set(cacheKey, issue);
// Clear any previous failure record on success
this.failedRequests.delete(cacheKey);
} else {
// Track failed request (404 or other issues)
this.trackFailedRequest(cacheKey);
}
return issue;
} catch (error) {
console.warn("Failed to fetch GitHub issue:", error);
this.trackFailedRequest(cacheKey);
return null;
} finally {
this.pendingRequests.delete(cacheKey);
}
}
private static async fetchIssue(
repoUrl: string,
issueNumber: number
): Promise<GitHubIssue | null> {
const repoPath = this.extractRepoPath(repoUrl);
if (!repoPath) return null;
const response = await this.makeRequest(
`/repos/${repoPath}/issues/${issueNumber}`
);
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`GitHub API error: ${response.status}`);
}
const issue: GitHubIssue = await response.json();
return issue;
}
static async getRepositoryIssues(
repoUrl: string,
query?: string,
options?: {
state?: "open" | "closed" | "all";
sort?: "created" | "updated" | "comments";
direction?: "asc" | "desc";
per_page?: number;
page?: number;
}
): Promise<GitHubIssue[]> {
const cacheKey = `${repoUrl}:${query || ""}:${JSON.stringify(options || {})}`;
// Check cache with TTL
if (this.repositoryIssuesCache.has(cacheKey)) {
const cached = this.repositoryIssuesCache.get(cacheKey)!;
if (Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.data;
}
// Remove expired cache
this.repositoryIssuesCache.delete(cacheKey);
}
// Check if this request has failed too many times
if (this.shouldSkipFailedRequest(cacheKey)) {
return [];
}
// Check if request is already pending
if (this.pendingRequests.has(cacheKey)) {
try {
return await this.pendingRequests.get(cacheKey);
} catch (error) {
return [];
}
}
// Create and store the promise
const requestPromise = this.fetchRepositoryIssues(repoUrl, query, options);
this.pendingRequests.set(cacheKey, requestPromise);
try {
const issues = await requestPromise;
// Cache the results with timestamp
this.repositoryIssuesCache.set(cacheKey, {
data: issues,
timestamp: Date.now(),
});
// Also cache individual issues
issues.forEach((issue) => {
const issueCacheKey = `${repoUrl}#${issue.number}`;
if (!this.issueCache.has(issueCacheKey)) {
this.issueCache.set(issueCacheKey, issue);
}
});
// Clear any previous failure record on success
this.failedRequests.delete(cacheKey);
return issues;
} catch (error) {
console.warn("Failed to fetch repository issues:", error);
this.trackFailedRequest(cacheKey);
return [];
} finally {
this.pendingRequests.delete(cacheKey);
}
}
private static async fetchRepositoryIssues(
repoUrl: string,
query?: string,
options?: {
state?: "open" | "closed" | "all";
sort?: "created" | "updated" | "comments";
direction?: "asc" | "desc";
per_page?: number;
page?: number;
}
): Promise<GitHubIssue[]> {
const repoPath = this.extractRepoPath(repoUrl);
if (!repoPath) return [];
// Build query parameters
const params = new URLSearchParams({
state: options?.state || "open",
sort: options?.sort || "created",
direction: options?.direction || "desc",
per_page: (options?.per_page || 10).toString(),
page: (options?.page || 1).toString(),
});
const url = `/repos/${repoPath}/issues?${params.toString()}`;
const response = await this.makeRequest(url);
if (!response.ok) return [];
const issues = await response.json();
// Filter by number if query is provided
if (query && query.trim()) {
const queryNum = query.trim();
return issues.filter((issue: GitHubIssue) =>
issue.number.toString().startsWith(queryNum)
);
}
return issues;
}
// New authenticated methods
static async createIssue(
repoUrl: string,
title: string,
body?: string,
labels?: string[]
): Promise<GitHubIssue | null> {
if (!this.isAuthenticated()) {
throw new Error("Authentication required to create issues");
}
const repoPath = this.extractRepoPath(repoUrl);
if (!repoPath) return null;
try {
const response = await this.makeAuthenticatedRequest(
`/repos/${repoPath}/issues`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
body: body || "",
labels: labels || [],
}),
}
);
if (!response.ok) {
throw new Error(`Failed to create issue: ${response.status}`);
}
const issue: GitHubIssue = await response.json();
// Add to cache
const cacheKey = `${repoUrl}#${issue.number}`;
this.issueCache.set(cacheKey, issue);
// Clear repository issues cache to force refresh
this.clearRepositoryCache(repoUrl);
return issue;
} catch (error) {
console.error("Failed to create issue:", error);
return null;
}
}
static async updateIssue(
repoUrl: string,
issueNumber: number,
updates: {
title?: string;
body?: string;
state?: "open" | "closed";
labels?: string[];
}
): Promise<GitHubIssue | null> {
if (!this.isAuthenticated()) {
throw new Error("Authentication required to update issues");
}
const repoPath = this.extractRepoPath(repoUrl);
if (!repoPath) return null;
try {
const response = await this.makeAuthenticatedRequest(
`/repos/${repoPath}/issues/${issueNumber}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updates),
}
);
if (!response.ok) {
throw new Error(`Failed to update issue: ${response.status}`);
}
const issue: GitHubIssue = await response.json();
// Update cache
const cacheKey = `${repoUrl}#${issue.number}`;
this.issueCache.set(cacheKey, issue);
// Clear repository issues cache to force refresh
this.clearRepositoryCache(repoUrl);
return issue;
} catch (error) {
console.error("Failed to update issue:", error);
return null;
}
}
static async addComment(
repoUrl: string,
issueNumber: number,
body: string
): Promise<boolean> {
if (!this.isAuthenticated()) {
throw new Error("Authentication required to add comments");
}
const repoPath = this.extractRepoPath(repoUrl);
if (!repoPath) return false;
try {
const response = await this.makeAuthenticatedRequest(
`/repos/${repoPath}/issues/${issueNumber}/comments`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ body }),
}
);
return response.ok;
} catch (error) {
console.error("Failed to add comment:", error);
return false;
}
}
// Method to clear cache for a specific repository (useful when switching repos)
static clearRepositoryCache(repoUrl: string): void {
const keysToDelete: string[] = [];
// Clear issue cache for this repo
this.issueCache.forEach((_, key) => {
if (key.startsWith(repoUrl)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach((key) => this.issueCache.delete(key));
// Clear repository issues cache for this repo
const repoKeysToDelete: string[] = [];
this.repositoryIssuesCache.forEach((_, key) => {
if (key.startsWith(repoUrl)) {
repoKeysToDelete.push(key);
}
});
repoKeysToDelete.forEach((key) => this.repositoryIssuesCache.delete(key));
// Clear failed requests for this repo
const failedKeysToDelete: string[] = [];
this.failedRequests.forEach((_, key) => {
if (key.startsWith(repoUrl)) {
failedKeysToDelete.push(key);
}
});
failedKeysToDelete.forEach((key) => this.failedRequests.delete(key));
}
// Method to clear all caches
static clearAllCaches(): void {
this.issueCache.clear();
this.repositoryIssuesCache.clear();
this.pendingRequests.clear();
this.failedRequests.clear();
}
// Helper method to check if a failed request should be skipped
private static shouldSkipFailedRequest(cacheKey: string): boolean {
const failureInfo = this.failedRequests.get(cacheKey);
if (!failureInfo) return false;
// If we've exceeded max attempts
if (failureInfo.count >= this.MAX_RETRY_ATTEMPTS) {
// Check if enough time has passed for a retry
const timeSinceLastAttempt = Date.now() - failureInfo.lastAttempt;
if (timeSinceLastAttempt < this.RETRY_BACKOFF_TIME) {
return true; // Skip this request
}
// Reset the failure count after backoff period
this.failedRequests.delete(cacheKey);
}
return false;
}
// Helper method to track failed requests
private static trackFailedRequest(cacheKey: string): void {
const existing = this.failedRequests.get(cacheKey);
if (existing) {
this.failedRequests.set(cacheKey, {
count: existing.count + 1,
lastAttempt: Date.now(),
});
} else {
this.failedRequests.set(cacheKey, {
count: 1,
lastAttempt: Date.now(),
});
}
}
// Debug method to get cache statistics
static getCacheStats(): {
issuesCached: number;
repositorySearchesCached: number;
failedRequests: number;
pendingRequests: number;
isAuthenticated: boolean;
authenticatedUser?: string;
} {
return {
issuesCached: this.issueCache.size,
repositorySearchesCached: this.repositoryIssuesCache.size,
failedRequests: this.failedRequests.size,
pendingRequests: this.pendingRequests.size,
isAuthenticated: this.isAuthenticated(),
authenticatedUser: this.authenticatedUser?.login,
};
}
private static extractRepoPath(url: string): string | null {
const match = url.match(/github\.com\/([\w\-\.]+\/[\w\-\.]+)/);
return match ? match[1].replace(".git", "") : null;
}
}
// Utility functions
export const parseIssueMentions = (text: string): IssueMention[] => {
const mentions: IssueMention[] = [];
const regex = /#(\d+)/g;
let match;
while ((match = regex.exec(text)) !== null) {
mentions.push({
number: parseInt(match[1], 10),
startIndex: match.index,
endIndex: match.index + match[0].length,
});
}
return mentions;
};