/**
* GitHub 知識庫客戶端
*/
import { getAppSecrets } from '../../utils/secrets.js'
export interface GitHubSearchResult {
name: string
path: string
sha: string
url: string
repository: { name: string; full_name: string }
score: number
textMatches?: Array<{
fragment: string
matches: Array<{ text: string; indices: number[] }>
}>
}
export interface GitHubFileContent {
name: string
path: string
sha: string
size: number
content: string
encoding: string
}
export type RepoType = 'internal' | 'alliance' | 'products'
export class GitHubClient {
private token: string
private owner: string
private baseUrl = 'https://api.github.com'
private repos: Record<RepoType, string> = {
internal: 'yes-ceramics-internal',
alliance: 'construction-alliance-database',
products: 'yes-ceramics-products',
}
constructor(token: string, owner: string) {
this.token = token
this.owner = owner
}
private async request<T = any>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`
const response = await fetch(url, {
...options,
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${this.token}`,
'X-GitHub-Api-Version': '2022-11-28',
...options.headers,
},
})
if (!response.ok) {
const error = await response.json().catch(() => ({})) as any
throw new Error(`GitHub API Error: ${response.status} - ${error.message || response.statusText}`)
}
return response.json() as Promise<T>
}
async searchCode(params: {
query: string
repos?: RepoType[]
path?: string
extension?: string
limit?: number
}): Promise<GitHubSearchResult[]> {
const { query, repos = ['internal', 'alliance'], path, extension, limit = 20 } = params
let q = query
const repoQueries = repos.map(r => `repo:${this.owner}/${this.repos[r]}`).join(' ')
q += ` ${repoQueries}`
if (path) q += ` path:${path}`
if (extension) q += ` extension:${extension}`
const result = await this.request<{
total_count: number
items: GitHubSearchResult[]
}>(`/search/code?q=${encodeURIComponent(q)}&per_page=${limit}`, {
headers: { Accept: 'application/vnd.github.text-match+json' },
})
return result.items
}
async getFileContent(params: { repo: RepoType; path: string }): Promise<GitHubFileContent> {
const repoName = this.repos[params.repo]
const result = await this.request<GitHubFileContent>(
`/repos/${this.owner}/${repoName}/contents/${params.path}`
)
if (result.encoding === 'base64' && result.content) {
result.content = Buffer.from(result.content, 'base64').toString('utf-8')
}
return result
}
async listDirectory(params: { repo: RepoType; path?: string }): Promise<Array<{
name: string
path: string
type: 'file' | 'dir'
size: number
}>> {
const repoName = this.repos[params.repo]
const path = params.path || ''
const result = await this.request<Array<{
name: string
path: string
type: string
size: number
}>>(`/repos/${this.owner}/${repoName}/contents/${path}`)
return result.map(item => ({
name: item.name,
path: item.path,
type: item.type as 'file' | 'dir',
size: item.size,
}))
}
async searchProducts(params: {
query: string
repos?: RepoType[]
category?: string
}): Promise<Array<{
name: string
path: string
repo: string
excerpt: string
}>> {
const results = await this.searchCode({
query: params.query,
repos: params.repos || ['internal', 'alliance'],
path: params.category ? `products/${params.category}` : 'products',
extension: 'md',
limit: 20,
})
return results.map(item => ({
name: item.name.replace('.md', ''),
path: item.path,
repo: item.repository.name,
excerpt: item.textMatches?.[0]?.fragment || '',
}))
}
async getProductPrice(sku: string): Promise<{
sku: string
cost?: number
wholesale?: number
retail?: number
found: boolean
} | null> {
try {
const results = await this.searchCode({
query: sku,
repos: ['internal'],
path: 'pricing',
extension: 'json',
limit: 1,
})
if (results.length === 0) {
return { sku, found: false }
}
const file = await this.getFileContent({
repo: 'internal',
path: results[0].path,
})
const priceData = JSON.parse(file.content)
const productPrice = Array.isArray(priceData)
? priceData.find((p: any) => p.sku === sku)
: priceData[sku]
if (!productPrice) {
return { sku, found: false }
}
return {
sku,
cost: productPrice.cost,
wholesale: productPrice.wholesale,
retail: productPrice.retail,
found: true,
}
} catch {
return null
}
}
}
let githubClient: GitHubClient | null = null
export async function getGitHubClient(): Promise<GitHubClient> {
if (!githubClient) {
const secrets = await getAppSecrets()
githubClient = new GitHubClient(secrets.githubToken, secrets.githubOwner)
}
return githubClient
}