bookwiz.io / lib / services / github-service.ts
github-service.ts
Raw
export interface VersionCommit {
  id: string
  message: string
  authorName: string
  authorEmail: string
  committedAt: Date
}

export interface FileVersion {
  filePath: string
  content: string
  changeType: 'added' | 'modified' | 'deleted'
}

export interface GitHubRepo {
  id: number
  name: string
  full_name: string
  owner: {
    login: string
    id: number
  }
  private: boolean
  html_url: string
  clone_url: string
  ssh_url: string
  default_branch: string
}

export interface GitHubCommit {
  sha: string
  message: string
  author: {
    name: string
    email: string
    date: string
  }
  url: string
  html_url: string
}

export class GitHubService {
  private baseUrl = 'https://api.github.com'

  /**
   * Get user's repositories
   */
  async getUserRepos(accessToken: string): Promise<GitHubRepo[]> {
    const response = await this.makeRequest('/user/repos', {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json'
      }
    })

    if (!response.ok) {
      throw new Error(`Failed to fetch repositories: ${response.statusText}`)
    }

    return await response.json()
  }

  /**
   * Create a new repository
   */
  async createRepo(
    accessToken: string,
    name: string,
    description?: string,
    isPrivate: boolean = true
  ): Promise<GitHubRepo> {
    const response = await this.makeRequest('/user/repos', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        name,
        description,
        private: isPrivate,
        auto_init: true
      })
    })

    if (!response.ok) {
      const error = await response.json()
      throw new Error(`Failed to create repository: ${error.message}`)
    }

    return await response.json()
  }

  /**
   * Get repository information
   */
  async getRepo(accessToken: string, owner: string, repo: string): Promise<GitHubRepo> {
    const response = await this.makeRequest(`/repos/${owner}/${repo}`, {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json'
      }
    })

    if (!response.ok) {
      throw new Error(`Failed to fetch repository: ${response.statusText}`)
    }

    return await response.json()
  }

  /**
   * Push a commit to GitHub
   */
  async pushCommit(
    accessToken: string,
    owner: string,
    repo: string,
    commit: VersionCommit,
    fileVersions: FileVersion[],
    branch: string = 'main'
  ): Promise<GitHubCommit> {
    try {
      // Get the current branch reference
      const branchRef = await this.getBranchRef(accessToken, owner, repo, branch)
      const parentSha = branchRef.object.sha

      // Get the current tree
      const parentTree = await this.getTree(accessToken, owner, repo, parentSha)

      // Create blobs for changed files
      const treeChanges = await Promise.all(
        fileVersions.map(async (fileVersion) => {
          if (fileVersion.changeType === 'deleted') {
            return {
              path: fileVersion.filePath,
              mode: '100644' as const,
              type: 'blob' as const,
              sha: null // null SHA means delete
            }
          } else {
            const blob = await this.createBlob(accessToken, owner, repo, fileVersion.content)
            return {
              path: fileVersion.filePath,
              mode: '100644' as const,
              type: 'blob' as const,
              sha: blob.sha
            }
          }
        })
      )

      // Create new tree
      const newTree = await this.createTree(accessToken, owner, repo, {
        base_tree: parentTree.sha,
        tree: treeChanges
      })

      // Create commit
      const newCommit = await this.createCommit(accessToken, owner, repo, {
        message: commit.message,
        tree: newTree.sha,
        parents: [parentSha],
        author: {
          name: commit.authorName,
          email: commit.authorEmail,
          date: commit.committedAt.toISOString()
        }
      })

      // Update branch reference
      await this.updateRef(accessToken, owner, repo, `heads/${branch}`, newCommit.sha)

      return {
        sha: newCommit.sha,
        message: newCommit.message,
        author: newCommit.author,
        url: newCommit.url,
        html_url: newCommit.html_url
      }
    } catch (error) {
      console.error('Error pushing commit to GitHub:', error)
      throw new Error(`Failed to push commit: ${error instanceof Error ? error.message : 'Unknown error'}`)
    }
  }

  /**
   * Pull latest commits from GitHub
   */
  async pullCommits(
    accessToken: string,
    owner: string,
    repo: string,
    branch: string = 'main',
    since?: Date
  ): Promise<GitHubCommit[]> {
    let url = `/repos/${owner}/${repo}/commits?sha=${branch}`
    if (since) {
      url += `&since=${since.toISOString()}`
    }

    const response = await this.makeRequest(url, {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json'
      }
    })

    if (!response.ok) {
      throw new Error(`Failed to fetch commits: ${response.statusText}`)
    }

    const commits = await response.json()
    return commits.map((commit: any) => ({
      sha: commit.sha,
      message: commit.commit.message,
      author: commit.commit.author,
      url: commit.url,
      html_url: commit.html_url
    }))
  }

  /**
   * Get file content from GitHub
   */
  async getFileContent(
    accessToken: string,
    owner: string,
    repo: string,
    path: string,
    ref?: string
  ): Promise<string> {
    let url = `/repos/${owner}/${repo}/contents/${path}`
    if (ref) {
      url += `?ref=${ref}`
    }

    const response = await this.makeRequest(url, {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json'
      }
    })

    if (!response.ok) {
      throw new Error(`Failed to fetch file content: ${response.statusText}`)
    }

    const data = await response.json()
    return Buffer.from(data.content, 'base64').toString('utf-8')
  }

  /**
   * Create or update a file in GitHub
   */
  async updateFile(
    accessToken: string,
    owner: string,
    repo: string,
    path: string,
    content: string,
    message: string,
    branch?: string,
    sha?: string
  ): Promise<any> {
    const body: any = {
      message,
      content: Buffer.from(content).toString('base64')
    }

    if (branch) body.branch = branch
    if (sha) body.sha = sha

    const response = await this.makeRequest(`/repos/${owner}/${repo}/contents/${path}`, {
      method: 'PUT',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    })

    if (!response.ok) {
      const error = await response.json()
      throw new Error(`Failed to update file: ${error.message}`)
    }

    return await response.json()
  }

  /**
   * Create a release/tag
   */
  async createRelease(
    accessToken: string,
    owner: string,
    repo: string,
    tagName: string,
    name: string,
    body?: string,
    draft: boolean = false,
    prerelease: boolean = false
  ): Promise<any> {
    const response = await this.makeRequest(`/repos/${owner}/${repo}/releases`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        tag_name: tagName,
        name,
        body,
        draft,
        prerelease
      })
    })

    if (!response.ok) {
      const error = await response.json()
      throw new Error(`Failed to create release: ${error.message}`)
    }

    return await response.json()
  }

  // Private helper methods

  private async makeRequest(endpoint: string, options: RequestInit = {}): Promise<Response> {
    const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`
    
    return fetch(url, {
      ...options,
      headers: {
        'User-Agent': 'BookWiz-App',
        ...options.headers
      }
    })
  }

  private async getBranchRef(accessToken: string, owner: string, repo: string, branch: string) {
    const response = await this.makeRequest(`/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json'
      }
    })

    if (!response.ok) {
      throw new Error(`Failed to get branch reference: ${response.statusText}`)
    }

    return await response.json()
  }

  private async getTree(accessToken: string, owner: string, repo: string, sha: string) {
    const response = await this.makeRequest(`/repos/${owner}/${repo}/git/trees/${sha}`, {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json'
      }
    })

    if (!response.ok) {
      throw new Error(`Failed to get tree: ${response.statusText}`)
    }

    return await response.json()
  }

  private async createBlob(accessToken: string, owner: string, repo: string, content: string) {
    const response = await this.makeRequest(`/repos/${owner}/${repo}/git/blobs`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        content: Buffer.from(content).toString('base64'),
        encoding: 'base64'
      })
    })

    if (!response.ok) {
      throw new Error(`Failed to create blob: ${response.statusText}`)
    }

    return await response.json()
  }

  private async createTree(accessToken: string, owner: string, repo: string, treeData: any) {
    const response = await this.makeRequest(`/repos/${owner}/${repo}/git/trees`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(treeData)
    })

    if (!response.ok) {
      throw new Error(`Failed to create tree: ${response.statusText}`)
    }

    return await response.json()
  }

  private async createCommit(accessToken: string, owner: string, repo: string, commitData: any) {
    const response = await this.makeRequest(`/repos/${owner}/${repo}/git/commits`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(commitData)
    })

    if (!response.ok) {
      throw new Error(`Failed to create commit: ${response.statusText}`)
    }

    return await response.json()
  }

  private async updateRef(accessToken: string, owner: string, repo: string, ref: string, sha: string) {
    const response = await this.makeRequest(`/repos/${owner}/${repo}/git/refs/${ref}`, {
      method: 'PATCH',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/vnd.github.v3+json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ sha })
    })

    if (!response.ok) {
      throw new Error(`Failed to update reference: ${response.statusText}`)
    }

    return await response.json()
  }
}

export const githubService = new GitHubService()