diff --git a/.github/scripts/ai-inspector-worker.mjs b/.github/scripts/ai-inspector-worker.mjs index e69a6735..7f92348b 100644 --- a/.github/scripts/ai-inspector-worker.mjs +++ b/.github/scripts/ai-inspector-worker.mjs @@ -14,20 +14,65 @@ const required = (name) => { return value; }; -const runGit = (args, options = {}) => { - execFileSync("git", args, { +const runCommandOutput = (command, args, options = {}) => + execFileSync(command, args, { stdio: "pipe", encoding: "utf8", ...options, }); -}; -const runGitOutput = (args, options = {}) => +const runGit = (args, options = {}) => { execFileSync("git", args, { stdio: "pipe", encoding: "utf8", ...options, }); +}; + +const runGitOutput = (args, options = {}) => runCommandOutput("git", args, options); + +const resolveGitHubToken = () => { + const envToken = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim(); + if (envToken) { + return envToken; + } + + try { + const ghToken = runCommandOutput("gh", ["auth", "token"]).trim(); + if (ghToken) { + return ghToken; + } + } catch { + // Ignore and throw with clear message below. + } + + throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN, or run `gh auth login`."); +}; + +const resolveGitHubRepository = () => { + const repository = process.env.GITHUB_REPOSITORY?.trim(); + if (repository) { + return repository; + } + + const remoteUrl = runGitOutput(["remote", "get-url", "origin"]).trim(); + const normalizedUrl = remoteUrl.startsWith("git@github.com:") + ? remoteUrl.replace("git@github.com:", "https://github.com/") + : remoteUrl; + const parsedUrl = new URL(normalizedUrl); + + if (parsedUrl.hostname !== "github.com") { + throw new Error(`Unsupported remote host for origin: ${parsedUrl.hostname}`); + } + + const pathname = parsedUrl.pathname.replace(/^\/+/, "").replace(/\.git$/, ""); + const [owner, repoName] = pathname.split("/"); + if (!owner || !repoName) { + throw new Error(`Failed to infer GITHUB_REPOSITORY from origin remote: ${remoteUrl}`); + } + + return `${owner}/${repoName}`; +}; const escapeMarkdown = (value) => String(value ?? "").replace(/`/g, "\\`"); @@ -63,8 +108,7 @@ const getPreviewUrl = (branchName) => { return template.replaceAll("{branch}", branchName.replaceAll("/", "-")); }; -const githubRequest = async (method, pathName, body) => { - const token = required("GITHUB_TOKEN"); +const githubRequest = async (token, method, pathName, body) => { const response = await fetch(`https://api.github.com${pathName}`, { method, headers: { @@ -84,8 +128,7 @@ const githubRequest = async (method, pathName, body) => { return response.json(); }; -const findOpenPrByHead = async (owner, repo, branchName) => { - const token = required("GITHUB_TOKEN"); +const findOpenPrByHead = async (token, owner, repo, branchName) => { const response = await fetch( `https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${owner}:${encodeURIComponent(branchName)}`, { @@ -149,7 +192,7 @@ const sendDiscordNotification = async ({ taskId, prUrl, previewUrl, instruction } }; -const requestPatchFromAiEndpoint = async ({ taskId, task, branchName }) => { +const requestPatchFromAiEndpoint = async ({ taskId, task, branchName, repository, baseBranch }) => { const endpoint = process.env.AI_INSPECTOR_PATCH_ENDPOINT; if (!endpoint) { return { @@ -168,9 +211,9 @@ const requestPatchFromAiEndpoint = async ({ taskId, task, branchName }) => { body: JSON.stringify({ taskId, task, - repository: process.env.GITHUB_REPOSITORY, + repository, branchName, - baseBranch: process.env.AI_INSPECTOR_BASE_BRANCH || "main", + baseBranch, }), }); @@ -215,11 +258,16 @@ const claimQueuedTask = async (db, collectionName) => { const queued = await db .collection(collectionName) .where("status", "==", "queued") - .orderBy("createdAt", "asc") .limit(10) .get(); - for (const doc of queued.docs) { + const orderedDocs = [...queued.docs].sort((a, b) => { + const aMillis = a.get("createdAt")?.toMillis?.() ?? Number.MAX_SAFE_INTEGER; + const bMillis = b.get("createdAt")?.toMillis?.() ?? Number.MAX_SAFE_INTEGER; + return aMillis - bMillis; + }); + + for (const doc of orderedDocs) { const taskRef = doc.ref; const claimedTask = await db.runTransaction(async (transaction) => { const snapshot = await transaction.get(taskRef); @@ -253,7 +301,8 @@ const claimQueuedTask = async (db, collectionName) => { }; const main = async () => { - const repo = required("GITHUB_REPOSITORY"); + const githubToken = resolveGitHubToken(); + const repo = resolveGitHubRepository(); const [owner, repoName] = repo.split("/"); const baseBranch = process.env.AI_INSPECTOR_BASE_BRANCH || "main"; const collectionName = process.env.AI_INSPECTOR_FIRESTORE_COLLECTION || "aiInspectorTasks"; @@ -292,7 +341,13 @@ const main = async () => { fs.writeFileSync(filePath, buildTaskMarkdown(taskId, task), "utf8"); runGit(["add", filePath]); - const aiResult = await requestPatchFromAiEndpoint({ taskId, task, branchName }); + const aiResult = await requestPatchFromAiEndpoint({ + taskId, + task, + branchName, + repository: repo, + baseBranch, + }); const patchApplied = applyPatch(aiResult.patch); const hasChanges = runGitOutput(["status", "--porcelain"]).trim().length > 0; @@ -306,7 +361,7 @@ const main = async () => { runGit(["commit", "-m", commitMessage]); runGit(["push", "-u", "origin", branchName]); - const existingPr = await findOpenPrByHead(owner, repoName, branchName); + const existingPr = await findOpenPrByHead(githubToken, owner, repoName, branchName); const title = aiResult.title || `[AI Inspector] ${String(task.instruction ?? "UI update request").slice(0, 72)}`.trim(); const body = [ @@ -324,7 +379,7 @@ const main = async () => { const pr = existingPr ?? - (await githubRequest("POST", `/repos/${owner}/${repoName}/pulls`, { + (await githubRequest(githubToken, "POST", `/repos/${owner}/${repoName}/pulls`, { title, head: branchName, base: baseBranch, diff --git a/.github/workflows/ai-inspector-worker.yml b/.github/workflows/ai-inspector-worker.yml deleted file mode 100644 index 0ae85b6b..00000000 --- a/.github/workflows/ai-inspector-worker.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: AI Inspector Worker - -on: - schedule: - - cron: "*/15 * * * *" - workflow_dispatch: - -jobs: - process: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "22" - - - name: Install worker dependencies - run: npm install firebase-admin - - - name: Run AI Inspector worker - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - AI_INSPECTOR_BASE_BRANCH: main - AI_INSPECTOR_FIRESTORE_COLLECTION: aiInspectorTasks - AI_INSPECTOR_FIREBASE_PROJECT_ID: ${{ secrets.AI_INSPECTOR_FIREBASE_PROJECT_ID }} - AI_INSPECTOR_FIREBASE_CLIENT_EMAIL: ${{ secrets.AI_INSPECTOR_FIREBASE_CLIENT_EMAIL }} - AI_INSPECTOR_FIREBASE_PRIVATE_KEY: ${{ secrets.AI_INSPECTOR_FIREBASE_PRIVATE_KEY }} - AI_INSPECTOR_PATCH_ENDPOINT: ${{ secrets.AI_INSPECTOR_PATCH_ENDPOINT }} - AI_INSPECTOR_PATCH_API_KEY: ${{ secrets.AI_INSPECTOR_PATCH_API_KEY }} - AI_INSPECTOR_PREVIEW_URL_TEMPLATE: ${{ secrets.AI_INSPECTOR_PREVIEW_URL_TEMPLATE }} - AI_INSPECTOR_DISCORD_WEBHOOK_URL: ${{ secrets.AI_INSPECTOR_DISCORD_WEBHOOK_URL }} - run: node .github/scripts/ai-inspector-worker.mjs diff --git a/README.md b/README.md index aa22fb32..f240f33c 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Admin 계정일 때 좌측 하단에 AI 인스펙터 플로팅 버튼이 노출 2. 자연어 수정 요청 입력 3. `POST /api/ai-inspector-requests` 호출 (클라이언트는 Firebase 직접 접근하지 않음) 4. API 서버가 관리자 권한을 검증하고 Firestore에 `queued` 상태로 저장 -5. GitHub Actions 크론(`.github/workflows/ai-inspector-worker.yml`)이 `queued -> processing`으로 작업 클레임 후 처리 +5. 로컬 워커(`pnpm ai-inspector:worker` 또는 `pnpm ai-inspector:worker:loop`)가 `queued -> processing`으로 작업 클레임 후 처리 6. 작업 결과를 `completed` 또는 `failed`로 업데이트하고 PR/프리뷰 링크를 Discord webhook으로 전송 ### Shared package @@ -101,17 +101,32 @@ Admin 계정일 때 좌측 하단에 AI 인스펙터 플로팅 버튼이 노출 - 로컬 개발: `apps/web/.env.local` - Vercel 배포: Vercel Project > Settings > Environment Variables (Preview/Production 둘 다) -### GitHub secrets (worker) +### Local worker env -`.github/workflows/ai-inspector-worker.yml`에서 사용합니다. +로컬 워커는 아래 환경변수를 사용합니다. - `AI_INSPECTOR_FIREBASE_PROJECT_ID` - `AI_INSPECTOR_FIREBASE_CLIENT_EMAIL` - `AI_INSPECTOR_FIREBASE_PRIVATE_KEY` +- `AI_INSPECTOR_FIRESTORE_COLLECTION` (optional, default: `aiInspectorTasks`) +- `AI_INSPECTOR_BASE_BRANCH` (optional, default: `main`) +- `GITHUB_TOKEN` or `GH_TOKEN` (required for PR 생성) +- `GITHUB_REPOSITORY` (optional, 미지정 시 `git remote origin`에서 자동 추론) - `AI_INSPECTOR_PATCH_ENDPOINT` (optional: AI patch 생성 endpoint) - `AI_INSPECTOR_PATCH_API_KEY` (optional) - `AI_INSPECTOR_PREVIEW_URL_TEMPLATE` (optional, example: `https://your-app-git-{branch}.vercel.app`) - `AI_INSPECTOR_DISCORD_WEBHOOK_URL` (optional) +- `AI_INSPECTOR_POLL_INTERVAL_SECONDS` (optional, loop 실행시 기본 900초) 등록 위치: -- GitHub Repository > Settings > Secrets and variables > Actions > Repository secrets +- 로컬 개발: `apps/web/.env.local` + +실행: + +```bash +# 단발 실행 (큐에서 1건 클레임 후 종료) +pnpm ai-inspector:worker + +# 반복 실행 (기본 15분 주기) +pnpm ai-inspector:worker:loop +``` diff --git a/package.json b/package.json index 81bab14d..b7c7e424 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "dev:web": "pnpm --filter @solid-connect/web run dev", "dev:admin": "pnpm --filter @solid-connect/admin run dev", "dev:debug": "turbo dev --filter=@solid-connect/web --filter=@solid-connect/admin", + "ai-inspector:worker": "node --env-file-if-exists=apps/web/.env.local .github/scripts/ai-inspector-worker.mjs", + "ai-inspector:worker:loop": "node --env-file-if-exists=apps/web/.env.local scripts/ai-inspector-worker-loop.mjs", "build": "turbo build", "sync:bruno": "turbo run sync:bruno", "lint": "turbo lint", @@ -22,6 +24,7 @@ "@biomejs/biome": "^2.3.11", "@commitlint/cli": "^20.2.0", "@commitlint/config-conventional": "^20.2.0", + "firebase-admin": "^13.7.0", "husky": "^9.1.7", "turbo": "^2.3.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9bc65ed..2d5efe01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@commitlint/config-conventional': specifier: ^20.2.0 version: 20.3.1 + firebase-admin: + specifier: ^13.7.0 + version: 13.7.0 husky: specifier: ^9.1.7 version: 9.1.7 diff --git a/scripts/ai-inspector-worker-loop.mjs b/scripts/ai-inspector-worker-loop.mjs new file mode 100644 index 00000000..e0f948f2 --- /dev/null +++ b/scripts/ai-inspector-worker-loop.mjs @@ -0,0 +1,53 @@ +import { execFileSync } from "node:child_process"; +import process from "node:process"; + +const ONE_SECOND = 1_000; +const defaultIntervalSeconds = 15 * 60; + +const parseIntervalSeconds = () => { + const rawValue = process.env.AI_INSPECTOR_POLL_INTERVAL_SECONDS; + if (!rawValue) { + return defaultIntervalSeconds; + } + + const value = Number(rawValue); + if (!Number.isFinite(value) || value <= 0) { + throw new Error("AI_INSPECTOR_POLL_INTERVAL_SECONDS must be a positive number."); + } + + return value; +}; + +const runOnce = async () => { + execFileSync("node", [".github/scripts/ai-inspector-worker.mjs"], { + stdio: "inherit", + env: process.env, + }); +}; + +const sleep = async (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const loop = async () => { + const intervalSeconds = parseIntervalSeconds(); + while (true) { + const startedAt = Date.now(); + try { + await runOnce(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[ai-inspector-worker-loop] run failed: ${message}`); + } + + const elapsed = Date.now() - startedAt; + const waitMs = Math.max(ONE_SECOND, intervalSeconds * ONE_SECOND - elapsed); + await sleep(waitMs); + } +}; + +loop().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/turbo.json b/turbo.json index d3499836..9c82ec56 100644 --- a/turbo.json +++ b/turbo.json @@ -10,7 +10,7 @@ "@solid-connect/web#build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**"], - "env": ["NODE_ENV", "NEXT_PUBLIC_*"] + "env": ["NODE_ENV", "NEXT_PUBLIC_*", "SENTRY_*", "FIREBASE_*", "AI_INSPECTOR_*"] }, "sync:bruno": { "outputs": ["src/apis/**"],