212 lines
5.8 KiB
JavaScript
212 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const REVIEW_HEADING = '## Tessl Skill Review';
|
|
|
|
function getRequiredEnv(name) {
|
|
const value = process.env[name];
|
|
if (!value) {
|
|
throw new Error(`${name} is required`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function githubRequest(pathname, options = {}) {
|
|
const token = getRequiredEnv('GITHUB_TOKEN');
|
|
const response = await fetch(`https://api.github.com${pathname}`, {
|
|
...options,
|
|
headers: {
|
|
Accept: 'application/vnd.github+json',
|
|
Authorization: `Bearer ${token}`,
|
|
'User-Agent': 'antigravity-awesome-skills/apply-skill-optimization',
|
|
...(options.headers || {}),
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`GitHub API ${pathname} failed (${response.status}): ${text}`);
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return null;
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
function runGit(args, options = {}) {
|
|
return execFileSync('git', args, {
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
...options,
|
|
});
|
|
}
|
|
|
|
function extractKeyImprovements(body) {
|
|
const section = body.match(/\*\*Key improvements:\*\*\n((?:- .+\n?)+)/);
|
|
if (!section) {
|
|
return [];
|
|
}
|
|
|
|
return [...section[1].matchAll(/^- (.+)$/gm)].map((match) => match[1]);
|
|
}
|
|
|
|
function extractOptimizedContent(body) {
|
|
const sections = new Map();
|
|
const normalized = body.replace(/\r\n/g, '\n');
|
|
const regex =
|
|
/### `([^`]+)`[\s\S]*?View full optimized SKILL\.md[\s\S]*?```markdown\n([\s\S]*?)\n```/g;
|
|
|
|
let match;
|
|
while ((match = regex.exec(normalized)) !== null) {
|
|
const skillPath = match[1];
|
|
const content = match[2].replace(/` ` `/g, '```');
|
|
sections.set(skillPath, content);
|
|
}
|
|
|
|
return sections;
|
|
}
|
|
|
|
function ensureRepoRelative(filePath) {
|
|
const cwd = process.cwd();
|
|
const resolved = path.resolve(cwd, filePath);
|
|
const relative = path.relative(cwd, resolved);
|
|
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
throw new Error(`Path traversal detected: ${filePath}`);
|
|
}
|
|
|
|
if (!filePath.endsWith('SKILL.md')) {
|
|
throw new Error(`Unexpected file path (expected SKILL.md): ${filePath}`);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
async function findLatestReviewComment(owner, repo, prNumber) {
|
|
let page = 1;
|
|
let latest = null;
|
|
|
|
while (true) {
|
|
const comments = await githubRequest(
|
|
`/repos/${owner}/${repo}/issues/${prNumber}/comments?per_page=100&page=${page}`,
|
|
);
|
|
|
|
for (const comment of comments) {
|
|
if (comment.body && comment.body.includes(REVIEW_HEADING)) {
|
|
latest = comment;
|
|
}
|
|
}
|
|
|
|
if (comments.length < 100) {
|
|
return latest;
|
|
}
|
|
|
|
page += 1;
|
|
}
|
|
}
|
|
|
|
async function postComment(owner, repo, prNumber, body) {
|
|
await githubRequest(`/repos/${owner}/${repo}/issues/${prNumber}/comments`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const repoSlug = getRequiredEnv('GITHUB_REPOSITORY');
|
|
const [owner, repo] = repoSlug.split('/');
|
|
const prNumber = process.env.PR_NUMBER || JSON.parse(
|
|
fs.readFileSync(getRequiredEnv('GITHUB_EVENT_PATH'), 'utf8'),
|
|
).inputs?.pr_number;
|
|
|
|
if (!prNumber) {
|
|
throw new Error('PR_NUMBER or workflow_dispatch input pr_number is required');
|
|
}
|
|
|
|
const pr = await githubRequest(`/repos/${owner}/${repo}/pulls/${prNumber}`);
|
|
|
|
if (pr.head.repo.full_name !== repoSlug) {
|
|
throw new Error(
|
|
'Auto-apply is only supported for PR branches that live in the base repository.',
|
|
);
|
|
}
|
|
|
|
const reviewComment = await findLatestReviewComment(owner, repo, prNumber);
|
|
if (!reviewComment || !reviewComment.body) {
|
|
throw new Error('No Tessl skill review comment found on this PR. Run the review first.');
|
|
}
|
|
|
|
const optimizedFiles = extractOptimizedContent(reviewComment.body);
|
|
if (optimizedFiles.size === 0) {
|
|
throw new Error('No optimized SKILL.md content found in the latest Tessl review comment.');
|
|
}
|
|
|
|
const branch = pr.head.ref;
|
|
console.log(`Applying optimization to PR #${prNumber} on ${branch}`);
|
|
|
|
runGit(['fetch', 'origin', branch]);
|
|
runGit(['checkout', '-B', branch, `origin/${branch}`]);
|
|
|
|
for (const [filePath, content] of optimizedFiles.entries()) {
|
|
const resolved = ensureRepoRelative(filePath);
|
|
fs.writeFileSync(resolved, content);
|
|
console.log(`Updated ${filePath}`);
|
|
}
|
|
|
|
runGit(['config', 'user.name', 'tessl-skill-review[bot]']);
|
|
runGit(['config', 'user.email', 'skill-review[bot]@users.noreply.github.com']);
|
|
runGit(['add', ...optimizedFiles.keys()]);
|
|
|
|
let committed = true;
|
|
try {
|
|
runGit(['commit', '-m', 'Apply optimized SKILL.md from Tessl review']);
|
|
} catch (error) {
|
|
const output = `${error.stdout || ''}\n${error.stderr || ''}`;
|
|
if (output.includes('nothing to commit')) {
|
|
committed = false;
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (!committed) {
|
|
await postComment(
|
|
owner,
|
|
repo,
|
|
prNumber,
|
|
'⚠️ No changes to apply. The PR branch already matches the latest Tessl optimization.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
runGit(['push', 'origin', `HEAD:${branch}`]);
|
|
|
|
const shortSha = runGit(['rev-parse', '--short', 'HEAD']).trim();
|
|
const updatedFiles = [...optimizedFiles.keys()].map((item) => `\`${item}\``).join(', ');
|
|
const improvements = extractKeyImprovements(reviewComment.body);
|
|
|
|
let body = `✅ Applied optimized ${updatedFiles} (${shortSha}).`;
|
|
if (improvements.length > 0) {
|
|
body += '\n\n**What changed:**';
|
|
for (const item of improvements.slice(0, 3)) {
|
|
body += `\n- ${item}`;
|
|
}
|
|
}
|
|
|
|
await postComment(owner, repo, prNumber, body);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.stack : String(error));
|
|
process.exitCode = 1;
|
|
});
|