From 4983afc4e2ed8a0adb372a509c98fd2c31bf1349 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 27 Mar 2026 17:38:00 +0900 Subject: [PATCH 1/6] fix(worker): use conventional commit messages for ai tasks --- .github/scripts/ai-inspector-worker.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/ai-inspector-worker.mjs b/.github/scripts/ai-inspector-worker.mjs index 7f92348b..6efe3b3e 100644 --- a/.github/scripts/ai-inspector-worker.mjs +++ b/.github/scripts/ai-inspector-worker.mjs @@ -356,8 +356,8 @@ const main = async () => { } const commitMessage = patchApplied - ? `[ai-inspector] apply task ${taskId}` - : `[ai-inspector] capture task ${taskId}`; + ? `feat(ai-inspector): apply task ${taskId}` + : `chore(ai-inspector): capture task ${taskId}`; runGit(["commit", "-m", commitMessage]); runGit(["push", "-u", "origin", branchName]); From 7cea4fc7f6828cb4a1be7c31489d10e1b3ca370a Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 27 Mar 2026 17:44:02 +0900 Subject: [PATCH 2/6] feat(worker): apply real edits and resolve vercel preview url --- .github/scripts/ai-inspector-worker.mjs | 245 +++++++++++++++++++++++- README.md | 16 +- 2 files changed, 251 insertions(+), 10 deletions(-) diff --git a/.github/scripts/ai-inspector-worker.mjs b/.github/scripts/ai-inspector-worker.mjs index 6efe3b3e..ce265c3a 100644 --- a/.github/scripts/ai-inspector-worker.mjs +++ b/.github/scripts/ai-inspector-worker.mjs @@ -31,6 +31,19 @@ const runGit = (args, options = {}) => { const runGitOutput = (args, options = {}) => runCommandOutput("git", args, options); +const runCommand = (command, args, options = {}) => { + execFileSync(command, args, { + stdio: "inherit", + encoding: "utf8", + ...options, + }); +}; + +const sleep = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + const resolveGitHubToken = () => { const envToken = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim(); if (envToken) { @@ -78,6 +91,26 @@ const escapeMarkdown = (value) => String(value ?? "").replace(/`/g, "\\`"); const toIso = () => new Date().toISOString(); +const listChangedFiles = () => + runGitOutput(["status", "--porcelain"]) + .split("\n") + .map((line) => line.trimEnd()) + .filter(Boolean) + .map((line) => { + const rawPath = line.slice(3); + const targetPath = rawPath.includes(" -> ") ? rawPath.split(" -> ").pop() : rawPath; + return String(targetPath ?? "").replace(/^"/, "").replace(/"$/, ""); + }); + +const assertCleanWorkingTree = () => { + const status = runGitOutput(["status", "--porcelain"]).trim(); + if (status) { + throw new Error( + "Working tree must be clean before running ai-inspector worker. Use a dedicated clean worktree.", + ); + } +}; + const buildTaskMarkdown = (taskId, task) => { const selector = task.selector ?? task.element?.selector ?? ""; const pageUrl = task.pageUrl ?? ""; @@ -195,10 +228,7 @@ const sendDiscordNotification = async ({ taskId, prUrl, previewUrl, instruction const requestPatchFromAiEndpoint = async ({ taskId, task, branchName, repository, baseBranch }) => { const endpoint = process.env.AI_INSPECTOR_PATCH_ENDPOINT; if (!endpoint) { - return { - patch: "", - summary: "AI_INSPECTOR_PATCH_ENDPOINT 미설정: 작업 파일만 커밋했습니다.", - }; + return null; } const apiKey = process.env.AI_INSPECTOR_PATCH_API_KEY; @@ -227,9 +257,188 @@ const requestPatchFromAiEndpoint = async ({ taskId, task, branchName, repository patch: typeof data.patch === "string" ? data.patch : "", summary: typeof data.summary === "string" ? data.summary : "AI patch applied", title: typeof data.title === "string" ? data.title : "", + appliedBy: "patch-endpoint", + }; +}; + +const buildCodexPrompt = ({ taskId, task, repository, baseBranch, branchName }) => { + const selector = task.selector ?? task.element?.selector ?? ""; + const pageUrl = task.pageUrl ?? ""; + const textSnippet = task.element?.textSnippet ?? ""; + const instruction = task.instruction ?? ""; + + return [ + "You are implementing one AI inspector UI task in this repository.", + "Apply real code changes that satisfy the request, keeping edits minimal and scoped.", + "Do not create commits, do not push, and do not modify environment files.", + "", + `Repository: ${repository}`, + `Base branch: ${baseBranch}`, + `Working branch: ${branchName}`, + `Task ID: ${taskId}`, + `Page URL: ${pageUrl}`, + `Selector: ${selector}`, + `Text snippet: ${textSnippet}`, + "", + "Instruction:", + instruction, + "", + "After applying changes, ensure files are saved and leave the repo ready for git add/commit.", + ].join("\n"); +}; + +const runLocalCodexEdit = async ({ taskId, task, branchName, repository, baseBranch }) => { + const codexEnabled = process.env.AI_INSPECTOR_LOCAL_CODEX_ENABLED?.trim() ?? "true"; + if (codexEnabled.toLowerCase() === "false") { + return null; + } + + const outputPath = path.resolve(".ai-inspector", `codex-last-message-${taskId}.txt`); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + + const args = [ + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--color", + "never", + "--output-last-message", + outputPath, + ]; + + const model = process.env.AI_INSPECTOR_CODEX_MODEL?.trim(); + if (model) { + args.push("--model", model); + } + + args.push(buildCodexPrompt({ taskId, task, repository, baseBranch, branchName })); + runCommand("codex", args, { env: process.env }); + + const summary = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8").trim() : ""; + fs.rmSync(outputPath, { force: true }); + + return { + patch: "", + summary: summary || "Local Codex edit applied.", + title: "", + appliedBy: "local-codex", }; }; +const resolveAiResult = async (context) => { + const endpointResult = await requestPatchFromAiEndpoint(context); + if (endpointResult) { + return endpointResult; + } + + const localCodexResult = await runLocalCodexEdit(context); + if (localCodexResult) { + return localCodexResult; + } + + throw new Error( + "No AI edit backend available. Configure AI_INSPECTOR_PATCH_ENDPOINT or enable local Codex execution.", + ); +}; + +const getPreviewUrlFromVercel = async (branchName) => { + const token = process.env.VERCEL_TOKEN?.trim(); + const projectId = process.env.VERCEL_PROJECT_ID?.trim(); + + if (!token || !projectId) { + return ""; + } + + const intervalMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_INTERVAL_MS ?? "10000"); + const timeoutMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_TIMEOUT_MS ?? "240000"); + const pollingIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 10000; + const pollingTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 240000; + const deadline = Date.now() + pollingTimeoutMs; + + while (Date.now() < deadline) { + const params = new URLSearchParams({ + projectId, + limit: "20", + target: "preview", + "meta-githubCommitRef": branchName, + }); + + const teamId = process.env.VERCEL_TEAM_ID?.trim(); + if (teamId) { + params.set("teamId", teamId); + } + + const response = await fetch(`https://api.vercel.com/v6/deployments?${params.toString()}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Failed to fetch Vercel deployment (${response.status}): ${body}`); + } + + const data = await response.json(); + const deployments = Array.isArray(data?.deployments) ? data.deployments : []; + const deployment = + deployments.find((item) => item?.meta?.githubCommitRef === branchName) ?? deployments[0] ?? null; + + if (deployment?.readyState === "READY" && deployment?.url) { + return `https://${deployment.url}`; + } + + if (deployment?.readyState === "ERROR" || deployment?.readyState === "CANCELED") { + return ""; + } + + await sleep(pollingIntervalMs); + } + + return ""; +}; + +const getPreviewUrlFromGitHubCommitStatus = async (token, owner, repo, commitSha) => { + const intervalMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_INTERVAL_MS ?? "10000"); + const timeoutMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_TIMEOUT_MS ?? "240000"); + const pollingIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 10000; + const pollingTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 240000; + const deadline = Date.now() + pollingTimeoutMs; + + while (Date.now() < deadline) { + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/commits/${commitSha}/status`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Failed to fetch commit statuses from GitHub (${response.status}): ${body}`); + } + + const data = await response.json(); + const statuses = Array.isArray(data?.statuses) ? data.statuses : []; + const urlStatus = + statuses.find((status) => typeof status?.target_url === "string" && status.target_url.includes("vercel.app")) ?? + statuses.find( + (status) => + typeof status?.context === "string" && + status.context.toLowerCase().includes("vercel") && + typeof status?.target_url === "string", + ); + + if (urlStatus?.target_url) { + return urlStatus.target_url; + } + + await sleep(pollingIntervalMs); + } + + return ""; +}; + const applyPatch = (patch) => { if (!patch.trim()) { return false; @@ -307,6 +516,8 @@ const main = async () => { const baseBranch = process.env.AI_INSPECTOR_BASE_BRANCH || "main"; const collectionName = process.env.AI_INSPECTOR_FIRESTORE_COLLECTION || "aiInspectorTasks"; + assertCleanWorkingTree(); + if (!owner || !repoName) { throw new Error(`Invalid GITHUB_REPOSITORY: ${repo}`); } @@ -341,7 +552,7 @@ const main = async () => { fs.writeFileSync(filePath, buildTaskMarkdown(taskId, task), "utf8"); runGit(["add", filePath]); - const aiResult = await requestPatchFromAiEndpoint({ + const aiResult = await resolveAiResult({ taskId, task, branchName, @@ -350,12 +561,19 @@ const main = async () => { }); const patchApplied = applyPatch(aiResult.patch); - const hasChanges = runGitOutput(["status", "--porcelain"]).trim().length > 0; - if (!hasChanges) { + runGit(["add", "-A"]); + const changedFiles = listChangedFiles(); + if (changedFiles.length === 0) { throw new Error("No changes to commit after AI inspector processing."); } - const commitMessage = patchApplied + const hasRealCodeChange = changedFiles.some((file) => file !== filePath); + if (!hasRealCodeChange) { + throw new Error("AI result did not include real code changes outside the task metadata file."); + } + + const isRealAiApply = patchApplied || aiResult.appliedBy === "local-codex"; + const commitMessage = isRealAiApply ? `feat(ai-inspector): apply task ${taskId}` : `chore(ai-inspector): capture task ${taskId}`; runGit(["commit", "-m", commitMessage]); @@ -387,7 +605,16 @@ const main = async () => { })); const prUrl = pr.html_url; - const previewUrl = getPreviewUrl(branchName); + const commitSha = runGitOutput(["rev-parse", "HEAD"]).trim(); + let previewUrl = getPreviewUrl(branchName); + try { + previewUrl = + (await getPreviewUrlFromVercel(branchName)) || + (await getPreviewUrlFromGitHubCommitStatus(githubToken, owner, repoName, commitSha)) || + previewUrl; + } catch (previewError) { + console.error(`Task ${taskId} preview URL resolution failed`, previewError); + } await taskRef.update({ status: "completed", diff --git a/README.md b/README.md index f240f33c..1dfa1c1f 100644 --- a/README.md +++ b/README.md @@ -112,15 +112,29 @@ Admin 계정일 때 좌측 하단에 AI 인스펙터 플로팅 버튼이 노출 - `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_ENDPOINT` (optional: external AI patch 생성 endpoint) - `AI_INSPECTOR_PATCH_API_KEY` (optional) +- `AI_INSPECTOR_LOCAL_CODEX_ENABLED` (optional, default: `true`) +- `AI_INSPECTOR_CODEX_MODEL` (optional, local codex 모델 고정) - `AI_INSPECTOR_PREVIEW_URL_TEMPLATE` (optional, example: `https://your-app-git-{branch}.vercel.app`) +- `VERCEL_TOKEN` (optional, 설정 시 실제 Vercel deployment URL 조회) +- `VERCEL_PROJECT_ID` (optional, `VERCEL_TOKEN`과 함께 필요) +- `VERCEL_TEAM_ID` (optional) +- `AI_INSPECTOR_VERCEL_PREVIEW_TIMEOUT_MS` (optional, default: `240000`) +- `AI_INSPECTOR_VERCEL_PREVIEW_INTERVAL_MS` (optional, default: `10000`) - `AI_INSPECTOR_DISCORD_WEBHOOK_URL` (optional) - `AI_INSPECTOR_POLL_INTERVAL_SECONDS` (optional, loop 실행시 기본 900초) 등록 위치: - 로컬 개발: `apps/web/.env.local` +참고: +- `AI_INSPECTOR_PATCH_ENDPOINT`가 없으면 로컬 `codex exec`로 실제 코드 수정을 시도합니다. +- task 메타 파일만 변경되고 실제 코드 변경이 없으면 워커는 실패 처리합니다. +- 워커 실행 전 git working tree가 clean 상태여야 합니다. +- `VERCEL_TOKEN`/`VERCEL_PROJECT_ID`가 있으면 Vercel API로 실제 Preview URL을 조회합니다. +- 위 값이 없어도 GitHub commit status의 Vercel `target_url`을 폴링해 Preview URL을 찾습니다. + 실행: ```bash From 09eac116847b615d2f017ce1c7185531584fe932 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 27 Mar 2026 17:50:59 +0900 Subject: [PATCH 3/6] fix(worker): prefer vercel.app preview url for discord --- .github/scripts/ai-inspector-worker.mjs | 142 ++++++++++++++++++++---- 1 file changed, 121 insertions(+), 21 deletions(-) diff --git a/.github/scripts/ai-inspector-worker.mjs b/.github/scripts/ai-inspector-worker.mjs index ce265c3a..19dd2f6c 100644 --- a/.github/scripts/ai-inspector-worker.mjs +++ b/.github/scripts/ai-inspector-worker.mjs @@ -403,6 +403,7 @@ const getPreviewUrlFromGitHubCommitStatus = async (token, owner, repo, commitSha const pollingIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 10000; const pollingTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 240000; const deadline = Date.now() + pollingTimeoutMs; + let lastKnownVercelUrl = ""; while (Date.now() < deadline) { const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/commits/${commitSha}/status`, { @@ -420,23 +421,123 @@ const getPreviewUrlFromGitHubCommitStatus = async (token, owner, repo, commitSha const data = await response.json(); const statuses = Array.isArray(data?.statuses) ? data.statuses : []; - const urlStatus = - statuses.find((status) => typeof status?.target_url === "string" && status.target_url.includes("vercel.app")) ?? - statuses.find( - (status) => - typeof status?.context === "string" && - status.context.toLowerCase().includes("vercel") && - typeof status?.target_url === "string", - ); - - if (urlStatus?.target_url) { - return urlStatus.target_url; + const deploymentUrlStatus = statuses.find( + (status) => typeof status?.target_url === "string" && status.target_url.includes("vercel.app"), + ); + if (deploymentUrlStatus?.target_url) { + return deploymentUrlStatus.target_url; + } + + const vercelStatus = statuses.find( + (status) => + typeof status?.context === "string" && + status.context.toLowerCase().includes("vercel") && + typeof status?.target_url === "string", + ); + if (vercelStatus?.target_url) { + lastKnownVercelUrl = vercelStatus.target_url; } await sleep(pollingIntervalMs); } - return ""; + return lastKnownVercelUrl; +}; + +const getPreviewUrlFromGitHubPrComments = async (token, owner, repo, prNumber) => { + const intervalMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_INTERVAL_MS ?? "10000"); + const timeoutMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_TIMEOUT_MS ?? "240000"); + const pollingIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 10000; + const pollingTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 240000; + const deadline = Date.now() + pollingTimeoutMs; + const vercelUrlPattern = /https:\/\/[^\s)]+\.vercel\.app[^\s)]*/gi; + let lastKnownVercelUrl = ""; + + while (Date.now() < deadline) { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments?per_page=50`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Failed to fetch PR comments from GitHub (${response.status}): ${body}`); + } + + const comments = await response.json(); + if (Array.isArray(comments)) { + for (const comment of comments) { + const body = typeof comment?.body === "string" ? comment.body : ""; + const matches = body.match(vercelUrlPattern); + if (matches && matches.length > 0) { + lastKnownVercelUrl = matches[0]; + if (lastKnownVercelUrl.includes(".vercel.app")) { + return lastKnownVercelUrl; + } + } + } + } + + await sleep(pollingIntervalMs); + } + + return lastKnownVercelUrl; +}; + +const normalizePreviewUrl = (url) => { + if (!url) { + return ""; + } + + const trimmed = url.trim(); + if (!trimmed) { + return ""; + } + + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return trimmed; + } + + if (trimmed.includes(".")) { + return `https://${trimmed}`; + } + + return trimmed; +}; + +const resolvePreviewUrl = async ({ token, owner, repo, branchName, commitSha, prNumber }) => { + const templateUrl = normalizePreviewUrl(getPreviewUrl(branchName)); + + try { + const vercelApiUrl = normalizePreviewUrl(await getPreviewUrlFromVercel(branchName)); + if (vercelApiUrl) { + return vercelApiUrl; + } + + const commitStatusUrl = normalizePreviewUrl(await getPreviewUrlFromGitHubCommitStatus(token, owner, repo, commitSha)); + if (commitStatusUrl.includes(".vercel.app")) { + return commitStatusUrl; + } + + const commentUrl = normalizePreviewUrl(await getPreviewUrlFromGitHubPrComments(token, owner, repo, prNumber)); + if (commentUrl) { + return commentUrl; + } + + if (commitStatusUrl) { + return commitStatusUrl; + } + } catch (previewError) { + console.error("Preview URL resolution failed", previewError); + } + + return templateUrl; }; const applyPatch = (patch) => { @@ -606,15 +707,14 @@ const main = async () => { const prUrl = pr.html_url; const commitSha = runGitOutput(["rev-parse", "HEAD"]).trim(); - let previewUrl = getPreviewUrl(branchName); - try { - previewUrl = - (await getPreviewUrlFromVercel(branchName)) || - (await getPreviewUrlFromGitHubCommitStatus(githubToken, owner, repoName, commitSha)) || - previewUrl; - } catch (previewError) { - console.error(`Task ${taskId} preview URL resolution failed`, previewError); - } + const previewUrl = await resolvePreviewUrl({ + token: githubToken, + owner, + repo: repoName, + branchName, + commitSha, + prNumber: pr.number, + }); await taskRef.update({ status: "completed", From ed1fd45e7020887f9801d8cb42b1be43331c6ea9 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 27 Mar 2026 18:00:40 +0900 Subject: [PATCH 4/6] feat(worker): send separate web/admin preview links to discord --- .github/scripts/ai-inspector-worker.mjs | 115 ++++++++++++++++++------ 1 file changed, 88 insertions(+), 27 deletions(-) diff --git a/.github/scripts/ai-inspector-worker.mjs b/.github/scripts/ai-inspector-worker.mjs index 19dd2f6c..ba23617f 100644 --- a/.github/scripts/ai-inspector-worker.mjs +++ b/.github/scripts/ai-inspector-worker.mjs @@ -181,7 +181,7 @@ const findOpenPrByHead = async (token, owner, repo, branchName) => { return Array.isArray(data) && data.length > 0 ? data[0] : null; }; -const sendDiscordNotification = async ({ taskId, prUrl, previewUrl, instruction }) => { +const sendDiscordNotification = async ({ taskId, prUrl, previewUrl, webPreviewUrl, adminPreviewUrl, instruction }) => { const webhook = process.env.AI_INSPECTOR_DISCORD_WEBHOOK_URL; if (!webhook) { return; @@ -208,8 +208,13 @@ const sendDiscordNotification = async ({ taskId, prUrl, previewUrl, instruction inline: false, }, { - name: "Preview", - value: previewUrl || "설정 없음", + name: "Web Preview", + value: webPreviewUrl || previewUrl || "설정 없음", + inline: false, + }, + { + name: "Admin Preview", + value: adminPreviewUrl || "설정 없음", inline: false, }, ], @@ -444,7 +449,7 @@ const getPreviewUrlFromGitHubCommitStatus = async (token, owner, repo, commitSha return lastKnownVercelUrl; }; -const getPreviewUrlFromGitHubPrComments = async (token, owner, repo, prNumber) => { +const getPreviewUrlsFromGitHubPrComments = async (token, owner, repo, prNumber) => { const intervalMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_INTERVAL_MS ?? "10000"); const timeoutMs = Number(process.env.AI_INSPECTOR_VERCEL_PREVIEW_TIMEOUT_MS ?? "240000"); const pollingIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 10000; @@ -452,6 +457,13 @@ const getPreviewUrlFromGitHubPrComments = async (token, owner, repo, prNumber) = const deadline = Date.now() + pollingTimeoutMs; const vercelUrlPattern = /https:\/\/[^\s)]+\.vercel\.app[^\s)]*/gi; let lastKnownVercelUrl = ""; + let webPreviewUrl = ""; + let adminPreviewUrl = ""; + + const isWebPreviewHost = (hostname) => + hostname.includes("solid-connection-web-git-") || hostname.includes("solid-connection-web-"); + const isAdminPreviewHost = (hostname) => + hostname.includes("solid-connect-web-admin-git-") || hostname.includes("solid-connect-web-admin-"); while (Date.now() < deadline) { const response = await fetch( @@ -476,9 +488,33 @@ const getPreviewUrlFromGitHubPrComments = async (token, owner, repo, prNumber) = const body = typeof comment?.body === "string" ? comment.body : ""; const matches = body.match(vercelUrlPattern); if (matches && matches.length > 0) { - lastKnownVercelUrl = matches[0]; - if (lastKnownVercelUrl.includes(".vercel.app")) { - return lastKnownVercelUrl; + for (const matchedUrl of matches) { + const normalized = normalizePreviewUrl(matchedUrl); + if (!normalized || !normalized.includes(".vercel.app")) { + continue; + } + + lastKnownVercelUrl = normalized; + + try { + const hostname = new URL(normalized).hostname.toLowerCase(); + if (!webPreviewUrl && isWebPreviewHost(hostname)) { + webPreviewUrl = normalized; + } + if (!adminPreviewUrl && isAdminPreviewHost(hostname)) { + adminPreviewUrl = normalized; + } + } catch { + // Ignore invalid URL parse errors and continue scanning. + } + } + + if (webPreviewUrl && adminPreviewUrl) { + return { + webPreviewUrl, + adminPreviewUrl, + anyVercelUrl: lastKnownVercelUrl, + }; } } } @@ -487,7 +523,11 @@ const getPreviewUrlFromGitHubPrComments = async (token, owner, repo, prNumber) = await sleep(pollingIntervalMs); } - return lastKnownVercelUrl; + return { + webPreviewUrl, + adminPreviewUrl, + anyVercelUrl: lastKnownVercelUrl, + }; }; const normalizePreviewUrl = (url) => { @@ -511,33 +551,50 @@ const normalizePreviewUrl = (url) => { return trimmed; }; -const resolvePreviewUrl = async ({ token, owner, repo, branchName, commitSha, prNumber }) => { +const resolvePreviewUrls = async ({ token, owner, repo, branchName, commitSha, prNumber }) => { const templateUrl = normalizePreviewUrl(getPreviewUrl(branchName)); + const isWebPreviewUrl = (url) => + url.includes("solid-connection-web-git-") || url.includes("solid-connection-web-"); + const isAdminPreviewUrl = (url) => + url.includes("solid-connect-web-admin-git-") || url.includes("solid-connect-web-admin-"); + const emptyPreviewUrls = { + previewUrl: templateUrl, + webPreviewUrl: "", + adminPreviewUrl: "", + }; try { const vercelApiUrl = normalizePreviewUrl(await getPreviewUrlFromVercel(branchName)); - if (vercelApiUrl) { - return vercelApiUrl; - } - const commitStatusUrl = normalizePreviewUrl(await getPreviewUrlFromGitHubCommitStatus(token, owner, repo, commitSha)); - if (commitStatusUrl.includes(".vercel.app")) { - return commitStatusUrl; - } - - const commentUrl = normalizePreviewUrl(await getPreviewUrlFromGitHubPrComments(token, owner, repo, prNumber)); - if (commentUrl) { - return commentUrl; - } - - if (commitStatusUrl) { - return commitStatusUrl; - } + const commentPreviewUrls = await getPreviewUrlsFromGitHubPrComments(token, owner, repo, prNumber); + + const webPreviewUrl = + commentPreviewUrls.webPreviewUrl || + [vercelApiUrl, commitStatusUrl, templateUrl].find((url) => Boolean(url) && isWebPreviewUrl(url)) || + ""; + + const adminPreviewUrl = + commentPreviewUrls.adminPreviewUrl || + [vercelApiUrl, commitStatusUrl, templateUrl].find((url) => Boolean(url) && isAdminPreviewUrl(url)) || + ""; + + const previewUrl = + webPreviewUrl || + commentPreviewUrls.anyVercelUrl || + vercelApiUrl || + commitStatusUrl || + templateUrl; + + return { + previewUrl, + webPreviewUrl, + adminPreviewUrl, + }; } catch (previewError) { console.error("Preview URL resolution failed", previewError); } - return templateUrl; + return emptyPreviewUrls; }; const applyPatch = (patch) => { @@ -707,7 +764,7 @@ const main = async () => { const prUrl = pr.html_url; const commitSha = runGitOutput(["rev-parse", "HEAD"]).trim(); - const previewUrl = await resolvePreviewUrl({ + const { previewUrl, webPreviewUrl, adminPreviewUrl } = await resolvePreviewUrls({ token: githubToken, owner, repo: repoName, @@ -721,6 +778,8 @@ const main = async () => { branchName, prUrl, previewUrl, + previewWebUrl: webPreviewUrl, + previewAdminUrl: adminPreviewUrl, completedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), }); @@ -730,6 +789,8 @@ const main = async () => { taskId, prUrl, previewUrl, + webPreviewUrl, + adminPreviewUrl, instruction: task.instruction, }); From 491afd708c70f481385033ffc8d685468c8e79a6 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 27 Mar 2026 18:01:23 +0900 Subject: [PATCH 5/6] fix(worker): format discord preview links with web/admin urls --- .github/scripts/ai-inspector-worker.mjs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/scripts/ai-inspector-worker.mjs b/.github/scripts/ai-inspector-worker.mjs index ba23617f..f5bd98ff 100644 --- a/.github/scripts/ai-inspector-worker.mjs +++ b/.github/scripts/ai-inspector-worker.mjs @@ -91,6 +91,23 @@ const escapeMarkdown = (value) => String(value ?? "").replace(/`/g, "\\`"); const toIso = () => new Date().toISOString(); +const toDisplayPreviewUrl = (value) => { + if (!value) { + return ""; + } + + try { + const parsed = new URL(value); + if (parsed.hostname.endsWith(".vercel.app") && (parsed.pathname === "" || parsed.pathname === "/") && !parsed.search && !parsed.hash) { + return `${parsed.origin}/`; + } + } catch { + return value; + } + + return value; +}; + const listChangedFiles = () => runGitOutput(["status", "--porcelain"]) .split("\n") @@ -187,6 +204,9 @@ const sendDiscordNotification = async ({ taskId, prUrl, previewUrl, webPreviewUr return; } + const webPreviewDisplay = toDisplayPreviewUrl(webPreviewUrl || previewUrl); + const adminPreviewDisplay = toDisplayPreviewUrl(adminPreviewUrl); + const timestamp = toIso(); const response = await fetch(webhook, { method: "POST", @@ -209,12 +229,12 @@ const sendDiscordNotification = async ({ taskId, prUrl, previewUrl, webPreviewUr }, { name: "Web Preview", - value: webPreviewUrl || previewUrl || "설정 없음", + value: webPreviewDisplay || "설정 없음", inline: false, }, { name: "Admin Preview", - value: adminPreviewUrl || "설정 없음", + value: adminPreviewDisplay || "설정 없음", inline: false, }, ], From c21d52ada33176fcdfab6560631d31520ad70b4d Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 27 Mar 2026 18:14:47 +0900 Subject: [PATCH 6/6] fix(worker): sanitize ai inspector pr title and body --- .github/scripts/ai-inspector-worker.mjs | 107 +++++++++++++++++++----- 1 file changed, 88 insertions(+), 19 deletions(-) diff --git a/.github/scripts/ai-inspector-worker.mjs b/.github/scripts/ai-inspector-worker.mjs index f5bd98ff..7b179759 100644 --- a/.github/scripts/ai-inspector-worker.mjs +++ b/.github/scripts/ai-inspector-worker.mjs @@ -91,6 +91,72 @@ const escapeMarkdown = (value) => String(value ?? "").replace(/`/g, "\\`"); const toIso = () => new Date().toISOString(); +const PR_TEXT_NOISE_PATTERNS = [ + /unsupported engine/i, + /progress:\s*resolved/i, + /^packages:\s*\+/i, + /done in \d+(\.\d+)?s/i, + /husky/i, + /warn/i, +]; + +const stripAnsi = (value) => String(value ?? "").replace(/\u001b\[[0-9;]*m/g, ""); + +const normalizeSingleLine = (value) => stripAnsi(value).replace(/\s+/g, " ").trim(); + +const sanitizePrTitle = (value) => { + const normalized = normalizeSingleLine(value); + if (!normalized) { + return "[AI Inspector] UI update request"; + } + + return normalized.slice(0, 120); +}; + +const sanitizePrBodyBlock = (value, maxLength = 1800) => { + const raw = stripAnsi(value).replace(/\r/g, ""); + if (!raw.trim()) { + return "N/A"; + } + + const filtered = raw + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => !PR_TEXT_NOISE_PATTERNS.some((pattern) => pattern.test(line))) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + if (!filtered) { + return "N/A"; + } + + return filtered.slice(0, maxLength).trim(); +}; + +const buildPrTitle = (aiTitle, instruction) => { + const titleFromAi = sanitizePrTitle(aiTitle); + if (titleFromAi && titleFromAi !== "[AI Inspector] UI update request") { + return titleFromAi; + } + + return sanitizePrTitle(`[AI Inspector] ${instruction || "UI update request"}`); +}; + +const buildPrBody = ({ taskId, task, summary }) => + [ + "## Inspector Task", + `- taskId: ${taskId}`, + `- pageUrl: ${normalizeSingleLine(task.pageUrl ?? "unknown") || "unknown"}`, + `- selector: \`${escapeMarkdown(normalizeSingleLine(task.selector ?? task.element?.selector ?? "unknown") || "unknown")}\``, + "", + "## Instruction", + sanitizePrBodyBlock(task.instruction ?? ""), + "", + "## Worker Summary", + sanitizePrBodyBlock(summary ?? "N/A"), + ].join("\n"); + const toDisplayPreviewUrl = (value) => { if (!value) { return ""; @@ -758,29 +824,32 @@ const main = async () => { runGit(["push", "-u", "origin", 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 = [ - `## Inspector Task`, - `- taskId: ${taskId}`, - `- pageUrl: ${task.pageUrl ?? "unknown"}`, - `- selector: \`${task.selector ?? task.element?.selector ?? "unknown"}\``, - "", - `## Instruction`, - `${task.instruction ?? ""}`, - "", - `## Worker Summary`, - `${aiResult.summary ?? "N/A"}`, - ].join("\n"); - - const pr = - existingPr ?? - (await githubRequest(githubToken, "POST", `/repos/${owner}/${repoName}/pulls`, { + const title = buildPrTitle(aiResult.title, task.instruction); + const body = buildPrBody({ + taskId, + task, + summary: aiResult.summary, + }); + + const shouldUpdateExistingPr = + (process.env.AI_INSPECTOR_UPDATE_EXISTING_PR ?? "true").trim().toLowerCase() !== "false"; + + let pr = existingPr; + if (existingPr && shouldUpdateExistingPr) { + pr = await githubRequest(githubToken, "PATCH", `/repos/${owner}/${repoName}/pulls/${existingPr.number}`, { + title, + body, + }); + } + + if (!pr) { + pr = await githubRequest(githubToken, "POST", `/repos/${owner}/${repoName}/pulls`, { title, head: branchName, base: baseBranch, body, - })); + }); + } const prUrl = pr.html_url; const commitSha = runGitOutput(["rev-parse", "HEAD"]).trim();