Files
openclaw-skill/skills/skill-review/handler.ts
Selig 9ab15e99e5 feat(skill-review): Agent PR workflow skill
Enables agents (tiangong, kaiwu, yucheng) to review skills
and submit improvement PRs via Gitea fork → branch → PR workflow.
2026-03-13 11:11:27 +08:00

241 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, { username: string; tokenEnv: string }> = {
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<any> {
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<void> {
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<string[]> {
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<GiteaFile> {
return giteaApi(
token,
'GET',
`/repos/${owner}/${REPO_NAME}/contents/${filepath}?ref=${ref}`
);
}
/** 建立分支 */
async function createBranch(
token: string,
owner: string,
branchName: string
): Promise<void> {
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<void> {
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<void> {
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<string, { skillMd: string; handlerTs: string }> = {};
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,
};