diff --git a/.github/workflows/automated-code-review.yml b/.github/workflows/automated-code-review.yml
new file mode 100644
index 0000000..96b6bb0
--- /dev/null
+++ b/.github/workflows/automated-code-review.yml
@@ -0,0 +1,50 @@
+name: Automated Code Review
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+jobs:
+ review:
+ if: ${{ !github.event.pull_request.draft }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Run automated review
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ OPENAI_REVIEW_MODEL: ${{ vars.OPENAI_REVIEW_MODEL || 'gpt-5.5' }}
+ REVIEW_REPORT_TITLE: "Automated PR Code Review"
+ REVIEW_CADENCE: "pull-request"
+ REVIEW_BASE: ${{ github.event.pull_request.base.sha }}
+ REVIEW_HEAD: ${{ github.event.pull_request.head.sha }}
+ REVIEW_GITHUB_COMMENT: "1"
+ REVIEW_EXPORT_NOTION: ${{ vars.REVIEW_EXPORT_NOTION || '0' }}
+ NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
+ NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
+ NOTION_TITLE_PROPERTY: ${{ vars.NOTION_TITLE_PROPERTY || 'Name' }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: bun run review -- --fail-on P1
+
+ - name: Upload review report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: automated-code-review
+ path: review-output/
diff --git a/.github/workflows/scheduled-study-report.yml b/.github/workflows/scheduled-study-report.yml
new file mode 100644
index 0000000..3ece2c1
--- /dev/null
+++ b/.github/workflows/scheduled-study-report.yml
@@ -0,0 +1,46 @@
+name: Scheduled Study Review Report
+
+on:
+ schedule:
+ - cron: "0 23 * * *"
+ - cron: "30 23 * * 4"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Publish scheduled review report
+ env:
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ OPENAI_REVIEW_MODEL: ${{ vars.OPENAI_REVIEW_MODEL || 'gpt-5.5' }}
+ REVIEW_REPORT_TITLE: "Scheduled Study Code Review"
+ REVIEW_CADENCE: ${{ github.event.schedule == '30 23 * * 4' && 'weekly-friday' || 'daily-morning' }}
+ REVIEW_BASE: "HEAD~1"
+ REVIEW_HEAD: "HEAD"
+ REVIEW_EXPORT_NOTION: "1"
+ NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
+ NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
+ NOTION_TITLE_PROPERTY: ${{ vars.NOTION_TITLE_PROPERTY || 'Name' }}
+ run: bun run review
+
+ - name: Upload scheduled report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: scheduled-study-review-report
+ path: review-output/
diff --git a/.gitignore b/.gitignore
index 9062b7a..300d093 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@
# testing
/coverage
+/review-output
# next.js
/.next/
diff --git a/AGENTS.md b/AGENTS.md
index 410036d..ebe751f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,6 +9,14 @@ bugs, regressions, security issues, auth problems, CORS problems, token exposure
missing validation, missing tests, broken API contracts, performance risks,
and maintainability problems.
+## Primary Review Criteria
+
+Use these criteria as the first pass for every review:
+
+1. Meaningful variable and function naming
+2. Readability improvements through early returns when they reduce nesting
+3. Runtime performance and complexity risks
+
## Severity
- P0: outage, data loss, critical security issue
@@ -23,4 +31,4 @@ and maintainability problems.
- Separate existing failures from newly introduced failures.
- Prefer concrete, fixable findings.
- Include impact, suggested fix, and verification step.
-- Do not report generic style preferences.
\ No newline at end of file
+- Do not report generic style preferences.
diff --git a/docs/automated-code-review.md b/docs/automated-code-review.md
new file mode 100644
index 0000000..17529f0
--- /dev/null
+++ b/docs/automated-code-review.md
@@ -0,0 +1,60 @@
+# Automated Code Review Pipeline
+
+This repository uses an LLM-backed review harness to review pull request diffs
+and publish structured feedback.
+
+## Flow
+
+1. A pull request is opened, synchronized, reopened, or marked ready for review.
+2. GitHub Actions checks out the repository and installs dependencies with Bun.
+3. `bun run review` collects the base/head diff and changed files.
+4. The harness runs local verification checks such as lint, typecheck, test, and build.
+5. `AGENTS.md` and `review-agents/*.md` are sent with the diff to the OpenAI Responses API.
+6. Findings are normalized into JSON and Markdown reports.
+7. The workflow creates or updates a single automated PR comment.
+8. Scheduled runs can publish the same report format to Notion.
+
+## Review Criteria
+
+The primary review criteria are:
+
+1. Meaningful variable and function naming
+2. Early returns where they improve readability and reduce nesting
+3. Runtime performance and complexity risks
+
+The harness also reviews bugs, regressions, security issues, auth/CORS risks,
+token exposure, missing validation, missing tests, broken API contracts, and
+maintainability risks.
+
+## Required Secrets
+
+- `OPENAI_API_KEY`: required for LLM review.
+- `NOTION_TOKEN`: required only when Notion publishing is enabled.
+- `NOTION_DATABASE_ID`: required only when Notion publishing is enabled.
+
+## Optional Variables
+
+- `OPENAI_REVIEW_MODEL`: defaults to `gpt-5.5`.
+- `REVIEW_EXPORT_NOTION`: set to `1` to publish scheduled or PR reports to Notion.
+- `NOTION_TITLE_PROPERTY`: defaults to `Name`.
+
+## Workflows
+
+- `.github/workflows/automated-code-review.yml` runs on pull requests and posts
+ a review comment.
+- `.github/workflows/scheduled-study-report.yml` runs every morning and once
+ again on Friday morning for a weekly study report cadence.
+
+## Local Verification
+
+Run the harness without calling OpenAI:
+
+```bash
+bun run review -- --skip-checks --skip-agents --base HEAD~1 --head HEAD
+```
+
+Run the full review locally after setting `OPENAI_API_KEY`:
+
+```bash
+bun run review -- --base HEAD~1 --head HEAD
+```
diff --git a/extension/content.css b/extension/content.css
index 386025c..910a201 100644
--- a/extension/content.css
+++ b/extension/content.css
@@ -1,39 +1,114 @@
#codetest-study-uploader-open {
position: fixed;
- right: 22px;
- bottom: 22px;
+ right: 16px;
+ bottom: 16px;
z-index: 2147483646;
border: 0;
- border-radius: 999px;
- background: #2563eb;
+ border-radius: 16px;
+ background: #3182f6;
color: #fff;
cursor: pointer;
font:
- 700 13px / 1 system-ui,
+ 800 13px / 1 "Pretendard Variable",
+ Pretendard,
+ system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
- padding: 12px 16px;
- box-shadow: 0 12px 30px rgba(37, 99, 235, 0.28);
+ padding: 14px 18px;
+ box-shadow: 0 14px 34px rgba(49, 130, 246, 0.3);
+ touch-action: none;
+ transition:
+ background 0.2s ease,
+ box-shadow 0.2s ease,
+ transform 0.2s ease;
+ user-select: none;
+}
+
+#codetest-study-uploader-open:hover {
+ background: #1b64da;
+}
+
+#codetest-study-uploader-open[data-dragging="true"] {
+ cursor: grabbing;
+ transition: none;
+ transform: scale(1.03);
+}
+
+#codetest-study-uploader-open[data-corner="top-left"] {
+ top: 16px;
+ right: auto;
+ bottom: auto;
+ left: 16px;
+}
+
+#codetest-study-uploader-open[data-corner="top-right"] {
+ top: 16px;
+ right: 16px;
+ bottom: auto;
+ left: auto;
+}
+
+#codetest-study-uploader-open[data-corner="bottom-left"] {
+ top: auto;
+ right: auto;
+ bottom: 16px;
+ left: 16px;
+}
+
+#codetest-study-uploader-open[data-corner="bottom-right"] {
+ top: auto;
+ right: 16px;
+ bottom: 16px;
+ left: auto;
}
#codetest-study-uploader-panel {
position: fixed;
- right: 22px;
- bottom: 78px;
+ right: 16px;
+ bottom: 82px;
z-index: 2147483647;
- width: min(360px, calc(100vw - 32px));
+ width: min(420px, calc(100vw - 16px));
box-sizing: border-box;
- border: 1px solid #d9e2f1;
- border-radius: 8px;
+ border: 1px solid #edf1f7;
+ border-radius: 26px;
background: #fff;
- color: #172033;
+ color: #191f28;
display: none;
- padding: 18px;
- box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22);
+ padding: 30px;
+ box-shadow: 0 18px 48px rgba(25, 31, 40, 0.16);
font-family:
- system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ "Pretendard Variable", Pretendard, system-ui, -apple-system,
+ BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+#codetest-study-uploader-panel[data-corner="top-left"] {
+ top: 82px;
+ right: auto;
+ bottom: auto;
+ left: 16px;
+}
+
+#codetest-study-uploader-panel[data-corner="top-right"] {
+ top: 82px;
+ right: 16px;
+ bottom: auto;
+ left: auto;
+}
+
+#codetest-study-uploader-panel[data-corner="bottom-left"] {
+ top: auto;
+ right: auto;
+ bottom: 82px;
+ left: 16px;
+}
+
+#codetest-study-uploader-panel[data-corner="bottom-right"] {
+ top: auto;
+ right: 16px;
+ bottom: 82px;
+ left: auto;
}
#codetest-study-uploader-panel[data-open="true"] {
@@ -48,64 +123,87 @@
display: flex;
align-items: flex-start;
justify-content: space-between;
- gap: 12px;
- margin-bottom: 14px;
+ gap: 14px;
+ margin-bottom: 25px;
+}
+
+.ctsu-header > div {
+ min-width: 0;
+ padding-left: 11px;
+ border-left: 4px solid #3182f6;
}
.ctsu-eyebrow {
- margin: 0 0 4px;
- color: #2563eb;
+ margin: 0 0 5px;
+ color: #3182f6;
font-size: 11px;
- font-weight: 800;
+ font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.ctsu-title {
display: block;
- max-width: 270px;
+ max-width: 326px;
overflow: hidden;
- color: #111827;
- font-size: 16px;
+ color: #191f28;
+ font-size: 20px;
+ font-weight: 800;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
+.ctsu-subtitle {
+ display: block;
+ margin-top: 6px;
+ color: #8b95a1;
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1.45;
+}
+
.ctsu-close {
- width: 28px;
- height: 28px;
+ width: 34px;
+ height: 34px;
border: 0;
- border-radius: 6px;
- background: #f3f6fb;
- color: #607086;
+ border-radius: 999px;
+ background: #f2f4f6;
+ color: #8b95a1;
cursor: pointer;
- font-size: 20px;
+ font-size: 22px;
line-height: 1;
}
+.ctsu-close:hover {
+ background: #e5e8eb;
+ color: #4e5968;
+}
+
.ctsu-field {
display: block;
- margin-top: 12px;
+ margin-top: 17px;
}
.ctsu-field span {
display: block;
- margin-bottom: 7px;
- color: #607086;
+ margin-bottom: 8px;
+ color: #6b7684;
font-size: 12px;
- font-weight: 700;
+ font-weight: 800;
}
.ctsu-difficulty,
.ctsu-memo {
width: 100%;
- border: 1px solid #d9e2f1;
- border-radius: 7px;
- background: #f8fafc;
- color: #172033;
+ border: 0;
+ border-radius: 16px;
+ background: #f8f9fb;
+ color: #191f28;
font:
- 500 14px / 1.45 system-ui,
+ 600 14px / 1.5 "Pretendard Variable",
+ Pretendard,
+ system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
@@ -114,52 +212,64 @@
}
.ctsu-difficulty {
- height: 38px;
- padding: 0 10px;
+ height: 50px;
+ padding: 0 16px;
}
.ctsu-memo {
- min-height: 112px;
- padding: 10px;
+ min-height: 155px;
+ padding: 17px;
resize: vertical;
}
.ctsu-difficulty:focus,
.ctsu-memo:focus {
- border-color: #2563eb;
background: #fff;
- box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
+ box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.16);
}
.ctsu-status {
- min-height: 18px;
- margin: 12px 0;
- color: #607086;
+ min-height: 46px;
+ margin: 18px 0;
+ border-radius: 12px;
+ background: #f2f4f6;
+ color: #6b7684;
font-size: 12px;
+ font-weight: 700;
line-height: 1.45;
+ padding: 12px 14px;
}
.ctsu-status[data-variant="error"] {
+ background: #fff1f2;
color: #dc2626;
}
.ctsu-status[data-variant="success"] {
+ background: #eefbf4;
color: #15803d;
}
.ctsu-submit {
width: 100%;
- height: 42px;
+ height: 54px;
border: 0;
- border-radius: 7px;
- background: #2563eb;
+ border-radius: 16px;
+ background: #3182f6;
color: #fff;
cursor: pointer;
- font-size: 14px;
- font-weight: 800;
+ font-size: 15px;
+ font-weight: 900;
+ box-shadow: 0 10px 22px rgba(49, 130, 246, 0.22);
+}
+
+.ctsu-submit:hover {
+ background: #1b64da;
}
.ctsu-submit:disabled {
+ background: #d4dbe6;
+ box-shadow: none;
+ color: #8b95a1;
cursor: not-allowed;
- opacity: 0.64;
}
diff --git a/extension/content.js b/extension/content.js
index 31a115a..3bfc973 100644
--- a/extension/content.js
+++ b/extension/content.js
@@ -1,7 +1,18 @@
const PROGRAMMERS_LEVELS = ["Lv.0", "Lv.1", "Lv.2", "Lv.3", "Lv.4", "Lv.5"];
+const DEFAULT_CORNER = "bottom-right";
+const DIFFICULTY_CACHE_PREFIX = "programmersDifficulty:";
+const FLOATING_CORNERS = new Set([
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+]);
+
let panel;
let observer;
let autoOpened = false;
+let acceptedDetected = false;
+let currentCorner = DEFAULT_CORNER;
function normalizeText(value) {
return value?.replace(/\s+/g, " ").trim() || "";
@@ -34,12 +45,140 @@ function getProblemTags() {
return Array.from(new Set(candidates)).slice(0, 5);
}
-function getDifficultyFromPage() {
- const text = normalizeText(document.body.innerText);
- const match = text.match(/Lv\.?\s*([0-5])/i);
+function parseDifficultyFromText(text) {
+ const normalizedText = normalizeText(text);
+ const match =
+ normalizedText.match(/Lv\.?\s*([0-5])/i) ||
+ normalizedText.match(/level["'\s:=]+([0-5])/i) ||
+ normalizedText.match(/difficulty["'\s:=]+(?:Lv\.?)?\s*([0-5])/i);
+
return match ? `Lv.${match[1]}` : "Lv.0";
}
+function getLessonId() {
+ return location.pathname.match(/\/lessons\/(\d+)/)?.[1] ?? "";
+}
+
+function findDifficultyNearLessonLink(html, lessonId) {
+ if (!lessonId) {
+ return "Lv.0";
+ }
+
+ const lessonIndex = html.indexOf(`/lessons/${lessonId}`);
+ if (lessonIndex === -1) {
+ return "Lv.0";
+ }
+
+ const nearbyText = html.slice(
+ Math.max(0, lessonIndex - 3000),
+ Math.min(html.length, lessonIndex + 3000),
+ );
+
+ return parseDifficultyFromText(nearbyText);
+}
+
+function getDifficultyFromPage() {
+ const candidates = [
+ document.body.innerText,
+ document.documentElement.innerHTML,
+ ...Array.from(document.querySelectorAll("meta, script"))
+ .map((element) => element.getAttribute("content") || element.textContent)
+ .filter(Boolean),
+ ];
+
+ for (const candidate of candidates) {
+ const difficulty = parseDifficultyFromText(candidate);
+ if (difficulty !== "Lv.0") {
+ return difficulty;
+ }
+ }
+
+ return "Lv.0";
+}
+
+async function getCachedDifficulty(lessonId) {
+ if (!lessonId) {
+ return "";
+ }
+
+ const cacheKey = `${DIFFICULTY_CACHE_PREFIX}${lessonId}`;
+ const cached = await chrome.storage.local.get(cacheKey);
+ return cached[cacheKey] || "";
+}
+
+function setCachedDifficulty(lessonId, difficulty) {
+ if (!lessonId || !difficulty || difficulty === "Lv.0") {
+ return;
+ }
+
+ chrome.storage.local.set({
+ [`${DIFFICULTY_CACHE_PREFIX}${lessonId}`]: difficulty,
+ });
+}
+
+async function fetchDifficultyFromChallengeSearch(title, lessonId) {
+ if (!title || !lessonId) {
+ return "Lv.0";
+ }
+
+ const encodedTitle = encodeURIComponent(title);
+ const searchPaths = [
+ `/learn/challenges?order=recent&search=${encodedTitle}`,
+ `/learn/challenges?order=acceptance_desc&search=${encodedTitle}`,
+ `/learn/challenges?order=level_asc&search=${encodedTitle}`,
+ ];
+
+ for (const path of searchPaths) {
+ const response = await fetch(path, { credentials: "include" });
+ const html = await response.text();
+ const difficulty = findDifficultyNearLessonLink(html, lessonId);
+
+ if (difficulty !== "Lv.0") {
+ return difficulty;
+ }
+ }
+
+ for (const level of [0, 1, 2, 3, 4, 5]) {
+ const response = await fetch(
+ `/learn/challenges?order=recent&levels=${level}&search=${encodedTitle}`,
+ { credentials: "include" },
+ );
+ const html = await response.text();
+
+ if (html.includes(`/lessons/${lessonId}`)) {
+ return `Lv.${level}`;
+ }
+ }
+
+ return "Lv.0";
+}
+
+async function detectDifficulty() {
+ const lessonId = getLessonId();
+ const pageDifficulty = getDifficultyFromPage();
+
+ if (pageDifficulty !== "Lv.0") {
+ setCachedDifficulty(lessonId, pageDifficulty);
+ return pageDifficulty;
+ }
+
+ const cachedDifficulty = await getCachedDifficulty(lessonId);
+ if (cachedDifficulty) {
+ return cachedDifficulty;
+ }
+
+ try {
+ const fetchedDifficulty = await fetchDifficultyFromChallengeSearch(
+ getProblemTitle(),
+ lessonId,
+ );
+ setCachedDifficulty(lessonId, fetchedDifficulty);
+ return fetchedDifficulty;
+ } catch {
+ return "Lv.0";
+ }
+}
+
function getProblemUrl() {
return `${location.origin}${location.pathname}`;
}
@@ -110,6 +249,110 @@ function looksAccepted() {
);
}
+function updateAcceptedState() {
+ if (looksAccepted()) {
+ acceptedDetected = true;
+ }
+
+ return acceptedDetected;
+}
+
+function isFloatingCorner(value) {
+ return FLOATING_CORNERS.has(value);
+}
+
+function nearestCorner(clientX, clientY) {
+ const vertical = clientY < window.innerHeight / 2 ? "top" : "bottom";
+ const horizontal = clientX < window.innerWidth / 2 ? "left" : "right";
+ return `${vertical}-${horizontal}`;
+}
+
+function applyFloatingCorner(corner) {
+ currentCorner = isFloatingCorner(corner) ? corner : DEFAULT_CORNER;
+
+ const button = document.getElementById("codetest-study-uploader-open");
+ if (button) {
+ button.dataset.corner = currentCorner;
+ button.style.left = "";
+ button.style.right = "";
+ button.style.top = "";
+ button.style.bottom = "";
+ }
+
+ if (panel) {
+ panel.dataset.corner = currentCorner;
+ }
+}
+
+async function restoreFloatingCorner() {
+ const { uploaderCorner } = await chrome.storage.local.get("uploaderCorner");
+ applyFloatingCorner(uploaderCorner);
+}
+
+function saveFloatingCorner(corner) {
+ chrome.storage.local.set({ uploaderCorner: corner });
+}
+
+function enableFloatingButtonDrag(button) {
+ let startX = 0;
+ let startY = 0;
+ let moved = false;
+
+ button.addEventListener("pointerdown", (event) => {
+ startX = event.clientX;
+ startY = event.clientY;
+ moved = false;
+ button.dataset.dragging = "true";
+ button.setPointerCapture(event.pointerId);
+ });
+
+ button.addEventListener("pointermove", (event) => {
+ if (button.dataset.dragging !== "true") {
+ return;
+ }
+
+ const distance = Math.hypot(event.clientX - startX, event.clientY - startY);
+ if (distance < 6 && !moved) {
+ return;
+ }
+
+ moved = true;
+ const rect = button.getBoundingClientRect();
+ const margin = 14;
+ const left = Math.min(
+ Math.max(event.clientX - rect.width / 2, margin),
+ window.innerWidth - rect.width - margin,
+ );
+ const top = Math.min(
+ Math.max(event.clientY - rect.height / 2, margin),
+ window.innerHeight - rect.height - margin,
+ );
+
+ button.style.left = `${left}px`;
+ button.style.top = `${top}px`;
+ button.style.right = "auto";
+ button.style.bottom = "auto";
+ });
+
+ button.addEventListener("pointerup", (event) => {
+ if (button.dataset.dragging !== "true") {
+ return;
+ }
+
+ button.dataset.dragging = "false";
+ button.releasePointerCapture(event.pointerId);
+
+ if (!moved) {
+ return;
+ }
+
+ const corner = nearestCorner(event.clientX, event.clientY);
+ button.dataset.dragged = "true";
+ applyFloatingCorner(corner);
+ saveFloatingCorner(corner);
+ });
+}
+
function createFloatingButton() {
if (document.getElementById("codetest-study-uploader-open")) {
return;
@@ -119,8 +362,18 @@ function createFloatingButton() {
button.id = "codetest-study-uploader-open";
button.type = "button";
button.textContent = "스터디 업로드";
- button.addEventListener("click", () => openPanel(false));
+ button.addEventListener("click", (event) => {
+ if (button.dataset.dragged === "true") {
+ event.preventDefault();
+ button.dataset.dragged = "false";
+ return;
+ }
+
+ openPanel(false);
+ });
document.body.appendChild(button);
+ enableFloatingButtonDrag(button);
+ restoreFloatingCorner();
}
function createPanel() {
@@ -131,6 +384,7 @@ function createPanel() {
Programmers
+
통과한 문제만 스터디에 올릴 수 있어요.
@@ -162,22 +416,32 @@ async function openPanel(fromAccepted) {
panel = createPanel();
}
+ if (fromAccepted) {
+ acceptedDetected = true;
+ }
+
const auth = await chrome.runtime.sendMessage({ type: "GET_AUTH_STATUS" });
+ const canUpload = updateAcceptedState();
panel.dataset.open = "true";
+ panel.dataset.ready = String(canUpload);
+ panel.dataset.corner = currentCorner;
panel.querySelector(".ctsu-title").textContent = getProblemTitle();
- panel.querySelector(".ctsu-difficulty").value = getDifficultyFromPage();
+ panel.querySelector(".ctsu-difficulty").value = await detectDifficulty();
const status = panel.querySelector(".ctsu-status");
+ const submitButton = panel.querySelector(".ctsu-submit");
if (!auth?.loggedIn) {
+ submitButton.disabled = true;
status.textContent = "확장 프로그램 아이콘을 눌러 먼저 로그인해주세요.";
status.dataset.variant = "error";
return;
}
- status.textContent = fromAccepted
- ? "통과가 감지됐어요. 메모만 적고 올리면 됩니다."
- : "메모를 적고 업로드할 수 있습니다.";
- status.dataset.variant = "idle";
+ submitButton.disabled = !canUpload;
+ status.textContent = canUpload
+ ? "통과가 확인됐어요. 메모를 적고 업로드할 수 있습니다."
+ : "아직 통과가 확인되지 않았어요. 통과 후 업로드할 수 있습니다.";
+ status.dataset.variant = canUpload ? "success" : "idle";
}
function closePanel() {
@@ -190,13 +454,24 @@ async function submitProblem() {
const submitButton = panel.querySelector(".ctsu-submit");
const status = panel.querySelector(".ctsu-status");
+ if (!updateAcceptedState()) {
+ submitButton.disabled = true;
+ panel.dataset.ready = "false";
+ status.textContent = "문제가 통과된 뒤에만 업로드할 수 있습니다.";
+ status.dataset.variant = "error";
+ return;
+ }
+
submitButton.disabled = true;
status.textContent = "코드와 문제 정보를 읽는 중입니다...";
status.dataset.variant = "idle";
const code = await readCodeFromPage();
+ const selectedDifficulty = panel.querySelector(".ctsu-difficulty").value;
+ const detectedDifficulty = await detectDifficulty();
const payload = {
- difficulty: panel.querySelector(".ctsu-difficulty").value,
+ difficulty:
+ detectedDifficulty !== "Lv.0" ? detectedDifficulty : selectedDifficulty,
memo: panel.querySelector(".ctsu-memo").value,
solution: code,
tags: getProblemTags(),
@@ -225,7 +500,7 @@ async function submitProblem() {
function startAcceptedObserver() {
observer = new MutationObserver(() => {
- if (!autoOpened && looksAccepted()) {
+ if (!autoOpened && updateAcceptedState()) {
autoOpened = true;
openPanel(true);
}
@@ -237,7 +512,7 @@ function startAcceptedObserver() {
});
window.setTimeout(() => {
- if (!autoOpened && looksAccepted()) {
+ if (!autoOpened && updateAcceptedState()) {
autoOpened = true;
openPanel(true);
}
diff --git a/extension/popup.css b/extension/popup.css
index c6cd517..75c1c1a 100644
--- a/extension/popup.css
+++ b/extension/popup.css
@@ -1,74 +1,99 @@
body {
- width: 320px;
+ width: 335px;
margin: 0;
- background: #f8fafc;
- color: #111827;
+ background: #f2f4f6;
+ color: #191f28;
font-family:
- system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ "Pretendard Variable", Pretendard, system-ui, -apple-system,
+ BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
- padding: 18px;
+ margin: 0;
+ background: #fff;
+ padding: 24px;
}
header {
- margin-bottom: 18px;
+ position: relative;
+ margin-bottom: 22px;
+ padding-left: 12px;
+}
+
+header::before {
+ position: absolute;
+ top: 2px;
+ left: 0;
+ width: 4px;
+ height: 24px;
+ border-radius: 999px;
+ background: #3182f6;
+ content: "";
}
header strong {
display: block;
- font-size: 16px;
+ color: #191f28;
+ font-size: 18px;
+ font-weight: 900;
line-height: 1.3;
}
header p {
- margin: 5px 0 0;
- color: #64748b;
+ margin: 6px 0 0;
+ color: #8b95a1;
font-size: 12px;
+ font-weight: 600;
line-height: 1.45;
}
label {
display: block;
- margin-bottom: 12px;
+ margin-bottom: 14px;
}
label span {
display: block;
- margin-bottom: 6px;
- color: #475569;
+ margin-bottom: 8px;
+ color: #6b7684;
font-size: 12px;
- font-weight: 700;
+ font-weight: 800;
}
input {
width: 100%;
- height: 38px;
+ height: 45px;
box-sizing: border-box;
- border: 1px solid #cbd5e1;
- border-radius: 7px;
- background: #fff;
- color: #111827;
- font-size: 13px;
+ border: 0;
+ border-radius: 16px;
+ background: #f8f9fb;
+ color: #191f28;
+ font-size: 14px;
+ font-weight: 600;
outline: none;
- padding: 0 10px;
+ padding: 0 15px;
}
input:focus {
- border-color: #2563eb;
- box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
+ background: #fff;
+ box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.16);
}
button {
width: 100%;
- height: 40px;
+ height: 48px;
border: 0;
- border-radius: 7px;
- background: #2563eb;
+ border-radius: 16px;
+ background: #3182f6;
color: #fff;
cursor: pointer;
- font-size: 13px;
- font-weight: 800;
+ font-size: 15px;
+ font-weight: 900;
+ box-shadow: 0 10px 22px rgba(49, 130, 246, 0.2);
+}
+
+button:hover {
+ background: #1b64da;
}
button:disabled {
@@ -77,43 +102,57 @@ button:disabled {
}
#logout {
- margin-top: 12px;
- background: #e2e8f0;
- color: #1e293b;
+ margin-top: 14px;
+ background: #f2f4f6;
+ box-shadow: none;
+ color: #4e5968;
+}
+
+#logout:hover {
+ background: #e5e8eb;
}
.account {
- border: 1px solid #dbe3ee;
- border-radius: 8px;
- background: #fff;
- padding: 12px;
+ border: 0;
+ border-radius: 16px;
+ background: #f8f9fb;
+ padding: 15px;
}
.account span {
display: block;
- color: #64748b;
+ color: #8b95a1;
font-size: 12px;
+ font-weight: 700;
line-height: 1.35;
}
.account strong {
display: block;
- margin-top: 4px;
- font-size: 15px;
+ margin-top: 5px;
+ color: #191f28;
+ font-size: 16px;
+ font-weight: 900;
}
#status {
- min-height: 18px;
- margin: 12px 0 0;
- color: #64748b;
+ min-height: 38px;
+ margin: 15px 0 0;
+ border-radius: 10px;
+ background: #f2f4f6;
+ color: #6b7684;
font-size: 12px;
+ font-weight: 700;
line-height: 1.45;
+ padding: 10px 12px;
}
#status[data-variant="error"] {
+ background: #fff1f2;
color: #dc2626;
}
#status[data-variant="success"] {
+ background: #eefbf4;
color: #15803d;
}
diff --git a/package.json b/package.json
index 5903aec..dd226a5 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"build": "bun next build",
"start": "bun ext start",
"lint": "bun biome check --apply .",
+ "review": "bun scripts/review-harness.ts",
"test": "bun test ./src/**/*.test.ts",
"format": "biome format --write",
"prepare": "husky"
diff --git a/review-agents/architecture-reviewer.md b/review-agents/architecture-reviewer.md
index e69de29..3a9dc80 100644
--- a/review-agents/architecture-reviewer.md
+++ b/review-agents/architecture-reviewer.md
@@ -0,0 +1,13 @@
+You are the architecture and maintainability reviewer.
+
+Focus only on:
+- code structure and responsibility boundaries
+- duplicated logic introduced by the change
+- hard-to-maintain control flow
+- unclear variable or function naming that can cause misunderstanding
+- early return opportunities that reduce nested branches and improve readability
+- broken API contracts between modules
+
+Return only concrete, actionable findings.
+Do not report broad style preferences.
+Use P0/P1/P2/P3 severity.
diff --git a/review-agents/bug-reviewer.md b/review-agents/bug-reviewer.md
index e69de29..ebdef18 100644
--- a/review-agents/bug-reviewer.md
+++ b/review-agents/bug-reviewer.md
@@ -0,0 +1,13 @@
+You are the bug reviewer.
+
+Focus only on:
+- user-facing regressions
+- runtime exceptions
+- incorrect async or state handling
+- broken edge cases
+- invalid assumptions about nullable or optional data
+- behavior that differs from the apparent product intent
+
+Return only concrete, actionable findings.
+Separate existing failures from newly introduced failures.
+Use P0/P1/P2/P3 severity.
diff --git a/review-agents/performance-reviewer.md b/review-agents/performance-reviewer.md
index e69de29..df033da 100644
--- a/review-agents/performance-reviewer.md
+++ b/review-agents/performance-reviewer.md
@@ -0,0 +1,12 @@
+You are the performance reviewer.
+
+Focus only on:
+- unnecessary repeated network calls
+- expensive re-renders or unstable React dependencies
+- inefficient loops or data transformations
+- avoidable bundle or runtime cost
+- complexity risks introduced by the change
+
+Return only concrete, actionable findings.
+Do not report micro-optimizations without user-visible impact.
+Use P0/P1/P2/P3 severity.
diff --git a/review-agents/reporter.md b/review-agents/reporter.md
index e69de29..bc891d7 100644
--- a/review-agents/reporter.md
+++ b/review-agents/reporter.md
@@ -0,0 +1,10 @@
+You are the report quality reviewer.
+
+Focus only on:
+- whether findings include file, line, impact, suggested fix, and verification
+- whether existing failures are clearly separated from new failures
+- whether the final report is understandable enough to publish to a study log
+- whether high-severity findings are prioritized correctly
+
+Return only concrete, actionable findings about the review report or automation output.
+Use P0/P1/P2/P3 severity.
diff --git a/scripts/review-harness.ts b/scripts/review-harness.ts
index c32e158..4a45137 100644
--- a/scripts/review-harness.ts
+++ b/scripts/review-harness.ts
@@ -1,832 +1,1121 @@
-// import { execFile, spawn } from "node:child_process";
-// import { existsSync } from "node:fs";
-// import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
-// import path from "node:path";
-// import { fileURLToPath } from "node:url";
-
-// type Severity = "P0" | "P1" | "P2" | "P3";
-
-// type Finding = {
-// severity: Severity;
-// title: string;
-// file?: string;
-// line?: number;
-// impact: string;
-// suggestedFix: string;
-// verification: string;
-// source: string;
-// existingFailure?: boolean;
-// };
-
-// type CheckResult = {
-// name: string;
-// command?: string;
-// exitCode?: number;
-// durationMs?: number;
-// stdout?: string;
-// stderr?: string;
-// skipped?: boolean;
-// reason?: string;
-// };
-
-// type AgentPrompt = {
-// name: string;
-// prompt: string;
-// filePath: string;
-// };
-
-// type ReviewReport = {
-// generatedAt: string;
-// repoRoot: string;
-// base: string;
-// head: string;
-// changedFiles: string[];
-// checks: CheckResult[];
-// findings: Finding[];
-// warnings: string[];
-// };
-
-// type CliOptions = {
-// base?: string;
-// head?: string;
-// agentCommand?: string;
-// failOn?: Severity;
-// jsonOut: string;
-// markdownOut: string;
-// skipChecks: boolean;
-// skipAgents: boolean;
-// notion: boolean;
-// githubComment: boolean;
-// prNumber?: string;
-// maxDiffBytes: number;
-// };
-
-// const __filename = fileURLToPath(import.meta.url);
-// const __dirname = path.dirname(__filename);
-// const repoRoot = path.resolve(__dirname, "..");
-// const severityRank: Record = { P0: 0, P1: 1, P2: 2, P3: 3 };
-
-// async function main() {
-// const options = parseArgs(process.argv.slice(2));
-// const warnings: string[] = [];
-
-// // 1. Read AGENTS.md.
-// const agentsInstructions = await readOptionalFile(path.join(repoRoot, "AGENTS.md"));
-// if (!agentsInstructions) {
-// warnings.push("AGENTS.md was not found or was empty.");
-// }
-
-// // 2. Read sub-agent prompts.
-// const agentPrompts = await readAgentPrompts(path.join(repoRoot, "review-agents"), warnings);
-
-// // 3. Resolve base/head commits.
-// const refs = await computeRefs(options);
-
-// // 4. Collect git diff.
-// const diff = await collectDiff(refs.base, refs.head, options.maxDiffBytes, warnings);
-
-// // 5. Collect changed files.
-// const changedFiles = await collectChangedFiles(refs.base, refs.head);
-
-// // 6. Run lint/typecheck/test/build.
-// const checks = options.skipChecks ? [] : await runChecks(warnings);
-
-// // 7. Run each sub-agent review.
-// const agentFindings = options.skipAgents
-// ? []
-// : await runReviewAgents({
-// agentPrompts,
-// agentCommand: options.agentCommand,
-// agentsInstructions,
-// base: refs.base,
-// changedFiles,
-// checks,
-// diff,
-// head: refs.head,
-// warnings,
-// });
-
-// const findingsFromChecks = checks.flatMap(checkResultToFinding);
-
-// // 8. Merge duplicate findings.
-// // 9. Sort by severity.
-// const findings = mergeAndSortFindings([...findingsFromChecks, ...agentFindings]);
-
-// const report: ReviewReport = {
-// generatedAt: new Date().toISOString(),
-// repoRoot,
-// base: refs.base,
-// head: refs.head,
-// changedFiles,
-// checks,
-// findings,
-// warnings,
-// };
-
-// await mkdir(path.dirname(options.jsonOut), { recursive: true });
-// await mkdir(path.dirname(options.markdownOut), { recursive: true });
-// await writeFile(options.jsonOut, `${JSON.stringify(report, null, 2)}\n`, "utf8");
-// await writeFile(options.markdownOut, renderMarkdown(report), "utf8");
-
-// // 10. Export to Notion.
-// if (options.notion) {
-// await exportToNotion(report, warnings);
-// await writeFile(options.jsonOut, `${JSON.stringify(report, null, 2)}\n`, "utf8");
-// await writeFile(options.markdownOut, renderMarkdown(report), "utf8");
-// }
-
-// // 11. Write a GitHub comment when this is a PR.
-// if (options.githubComment) {
-// await commentOnGitHubPr(report, options.prNumber, warnings);
-// await writeFile(options.jsonOut, `${JSON.stringify(report, null, 2)}\n`, "utf8");
-// await writeFile(options.markdownOut, renderMarkdown(report), "utf8");
-// }
-
-// printSummary(report, options);
-
-// // 12. Optionally fail on P0/P1 or another configured threshold.
-// if (options.failOn && findings.some((finding) => severityRank[finding.severity] <= severityRank[options.failOn!])) {
-// process.exitCode = 1;
-// }
-// }
-
-// function parseArgs(args: string[]): CliOptions {
-// const options: CliOptions = {
-// base: process.env.REVIEW_BASE,
-// head: process.env.REVIEW_HEAD,
-// agentCommand: process.env.REVIEW_AGENT_CMD,
-// failOn: parseSeverity(process.env.REVIEW_FAIL_ON),
-// jsonOut: path.resolve(repoRoot, process.env.REVIEW_JSON_OUT ?? "review-output/report.json"),
-// markdownOut: path.resolve(repoRoot, process.env.REVIEW_MARKDOWN_OUT ?? "review-output/report.md"),
-// skipChecks: process.env.REVIEW_SKIP_CHECKS === "1",
-// skipAgents: process.env.REVIEW_SKIP_AGENTS === "1",
-// notion: process.env.REVIEW_EXPORT_NOTION === "1",
-// githubComment: process.env.REVIEW_GITHUB_COMMENT === "1",
-// prNumber: process.env.PR_NUMBER,
-// maxDiffBytes: Number(process.env.REVIEW_MAX_DIFF_BYTES ?? 180_000),
-// };
-
-// for (let index = 0; index < args.length; index += 1) {
-// const arg = args[index];
-// const next = args[index + 1];
-
-// if (arg === "--base" && next) {
-// options.base = next;
-// index += 1;
-// } else if (arg === "--head" && next) {
-// options.head = next;
-// index += 1;
-// } else if (arg === "--agent-cmd" && next) {
-// options.agentCommand = next;
-// index += 1;
-// } else if (arg === "--fail-on" && next) {
-// options.failOn = parseSeverity(next);
-// index += 1;
-// } else if (arg === "--json-out" && next) {
-// options.jsonOut = path.resolve(repoRoot, next);
-// index += 1;
-// } else if (arg === "--markdown-out" && next) {
-// options.markdownOut = path.resolve(repoRoot, next);
-// index += 1;
-// } else if (arg === "--max-diff-bytes" && next) {
-// options.maxDiffBytes = Number(next);
-// index += 1;
-// } else if (arg === "--pr" && next) {
-// options.prNumber = next;
-// index += 1;
-// } else if (arg === "--skip-checks") {
-// options.skipChecks = true;
-// } else if (arg === "--skip-agents") {
-// options.skipAgents = true;
-// } else if (arg === "--notion") {
-// options.notion = true;
-// } else if (arg === "--github-comment") {
-// options.githubComment = true;
-// }
-// }
-
-// return options;
-// }
-
-// async function computeRefs(options: CliOptions) {
-// const head = options.head ?? (await git(["rev-parse", "HEAD"])).stdout.trim();
-// const base = options.base ?? (await detectBaseRef(head));
-
-// return { base, head };
-// }
-
-// async function detectBaseRef(head: string) {
-// const candidates = [
-// process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : undefined,
-// process.env.GITHUB_BASE_REF,
-// "origin/main",
-// "origin/master",
-// "main",
-// "master",
-// `${head}~1`,
-// ].filter(Boolean) as string[];
-
-// for (const candidate of candidates) {
-// const mergeBase = await git(["merge-base", head, candidate], { allowFailure: true });
-// if (mergeBase.exitCode === 0 && mergeBase.stdout.trim()) {
-// return mergeBase.stdout.trim();
-// }
-// }
-
-// throw new Error("Could not detect a base ref. Pass --base [ or set REVIEW_BASE.");
-// }
-
-// async function collectDiff(base: string, head: string, maxBytes: number, warnings: string[]) {
-// const diff = (await git(["diff", "--find-renames", "--find-copies", "--unified=80", `${base}...${head}`])).stdout;
-
-// if (Buffer.byteLength(diff, "utf8") <= maxBytes) {
-// return diff;
-// }
-
-// warnings.push(`Diff was truncated to ${maxBytes} bytes for agent prompts.`);
-// return `${Buffer.from(diff, "utf8").subarray(0, maxBytes).toString("utf8")}\n\n[diff truncated]\n`;
-// }
-
-// async function collectChangedFiles(base: string, head: string) {
-// const output = (await git(["diff", "--name-only", `${base}...${head}`])).stdout.trim();
-// return output ? output.split(/\r?\n/).filter(Boolean) : [];
-// }
-
-// async function readAgentPrompts(agentDir: string, warnings: string[]) {
-// if (!existsSync(agentDir)) {
-// warnings.push("review-agents directory was not found.");
-// return [];
-// }
-
-// const entries = await readdir(agentDir, { withFileTypes: true });
-// const prompts: AgentPrompt[] = [];
-
-// for (const entry of entries) {
-// if (!entry.isFile() || !entry.name.endsWith(".md")) {
-// continue;
-// }
-
-// const filePath = path.join(agentDir, entry.name);
-// const prompt = (await readOptionalFile(filePath)).trim();
-// if (!prompt) {
-// warnings.push(`${entry.name} is empty and was skipped.`);
-// continue;
-// }
-
-// prompts.push({
-// name: entry.name.replace(/\.md$/u, ""),
-// prompt,
-// filePath,
-// });
-// }
-
-// return prompts;
-// }
-
-// async function runChecks(warnings: string[]) {
-// const packageJson = await readPackageJson();
-// const packageManager = detectPackageManager();
-// const scripts = packageJson?.scripts ?? {};
-// const requestedChecks = (process.env.REVIEW_CHECKS ?? "lint,typecheck,test,build")
-// .split(",")
-// .map((check) => check.trim())
-// .filter(Boolean);
-
-// const results: CheckResult[] = [];
-
-// for (const check of requestedChecks) {
-// const command = resolveCheckCommand(check, scripts, packageManager);
-// if (!command) {
-// results.push({
-// name: check,
-// skipped: true,
-// reason: `No ${check} script or safe fallback was found.`,
-// });
-// continue;
-// }
-
-// const startedAt = Date.now();
-// const result = await runShellCommand(command, { allowFailure: true });
-// const checkResult: CheckResult = {
-// name: check,
-// command,
-// exitCode: result.exitCode,
-// durationMs: Date.now() - startedAt,
-// stdout: trimOutput(result.stdout),
-// stderr: trimOutput(result.stderr),
-// };
-// results.push(checkResult);
-
-// if (result.exitCode !== 0) {
-// warnings.push(`${check} failed with exit code ${result.exitCode}.`);
-// }
-// }
-
-// return results;
-// }
-
-// function resolveCheckCommand(check: string, scripts: Record, packageManager: string) {
-// if (check === "lint" && scripts.lint && /\bbiome\b/u.test(scripts.lint) && /--(?:apply|write)\b/u.test(scripts.lint)) {
-// return `${packageManager} biome check .`;
-// }
-
-// if (scripts[check]) {
-// return `${packageManager} run ${check}`;
-// }
-
-// if (check === "typecheck" && existsSync(path.join(repoRoot, "tsconfig.json"))) {
-// const localTsc = path.join(repoRoot, "node_modules", "typescript", "bin", "tsc");
-// if (existsSync(localTsc)) {
-// return `${quote(process.execPath)} ${quote(localTsc)} --noEmit --pretty false`;
-// }
-// }
-
-// return undefined;
-// }
-
-// async function runReviewAgents(input: {
-// agentPrompts: AgentPrompt[];
-// agentCommand?: string;
-// agentsInstructions: string;
-// base: string;
-// head: string;
-// changedFiles: string[];
-// checks: CheckResult[];
-// diff: string;
-// warnings: string[];
-// }) {
-// if (!input.agentCommand) {
-// input.warnings.push("No REVIEW_AGENT_CMD or --agent-cmd was provided, so sub-agent review was skipped.");
-// return [];
-// }
-
-// const findings: Finding[] = [];
-
-// for (const agentPrompt of input.agentPrompts) {
-// const prompt = buildAgentPrompt({
-// agentPrompt,
-// agentsInstructions: input.agentsInstructions,
-// base: input.base,
-// changedFiles: input.changedFiles,
-// checks: input.checks,
-// diff: input.diff,
-// head: input.head,
-// });
-
-// const result = await runShellCommand(input.agentCommand, {
-// allowFailure: true,
-// stdin: prompt,
-// timeoutMs: Number(process.env.REVIEW_AGENT_TIMEOUT_MS ?? 300_000),
-// });
-
-// if (result.exitCode !== 0) {
-// input.warnings.push(`${agentPrompt.name} exited with code ${result.exitCode}.`);
-// }
-
-// const parsed = parseAgentFindings(result.stdout || result.stderr, agentPrompt.name, input.warnings);
-// findings.push(...parsed);
-// }
-
-// return findings;
-// }
-
-// function buildAgentPrompt(input: {
-// agentPrompt: AgentPrompt;
-// agentsInstructions: string;
-// base: string;
-// head: string;
-// changedFiles: string[];
-// checks: CheckResult[];
-// diff: string;
-// }) {
-// return [
-// input.agentsInstructions,
-// "",
-// `# Sub-agent role: ${input.agentPrompt.name}`,
-// input.agentPrompt.prompt,
-// "",
-// "# Required output",
-// "Return only JSON in this shape:",
-// JSON.stringify(
-// {
-// findings: [
-// {
-// severity: "P1",
-// title: "Short actionable title",
-// file: "src/example.ts",
-// line: 10,
-// impact: "What breaks or what risk is introduced",
-// suggestedFix: "Concrete fix",
-// verification: "How to verify the fix",
-// existingFailure: false,
-// },
-// ],
-// },
-// null,
-// 2,
-// ),
-// "Use an empty findings array if there are no actionable findings.",
-// "",
-// "# Review context",
-// `Base: ${input.base}`,
-// `Head: ${input.head}`,
-// "",
-// "# Changed files",
-// input.changedFiles.join("\n") || "(none)",
-// "",
-// "# Check results",
-// JSON.stringify(input.checks, null, 2),
-// "",
-// "# Diff",
-// input.diff || "(empty diff)",
-// ].join("\n");
-// }
-
-// function parseAgentFindings(rawOutput: string, source: string, warnings: string[]) {
-// const jsonText = extractJson(rawOutput);
-// if (!jsonText) {
-// warnings.push(`${source} did not return parseable JSON.`);
-// return [];
-// }
-
-// try {
-// const parsed = JSON.parse(jsonText) as { findings?: Partial[] };
-// if (!Array.isArray(parsed.findings)) {
-// warnings.push(`${source} JSON did not include a findings array.`);
-// return [];
-// }
-
-// return parsed.findings
-// .map((finding) => normalizeFinding(finding, source))
-// .filter((finding): finding is Finding => Boolean(finding));
-// } catch (error) {
-// warnings.push(`${source} JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
-// return [];
-// }
-// }
-
-// function normalizeFinding(finding: Partial, source: string): Finding | undefined {
-// const severity = parseSeverity(finding.severity);
-// if (!severity || !finding.title || !finding.impact || !finding.suggestedFix || !finding.verification) {
-// return undefined;
-// }
-
-// const line = Number(finding.line);
-
-// return {
-// severity,
-// title: finding.title,
-// file: finding.file,
-// line: Number.isFinite(line) ? line : undefined,
-// impact: finding.impact,
-// suggestedFix: finding.suggestedFix,
-// verification: finding.verification,
-// source,
-// existingFailure: Boolean(finding.existingFailure),
-// };
-// }
-
-// function extractJson(rawOutput: string) {
-// const fenced = rawOutput.match(/```(?:json)?\s*([\s\S]*?)```/u);
-// if (fenced?.[1]) {
-// return fenced[1].trim();
-// }
-
-// const start = rawOutput.indexOf("{");
-// const end = rawOutput.lastIndexOf("}");
-// if (start >= 0 && end > start) {
-// return rawOutput.slice(start, end + 1).trim();
-// }
-
-// return undefined;
-// }
-
-// function checkResultToFinding(check: CheckResult): Finding[] {
-// if (check.skipped || check.exitCode === undefined || check.exitCode === 0) {
-// return [];
-// }
-
-// const severity: Severity = check.name === "build" || check.name === "typecheck" || check.name === "test" ? "P1" : "P2";
-// const output = [check.stderr, check.stdout].filter(Boolean).join("\n").trim();
-
-// return [
-// {
-// severity,
-// title: `${check.name} failed`,
-// file: "package.json",
-// impact: `The ${check.name} check exits with code ${check.exitCode}, so the change is not currently passing CI-equivalent verification.`,
-// suggestedFix: `Run \`${check.command}\` locally, fix the reported failure, and rerun the harness.`,
-// verification: `\`${check.command}\` exits with code 0.`,
-// source: "harness",
-// existingFailure: false,
-// ...(output ? { suggestedFix: `Run \`${check.command}\` locally and fix the reported failure. First output:\n${trimOutput(output, 1_000)}` } : {}),
-// },
-// ];
-// }
-
-// function mergeAndSortFindings(findings: Finding[]) {
-// const merged = new Map();
-
-// for (const finding of findings) {
-// const key = [
-// finding.severity,
-// finding.file ?? "",
-// finding.line ?? "",
-// normalizeText(finding.title),
-// ].join("|");
-
-// const existing = merged.get(key);
-// if (!existing) {
-// merged.set(key, finding);
-// continue;
-// }
-
-// existing.source = Array.from(new Set([...existing.source.split(", "), finding.source])).join(", ");
-// }
-
-// return Array.from(merged.values()).sort((left, right) => {
-// const severityDelta = severityRank[left.severity] - severityRank[right.severity];
-// if (severityDelta !== 0) {
-// return severityDelta;
-// }
-
-// return `${left.file ?? ""}:${left.line ?? 0}:${left.title}`.localeCompare(
-// `${right.file ?? ""}:${right.line ?? 0}:${right.title}`,
-// );
-// });
-// }
-
-// async function exportToNotion(report: ReviewReport, warnings: string[]) {
-// const token = process.env.NOTION_TOKEN;
-// const databaseId = process.env.NOTION_DATABASE_ID;
-// const titleProperty = process.env.NOTION_TITLE_PROPERTY ?? "Name";
-
-// if (!token || !databaseId) {
-// warnings.push("Notion export skipped because NOTION_TOKEN or NOTION_DATABASE_ID is missing.");
-// return;
-// }
-
-// const markdown = renderMarkdown(report);
-// const response = await fetch("https://api.notion.com/v1/pages", {
-// method: "POST",
-// headers: {
-// Authorization: `Bearer ${token}`,
-// "Content-Type": "application/json",
-// "Notion-Version": "2022-06-28",
-// },
-// body: JSON.stringify({
-// parent: { database_id: databaseId },
-// properties: {
-// [titleProperty]: {
-// title: [{ text: { content: `Code review ${shortSha(report.head)}` } }],
-// },
-// },
-// children: chunkText(markdown, 1_900).slice(0, 90).map((chunk) => ({
-// object: "block",
-// type: "paragraph",
-// paragraph: {
-// rich_text: [{ type: "text", text: { content: chunk } }],
-// },
-// })),
-// }),
-// });
-
-// if (!response.ok) {
-// warnings.push(`Notion export failed: ${response.status} ${await response.text()}`);
-// }
-// }
-
-// async function commentOnGitHubPr(report: ReviewReport, prNumber: string | undefined, warnings: string[]) {
-// const token = process.env.GITHUB_TOKEN;
-// const repository = process.env.GITHUB_REPOSITORY;
-// const pullRequestNumber = prNumber ?? (await detectGitHubPrNumber());
-
-// if (!token || !repository || !pullRequestNumber) {
-// warnings.push("GitHub PR comment skipped because GITHUB_TOKEN, GITHUB_REPOSITORY, or PR number is missing.");
-// return;
-// }
-
-// const response = await fetch(`https://api.github.com/repos/${repository}/issues/${pullRequestNumber}/comments`, {
-// method: "POST",
-// headers: {
-// Authorization: `Bearer ${token}`,
-// Accept: "application/vnd.github+json",
-// "Content-Type": "application/json",
-// "X-GitHub-Api-Version": "2022-11-28",
-// },
-// body: JSON.stringify({ body: renderMarkdown(report).slice(0, 60_000) }),
-// });
-
-// if (!response.ok) {
-// warnings.push(`GitHub comment failed: ${response.status} ${await response.text()}`);
-// }
-// }
-
-// async function detectGitHubPrNumber() {
-// const eventPath = process.env.GITHUB_EVENT_PATH;
-// if (!eventPath || !existsSync(eventPath)) {
-// return undefined;
-// }
-
-// const event = JSON.parse(await readFile(eventPath, "utf8")) as { pull_request?: { number?: number } };
-// return event.pull_request?.number ? String(event.pull_request.number) : undefined;
-// }
-
-// function renderMarkdown(report: ReviewReport) {
-// const lines = [
-// "# Automated Code Review",
-// "",
-// `- Generated: ${report.generatedAt}`,
-// `- Base: \`${report.base}\``,
-// `- Head: \`${report.head}\``,
-// `- Changed files: ${report.changedFiles.length}`,
-// `- Findings: ${report.findings.length}`,
-// "",
-// "## Checks",
-// "",
-// ...report.checks.map((check) => {
-// if (check.skipped) {
-// return `- ${check.name}: skipped (${check.reason})`;
-// }
-
-// return `- ${check.name}: ${check.exitCode === 0 ? "passed" : `failed (${check.exitCode})`} - \`${check.command}\``;
-// }),
-// "",
-// "## Findings",
-// "",
-// ];
-
-// if (report.findings.length === 0) {
-// lines.push("No actionable findings.");
-// } else {
-// for (const finding of report.findings) {
-// const location = finding.file ? `${finding.file}${finding.line ? `:${finding.line}` : ""}` : "No file";
-// lines.push(
-// `### ${finding.severity} ${finding.title}`,
-// "",
-// `- Location: ${location}`,
-// `- Source: ${finding.source}`,
-// `- Existing failure: ${finding.existingFailure ? "yes" : "no"}`,
-// `- Impact: ${finding.impact}`,
-// `- Suggested fix: ${finding.suggestedFix}`,
-// `- Verification: ${finding.verification}`,
-// "",
-// );
-// }
-// }
-
-// if (report.warnings.length > 0) {
-// lines.push("", "## Warnings", "", ...report.warnings.map((warning) => `- ${warning}`));
-// }
-
-// return `${lines.join("\n")}\n`;
-// }
-
-// function printSummary(report: ReviewReport, options: CliOptions) {
-// console.log(renderMarkdown(report));
-// console.log(`JSON report: ${options.jsonOut}`);
-// console.log(`Markdown report: ${options.markdownOut}`);
-// }
-
-// async function readPackageJson() {
-// const packagePath = path.join(repoRoot, "package.json");
-// if (!existsSync(packagePath)) {
-// return undefined;
-// }
-
-// return JSON.parse(await readFile(packagePath, "utf8")) as { scripts?: Record };
-// }
-
-// function detectPackageManager() {
-// if (existsSync(path.join(repoRoot, "bun.lock"))) {
-// return "bun";
-// }
-
-// if (existsSync(path.join(repoRoot, "pnpm-lock.yaml"))) {
-// return "pnpm";
-// }
-
-// if (existsSync(path.join(repoRoot, "yarn.lock"))) {
-// return "yarn";
-// }
-
-// return "npm";
-// }
-
-// async function readOptionalFile(filePath: string) {
-// if (!existsSync(filePath)) {
-// return "";
-// }
-
-// return readFile(filePath, "utf8");
-// }
-
-// async function git(args: string[], options: { allowFailure?: boolean } = {}) {
-// return execFileCapture("git", ["-c", `safe.directory=${repoRoot.replaceAll("\\", "/")}`, ...args], options);
-// }
-
-// async function execFileCapture(command: string, args: string[], options: { allowFailure?: boolean } = {}) {
-// return new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => {
-// execFile(command, args, { cwd: repoRoot, maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
-// const exitCode =
-// error && typeof error === "object" && "code" in error && typeof error.code === "number" ? error.code : 0;
-// const result = { stdout, stderr, exitCode };
-
-// if (error && !options.allowFailure) {
-// reject(Object.assign(error, result));
-// return;
-// }
-
-// resolve(result);
-// });
-// });
-// }
-
-// async function runShellCommand(
-// command: string,
-// options: { allowFailure?: boolean; stdin?: string; timeoutMs?: number } = {},
-// ) {
-// return new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => {
-// const child = spawn(command, {
-// cwd: repoRoot,
-// shell: true,
-// stdio: ["pipe", "pipe", "pipe"],
-// timeout: options.timeoutMs,
-// });
-
-// let stdout = "";
-// let stderr = "";
-
-// child.stdout.on("data", (chunk: Buffer) => {
-// stdout += chunk.toString("utf8");
-// });
-
-// child.stderr.on("data", (chunk: Buffer) => {
-// stderr += chunk.toString("utf8");
-// });
-
-// child.on("error", (error) => {
-// if (options.allowFailure) {
-// resolve({ stdout, stderr: `${stderr}\n${error.message}`.trim(), exitCode: 1 });
-// return;
-// }
-
-// reject(error);
-// });
-
-// child.on("close", (code) => {
-// const exitCode = code ?? 1;
-// const result = { stdout, stderr, exitCode };
-
-// if (exitCode !== 0 && !options.allowFailure) {
-// reject(Object.assign(new Error(`Command failed: ${command}`), result));
-// return;
-// }
-
-// resolve(result);
-// });
-
-// if (options.stdin) {
-// child.stdin.write(options.stdin);
-// }
-// child.stdin.end();
-// });
-// }
-
-// function parseSeverity(value: unknown): Severity | undefined {
-// return value === "P0" || value === "P1" || value === "P2" || value === "P3" ? value : undefined;
-// }
-
-// function normalizeText(value: string) {
-// return value.toLowerCase().replace(/\s+/gu, " ").trim();
-// }
-
-// function quote(value: string) {
-// return `"${value.replace(/"/gu, '\\"')}"`;
-// }
-
-// function shortSha(value: string) {
-// return value.slice(0, 7);
-// }
-
-// function chunkText(value: string, chunkSize: number) {
-// const chunks: string[] = [];
-// for (let index = 0; index < value.length; index += chunkSize) {
-// chunks.push(value.slice(index, index + chunkSize));
-// }
-// return chunks;
-// }
-
-// function trimOutput(value = "", maxLength = 4_000) {
-// const normalized = value.trim();
-// if (normalized.length <= maxLength) {
-// return normalized;
-// }
-
-// return `${normalized.slice(0, maxLength)}\n[output truncated]`;
-// }
-
-// main().catch((error) => {
-// console.error(error instanceof Error ? error.stack ?? error.message : error);
-// process.exitCode = 1;
-// });
+import { execFile, spawn } from "node:child_process";
+import { existsSync } from "node:fs";
+import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+type Severity = "P0" | "P1" | "P2" | "P3";
+
+type Finding = {
+ severity: Severity;
+ title: string;
+ file?: string;
+ line?: number;
+ impact: string;
+ suggestedFix: string;
+ verification: string;
+ source: string;
+ existingFailure: boolean;
+};
+
+type CheckResult = {
+ name: string;
+ command?: string;
+ exitCode?: number;
+ durationMs?: number;
+ stdout?: string;
+ stderr?: string;
+ skipped?: boolean;
+ reason?: string;
+};
+
+type AgentPrompt = {
+ name: string;
+ prompt: string;
+ filePath: string;
+};
+
+type ReviewReport = {
+ title: string;
+ cadence: string;
+ generatedAt: string;
+ repoRoot: string;
+ base: string;
+ head: string;
+ changedFiles: string[];
+ checks: CheckResult[];
+ findings: Finding[];
+ warnings: string[];
+};
+
+type CliOptions = {
+ base?: string;
+ head?: string;
+ title: string;
+ cadence: string;
+ failOn?: Severity;
+ jsonOut: string;
+ markdownOut: string;
+ skipChecks: boolean;
+ skipAgents: boolean;
+ notion: boolean;
+ githubComment: boolean;
+ prNumber?: string;
+ maxDiffBytes: number;
+};
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const repoRoot = path.resolve(__dirname, "..");
+const severityRank: Record = { P0: 0, P1: 1, P2: 2, P3: 3 };
+const githubCommentMarker = "";
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ const warnings: string[] = [];
+
+ const agentsInstructions = await readOptionalFile(
+ path.join(repoRoot, "AGENTS.md"),
+ );
+ if (!agentsInstructions.trim()) {
+ warnings.push("AGENTS.md was not found or was empty.");
+ }
+
+ const agentPrompts = await readAgentPrompts(
+ path.join(repoRoot, "review-agents"),
+ warnings,
+ );
+ const refs = await computeRefs(options);
+ const diff = await collectDiff(
+ refs.base,
+ refs.head,
+ options.maxDiffBytes,
+ warnings,
+ );
+ const changedFiles = await collectChangedFiles(refs.base, refs.head);
+ const checks = options.skipChecks ? [] : await runChecks(warnings);
+ const agentFindings = options.skipAgents
+ ? []
+ : await runOpenAiReviewAgents({
+ agentPrompts,
+ agentsInstructions,
+ base: refs.base,
+ changedFiles,
+ checks,
+ diff,
+ head: refs.head,
+ warnings,
+ });
+ const findings = mergeAndSortFindings([
+ ...checks.flatMap(checkResultToFinding),
+ ...agentFindings,
+ ]);
+
+ const report: ReviewReport = {
+ title: options.title,
+ cadence: options.cadence,
+ generatedAt: new Date().toISOString(),
+ repoRoot,
+ base: refs.base,
+ head: refs.head,
+ changedFiles,
+ checks,
+ findings,
+ warnings,
+ };
+
+ await writeReport(report, options);
+
+ if (options.notion) {
+ await exportToNotion(report, warnings);
+ await writeReport(report, options);
+ }
+
+ if (options.githubComment) {
+ await commentOnGitHubPr(report, options.prNumber, warnings);
+ await writeReport(report, options);
+ }
+
+ printSummary(report, options);
+
+ const failOn = options.failOn;
+ if (
+ failOn &&
+ findings.some(
+ (finding) => severityRank[finding.severity] <= severityRank[failOn],
+ )
+ ) {
+ process.exitCode = 1;
+ }
+}
+
+function parseArgs(args: string[]): CliOptions {
+ const options: CliOptions = {
+ base: process.env.REVIEW_BASE,
+ head: process.env.REVIEW_HEAD,
+ title: process.env.REVIEW_REPORT_TITLE ?? "Automated Code Review",
+ cadence: process.env.REVIEW_CADENCE ?? "pull-request",
+ failOn: parseSeverity(process.env.REVIEW_FAIL_ON),
+ jsonOut: path.resolve(
+ repoRoot,
+ process.env.REVIEW_JSON_OUT ?? "review-output/report.json",
+ ),
+ markdownOut: path.resolve(
+ repoRoot,
+ process.env.REVIEW_MARKDOWN_OUT ?? "review-output/report.md",
+ ),
+ skipChecks: process.env.REVIEW_SKIP_CHECKS === "1",
+ skipAgents: process.env.REVIEW_SKIP_AGENTS === "1",
+ notion: process.env.REVIEW_EXPORT_NOTION === "1",
+ githubComment: process.env.REVIEW_GITHUB_COMMENT === "1",
+ prNumber: process.env.PR_NUMBER,
+ maxDiffBytes: Number(process.env.REVIEW_MAX_DIFF_BYTES ?? 180_000),
+ };
+
+ for (let index = 0; index < args.length; index += 1) {
+ const arg = args[index];
+ const next = args[index + 1];
+
+ if (arg === "--base" && next) {
+ options.base = next;
+ index += 1;
+ } else if (arg === "--head" && next) {
+ options.head = next;
+ index += 1;
+ } else if (arg === "--title" && next) {
+ options.title = next;
+ index += 1;
+ } else if (arg === "--cadence" && next) {
+ options.cadence = next;
+ index += 1;
+ } else if (arg === "--fail-on" && next) {
+ options.failOn = parseSeverity(next);
+ index += 1;
+ } else if (arg === "--json-out" && next) {
+ options.jsonOut = path.resolve(repoRoot, next);
+ index += 1;
+ } else if (arg === "--markdown-out" && next) {
+ options.markdownOut = path.resolve(repoRoot, next);
+ index += 1;
+ } else if (arg === "--max-diff-bytes" && next) {
+ options.maxDiffBytes = Number(next);
+ index += 1;
+ } else if (arg === "--pr" && next) {
+ options.prNumber = next;
+ index += 1;
+ } else if (arg === "--skip-checks") {
+ options.skipChecks = true;
+ } else if (arg === "--skip-agents") {
+ options.skipAgents = true;
+ } else if (arg === "--notion") {
+ options.notion = true;
+ } else if (arg === "--github-comment") {
+ options.githubComment = true;
+ }
+ }
+
+ return options;
+}
+
+async function computeRefs(options: CliOptions) {
+ const head = options.head ?? (await git(["rev-parse", "HEAD"])).stdout.trim();
+ const base = options.base ?? (await detectBaseRef(head));
+
+ return { base, head };
+}
+
+async function detectBaseRef(head: string) {
+ const candidates = [
+ process.env.GITHUB_BASE_REF
+ ? `origin/${process.env.GITHUB_BASE_REF}`
+ : undefined,
+ process.env.GITHUB_BASE_REF,
+ "origin/main",
+ "origin/master",
+ "main",
+ "master",
+ `${head}~1`,
+ ].filter(Boolean) as string[];
+
+ for (const candidate of candidates) {
+ const mergeBase = await git(["merge-base", head, candidate], {
+ allowFailure: true,
+ });
+ if (mergeBase.exitCode === 0 && mergeBase.stdout.trim()) {
+ return mergeBase.stdout.trim();
+ }
+ }
+
+ throw new Error(
+ "Could not detect a base ref. Pass --base ][ or set REVIEW_BASE.",
+ );
+}
+
+async function collectDiff(
+ base: string,
+ head: string,
+ maxBytes: number,
+ warnings: string[],
+) {
+ const diff = (
+ await git([
+ "diff",
+ "--find-renames",
+ "--find-copies",
+ "--unified=80",
+ `${base}...${head}`,
+ ])
+ ).stdout;
+
+ if (Buffer.byteLength(diff, "utf8") <= maxBytes) {
+ return diff;
+ }
+
+ warnings.push(
+ `Diff was truncated to ${maxBytes} bytes for the model prompt.`,
+ );
+ return `${Buffer.from(diff, "utf8").subarray(0, maxBytes).toString("utf8")}\n\n[diff truncated]\n`;
+}
+
+async function collectChangedFiles(base: string, head: string) {
+ const output = (
+ await git(["diff", "--name-only", `${base}...${head}`])
+ ).stdout.trim();
+ return output ? output.split(/\r?\n/u).filter(Boolean) : [];
+}
+
+async function readAgentPrompts(agentDir: string, warnings: string[]) {
+ if (!existsSync(agentDir)) {
+ warnings.push("review-agents directory was not found.");
+ return [];
+ }
+
+ const entries = await readdir(agentDir, { withFileTypes: true });
+ const prompts: AgentPrompt[] = [];
+
+ for (const entry of entries) {
+ if (!entry.isFile() || !entry.name.endsWith(".md")) {
+ continue;
+ }
+
+ const filePath = path.join(agentDir, entry.name);
+ const prompt = (await readOptionalFile(filePath)).trim();
+ if (!prompt) {
+ warnings.push(`${entry.name} is empty and was skipped.`);
+ continue;
+ }
+
+ prompts.push({
+ name: entry.name.replace(/\.md$/u, ""),
+ prompt,
+ filePath,
+ });
+ }
+
+ return prompts;
+}
+
+async function runChecks(warnings: string[]) {
+ const packageJson = await readPackageJson();
+ const packageManager = detectPackageManager();
+ const scripts = packageJson?.scripts ?? {};
+ const requestedChecks = (
+ process.env.REVIEW_CHECKS ?? "lint,typecheck,test,build"
+ )
+ .split(",")
+ .map((check) => check.trim())
+ .filter(Boolean);
+ const results: CheckResult[] = [];
+
+ for (const check of requestedChecks) {
+ const command = resolveCheckCommand(check, scripts, packageManager);
+ if (!command) {
+ results.push({
+ name: check,
+ skipped: true,
+ reason: `No ${check} script or safe fallback was found.`,
+ });
+ continue;
+ }
+
+ const startedAt = Date.now();
+ const result = await runShellCommand(command, { allowFailure: true });
+ results.push({
+ name: check,
+ command,
+ exitCode: result.exitCode,
+ durationMs: Date.now() - startedAt,
+ stdout: trimOutput(result.stdout),
+ stderr: trimOutput(result.stderr),
+ });
+
+ if (result.exitCode !== 0) {
+ warnings.push(`${check} failed with exit code ${result.exitCode}.`);
+ }
+ }
+
+ return results;
+}
+
+function resolveCheckCommand(
+ check: string,
+ scripts: Record,
+ packageManager: string,
+) {
+ if (
+ check === "lint" &&
+ scripts.lint &&
+ /\bbiome\b/u.test(scripts.lint) &&
+ /--(?:apply|write)\b/u.test(scripts.lint)
+ ) {
+ return `${packageManager} biome check .`;
+ }
+
+ if (scripts[check]) {
+ return `${packageManager} run ${check}`;
+ }
+
+ if (
+ check === "typecheck" &&
+ existsSync(path.join(repoRoot, "tsconfig.json"))
+ ) {
+ const localTsc = path.join(
+ repoRoot,
+ "node_modules",
+ "typescript",
+ "bin",
+ "tsc",
+ );
+ if (existsSync(localTsc)) {
+ return `${quote(process.execPath)} ${quote(localTsc)} --noEmit --pretty false`;
+ }
+ }
+
+ return undefined;
+}
+
+async function runOpenAiReviewAgents(input: {
+ agentPrompts: AgentPrompt[];
+ agentsInstructions: string;
+ base: string;
+ head: string;
+ changedFiles: string[];
+ checks: CheckResult[];
+ diff: string;
+ warnings: string[];
+}) {
+ const apiKey = process.env.OPENAI_API_KEY;
+ if (!apiKey) {
+ input.warnings.push(
+ "OpenAI review skipped because OPENAI_API_KEY is missing.",
+ );
+ return [];
+ }
+
+ if (input.agentPrompts.length === 0) {
+ input.warnings.push(
+ "OpenAI review skipped because there are no non-empty review agent prompts.",
+ );
+ return [];
+ }
+
+ const findings: Finding[] = [];
+
+ for (const agentPrompt of input.agentPrompts) {
+ const prompt = buildAgentPrompt({
+ agentPrompt,
+ agentsInstructions: input.agentsInstructions,
+ base: input.base,
+ changedFiles: input.changedFiles,
+ checks: input.checks,
+ diff: input.diff,
+ head: input.head,
+ });
+
+ try {
+ const rawOutput = await createOpenAiReview(prompt);
+ findings.push(
+ ...parseAgentFindings(rawOutput, agentPrompt.name, input.warnings),
+ );
+ } catch (error) {
+ input.warnings.push(
+ `${agentPrompt.name} OpenAI review failed: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+
+ return findings;
+}
+
+async function createOpenAiReview(prompt: string) {
+ const model = process.env.OPENAI_REVIEW_MODEL ?? "gpt-5.5";
+ const response = await fetch("https://api.openai.com/v1/responses", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ model,
+ instructions:
+ "You are a strict automated code review agent. Return only JSON that matches the requested shape. Do not include markdown fences.",
+ input: prompt,
+ text: {
+ format: {
+ type: "json_schema",
+ name: "code_review_findings",
+ strict: true,
+ schema: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ findings: {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ severity: {
+ type: "string",
+ enum: ["P0", "P1", "P2", "P3"],
+ },
+ title: { type: "string" },
+ file: { type: "string" },
+ line: { type: "integer" },
+ impact: { type: "string" },
+ suggestedFix: { type: "string" },
+ verification: { type: "string" },
+ existingFailure: { type: "boolean" },
+ },
+ required: [
+ "severity",
+ "title",
+ "file",
+ "line",
+ "impact",
+ "suggestedFix",
+ "verification",
+ "existingFailure",
+ ],
+ },
+ },
+ },
+ required: ["findings"],
+ },
+ },
+ },
+ }),
+ });
+
+ const body = await response.text();
+ if (!response.ok) {
+ throw new Error(
+ `OpenAI API returned ${response.status}: ${trimOutput(body, 1_000)}`,
+ );
+ }
+
+ return extractOpenAiText(JSON.parse(body));
+}
+
+function buildAgentPrompt(input: {
+ agentPrompt: AgentPrompt;
+ agentsInstructions: string;
+ base: string;
+ head: string;
+ changedFiles: string[];
+ checks: CheckResult[];
+ diff: string;
+}) {
+ return [
+ input.agentsInstructions,
+ "",
+ `# Sub-agent role: ${input.agentPrompt.name}`,
+ input.agentPrompt.prompt,
+ "",
+ "# Required output",
+ "Return only JSON in this shape:",
+ JSON.stringify(
+ {
+ findings: [
+ {
+ severity: "P1",
+ title: "Short actionable title",
+ file: "src/example.ts",
+ line: 10,
+ impact: "What breaks or what risk is introduced",
+ suggestedFix: "Concrete fix",
+ verification: "How to verify the fix",
+ existingFailure: false,
+ },
+ ],
+ },
+ null,
+ 2,
+ ),
+ "Use an empty findings array if there are no actionable findings.",
+ "",
+ "# Review context",
+ `Base: ${input.base}`,
+ `Head: ${input.head}`,
+ "",
+ "# Changed files",
+ input.changedFiles.join("\n") || "(none)",
+ "",
+ "# Check results",
+ JSON.stringify(input.checks, null, 2),
+ "",
+ "# Diff",
+ input.diff || "(empty diff)",
+ ].join("\n");
+}
+
+function parseAgentFindings(
+ rawOutput: string,
+ source: string,
+ warnings: string[],
+) {
+ const jsonText = extractJson(rawOutput);
+ if (!jsonText) {
+ warnings.push(`${source} did not return parseable JSON.`);
+ return [];
+ }
+
+ try {
+ const parsed = JSON.parse(jsonText) as { findings?: Partial[] };
+ if (!Array.isArray(parsed.findings)) {
+ warnings.push(`${source} JSON did not include a findings array.`);
+ return [];
+ }
+
+ return parsed.findings
+ .map((finding) => normalizeFinding(finding, source))
+ .filter((finding): finding is Finding => Boolean(finding));
+ } catch (error) {
+ warnings.push(
+ `${source} JSON parse failed: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ return [];
+ }
+}
+
+function normalizeFinding(
+ finding: Partial,
+ source: string,
+): Finding | undefined {
+ const severity = parseSeverity(finding.severity);
+ const line = Number(finding.line);
+
+ if (
+ !severity ||
+ !finding.title ||
+ !finding.impact ||
+ !finding.suggestedFix ||
+ !finding.verification
+ ) {
+ return undefined;
+ }
+
+ return {
+ severity,
+ title: finding.title,
+ file: finding.file,
+ line: Number.isFinite(line) && line > 0 ? line : undefined,
+ impact: finding.impact,
+ suggestedFix: finding.suggestedFix,
+ verification: finding.verification,
+ source,
+ existingFailure: Boolean(finding.existingFailure),
+ };
+}
+
+function extractOpenAiText(response: unknown) {
+ const maybe = response as {
+ output_text?: string;
+ output?: Array<{ content?: Array<{ type?: string; text?: string }> }>;
+ };
+
+ if (maybe.output_text) {
+ return maybe.output_text;
+ }
+
+ const parts =
+ maybe.output?.flatMap(
+ (item) =>
+ item.content
+ ?.filter((content) => content.type === "output_text" && content.text)
+ .map((content) => content.text ?? "") ?? [],
+ ) ?? [];
+
+ return parts.join("\n");
+}
+
+function extractJson(rawOutput: string) {
+ const fenced = rawOutput.match(/```(?:json)?\s*([\s\S]*?)```/u);
+ if (fenced?.[1]) {
+ return fenced[1].trim();
+ }
+
+ const start = rawOutput.indexOf("{");
+ const end = rawOutput.lastIndexOf("}");
+ if (start >= 0 && end > start) {
+ return rawOutput.slice(start, end + 1).trim();
+ }
+
+ return undefined;
+}
+
+function checkResultToFinding(check: CheckResult): Finding[] {
+ if (check.skipped || check.exitCode === undefined || check.exitCode === 0) {
+ return [];
+ }
+
+ const severity: Severity =
+ check.name === "build" ||
+ check.name === "typecheck" ||
+ check.name === "test"
+ ? "P1"
+ : "P2";
+ const output = [check.stderr, check.stdout].filter(Boolean).join("\n").trim();
+
+ return [
+ {
+ severity,
+ title: `${check.name} failed`,
+ file: "package.json",
+ impact: `The ${check.name} check exits with code ${check.exitCode}, so the change is not currently passing CI-equivalent verification.`,
+ suggestedFix: output
+ ? `Run \`${check.command}\` locally and fix the reported failure. First output:\n${trimOutput(output, 1_000)}`
+ : `Run \`${check.command}\` locally, fix the reported failure, and rerun the harness.`,
+ verification: `\`${check.command}\` exits with code 0.`,
+ source: "harness",
+ existingFailure: false,
+ },
+ ];
+}
+
+function mergeAndSortFindings(findings: Finding[]) {
+ const merged = new Map();
+
+ for (const finding of findings) {
+ const key = [
+ finding.severity,
+ finding.file ?? "",
+ finding.line ?? "",
+ normalizeText(finding.title),
+ ].join("|");
+ const existing = merged.get(key);
+
+ if (!existing) {
+ merged.set(key, finding);
+ continue;
+ }
+
+ existing.source = Array.from(
+ new Set([...existing.source.split(", "), finding.source]),
+ ).join(", ");
+ }
+
+ return Array.from(merged.values()).sort((left, right) => {
+ const severityDelta =
+ severityRank[left.severity] - severityRank[right.severity];
+ if (severityDelta !== 0) {
+ return severityDelta;
+ }
+
+ return `${left.file ?? ""}:${left.line ?? 0}:${left.title}`.localeCompare(
+ `${right.file ?? ""}:${right.line ?? 0}:${right.title}`,
+ );
+ });
+}
+
+async function exportToNotion(report: ReviewReport, warnings: string[]) {
+ const token = process.env.NOTION_TOKEN;
+ const databaseId = process.env.NOTION_DATABASE_ID;
+ const titleProperty = process.env.NOTION_TITLE_PROPERTY ?? "Name";
+
+ if (!token || !databaseId) {
+ warnings.push(
+ "Notion export skipped because NOTION_TOKEN or NOTION_DATABASE_ID is missing.",
+ );
+ return;
+ }
+
+ const markdown = renderMarkdown(report);
+ const response = await fetch("https://api.notion.com/v1/pages", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ "Notion-Version": "2022-06-28",
+ },
+ body: JSON.stringify({
+ parent: { database_id: databaseId },
+ properties: {
+ [titleProperty]: {
+ title: [
+ {
+ text: { content: `${report.title} ${shortSha(report.head)}` },
+ },
+ ],
+ },
+ },
+ children: chunkText(markdown, 1_900)
+ .slice(0, 90)
+ .map((chunk) => ({
+ object: "block",
+ type: "paragraph",
+ paragraph: {
+ rich_text: [{ type: "text", text: { content: chunk } }],
+ },
+ })),
+ }),
+ });
+
+ if (!response.ok) {
+ warnings.push(
+ `Notion export failed: ${response.status} ${await response.text()}`,
+ );
+ }
+}
+
+async function commentOnGitHubPr(
+ report: ReviewReport,
+ prNumber: string | undefined,
+ warnings: string[],
+) {
+ const token = process.env.GITHUB_TOKEN;
+ const repository = process.env.GITHUB_REPOSITORY;
+ const pullRequestNumber = prNumber ?? (await detectGitHubPrNumber());
+
+ if (!token || !repository || !pullRequestNumber) {
+ warnings.push(
+ "GitHub PR comment skipped because GITHUB_TOKEN, GITHUB_REPOSITORY, or PR number is missing.",
+ );
+ return;
+ }
+
+ const headers = {
+ Authorization: `Bearer ${token}`,
+ Accept: "application/vnd.github+json",
+ "Content-Type": "application/json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ };
+ const commentsUrl = `https://api.github.com/repos/${repository}/issues/${pullRequestNumber}/comments`;
+ const commentBody = renderGitHubComment(report).slice(0, 60_000);
+ const existingCommentId = await findExistingReviewComment(
+ commentsUrl,
+ headers,
+ );
+ const response = existingCommentId
+ ? await fetch(
+ `https://api.github.com/repos/${repository}/issues/comments/${existingCommentId}`,
+ {
+ method: "PATCH",
+ headers,
+ body: JSON.stringify({ body: commentBody }),
+ },
+ )
+ : await fetch(commentsUrl, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ body: commentBody }),
+ });
+
+ if (!response.ok) {
+ warnings.push(
+ `GitHub comment failed: ${response.status} ${await response.text()}`,
+ );
+ }
+}
+
+async function findExistingReviewComment(
+ commentsUrl: string,
+ headers: Record,
+) {
+ const response = await fetch(`${commentsUrl}?per_page=100`, { headers });
+ if (!response.ok) {
+ return undefined;
+ }
+
+ const comments = (await response.json()) as Array<{
+ id?: number;
+ body?: string;
+ user?: { type?: string };
+ }>;
+ const existing = comments.find(
+ (comment) =>
+ comment.body?.includes(githubCommentMarker) &&
+ comment.user?.type === "Bot",
+ );
+
+ return existing?.id;
+}
+
+async function detectGitHubPrNumber() {
+ const eventPath = process.env.GITHUB_EVENT_PATH;
+ if (!eventPath || !existsSync(eventPath)) {
+ return undefined;
+ }
+
+ const event = JSON.parse(await readFile(eventPath, "utf8")) as {
+ pull_request?: { number?: number };
+ };
+ return event.pull_request?.number
+ ? String(event.pull_request.number)
+ : undefined;
+}
+
+async function writeReport(report: ReviewReport, options: CliOptions) {
+ await mkdir(path.dirname(options.jsonOut), { recursive: true });
+ await mkdir(path.dirname(options.markdownOut), { recursive: true });
+ await writeFile(
+ options.jsonOut,
+ `${JSON.stringify(report, null, 2)}\n`,
+ "utf8",
+ );
+ await writeFile(options.markdownOut, renderMarkdown(report), "utf8");
+}
+
+function renderMarkdown(report: ReviewReport) {
+ const lines = [
+ `# ${report.title}`,
+ "",
+ `- Cadence: ${report.cadence}`,
+ `- Generated: ${report.generatedAt}`,
+ `- Base: \`${report.base}\``,
+ `- Head: \`${report.head}\``,
+ `- Changed files: ${report.changedFiles.length}`,
+ `- Findings: ${report.findings.length}`,
+ "",
+ "## Checks",
+ "",
+ ...report.checks.map((check) => {
+ if (check.skipped) {
+ return `- ${check.name}: skipped (${check.reason})`;
+ }
+
+ return `- ${check.name}: ${check.exitCode === 0 ? "passed" : `failed (${check.exitCode})`} - \`${check.command}\``;
+ }),
+ "",
+ "## Findings",
+ "",
+ ];
+
+ if (report.findings.length === 0) {
+ lines.push("No actionable findings.");
+ } else {
+ for (const finding of report.findings) {
+ const location = finding.file
+ ? `${finding.file}${finding.line ? `:${finding.line}` : ""}`
+ : "No file";
+ lines.push(
+ `### ${finding.severity} ${finding.title}`,
+ "",
+ `- Location: ${location}`,
+ `- Source: ${finding.source}`,
+ `- Existing failure: ${finding.existingFailure ? "yes" : "no"}`,
+ `- Impact: ${finding.impact}`,
+ `- Suggested fix: ${finding.suggestedFix}`,
+ `- Verification: ${finding.verification}`,
+ "",
+ );
+ }
+ }
+
+ if (report.warnings.length > 0) {
+ lines.push(
+ "",
+ "## Warnings",
+ "",
+ ...report.warnings.map((warning) => `- ${warning}`),
+ );
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
+function renderGitHubComment(report: ReviewReport) {
+ return `${githubCommentMarker}\n${renderMarkdown(report)}`;
+}
+
+function printSummary(report: ReviewReport, options: CliOptions) {
+ console.log(renderMarkdown(report));
+ console.log(`JSON report: ${options.jsonOut}`);
+ console.log(`Markdown report: ${options.markdownOut}`);
+}
+
+async function readPackageJson() {
+ const packagePath = path.join(repoRoot, "package.json");
+ if (!existsSync(packagePath)) {
+ return undefined;
+ }
+
+ return JSON.parse(await readFile(packagePath, "utf8")) as {
+ scripts?: Record;
+ };
+}
+
+function detectPackageManager() {
+ if (existsSync(path.join(repoRoot, "bun.lock"))) {
+ return "bun";
+ }
+
+ if (existsSync(path.join(repoRoot, "pnpm-lock.yaml"))) {
+ return "pnpm";
+ }
+
+ if (existsSync(path.join(repoRoot, "yarn.lock"))) {
+ return "yarn";
+ }
+
+ return "npm";
+}
+
+async function readOptionalFile(filePath: string) {
+ if (!existsSync(filePath)) {
+ return "";
+ }
+
+ return readFile(filePath, "utf8");
+}
+
+async function git(args: string[], options: { allowFailure?: boolean } = {}) {
+ return execFileCapture(
+ "git",
+ ["-c", `safe.directory=${repoRoot.replaceAll("\\", "/")}`, ...args],
+ options,
+ );
+}
+
+async function execFileCapture(
+ command: string,
+ args: string[],
+ options: { allowFailure?: boolean } = {},
+) {
+ return new Promise<{ stdout: string; stderr: string; exitCode: number }>(
+ (resolve, reject) => {
+ execFile(
+ command,
+ args,
+ { cwd: repoRoot, maxBuffer: 20 * 1024 * 1024 },
+ (error, stdout, stderr) => {
+ const exitCode =
+ error &&
+ typeof error === "object" &&
+ "code" in error &&
+ typeof error.code === "number"
+ ? error.code
+ : 0;
+ const result = { stdout, stderr, exitCode };
+
+ if (error && !options.allowFailure) {
+ reject(Object.assign(error, result));
+ return;
+ }
+
+ resolve(result);
+ },
+ );
+ },
+ );
+}
+
+async function runShellCommand(
+ command: string,
+ options: { allowFailure?: boolean; stdin?: string; timeoutMs?: number } = {},
+) {
+ return new Promise<{ stdout: string; stderr: string; exitCode: number }>(
+ (resolve, reject) => {
+ const child = spawn(command, {
+ cwd: repoRoot,
+ shell: true,
+ stdio: ["pipe", "pipe", "pipe"],
+ timeout: options.timeoutMs,
+ });
+ let stdout = "";
+ let stderr = "";
+
+ child.stdout.on("data", (chunk: Buffer) => {
+ stdout += chunk.toString("utf8");
+ });
+
+ child.stderr.on("data", (chunk: Buffer) => {
+ stderr += chunk.toString("utf8");
+ });
+
+ child.on("error", (error) => {
+ if (options.allowFailure) {
+ resolve({
+ stdout,
+ stderr: `${stderr}\n${error.message}`.trim(),
+ exitCode: 1,
+ });
+ return;
+ }
+
+ reject(error);
+ });
+
+ child.on("close", (code) => {
+ const exitCode = code ?? 1;
+ const result = { stdout, stderr, exitCode };
+
+ if (exitCode !== 0 && !options.allowFailure) {
+ reject(
+ Object.assign(new Error(`Command failed: ${command}`), result),
+ );
+ return;
+ }
+
+ resolve(result);
+ });
+
+ if (options.stdin) {
+ child.stdin.write(options.stdin);
+ }
+ child.stdin.end();
+ },
+ );
+}
+
+function parseSeverity(value: unknown): Severity | undefined {
+ return value === "P0" || value === "P1" || value === "P2" || value === "P3"
+ ? value
+ : undefined;
+}
+
+function normalizeText(value: string) {
+ return value.toLowerCase().replace(/\s+/gu, " ").trim();
+}
+
+function quote(value: string) {
+ return `"${value.replace(/"/gu, '\\"')}"`;
+}
+
+function shortSha(value: string) {
+ return value.slice(0, 7);
+}
+
+function chunkText(value: string, chunkSize: number) {
+ const chunks: string[] = [];
+ for (let index = 0; index < value.length; index += chunkSize) {
+ chunks.push(value.slice(index, index + chunkSize));
+ }
+ return chunks;
+}
+
+function trimOutput(value = "", maxLength = 4_000) {
+ const normalized = value.trim();
+ if (normalized.length <= maxLength) {
+ return normalized;
+ }
+
+ return `${normalized.slice(0, maxLength)}\n[output truncated]`;
+}
+
+main().catch((error) => {
+ console.error(
+ error instanceof Error ? (error.stack ?? error.message) : error,
+ );
+ process.exitCode = 1;
+});
diff --git a/src/app/(with-sidebar)/settings/components/AccountSetting/modal/ExitStudy.tsx b/src/app/(with-sidebar)/settings/components/AccountSetting/modal/ExitStudy.tsx
new file mode 100644
index 0000000..73d3880
--- /dev/null
+++ b/src/app/(with-sidebar)/settings/components/AccountSetting/modal/ExitStudy.tsx
@@ -0,0 +1,42 @@
+import { AlertTriangle } from "lucide-react";
+import Button from "@/components/ui/Button";
+
+interface deleteStudyModalProp {
+ onCancel: () => void;
+ onConfirm: () => void;
+}
+
+export function ExitStudy({ onCancel, onConfirm }: deleteStudyModalProp) {
+ return (
+ ]
+ {/* 1. 이모지 대신 세련된 Lucide 아이콘 배치 (토스 경고용 소프트 핑크/레드 배경) */}
+
+
+ 스터디를 탈퇴하시겠습니까?
+
+
+ 초대 코드만 있으면 언제든 다시 들어올 수 있어요.
+
+
+ 정말 이 스터디를 나가시겠어요?
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(with-sidebar)/settings/components/AccountSetting/service.ts b/src/app/(with-sidebar)/settings/components/AccountSetting/service.ts
index e69de29..4087f64 100644
--- a/src/app/(with-sidebar)/settings/components/AccountSetting/service.ts
+++ b/src/app/(with-sidebar)/settings/components/AccountSetting/service.ts
@@ -0,0 +1,24 @@
+import { supabase } from "@/lib/supabase";
+
+// 스터디 탈퇴
+export const leaveStudy = async (studyId: number) => {
+ const user = (await supabase.auth.getUser()).data.user;
+ if (!user) throw new Error("로그인 필요");
+
+ const { data: member, error } = await supabase
+ .from("study_members")
+ .select("role")
+ .eq("study_id", studyId)
+ .eq("user_id", user.id)
+ .single();
+ if (error) throw error;
+ if (member.role === "스터디장")
+ throw new Error("스터디장은 위임 후 탈퇴할 수 있어요");
+
+ const { error: deleteError } = await supabase
+ .from("study_members")
+ .delete()
+ .eq("study_id", studyId)
+ .eq("user_id", user.id);
+ if (deleteError) throw deleteError;
+};
diff --git a/src/app/(with-sidebar)/settings/components/AccountSetting/view.tsx b/src/app/(with-sidebar)/settings/components/AccountSetting/view.tsx
index 7f12703..b85475c 100644
--- a/src/app/(with-sidebar)/settings/components/AccountSetting/view.tsx
+++ b/src/app/(with-sidebar)/settings/components/AccountSetting/view.tsx
@@ -1,6 +1,47 @@
+import { useQuery } from "@tanstack/react-query";
+import { useSetAtom } from "jotai";
import { ChevronRight, LogOut, Trash2 } from "lucide-react";
+import { getMyMemberInfo } from "@/lib/api/study";
+import { alertAtom } from "@/lib/store/alertStore";
+import { modalAtom } from "@/lib/store/modalStore";
+import type { UserProfile } from "@/lib/types/study";
+import { ExitStudy } from "./modal/ExitStudy";
+import { leaveStudy } from "./service";
function AccountSetting() {
+ const setModal = useSetAtom(modalAtom);
+ const setAlert = useSetAtom(alertAtom);
+
+ const { data: myInfo } = useQuery({
+ queryKey: ["myInfoInAccountSetting"],
+ queryFn: getMyMemberInfo,
+ });
+ const checkExitStudy = () => {
+ if (!myInfo?.id) return;
+
+ setModal({
+ title: "",
+ children: (
+ setModal(null)}
+ onConfirm={() => exitStudy(myInfo.study_id)}
+ />
+ ),
+ });
+ };
+ const exitStudy = (id: number) => {
+ try {
+ leaveStudy(id);
+ } catch (error) {
+ setAlert({
+ title: " 실패",
+ content: "스터디 정보가 존재하지 않습니다",
+ variant: true,
+ });
+ console.log(error);
+ }
+ };
+
return (
@@ -28,6 +69,7 @@ function AccountSetting() {