utils.ts•4.92 kB
/**
* GitHub APIリクエストの作成とレスポンス処理用のユーティリティ関数。
*/
import { createGitHubError } from './errors'
/**
* GitHub MCPサーバーのバージョン情報
*/
export const VERSION = '0.1.0'
// GitHubリクエストのオプション型
export 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')) {
return response.json()
}
return response.text()
}
/**
* クエリパラメータ付きURLを生成
*/
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 = `claude-ts-mcps/github/v${VERSION}`
/**
* 指定したアカウントプロファイルのGitHubトークンを取得する
* @param accountProfile アカウントプロファイル名(省略時はデフォルト)
* @returns 対応するGitHubトークン
*/
export function getGitHubToken(accountProfile?: string): string | undefined {
if (!accountProfile || accountProfile === 'default') {
// 従来の環境変数名をデフォルトとして維持
return process.env.GITHUB_PERSONAL_ACCESS_TOKEN
}
// アカウントプロファイル名に基づいてトークンを取得
// 例: 'work' → GITHUB_TOKEN_WORK
const tokenEnvName = `GITHUB_TOKEN_${accountProfile.toUpperCase()}`
return process.env[tokenEnvName]
}
/**
* 適切なエラー処理を伴うGitHub APIリクエストを行う
*/
export async function githubRequest(
url: string,
options: RequestOptions = {},
accountProfile?: string,
): Promise<unknown> {
const headers: Record<string, string> = {
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
'User-Agent': USER_AGENT,
...options.headers,
}
const token = getGitHubToken(accountProfile)
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(url, {
method: options.method || 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
})
const responseBody = await parseResponseBody(response)
if (!response.ok) {
throw createGitHubError(response.status, responseBody)
}
return responseBody
}
/**
* GitHub操作用にブランチ名を検証
*/
export function validateBranchName(branch: string): string {
const sanitized = branch.trim()
if (!sanitized) {
throw new Error('Branch name cannot be empty')
}
if (sanitized.includes('..')) {
throw new Error("Branch name cannot contain '..'")
}
if (/[\s~^:?*[\\\]]/.test(sanitized)) {
throw new Error('Branch name contains invalid characters')
}
if (sanitized.startsWith('/') || sanitized.endsWith('/')) {
throw new Error("Branch name cannot start or end with '/'")
}
if (sanitized.endsWith('.lock')) {
throw new Error("Branch name cannot end with '.lock'")
}
return sanitized
}
/**
* GitHub操作用にリポジトリ名を検証
*/
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
}
/**
* GitHub操作用にオーナー名を検証
*/
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
}
/**
* リポジトリ内にブランチが存在するか確認
*/
export async function checkBranchExists(
owner: string,
repo: string,
branch: string,
): Promise<boolean> {
try {
await githubRequest(
`https://api.github.com/repos/${owner}/${repo}/branches/${branch}`,
)
return true
} catch (error) {
if (
error &&
typeof error === 'object' &&
'status' in error &&
error.status === 404
) {
return false
}
throw error
}
}