import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Octokit } from '@octokit/rest';
@Injectable()
export class GithubService {
private octokit: Octokit | null = null;
private octokitPromise: Promise<Octokit> | null = null;
constructor(private configService?: ConfigService) {}
private async getOctokit(): Promise<Octokit> {
if (this.octokit) {
return this.octokit;
}
if (this.octokitPromise) {
return this.octokitPromise;
}
this.octokitPromise = (async () => {
try {
// Dynamic import for ES module
const { Octokit } = await import('@octokit/rest');
// Get token from environment or use a default one (limited to public repos)
let token = '';
if (this.configService) {
token = (this.configService.get<string>('GITHUB_PERSONAL_ACCESS_TOKEN') || '').trim();
} else if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) {
token = process.env.GITHUB_PERSONAL_ACCESS_TOKEN.trim();
}
// Remove 'Bearer ' prefix if present (Octokit adds it automatically)
token = token.replace(/^Bearer\s+/i, '');
console.log('Initializing Octokit with token:', token ? '***' + token.slice(-4) : 'No token (public access only)');
this.octokit = new Octokit({
auth: token || undefined, // Use undefined instead of empty string
request: {
timeout: 10000 // 10 second timeout
}
});
// Test the connection
await this.octokit.rest.users.getAuthenticated().catch(() => {
console.log('GitHub authentication failed. Continuing with unauthenticated access (rate limits will apply).');
});
return this.octokit;
} catch (error) {
console.error('Error initializing Octokit:', error);
throw new Error(`Failed to initialize GitHub client: ${error instanceof Error ? error.message : String(error)}`);
}
})();
return this.octokitPromise;
}
async commitChanges({
repoOwner,
repoName,
branchName,
commitMessage,
filesToCommit,
}: {
repoOwner: string;
repoName: string;
branchName: string;
commitMessage: string;
filesToCommit: Array<{ path: string; content: string }>;
}) {
try {
const octokit = await this.getOctokit();
// Creating or updating files on GitHub
if (!filesToCommit || filesToCommit.length === 0) {
throw new Error('No files to commit');
}
const file = filesToCommit[0];
if (!file) {
throw new Error('No files to commit');
}
// Check if file exists first to get SHA (required for updates)
let sha: string | undefined;
try {
const existingFile = await octokit.repos.getContent({
owner: repoOwner,
repo: repoName,
path: file.path,
ref: branchName,
});
if (Array.isArray(existingFile.data)) {
throw new Error('Path is a directory, not a file');
}
sha = existingFile.data.sha;
} catch (error: any) {
// File doesn't exist yet, that's okay - we'll create it
if (error.status !== 404) {
throw error;
}
}
const response = await octokit.repos.createOrUpdateFileContents({
owner: repoOwner,
repo: repoName,
path: file.path,
message: commitMessage,
content: Buffer.from(file.content).toString('base64'),
branch: branchName,
...(sha && { sha }), // Include SHA if file exists (required for updates)
});
return {
content: [
{
type: 'text',
text: `Committed changes to ${repoName} on branch ${branchName}: ${commitMessage}`,
},
],
};
} catch (error) {
console.error('Error committing changes:', error);
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: 'text',
text: `Failed to commit changes: ${message}`,
},
],
};
}
}
async getRepoInfo({
repoOwner,
repoName,
}: {
repoOwner: string;
repoName: string;
}) {
try {
console.log(`Fetching repository info for ${repoOwner}/${repoName}`);
const octokit = await this.getOctokit();
// Fetch repository info using GitHub's REST API
console.log('Sending request to GitHub API...');
const response = await octokit.rest.repos.get({
owner: repoOwner,
repo: repoName,
mediaType: {
format: 'raw',
},
}).catch(error => {
console.error('GitHub API error:', error);
throw error;
});
console.log('Received response from GitHub API');
return {
content: [
{
type: 'text',
text: `Repository Info:
Name: ${response.data.name}
Owner: ${response.data.owner?.login || 'Unknown'}
Default Branch: ${response.data.default_branch}
Description: ${response.data.description || 'No description'}
Stars: ${response.data.stargazers_count}
Forks: ${response.data.forks_count}
Private: ${response.data.private}
URL: ${response.data.html_url}`,
},
],
};
} catch (error) {
console.error('Error in getRepoInfo:', error);
let errorMessage = 'Unknown error occurred';
if (error.status === 404) {
errorMessage = `Repository '${repoOwner}/${repoName}' not found or access denied`;
} else if (error.status === 403) {
errorMessage = 'GitHub API rate limit exceeded. Please set GITHUB_PERSONAL_ACCESS_TOKEN in your .env file.';
} else if (error.response) {
errorMessage = `GitHub API error: ${error.response.data?.message || error.message}`;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
}
return {
content: [
{
type: 'text',
text: `Failed to fetch repository info: ${errorMessage}`,
},
],
};
}
}
async createBranch({
repoOwner,
repoName,
branchName,
baseBranch,
}: {
repoOwner: string;
repoName: string;
branchName: string;
baseBranch?: string;
}) {
try {
const octokit = await this.getOctokit();
// Get the SHA of the base branch (or default branch if not specified)
const baseBranchName = baseBranch || 'main';
const refResponse = await octokit.git.getRef({
owner: repoOwner,
repo: repoName,
ref: `heads/${baseBranchName}`,
});
const baseSha = refResponse.data.object.sha;
// Create the new branch
await octokit.git.createRef({
owner: repoOwner,
repo: repoName,
ref: `refs/heads/${branchName}`,
sha: baseSha,
});
return {
content: [
{
type: 'text',
text: `Successfully created branch '${branchName}' from '${baseBranchName}' in repository ${repoOwner}/${repoName}`,
},
],
};
} catch (error) {
console.error('Error creating branch:', error);
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: 'text',
text: `Failed to create branch: ${message}`,
},
],
};
}
}
async listBranches({
repoOwner,
repoName,
}: {
repoOwner: string;
repoName: string;
}) {
try {
const octokit = await this.getOctokit();
// Fetch all branches using GitHub's REST API
const response = await octokit.repos.listBranches({
owner: repoOwner,
repo: repoName,
});
const branches = response.data.map((branch) => ({
name: branch.name,
protected: branch.protected || false,
sha: branch.commit.sha,
}));
const branchList = branches
.map((b) => `- ${b.name}${b.protected ? ' (protected)' : ''} (${b.sha.substring(0, 7)})`)
.join('\n');
return {
content: [
{
type: 'text',
text: `Branches in ${repoOwner}/${repoName}:\n${branchList}\n\nTotal: ${branches.length} branch${branches.length !== 1 ? 'es' : ''}`,
},
],
};
} catch (error) {
console.error('Error listing branches:', error);
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: 'text',
text: `Failed to list branches: ${message}`,
},
],
};
}
}
async getFileContents({
repoOwner,
repoName,
filePath,
branch,
}: {
repoOwner: string;
repoName: string;
filePath: string;
branch?: string;
}) {
try {
const octokit = await this.getOctokit();
const response = await octokit.repos.getContent({
owner: repoOwner,
repo: repoName,
path: filePath,
...(branch && { ref: branch }),
});
if (Array.isArray(response.data)) {
return {
content: [
{
type: 'text',
text: `Path "${filePath}" is a directory, not a file. Use list_files to see directory contents.`,
},
],
};
}
// Check if it's a file (not symlink or submodule)
if (response.data.type !== 'file' || !('content' in response.data)) {
return {
content: [
{
type: 'text',
text: `Path "${filePath}" is not a regular file (type: ${response.data.type}).`,
},
],
};
}
// Decode base64 content
const content = Buffer.from(response.data.content, 'base64').toString('utf-8');
return {
content: [
{
type: 'text',
text: `File: ${filePath}\nSize: ${response.data.size} bytes\nSHA: ${response.data.sha}\n\nContent:\n${content}`,
},
],
};
} catch (error) {
console.error('Error fetching file contents:', error);
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: 'text',
text: `Failed to fetch file contents: ${message}`,
},
],
};
}
}
async listCommits({
repoOwner,
repoName,
branch,
perPage,
}: {
repoOwner: string;
repoName: string;
branch?: string;
perPage?: number;
}) {
try {
const octokit = await this.getOctokit();
const response = await octokit.repos.listCommits({
owner: repoOwner,
repo: repoName,
...(branch && { sha: branch }),
per_page: perPage || 10,
});
const commits = response.data.map((commit) => ({
sha: commit.sha.substring(0, 7),
message: commit.commit.message.split('\n')[0], // First line only
author: commit.commit.author?.name || 'Unknown',
date: commit.commit.author?.date || 'Unknown',
url: commit.html_url,
}));
const commitList = commits
.map(
(c) =>
`- ${c.sha} - ${c.message}\n Author: ${c.author} | Date: ${c.date}\n URL: ${c.url}`,
)
.join('\n\n');
return {
content: [
{
type: 'text',
text: `Recent commits in ${repoOwner}/${repoName}${branch ? ` (branch: ${branch})` : ''}:\n\n${commitList}\n\nTotal: ${commits.length} commit${commits.length !== 1 ? 's' : ''} shown`,
},
],
};
} catch (error) {
console.error('Error listing commits:', error);
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: 'text',
text: `Failed to list commits: ${message}`,
},
],
};
}
}
}