/** * skill-review handler * 提供 Gitea API 操作的輔助函式,供 agent 審查 skill 並提交 PR。 */ const GITEA_URL = process.env.GITEA_URL || 'https://git.nature.edu.kg'; const UPSTREAM_OWNER = 'Selig'; const REPO_NAME = 'openclaw-skill'; // Agent ID → Gitea 帳號 & token 環境變數對應 const AGENT_MAP: Record = { tiangong: { username: 'tiangong', tokenEnv: 'GITEA_TOKEN_TIANGONG' }, kaiwu: { username: 'kaiwu', tokenEnv: 'GITEA_TOKEN_KAIWU' }, yucheng: { username: 'yucheng', tokenEnv: 'GITEA_TOKEN_YUCHENG' }, }; interface GiteaFile { name: string; path: string; sha: string; content?: string; encoding?: string; } async function giteaApi( token: string, method: string, path: string, body?: any ): Promise { const url = `${GITEA_URL}/api/v1${path}`; const opts: RequestInit = { method, headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json', }, }; if (body) opts.body = JSON.stringify(body); const res = await fetch(url, opts); const text = await res.text(); if (!res.ok) { throw new Error(`Gitea API ${method} ${path} → ${res.status}: ${text}`); } return text ? JSON.parse(text) : null; } /** 同步 fork(用 Gitea merge upstream API) */ async function syncFork(token: string, owner: string): Promise { try { // Gitea 1.25: POST /repos/{owner}/{repo}/merge-upstream await giteaApi(token, 'POST', `/repos/${owner}/${REPO_NAME}/merge-upstream`, { branch: 'main', }); } catch (e: any) { // 如果 API 不存在或已同步,忽略 if (!e.message.includes('409')) { console.warn('syncFork warning:', e.message); } } } /** 列出 skills 目錄下的所有 skill */ async function listSkills(token: string, owner: string): Promise { const items = await giteaApi( token, 'GET', `/repos/${owner}/${REPO_NAME}/contents/skills?ref=main` ); return items .filter((item: any) => item.type === 'dir') .map((item: any) => item.name); } /** 讀取檔案內容 */ async function readFile( token: string, owner: string, filepath: string, ref = 'main' ): Promise { return giteaApi( token, 'GET', `/repos/${owner}/${REPO_NAME}/contents/${filepath}?ref=${ref}` ); } /** 建立分支 */ async function createBranch( token: string, owner: string, branchName: string ): Promise { await giteaApi(token, 'POST', `/repos/${owner}/${REPO_NAME}/branches`, { new_branch_name: branchName, old_branch_name: 'main', }); } /** 更新檔案(需要 sha) */ async function updateFile( token: string, owner: string, filepath: string, content: string, sha: string, branch: string, message: string ): Promise { const b64 = Buffer.from(content, 'utf-8').toString('base64'); await giteaApi( token, 'PUT', `/repos/${owner}/${REPO_NAME}/contents/${filepath}`, { content: b64, sha, message, branch } ); } /** 建立新檔案 */ async function createFile( token: string, owner: string, filepath: string, content: string, branch: string, message: string ): Promise { const b64 = Buffer.from(content, 'utf-8').toString('base64'); await giteaApi( token, 'POST', `/repos/${owner}/${REPO_NAME}/contents/${filepath}`, { content: b64, message, branch } ); } /** 建立 PR(從 fork 到 upstream) */ async function createPR( token: string, agentUsername: string, title: string, body: string, branch: string ): Promise<{ url: string; number: number }> { const pr = await giteaApi( token, 'POST', `/repos/${UPSTREAM_OWNER}/${REPO_NAME}/pulls`, { title, body, head: `${agentUsername}:${branch}`, base: 'main', } ); return { url: pr.html_url, number: pr.number }; } export async function handler(ctx: any) { // 偵測當前 agent const agentId = ctx.env?.OPENCLAW_AGENT_ID || ctx.agentId || 'unknown'; const agentConfig = AGENT_MAP[agentId]; if (!agentConfig) { return { reply: `❌ 無法辨識 agent: ${agentId}\n支援的 agent: ${Object.keys(AGENT_MAP).join(', ')}`, }; } const token = ctx.env?.[agentConfig.tokenEnv] || process.env[agentConfig.tokenEnv]; if (!token) { return { reply: `❌ 找不到 ${agentConfig.tokenEnv},請確認 .env 設定。`, }; } const username = agentConfig.username; try { // Step 1: 同步 fork await syncFork(token, username); // Step 2: 列出所有 skills const skills = await listSkills(token, username); // Step 3: 讀取每個 skill 的內容 const skillContents: Record = {}; for (const skill of skills) { try { const skillMdFile = await readFile(token, username, `skills/${skill}/SKILL.md`); const handlerFile = await readFile(token, username, `skills/${skill}/handler.ts`); skillContents[skill] = { skillMd: Buffer.from(skillMdFile.content || '', 'base64').toString('utf-8'), handlerTs: Buffer.from(handlerFile.content || '', 'base64').toString('utf-8'), }; } catch { // 跳過讀取失敗的 skill } } // 回傳資料供 agent 分析 return { reply: `✅ Fork 已同步,共讀取 ${Object.keys(skillContents).length} 個 skills。\n\n請根據你的角色審查以下 skills,選擇一個提出改進 PR。`, data: { agentId, username, skills: skillContents, // 提供 API helper 資訊,讓 agent 知道可以用 exec 呼叫 api: { createBranch: 'handler.createBranch(token, owner, branchName)', updateFile: 'handler.updateFile(token, owner, filepath, content, sha, branch, message)', createFile: 'handler.createFile(token, owner, filepath, content, branch, message)', createPR: 'handler.createPR(token, agentUsername, title, body, branch)', }, }, metadata: { agentId, username, skillCount: Object.keys(skillContents).length }, }; } catch (err: any) { return { reply: `❌ 執行失敗: ${err.message}`, error: err.message, }; } } // 匯出 helper 供 agent 透過 exec 使用 export { syncFork, listSkills, readFile, createBranch, updateFile, createFile, createPR, giteaApi, AGENT_MAP, };