v0.2#10
Conversation
관리자 페이지 제작
기술 카테고리 검색 대상을 depth 2로 제한해 상위 depth 노출 문제 수정
내 질문 세트 목록에 계열/직무/기술 필터 추가
내 질문 세트에서 공유하기/공유 해제(visibility GLOBAL/PRIVATE) 지원
공유된 세트를 질문 찾아보기에서 타 사용자도 조회/연습 가능하도록 연동
owner 외 수정/삭제 불가 권한 정책 유지
질문 찾아보기에 질문별 저장하기 추가 (POST /api/interview/tech/questions/{questionId}/save)
질문 찾아보기에서 owner userId 노출 제거, 제작자 이름만 표시
AI 생성 세트 판별/표시 로직 보강(배지 노출)
AUTO:, (공용) 레거시 제목 의존 제거 및 정규화 처리
사이드바 메뉴 구조를 “모의 면접 / 질문 연습 / 질문 관리”로 재구성
콘텐츠 길이가 길어도 사이드바가 고정되도록 레이아웃 수정
마이페이지 Gemini API 키 UI 개선
등록 시 입력창/저장 비활성화, 키 제거 활성화
미등록 시 저장 활성화
버튼 텍스트 줄바꿈 깨짐(세로 표시) 수정
로컬 파비콘 반영 문제 수정 (index.html favicon 링크/캐시버스터 갱신)
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (10)
📝 WalkthroughWalkthrough관리자 콘솔과 문제 탐색 페이지를 추가하고 사이드바를 섹션 기반으로 리팩토링했으며 Gemini 과부하 감지·전용 모달, 관련 API 헬퍼들 및 관리자 상태 훅을 도입했습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant User as "사용자 (브라우저)"
participant UI as "AdminConsolePage / Components"
participant AdminAPI as "src/lib/adminApi"
participant Server as "백엔드 API"
User->>UI: 멤버 액션(조회/활성화/비활성화) 요청
UI->>AdminAPI: getAdminMembers / updateAdminMember 호출
AdminAPI->>Server: HTTP 요청 (/api/admin/...)
Server-->>AdminAPI: 응답 (성공/에러)
AdminAPI-->>UI: 결과 반환
UI-->>User: 목록 갱신 / 모달 표시
sequenceDiagram
autonumber
participant User as "사용자 (브라우저)"
participant UI as "InterviewStartPage / TechPracticePage / QuestionBrowsePage"
participant InterviewAPI as "src/lib/interviewApi"
participant Server as "백엔드 API"
participant Gemini as "LLM/Gemini 서비스"
User->>UI: 연습/면접 시작 요청
UI->>InterviewAPI: startTechInterview (서버 경유)
InterviewAPI->>Server: 면접 생성 요청
Server->>Gemini: 질문/응답 생성 호출
alt Gemini 과부하 발생
Gemini-->>Server: 503 / 과부하 에러
Server-->>InterviewAPI: 에러 응답
InterviewAPI-->>UI: 에러 전파
UI-->>User: `GeminiOverloadModal` 표시
else 정상 응답
Gemini-->>Server: 생성 결과
Server-->>InterviewAPI: 성공 응답
InterviewAPI-->>UI: 세션 ID / 질문 데이터
UI-->>User: 면접 화면으로 이동
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/pages/content/QuestionSetsPage.jsx (2)
807-827:⚠️ Potential issue | 🟠 Major세션 응답을 확인하기 전에 저장/이동합니다.
sessionId또는currentQuestion가 비어도 바로 세션을 저장하고 이동하고 있습니다. 이 상태는 세션 페이지에서 복구하기 어렵습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/QuestionSetsPage.jsx` around lines 807 - 827, The code calls saveTechInterviewSession and navigate immediately after startTechInterview without validating the response; modify the flow in the handler around startTechInterview so you await and validate the response (ensure response.sessionId and response.currentQuestion are present/non-null), only call saveTechInterviewSession and navigate("/content/interview/session") after that validation, and add error handling (try/catch or conditional fallback) to log/show an error and avoid navigation when the session response is invalid; reference startTechInterview, saveTechInterviewSession, response.sessionId, response.currentQuestion, and navigate to locate and update the logic.
689-717:⚠️ Potential issue | 🟠 Major부분 실패 후 재저장이 중복 세트를 만듭니다.
세트는 먼저 생성하고 문답을 순차 추가한 뒤, 일부만 실패하면 모달을 열린 채로 반환합니다. 사용자가 다시 저장하면 이미 일부가 저장된 세트와 별개로 새 세트가 또 만들어집니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/QuestionSetsPage.jsx` around lines 689 - 717, The code creates a set (createdSet) and then partially fails adding questions, but returns with the modal open so a retry creates a duplicate set; to fix this, perform a rollback when failedRows.length > 0 by deleting the partially-created set (call the existing API like deleteInterviewSet(createdSet.setId) or similar) before setting the modal error and returning, and ensure the delete is awaited and any delete error is caught/logged (but does not mask the original failure). Update the block that handles failedRows (which references createdSet, normalizedRows, addQuestionToInterviewSet, setModalErrorMessage, loadPage) to delete the created set on partial failure to avoid duplicates on retry.
🧹 Nitpick comments (2)
src/components/GeminiOverloadModal.jsx (1)
1-27: Escape 키로 모달 닫기 기능 누락.접근성 속성은 잘 구현되어 있으나, Escape 키를 눌러 모달을 닫는 기능이 없습니다. 사용자 경험 향상을 위해 추가를 고려해 보세요.
♻️ Escape 키 핸들링 추가 예시
+import { useEffect } from "react"; + -export const GeminiOverloadModal = ({ onClose }) => ( +export const GeminiOverloadModal = ({ onClose }) => { + useEffect(() => { + const handleKeyDown = (event) => { + if (event.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + return ( <div className="fixed inset-0 z-[130] flex items-center justify-center bg-black/45 px-4"> ... </div> -); + ); +};🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/GeminiOverloadModal.jsx` around lines 1 - 27, The GeminiOverloadModal component lacks Escape-key handling; add a React effect inside GeminiOverloadModal that registers a document-level keydown listener, checks for the Escape key (e.key === 'Escape' || e.key === 'Esc'), and calls the provided onClose prop when pressed; ensure the listener is removed in the effect cleanup to avoid leaks and only attaches while the modal is mounted.src/pages/content/InterviewStartPage.jsx (1)
236-263: 계열 필터의 조인 키는 문자열보다 ID가 낫습니다.
categoryTree와jobs를 직무명 문자열로 매칭하고 있어서, 공백/표기 차이만 있어도 정상 직무가 현재 계열에서 빠질 수 있습니다. 가능하면 부모 관계는 사람 읽는 이름이 아니라 안정적인 식별자로 맞춰 주세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/InterviewStartPage.jsx` around lines 236 - 263, The code maps jobs to branches via job name strings which is fragile; replace the string-based join with an ID-based join: in the jobNameToBranchId logic stop using jobCategory.name as key and instead map from the job category's stable id (e.g., use jobCategory.id or whatever unique id field exists on categoryTree items) to its branch id (jobCategory.parentId), and in visibleJobs compare the job's category id field (e.g., job.categoryId or job.parentId) against branchFilter rather than deriving a key from job.displayName/job.name; update references in jobNameToBranchId and visibleJobs to use those id fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/App.jsx`:
- Around line 45-46: 현재 AdminConsolePage 라우트가 GeminiApiKeyGuard가 적용된
GuardedContentRoutes 내부에 있어 API 키가 없는 어드민 사용자가 접근할 수 없습니다; AdminConsolePage 라우트를
GuardedContentRoutes 밖으로 이동하거나 GeminiApiKeyGuard 대신 별도의 어드민 전용 인증 가드(예:
AdminAuthGuard)를 적용하도록 수정하세요 — 파일 내에서 Route path="/content/admin"
element={<AdminConsolePage />} 항목을 찾아 GuardedContentRoutes 블록 외부로 옮기거나 해당 Route에
적용된 가드를 AdminConsolePage 전용 가드로 교체하십시오.
In `@src/components/Sidebar.jsx`:
- Around line 18-42: Sidebar and MobileSidebarDrawer both call
getMyProfile()/extractProfile to set fetchedIsAdmin, causing duplicate API calls
when both render; refactor by lifting the admin fetch into a shared place
(either fetch once in the common parent and pass the boolean via the isAdmin
prop, or extract into a shared hook/context like useAdminStatus that returns
resolvedIsAdmin/fetchedIsAdmin and use that in both components) and remove the
local loadRole logic from Sidebar and MobileSidebarDrawer so they rely on the
shared prop/hook; keep the resolvedIsAdmin logic (typeof isAdmin === "boolean" ?
isAdmin : fetchedIsAdmin) to preserve explicit overrides.
In `@src/lib/geminiErrorUtils.js`:
- Around line 17-25: The isGeminiOverloadError function currently returns true
for any error with status === 503; change the logic so a 503 only counts as a
Gemini overload when the error message also signals Gemini/overload (use
GEMINI_KEYWORDS and OVERLOAD_KEYWORDS). Update isGeminiOverloadError to check
message content first (e.g., hasGeminiKeyword && hasOverloadKeyword) and only
treat status 503 as a match when at least one of those keyword checks passes,
preserving existing message-based detection otherwise.
In `@src/pages/content/AdminConsolePage.jsx`:
- Around line 711-715: The clickable rows currently only respond to mouse
clicks; update the interactive elements (e.g., the member row using
setSelectedMemberId and the similar setSelectedSetId/setSelectedCategoryId
usages at the other locations) to be keyboard-accessible by either replacing the
clickable container with a semantic button or by adding tabIndex={0},
role="button", and an onKeyDown handler that triggers selection on Enter or
Space; ensure the same CSS/classes remain (including using the selected boolean
for styling) and that setSelectedMemberId / setSelectedSetId /
setSelectedCategoryId are invoked from both onClick and the key handler so
keyboard users can open the detail panel.
- Around line 445-454: The soft-delete flow in handleSoftDeleteMember calls
softDeleteAdminMember and loadMembers but does not update the
selectedMemberDetail, leaving the right-side panel showing pre-delete data;
after awaiting softDeleteAdminMember and loadMembers, refresh the detail by
reloading the selected member detail (e.g., call the same fetch used to populate
selectedMemberDetail or invoke loadMemberDetail(selectedMemberId)) or clear it
via setSelectedMemberDetail(null) so the UI reflects the masked/deleted state;
update handleSoftDeleteMember to either fetch and set the updated member detail
or clear it depending on desired UX.
In `@src/pages/content/InterviewStartPage.jsx`:
- Around line 435-437: 현재 조건문이 문서 개수 === 0일 때만 차단하여 포트폴리오만 선택한 경우 통과되는 문제가 있습니다;
InterviewStartPage.jsx의 시작 로직(예: startInterview/handleStart)에서
setShowPrereqGuideModal과 setPageErrorMessage를 호출하는 부분을 찾아 문서 검증 조건을 이력서/자기소개서
업로드 및 분석 완료 여부로 변경하거나 안내 문구를 업로드만 요구하도록 일치시켜 주세요; 구체적으로는 포트폴리오만 선택된 케이스를 막기 위해
"documents.length === 0" 체크를 "hasResume && hasCoverLetter && analysisComplete"
같은 명확한 불리언 플래그(혹은 resume/coverLetter 존재 및 분석 상태 검사)를 사용해 대체하거나, 안내문을 현재 로직(문서 존재
여부만 요구)과 동일하게 수정하고 동일한 변경을 686-699 블록에도 적용하세요.
In `@src/pages/content/QuestionBrowsePage.jsx`:
- Around line 153-173: The code calls saveTechInterviewSession and navigate
immediately after startTechInterview without validating the response, which can
open an unrecoverable session page if response.sessionId or
response.currentQuestion is missing; update the flow in the startTechInterview
handling so you first verify response.sessionId and response.currentQuestion are
present and valid (non-null/defined), only then call
saveTechInterviewSession({...}) and navigate("/content/interview/session");
otherwise handle the failure path (e.g., show an error/notification and abort
saving/navigating) and avoid calling saveTechInterviewSession or navigate when
the validation fails.
- Around line 68-82: loadPage currently uses Promise.all over normalizedSets.map
calling getInterviewSetQuestions so a single failure rejects the whole load;
change this to absorb per-set errors (e.g., use Promise.allSettled or wrap each
getInterviewSetQuestions call in try/catch) and return an empty questions array
or error flag for that set instead, then call setSets(details) and preserve
setSelectedSetId logic; alternatively implement lazy loading by deferring
getInterviewSetQuestions until a set is selected (move getInterviewSetQuestions
call out of loadPage and load on selection in the component that handles set
selection). Ensure references: loadPage, getGlobalInterviewSets,
getInterviewSetQuestions, setSets, setSelectedSetId.
In `@src/pages/content/QuestionSetsPage.jsx`:
- Around line 483-501: loadPage currently maps normalizedSets into a Promise.all
that calls getInterviewSetQuestions for every set, so a single rejection aborts
the whole load; change this to either fetch questions lazily on selection or
make per-set requests resilient by catching errors per-promise. Specifically,
update the normalizedSets.map in loadPage so each async fetch of
getInterviewSetQuestions(set.setId) is wrapped with try/catch (or .catch) and
returns an empty questions array on error, then call setSets(details) and
preserve the setSelectedSetId logic; alternatively move getInterviewSetQuestions
out of loadPage and only call it when a set is selected (e.g., in the select
handler) to avoid loading-all failures.
- Around line 1060-1072: The article element handling navigation via onClick
only is not keyboard accessible; make the question card an actual interactive
control or add keyboard handlers: either replace the <article> with a semantic
interactive element (e.g., a <button> or <a> styled the same) or keep the
article but add tabIndex={0}, role="button", an onKeyDown handler that triggers
setSelectedQuestion when Enter or Space is pressed, and an appropriate
aria-label; ensure the handler passes the same payload (using question and
selectedSet fields: question.questionId, setId: selectedSet.setId, setTitle:
selectedSet.title, createdAt: selectedSet.createdAt, categoryName:
question.skillName || question.categoryName) so keyboard users can open the
detail view.
- Around line 358-361: The UI promises a new category but the save flow only
uses rawSkillName and leaves categoryId null; update the save handlers so when
filteredCategories.length === 0 and skillQuery is present you first call the
existing category-creation API (e.g., createCategory / createSkillCategory) with
rawSkillName, await the response to get the new category id, then include that
id in the payloads that currently set categoryId to null (the payloads built
using rawSkillName and categoryId). Apply the same change to both save flows
referenced (the save path around filteredCategories check and the other block at
665-679) so newly created category IDs are written into the question/set
creation requests.
---
Outside diff comments:
In `@src/pages/content/QuestionSetsPage.jsx`:
- Around line 807-827: The code calls saveTechInterviewSession and navigate
immediately after startTechInterview without validating the response; modify the
flow in the handler around startTechInterview so you await and validate the
response (ensure response.sessionId and response.currentQuestion are
present/non-null), only call saveTechInterviewSession and
navigate("/content/interview/session") after that validation, and add error
handling (try/catch or conditional fallback) to log/show an error and avoid
navigation when the session response is invalid; reference startTechInterview,
saveTechInterviewSession, response.sessionId, response.currentQuestion, and
navigate to locate and update the logic.
- Around line 689-717: The code creates a set (createdSet) and then partially
fails adding questions, but returns with the modal open so a retry creates a
duplicate set; to fix this, perform a rollback when failedRows.length > 0 by
deleting the partially-created set (call the existing API like
deleteInterviewSet(createdSet.setId) or similar) before setting the modal error
and returning, and ensure the delete is awaited and any delete error is
caught/logged (but does not mask the original failure). Update the block that
handles failedRows (which references createdSet, normalizedRows,
addQuestionToInterviewSet, setModalErrorMessage, loadPage) to delete the created
set on partial failure to avoid duplicates on retry.
---
Nitpick comments:
In `@src/components/GeminiOverloadModal.jsx`:
- Around line 1-27: The GeminiOverloadModal component lacks Escape-key handling;
add a React effect inside GeminiOverloadModal that registers a document-level
keydown listener, checks for the Escape key (e.key === 'Escape' || e.key ===
'Esc'), and calls the provided onClose prop when pressed; ensure the listener is
removed in the effect cleanup to avoid leaks and only attaches while the modal
is mounted.
In `@src/pages/content/InterviewStartPage.jsx`:
- Around line 236-263: The code maps jobs to branches via job name strings which
is fragile; replace the string-based join with an ID-based join: in the
jobNameToBranchId logic stop using jobCategory.name as key and instead map from
the job category's stable id (e.g., use jobCategory.id or whatever unique id
field exists on categoryTree items) to its branch id (jobCategory.parentId), and
in visibleJobs compare the job's category id field (e.g., job.categoryId or
job.parentId) against branchFilter rather than deriving a key from
job.displayName/job.name; update references in jobNameToBranchId and visibleJobs
to use those id fields.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: f343bc22-5b36-4295-9022-104877cfee0c
⛔ Files ignored due to path filters (1)
public/favicon.icois excluded by!**/*.ico
📒 Files selected for processing (15)
index.htmlsrc/App.jsxsrc/components/GeminiOverloadModal.jsxsrc/components/MobileSidebarDrawer.jsxsrc/components/Sidebar.jsxsrc/components/sidebarMenuItems.jssrc/lib/adminApi.jssrc/lib/geminiErrorUtils.jssrc/lib/interviewApi.jssrc/pages/content/AdminConsolePage.jsxsrc/pages/content/InterviewStartPage.jsxsrc/pages/content/MyPage.jsxsrc/pages/content/QuestionBrowsePage.jsxsrc/pages/content/QuestionSetsPage.jsxsrc/pages/content/TechPracticePage.jsx
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/pages/content/MyPage.jsx (1)
723-745:⚠️ Potential issue | 🟠 Major등록된 키를 바로 교체할 수 없게 막혀 있습니다.
hasGeminiApiKey가true이면 입력창과 저장 버튼이 모두 비활성화돼서, 유출되었거나 폐기된 키를 새 키로 바로 교체할 수 없습니다.updateMyGeminiApiKey가 이미 있는 만큼 기존 키가 있어도 새 키 저장은 허용하고, 삭제는 별도 선택지로 두는 편이 안전합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/MyPage.jsx` around lines 723 - 745, Currently the UI blocks replacing a stored Gemini key because both the input and the "저장" button are disabled when hasGeminiApiKey is true; change the logic so the input and submit button are only disabled while geminiApiKeySubmitting is true (remove hasGeminiApiKey from their disabled props) but keep the "키 제거" button controlled by hasGeminiApiKey and geminiApiKeySubmitting; keep the placeholder behavior (e.g., show a note when hasGeminiApiKey) and ensure submitGeminiApiKey calls the existing updateMyGeminiApiKey flow to allow overwriting an existing key while still allowing explicit deletion via removeGeminiApiKey.
♻️ Duplicate comments (2)
src/lib/geminiErrorUtils.js (1)
23-25:⚠️ Potential issue | 🟡 Minor503 분기에서 Gemini 판별이 아직 너무 느슨합니다.
지금도
status === 503이면hasGeminiKeyword || hasOverloadKeyword를 반환해서, 메시지에timeout이나503만 있어도 다른 백엔드 장애를 Gemini 과부하로 오인합니다. Gemini 전용 모달을 띄우는 조건이면 여기서는 최소한 Gemini 식별자까지 같이 요구하는 편이 안전합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/geminiErrorUtils.js` around lines 23 - 25, The 503 branch currently returns hasGeminiKeyword || hasOverloadKeyword, which misclassifies non-Gemini 503s; change the condition so it requires the Gemini identifier plus overload cues (i.e., return hasGeminiKeyword && hasOverloadKeyword) — update the branch that checks status === 503 to require hasGeminiKeyword in conjunction with hasOverloadKeyword (using the variables/status symbol names already in the file) so Gemini-only modal logic only triggers when a Gemini identifier is present.src/pages/content/QuestionBrowsePage.jsx (1)
316-328:⚠️ Potential issue | 🟠 Major질문 상세 카드가 키보드로 열리지 않습니다.
article에onClick만 있어서 포커스 진입과Enter/Space동작이 없습니다. 내부에 저장 버튼이 있으니 카드 전체를button으로 바꾸기 어렵더라도role="button",tabIndex={0},onKeyDown은 같이 넣어 주세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/QuestionBrowsePage.jsx` around lines 316 - 328, The article element only has onClick so it isn't keyboard-accessible; make it act like a button by adding role="button", tabIndex={0}, and an onKeyDown handler that triggers the same selection logic as onClick (call the same handler that calls setSelectedQuestion({...question, setId: selectedSet.setId, setTitle: selectedSet.title, createdAt: selectedSet.createdAt, categoryName: question.skillName || question.categoryName})). To avoid duplication and to keep focus handling consistent, extract the selection logic into a named function (e.g., handleSelectQuestion) and use it for onClick and for onKeyDown to handle Enter and Space (prevent default for Space), while ensuring inner interactive elements (like the save button) still receive native focus and events.
🧹 Nitpick comments (1)
src/components/GeminiApiKeyGuard.jsx (1)
4-11: 가이드 이미지 8장을 가드 진입 시점에 모두 번들로 당기고 있습니다.이 컴포넌트는 보호된 페이지마다 렌더링되는데, PNG들을 정적 import하면 API 키가 이미 등록된 사용자도 초기 로딩 비용을 그대로 부담합니다. 가이드 모달과 이미지들은 실제로 열 때만
lazy/동적 import로 불러오는 쪽이 낫습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/GeminiApiKeyGuard.jsx` around lines 4 - 11, GeminiApiKeyGuard currently statically imports guideImage1..8 which forces all PNGs into the bundle for every protected page render; change the implementation so the images are loaded only when the guide modal opens (e.g., remove the top-level imports in GeminiApiKeyGuard.jsx and instead dynamically import the images inside the handler that toggles the GuideModal or showGuide state). Use dynamic import(...) (or React.lazy + Suspense if wrapping a GuideModal component) or Promise.all of import(...) for guideImage1..8 when opening the modal, set loaded image URLs into local state (e.g., guideImages or setGuideAssets) and pass those to GuideModal so users who already have an API key don’t pay the initial PNG load cost.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/components/Sidebar.jsx`:
- Line 38: The aside in Sidebar.jsx uses "fixed left-0" which pins the sidebar
to the viewport and pulls it out of its parent layout; update the Sidebar
component's root element className to use "sticky top-[54px]" (remove "fixed"
and "left-0") so the sidebar remains positioned relative to its parent/container
(keep existing height, width, z-20 and other classes) and verify z-index/top
values still match the header behavior.
In `@src/hooks/useAdminStatus.js`:
- Around line 5-29: The module-level caching (cachedAdminStatus,
adminStatusPromise) causes stale admin state to leak across users; change
fetchAdminStatus to key caches by current user identity (e.g., maintain
cachedAdminStatusByUser: Record<userId, boolean> and adminStatusPromiseByUser:
Record<userId, Promise<boolean>>) or provide/ call a clearAdminStatusCache
function on login/logout; only set the cachedAdminStatus entry after a
successful getMyProfile+extractProfile resolution (do not cache false results
from the catch), and ensure adminStatusPromise is scoped to the same user key so
concurrent calls for the same user share the promise but different users don’t.
Use getMyProfile/extractProfile to derive the user id for the cache key or wire
into your auth module to invalidate the cache on identity change.
In `@src/pages/content/AdminConsolePage.jsx`:
- Around line 366-383: filteredCategoryList changes aren't synced to the
selectedCategoryId, so items hidden by filters remain active in the editor; add
an effect that watches filteredCategoryList (and categoryDepth0/1/2 filters) and
updates selectedCategoryId via setSelectedCategoryId to either the first item id
of filteredCategoryList or null when the filtered list is empty (apply same fix
where selection is managed around selectedCategoryId usage, e.g., the same logic
area referenced later around lines 1086-1159). Ensure the effect uses the same
identity types (String/Number) as filteredCategoryList and short-circuits if the
current selectedCategoryId is still present to avoid unnecessary state changes.
In `@src/pages/content/InterviewStartPage.jsx`:
- Around line 243-263: The current filtering uses jobNameToBranchId (built from
categoryTree by name) and visibleJobs to decide branch membership, which breaks
for duplicate names and when handleCreateJob only reloads jobItems without
storing branch info; change the logic to use a stable identifier instead of
name: update job creation (handleCreateJob) to persist the branch/category id on
the new job object (e.g., job.branchId or job.categoryId) so the item carries
its branch, change jobNameToBranchId mapping to not be the sole source (or
remove it) and instead have visibleJobs check job.branchId (or job.id→category
mapping) when branchFilter is set, and ensure the creation modal
(QuestionSetsPage) and any mapping code allow duplicate job.displayName under
different branches by keying lookups on the job id + branchId pair rather than
displayName alone.
In `@src/pages/content/QuestionSetsPage.jsx`:
- Around line 156-177: The UI currently allows identical job/skill names under
different parents (see canCreateJob/canCreateSkill using
selectedBranchId/selectedJobId) but creation and list filtering only
send/compare jobName/skillName (jobName, skillName) so sets lose their category
context and filters collide; fix by including the parent/category identifier
(selectedBranchId/selectedJobId or a computed fullPath) in the create/save
payload for sets and in the query/filtering logic (the code paths that build the
create request and the set list filter predicates) and update comparisons to
match both name and category id (or fullPath) instead of name-only so items with
the same name under different parents are distinguished.
---
Outside diff comments:
In `@src/pages/content/MyPage.jsx`:
- Around line 723-745: Currently the UI blocks replacing a stored Gemini key
because both the input and the "저장" button are disabled when hasGeminiApiKey is
true; change the logic so the input and submit button are only disabled while
geminiApiKeySubmitting is true (remove hasGeminiApiKey from their disabled
props) but keep the "키 제거" button controlled by hasGeminiApiKey and
geminiApiKeySubmitting; keep the placeholder behavior (e.g., show a note when
hasGeminiApiKey) and ensure submitGeminiApiKey calls the existing
updateMyGeminiApiKey flow to allow overwriting an existing key while still
allowing explicit deletion via removeGeminiApiKey.
---
Duplicate comments:
In `@src/lib/geminiErrorUtils.js`:
- Around line 23-25: The 503 branch currently returns hasGeminiKeyword ||
hasOverloadKeyword, which misclassifies non-Gemini 503s; change the condition so
it requires the Gemini identifier plus overload cues (i.e., return
hasGeminiKeyword && hasOverloadKeyword) — update the branch that checks status
=== 503 to require hasGeminiKeyword in conjunction with hasOverloadKeyword
(using the variables/status symbol names already in the file) so Gemini-only
modal logic only triggers when a Gemini identifier is present.
In `@src/pages/content/QuestionBrowsePage.jsx`:
- Around line 316-328: The article element only has onClick so it isn't
keyboard-accessible; make it act like a button by adding role="button",
tabIndex={0}, and an onKeyDown handler that triggers the same selection logic as
onClick (call the same handler that calls setSelectedQuestion({...question,
setId: selectedSet.setId, setTitle: selectedSet.title, createdAt:
selectedSet.createdAt, categoryName: question.skillName ||
question.categoryName})). To avoid duplication and to keep focus handling
consistent, extract the selection logic into a named function (e.g.,
handleSelectQuestion) and use it for onClick and for onKeyDown to handle Enter
and Space (prevent default for Space), while ensuring inner interactive elements
(like the save button) still receive native focus and events.
---
Nitpick comments:
In `@src/components/GeminiApiKeyGuard.jsx`:
- Around line 4-11: GeminiApiKeyGuard currently statically imports
guideImage1..8 which forces all PNGs into the bundle for every protected page
render; change the implementation so the images are loaded only when the guide
modal opens (e.g., remove the top-level imports in GeminiApiKeyGuard.jsx and
instead dynamically import the images inside the handler that toggles the
GuideModal or showGuide state). Use dynamic import(...) (or React.lazy +
Suspense if wrapping a GuideModal component) or Promise.all of import(...) for
guideImage1..8 when opening the modal, set loaded image URLs into local state
(e.g., guideImages or setGuideAssets) and pass those to GuideModal so users who
already have an API key don’t pay the initial PNG load cost.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: bd79b127-3a80-487c-afed-8a5774b061e6
⛔ Files ignored due to path filters (9)
src/assets/api_guide/1.pngis excluded by!**/*.pngsrc/assets/api_guide/2.pngis excluded by!**/*.pngsrc/assets/api_guide/3.pngis excluded by!**/*.pngsrc/assets/api_guide/4.pngis excluded by!**/*.pngsrc/assets/api_guide/5.pngis excluded by!**/*.pngsrc/assets/api_guide/6.pngis excluded by!**/*.pngsrc/assets/api_guide/7.pngis excluded by!**/*.pngsrc/assets/api_guide/8.pngis excluded by!**/*.pngsrc/assets/react.svgis excluded by!**/*.svg
📒 Files selected for processing (13)
src/App.jsxsrc/components/GeminiApiKeyGuard.jsxsrc/components/GeminiOverloadModal.jsxsrc/components/MobileSidebarDrawer.jsxsrc/components/Sidebar.jsxsrc/hooks/useAdminStatus.jssrc/lib/adminApi.jssrc/lib/geminiErrorUtils.jssrc/pages/content/AdminConsolePage.jsxsrc/pages/content/InterviewStartPage.jsxsrc/pages/content/MyPage.jsxsrc/pages/content/QuestionBrowsePage.jsxsrc/pages/content/QuestionSetsPage.jsx
🚧 Files skipped from review as they are similar to previous changes (1)
- src/App.jsx
| let cachedAdminStatus = null; | ||
| let adminStatusPromise = null; | ||
|
|
||
| const fetchAdminStatus = async () => { | ||
| if (typeof cachedAdminStatus === "boolean") { | ||
| return cachedAdminStatus; | ||
| } | ||
| if (!adminStatusPromise) { | ||
| adminStatusPromise = getMyProfile() | ||
| .then((payload) => { | ||
| const profile = extractProfile(payload); | ||
| cachedAdminStatus = String(profile?.role || "").toUpperCase() === "ADMIN"; | ||
| return cachedAdminStatus; | ||
| }) | ||
| .catch((error) => { | ||
| console.error("관리자 상태 확인에 실패했습니다.", error); | ||
| cachedAdminStatus = false; | ||
| return false; | ||
| }) | ||
| .finally(() => { | ||
| adminStatusPromise = null; | ||
| }); | ||
| } | ||
| return adminStatusPromise; | ||
| }; |
There was a problem hiding this comment.
전역 캐시가 사용자 변경 후에도 남아 잘못된 admin 상태를 재사용합니다.
cachedAdminStatus를 모듈 전역에 두고 바로 재사용해서, SPA에서 로그아웃 후 다른 계정으로 다시 로그인하면 이전 사용자의 권한이 그대로 남을 수 있습니다. 여기서 실패까지 false로 캐시하고 있어서 일시적인 조회 실패 뒤 재시도도 막힙니다. 사용자 식별자 기준으로 캐시를 나누거나, 로그인/로그아웃 시 캐시를 비우고 실패 결과는 캐시하지 않는 쪽이 안전합니다.
🛠️ 제안된 수정
let cachedAdminStatus = null;
let adminStatusPromise = null;
+
+export const resetAdminStatusCache = () => {
+ cachedAdminStatus = null;
+ adminStatusPromise = null;
+};
const fetchAdminStatus = async () => {
if (typeof cachedAdminStatus === "boolean") {
return cachedAdminStatus;
}
@@
})
.catch((error) => {
console.error("관리자 상태 확인에 실패했습니다.", error);
- cachedAdminStatus = false;
return false;
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let cachedAdminStatus = null; | |
| let adminStatusPromise = null; | |
| const fetchAdminStatus = async () => { | |
| if (typeof cachedAdminStatus === "boolean") { | |
| return cachedAdminStatus; | |
| } | |
| if (!adminStatusPromise) { | |
| adminStatusPromise = getMyProfile() | |
| .then((payload) => { | |
| const profile = extractProfile(payload); | |
| cachedAdminStatus = String(profile?.role || "").toUpperCase() === "ADMIN"; | |
| return cachedAdminStatus; | |
| }) | |
| .catch((error) => { | |
| console.error("관리자 상태 확인에 실패했습니다.", error); | |
| cachedAdminStatus = false; | |
| return false; | |
| }) | |
| .finally(() => { | |
| adminStatusPromise = null; | |
| }); | |
| } | |
| return adminStatusPromise; | |
| }; | |
| let cachedAdminStatus = null; | |
| let adminStatusPromise = null; | |
| export const resetAdminStatusCache = () => { | |
| cachedAdminStatus = null; | |
| adminStatusPromise = null; | |
| }; | |
| const fetchAdminStatus = async () => { | |
| if (typeof cachedAdminStatus === "boolean") { | |
| return cachedAdminStatus; | |
| } | |
| if (!adminStatusPromise) { | |
| adminStatusPromise = getMyProfile() | |
| .then((payload) => { | |
| const profile = extractProfile(payload); | |
| cachedAdminStatus = String(profile?.role || "").toUpperCase() === "ADMIN"; | |
| return cachedAdminStatus; | |
| }) | |
| .catch((error) => { | |
| console.error("관리자 상태 확인에 실패했습니다.", error); | |
| return false; | |
| }) | |
| .finally(() => { | |
| adminStatusPromise = null; | |
| }); | |
| } | |
| return adminStatusPromise; | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/hooks/useAdminStatus.js` around lines 5 - 29, The module-level caching
(cachedAdminStatus, adminStatusPromise) causes stale admin state to leak across
users; change fetchAdminStatus to key caches by current user identity (e.g.,
maintain cachedAdminStatusByUser: Record<userId, boolean> and
adminStatusPromiseByUser: Record<userId, Promise<boolean>>) or provide/ call a
clearAdminStatusCache function on login/logout; only set the cachedAdminStatus
entry after a successful getMyProfile+extractProfile resolution (do not cache
false results from the catch), and ensure adminStatusPromise is scoped to the
same user key so concurrent calls for the same user share the promise but
different users don’t. Use getMyProfile/extractProfile to derive the user id for
the cache key or wire into your auth module to invalidate the cache on identity
change.
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/pages/content/MyPage.jsx (1)
721-736:⚠️ Potential issue | 🟡 Minor저장/삭제가 같은 로딩 상태를 써서 버튼 문구가 틀어집니다.
removeGeminiApiKey()가 돌 때도 저장 버튼이"저장 중..."으로 바뀝니다. 현재 액션을 분리해서 버튼별 로딩 상태와 라벨을 계산하는 편이 맞습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/MyPage.jsx` around lines 721 - 736, The two buttons share a single loading flag (geminiApiKeySubmitting) causing the wrong label when removeGeminiApiKey runs; introduce a separate removingGeminiApiKey state (or a per-action enum) and use it to compute each button's disabled prop and label: keep submitGeminiApiKey using geminiApiKeySubmitting and the "저장 중..." label, have removeGeminiApiKey set/removing and show "삭제 중..." while disabling only the remove button, and update JSX around submitGeminiApiKey, removeGeminiApiKey, hasGeminiApiKey to reference the new state for correct labels and disabled behavior.src/pages/content/TechPracticePage.jsx (1)
247-263:⚠️ Potential issue | 🟠 Major세션 스냅샷 계약을 맞춰 주세요.
여기는
response.sessionId/response.currentQuestion검증 없이 바로 저장하고,questionCount도 메타데이터에 넣지 않습니다.InterviewSessionPage.jsxLine 156은sessionMetadata.questionCount로 마지막 질문을 판정하므로, 지금 상태면 기술 연습 세션은 종료 보호가 꺼지고 응답이 비정상이면 깨진 스냅샷까지 저장됩니다.수정 예시
const response = await startTechInterview({ categoryId: category.categoryId || null, questionCount: QUESTION_COUNT, difficulty: ratingToDifficulty(selectedRating), }); + if (!response?.sessionId || !response?.currentQuestion) { + setPageErrorMessage("연습 세션을 시작했지만 첫 질문을 불러오지 못했습니다."); + return; + } saveTechInterviewSession({ sessionId: response.sessionId, currentQuestion: response.currentQuestion, pendingResult: null, completed: false, metadata: { apiBasePath: "/api/interview/tech", categoryName: category.name, difficultyLabel: ratingToDifficulty(selectedRating), + questionCount: QUESTION_COUNT, selectedDocuments: {}, }, });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/TechPracticePage.jsx` around lines 247 - 263, startTechInterview 호출 결과를 검증하지 않고 saveTechInterviewSession에 바로 저장하고 있어 세션 스냅샷이 깨질 수 있으니, startTechInterview의 반환값(response)에서 sessionId와 currentQuestion이 유효한지 확인하고(없거나 비정상일 경우 저장을 중단하고 오류 처리), saveTechInterviewSession에 전달하는 metadata에 questionCount: QUESTION_COUNT를 추가해 InterviewSessionPage에서 기대하는 sessionMetadata.questionCount를 채우도록 수정하세요; 관련 심볼: startTechInterview, saveTechInterviewSession, response.sessionId, response.currentQuestion, QUESTION_COUNT, sessionMetadata.questionCount.
♻️ Duplicate comments (3)
src/components/Sidebar.jsx (1)
34-34:⚠️ Potential issue | 🟠 Major
fixed left-0때문에 사이드바가 부모 레이아웃에서 이탈합니다.
src/pages/content/AdminConsolePage.jsx:757-764처럼 사이드바 슬롯을 따로 잡는 화면에서는 빈 칼럼만 남고 실제 사이드바는 viewport 왼쪽으로 붙습니다. 여기서는 부모 칼럼 기준으로 붙는sticky top-[54px]쪽이 안전합니다.🧩 제안 수정
- <aside className="fixed left-0 top-[54px] z-20 flex h-[calc(100vh-54px)] w-[272px] flex-col border-r border-[`#e8e8e8`] bg-[`#f8f8f8`]"> + <aside className="sticky top-[54px] z-20 flex h-[calc(100vh-54px)] w-[272px] flex-col border-r border-[`#e8e8e8`] bg-[`#f8f8f8`]">🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/Sidebar.jsx` at line 34, The sidebar element in Sidebar.jsx uses "fixed left-0" which detaches it from the parent layout; change the aside's positioning to "sticky top-[54px]" (remove "fixed" and "left-0") so the Sidebar component's aside is positioned relative to its parent column and remains within the layout flow while keeping the same top offset and existing sizing/border/bg classes.src/pages/content/QuestionBrowsePage.jsx (1)
393-406:⚠️ Potential issue | 🟠 Major질문 상세 진입을 키보드에서도 가능하게 해 주세요.
카드가 여전히
onClick만 받아서 키보드 사용자는 상세 모달을 열 수 없습니다. 내부 저장 버튼은 유지하되 카드 컨테이너에tabIndex,role="button",Enter/Space처리를 추가해 주세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/QuestionBrowsePage.jsx` around lines 393 - 406, The article card mapping over (selectedSet.questions || []) uses only onClick to call setSelectedQuestion, preventing keyboard users from opening the detail modal; update the article element rendered in that map to include tabIndex={0}, role="button", and an onKeyDown handler that listens for Enter and Space and invokes the same handler used by onClick (the setSelectedQuestion call that adds setId/setTitle/createdAt/categoryName), ensuring the existing inner save button remains unchanged; keep the key prop (question.questionId) and reuse the same object construction so behavior is identical for mouse and keyboard.src/pages/content/AdminConsolePage.jsx (1)
380-397:⚠️ Potential issue | 🟠 Major필터된 목록과 선택 상태를 동기화해 주세요.
필터를 바꿔서 현재
selectedCategoryId가filteredCategoryList에서 빠져도 오른쪽 편집 패널은 계속 그 숨겨진 카테고리를 수정합니다. 화면에 없는 항목을 저장/삭제할 수 있어서 위험합니다.수정 예시
+ useEffect(() => { + if (filteredCategoryList.some((item) => item.categoryId === selectedCategoryId)) return; + setSelectedCategoryId(filteredCategoryList[0]?.categoryId ?? null); + }, [filteredCategoryList, selectedCategoryId]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/AdminConsolePage.jsx` around lines 380 - 397, filteredCategoryList can exclude the currently selectedCategoryId when filters change, leaving the edit panel bound to a hidden category; add a synchronization effect that runs when filteredCategoryList (or its dependencies: categoryById, categoryDepth0Filter, categoryDepth1Filter, categoryDepth2Filter, sortedCategories) changes and checks whether selectedCategoryId is present in filteredCategoryList, and if not call the same state updater used for selection (e.g., setSelectedCategoryId) to clear the selection or set it to a safe default (null or the first item in filteredCategoryList) so the edit panel never shows an out-of-filter category.
🧹 Nitpick comments (2)
.github/workflows/jules-code-review.yml (1)
3-9: PR 단위concurrency를 추가하는 편이 좋겠습니다.지금은
synchronize마다 새 런이 뜨는데 이전 런을 취소하지 않습니다. 연속 푸시가 들어오면 중복 리뷰나 중복 수정 PR이 생기기 쉬워집니다. 이 저장소는.coderabbit.yaml:1-4기준으로 다른 리뷰 봇도 이미 켜져 있어서 더 그렇습니다.예시 수정
name: Jules AI Code Review (Korean) +concurrency: + group: jules-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + on: pull_request: types: [opened, synchronize] # PR이 생성되거나 새로운 커밋이 push될 때 실행🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.github/workflows/jules-code-review.yml around lines 3 - 9, Add a GitHub Actions concurrency policy to this workflow so in-progress runs for the same PR are cancelled on new pushes: update the workflow (job "review") to include a top-level concurrency block using a group keyed by the PR number (e.g., github.workflow + github.event.pull_request.number) and set cancel-in-progress: true so only the latest "review" run for a given pull_request remains.src/lib/savedQuestionUtils.js (1)
1-3: 문구 매칭보다 안정적인 에러 식별자를 우선 보세요.지금처럼
error.message의 부분 문자열에 기대면 서버 문구나 번역이 조금만 바뀌어도 북마크 성공 처리 분기가 바로 깨집니다. 가능하면 API에서 내려주는status/code를 우선 확인하고, 이 문구 매칭은 마지막 fallback으로만 두는 편이 안전합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/savedQuestionUtils.js` around lines 1 - 3, The isAlreadySavedQuestionError helper should avoid relying solely on message substring matching; update the function to first check for structured identifiers (e.g., error.code, error.status, or error.response?.data?.code) that the API may provide (for example look for a 409/CONFLICT status or a specific code like "ALREADY_SAVED"), and only fall back to checking message.includes("이미 저장된 질문") if none of those structured fields exist; modify isAlreadySavedQuestionError to check in that order (error.code -> error.status -> error.response?.data?.code -> message).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.github/workflows/jules-code-review.yml:
- Around line 18-20: The workflow step "Trigger Jules Agent" currently uses a
mutable tag 'uses: google-labs-code/jules-invoke@v1'; replace that tag with the
action's full commit SHA to pin the third-party action (e.g., 'uses:
google-labs-code/jules-invoke@<full-commit-sha>') so the workflow references an
immutable commit; locate the step by the job/step name "Trigger Jules Agent" and
update the 'uses' value to the exact commit SHA from the action repository (you
can copy the SHA from the action's GitHub commits page or release) to comply
with supply-chain security guidance.
In `@src/components/SupportReportModal.jsx`:
- Around line 75-90: The modal lacks accessibility semantics and focus
management: update the SupportReportModal component to add role="dialog" and
aria-modal="true" on the panel div, give the heading an id and set
aria-labelledby to that id (use the existing heading text element), and ensure
keyboard focus moves into the modal on open (set initial focus to the first
input or the close button) and is trapped inside while open (implement a simple
focus trap that restores focus on close); also ensure background elements are
not focusable while the modal is open and reuse the existing sending and onClose
logic so onClose still respects the sending flag.
In `@src/lib/userApi.js`:
- Line 5: hasMyProfileImage is a module-global boolean that persists across
sessions causing stale avatar URLs; change this by removing reliance on a single
module-level flag and add a reset/clear API (e.g., export resetMyProfileCache or
clearProfileImageState) and call it from logout/session-expiry handlers, and
also clear the flag inside failure branches of getMyProfile() and PROFILE_IMAGE
upload logic so getMyProfileImageUrl() cannot return a stale image; update
usages in getMyProfile(), PROFILE_IMAGE upload/error handling, and callers like
getMyProfileImageUrl()/MyPage to rely on the resetable state rather than a
permanent module-global.
In `@src/pages/content/AdminConsolePage.jsx`:
- Around line 647-661: Partially successful category save leaves the UI stale
because updateAdminInterviewCategory and moveAdminInterviewCategory are separate
calls and loadCategories() is skipped when the second call fails; ensure
loadCategories() runs after any combination of success/failure so the UI
reflects server state. Refactor the save flow around
updateAdminInterviewCategory(selectedCategoryId, ...) and
moveAdminInterviewCategory(selectedCategoryId, ...) so that you always call
await loadCategories() in a finally block (or after both ops with try/catch
per-op), handle/log errors from the move call without skipping the final reload,
and surface an appropriate error/notification while still refreshing the
category list to match the server.
In `@src/pages/content/InterviewSessionPage.jsx`:
- Around line 156-173: The guard for the unload overlay is relying on the
derived boolean isFinalizingSession (computed from submitting && isLastQuestion)
which flips to false when setCurrentQuestion(null) runs before
loadSessionResults() finishes; change the logic to use an explicit "finalizing"
state that is set when submission starts and cleared only after
loadSessionResults() completes (or base the effect solely on a new finalizing
flag instead of isLastQuestion/currentQuestion), e.g. introduce a
finalizingSession boolean you set in the submit handler before calling
loadSessionResults() and clear after it resolves, then update the useEffect to
depend on finalizingSession (and keep references to
currentQuestion/sessionMetadata for context if needed) so the beforeunload guard
stays active until results fully load.
In `@src/pages/content/InterviewStartPage.jsx`:
- Around line 438-459: When creating a new skill in handleCreateSkill the code
updates selectedCategoryIds but does not clear selectedQuestionSetId, leaving
both questionSetId and categoryIds set and causing ambiguous priority; update
handleCreateSkill to also clear the selected question set (call
setSelectedQuestionSetId(null) or the same reset used in the manual selection
flow) right after applying prioritizeCreatedSelection (or whenever you finalize
the new category selection) so the question-set selection is reset consistently
with the manual selection path.
- Around line 339-349: visibleQuestionSets currently only checks set.jobNames
(array) when matching selectedJob, causing sets that provide a single jobName
(set.jobName) to be excluded; update the filter logic in the visibleQuestionSets
useMemo to treat set.jobName the same as an array by building setJobNames =
Array.isArray(set.jobNames) ? set.jobNames.map(...) : [String(set.jobName ||
"").trim().toLowerCase()], then proceed to check includes(normalizedJobName) or
includes("공통"); reference visibleQuestionSets, branchFilter, branchItems,
myQuestionSets, selectedJob, set.jobNames and set.jobName when making the
change.
In `@src/pages/content/QuestionBrowsePage.jsx`:
- Around line 179-221: The page allows navigation while a session start is in
progress (handled by handleStartSetPractice using
isStartingSetLaunch/setIsStartingSetLaunch), which can cause the async
startTechInterview to finish after the user leaves and incorrectly save session
and navigate; fix by blocking navigation and showing a blocking overlay while
isStartingSetLaunch is true: add a blocking UI (modal/overlay) rendered from
QuestionBrowsePage when isStartingSetLaunch is set, register a navigation guard
(react-router navigation blocker or history.block / useBeforeUnload) in a
useEffect tied to isStartingSetLaunch to prevent route changes, and disable
menu/sidebar/logout/point buttons (or propagate a disabled prop) while
isStartingSetLaunch is true; apply the same change to the other start handler
referenced in the 242-265 region so both entry points prevent leaving until the
start completes or fails.
- Around line 197-209: The metadata object passed to saveTechInterviewSession is
missing questionCount which InterviewSessionPage.jsx expects via
sessionMetadata.questionCount; update the call to saveTechInterviewSession (the
metadata passed in the block around saveTechInterviewSession) to include
questionCount populated from the question set (e.g., prefer
setItem.questionCount if present, otherwise derive from
Array.isArray(setItem.questions) ? setItem.questions.length), keeping the
existing fields (apiBasePath, fromQuestionSet, saveHistory, categoryName,
jobName/primaryJobName) unchanged so the beforeunload guard and end-of-set
overlay work for question-set sessions.
---
Outside diff comments:
In `@src/pages/content/MyPage.jsx`:
- Around line 721-736: The two buttons share a single loading flag
(geminiApiKeySubmitting) causing the wrong label when removeGeminiApiKey runs;
introduce a separate removingGeminiApiKey state (or a per-action enum) and use
it to compute each button's disabled prop and label: keep submitGeminiApiKey
using geminiApiKeySubmitting and the "저장 중..." label, have removeGeminiApiKey
set/removing and show "삭제 중..." while disabling only the remove button, and
update JSX around submitGeminiApiKey, removeGeminiApiKey, hasGeminiApiKey to
reference the new state for correct labels and disabled behavior.
In `@src/pages/content/TechPracticePage.jsx`:
- Around line 247-263: startTechInterview 호출 결과를 검증하지 않고
saveTechInterviewSession에 바로 저장하고 있어 세션 스냅샷이 깨질 수 있으니, startTechInterview의
반환값(response)에서 sessionId와 currentQuestion이 유효한지 확인하고(없거나 비정상일 경우 저장을 중단하고 오류
처리), saveTechInterviewSession에 전달하는 metadata에 questionCount: QUESTION_COUNT를 추가해
InterviewSessionPage에서 기대하는 sessionMetadata.questionCount를 채우도록 수정하세요; 관련 심볼:
startTechInterview, saveTechInterviewSession, response.sessionId,
response.currentQuestion, QUESTION_COUNT, sessionMetadata.questionCount.
---
Duplicate comments:
In `@src/components/Sidebar.jsx`:
- Line 34: The sidebar element in Sidebar.jsx uses "fixed left-0" which detaches
it from the parent layout; change the aside's positioning to "sticky top-[54px]"
(remove "fixed" and "left-0") so the Sidebar component's aside is positioned
relative to its parent column and remains within the layout flow while keeping
the same top offset and existing sizing/border/bg classes.
In `@src/pages/content/AdminConsolePage.jsx`:
- Around line 380-397: filteredCategoryList can exclude the currently
selectedCategoryId when filters change, leaving the edit panel bound to a hidden
category; add a synchronization effect that runs when filteredCategoryList (or
its dependencies: categoryById, categoryDepth0Filter, categoryDepth1Filter,
categoryDepth2Filter, sortedCategories) changes and checks whether
selectedCategoryId is present in filteredCategoryList, and if not call the same
state updater used for selection (e.g., setSelectedCategoryId) to clear the
selection or set it to a safe default (null or the first item in
filteredCategoryList) so the edit panel never shows an out-of-filter category.
In `@src/pages/content/QuestionBrowsePage.jsx`:
- Around line 393-406: The article card mapping over (selectedSet.questions ||
[]) uses only onClick to call setSelectedQuestion, preventing keyboard users
from opening the detail modal; update the article element rendered in that map
to include tabIndex={0}, role="button", and an onKeyDown handler that listens
for Enter and Space and invokes the same handler used by onClick (the
setSelectedQuestion call that adds setId/setTitle/createdAt/categoryName),
ensuring the existing inner save button remains unchanged; keep the key prop
(question.questionId) and reuse the same object construction so behavior is
identical for mouse and keyboard.
---
Nitpick comments:
In @.github/workflows/jules-code-review.yml:
- Around line 3-9: Add a GitHub Actions concurrency policy to this workflow so
in-progress runs for the same PR are cancelled on new pushes: update the
workflow (job "review") to include a top-level concurrency block using a group
keyed by the PR number (e.g., github.workflow +
github.event.pull_request.number) and set cancel-in-progress: true so only the
latest "review" run for a given pull_request remains.
In `@src/lib/savedQuestionUtils.js`:
- Around line 1-3: The isAlreadySavedQuestionError helper should avoid relying
solely on message substring matching; update the function to first check for
structured identifiers (e.g., error.code, error.status, or
error.response?.data?.code) that the API may provide (for example look for a
409/CONFLICT status or a specific code like "ALREADY_SAVED"), and only fall back
to checking message.includes("이미 저장된 질문") if none of those structured fields
exist; modify isAlreadySavedQuestionError to check in that order (error.code ->
error.status -> error.response?.data?.code -> message).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 894ee05d-e6a7-4e8a-9987-463ecb7a6ada
📒 Files selected for processing (21)
.github/workflows/jules-code-review.ymlsrc/components/ContentTopNav.jsxsrc/components/MobileSidebarDrawer.jsxsrc/components/Sidebar.jsxsrc/components/SupportReportModal.jsxsrc/lib/categoryPresentation.jssrc/lib/interviewApi.jssrc/lib/savedQuestionUtils.jssrc/lib/supportApi.jssrc/lib/userApi.jssrc/pages/content/AdminConsolePage.jsxsrc/pages/content/FileUploadPage.jsxsrc/pages/content/InterviewSessionPage.jsxsrc/pages/content/InterviewStartPage.jsxsrc/pages/content/MyPage.jsxsrc/pages/content/PointChargePage.jsxsrc/pages/content/QuestionBrowsePage.jsxsrc/pages/content/QuestionSetsPage.jsxsrc/pages/content/SavedQuestionsPage.jsxsrc/pages/content/SessionHistoryTemplate.jsxsrc/pages/content/TechPracticePage.jsx
💤 Files with no reviewable changes (2)
- src/pages/content/PointChargePage.jsx
- src/pages/content/FileUploadPage.jsx
| - name: Trigger Jules Agent | ||
| # Jules 공식 액션 호출 (v1 또는 최신 버전 확인 필요) | ||
| uses: google-labs-code/jules-invoke@v1 |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
GitHub Actions security best practices pinning commit SHA third-party actions
💡 Result:
GitHub Actions security best practices for third‑party actions (SHA pinning)
-
Pin every third‑party action to a full-length commit SHA (not
@main,@master, or a moving tag like@v1). This is GitHub’s recommended way to make an action reference immutable and reduce supply-chain risk. [1]
Example:- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
Add a comment with the release version to keep SHA pins maintainable. [4]
-
Verify the SHA is from the action’s official repository (not a fork) before pinning. [1]
-
Prefer SHAs that correspond to a tagged release (so updates are intentional and trackable). [4]
-
Use governance controls to enforce pinning and reduce your allowed surface area:
- Turn on org/enterprise/repo policy to require actions be pinned to a full-length SHA. [1][3]
- Use “allowed actions and reusable workflows” policies to allowlist trusted actions and block known-bad actions/versions (GitHub added explicit blocking and SHA-pinning enforcement in policy controls). [2][3]
-
Audit actions and avoid “mutable dependencies” inside actions (e.g., actions that download and execute code at runtime, or reference unpinned images/other actions), because pinning the top-level action SHA won’t make those parts immutable. [1][4]
-
Automate updates carefully: GitHub’s Well-Architected guidance notes Dependabot can help keep action versions updated, but security alerts may not apply the same way when you pin by SHA (you’ll typically rely on version-update PRs and your own review process). [4]
Sources: GitHub Docs secure use / hardening guidance [1], GitHub Changelog policy enforcement update [2], GitHub Enterprise policy docs [3], GitHub Well-Architected Actions security recommendations [4].
🌐 Web query:
google-labs-code jules-invoke GitHub Action
💡 Result:
google-labs-code/jules-invoke is a GitHub Action published from the google-labs-code/jules-action repo that lets you trigger Google Labs’ Jules remote coding agent from any GitHub workflow event (schedule, issues, PRs, workflow_dispatch, etc.). [1]
Minimal example:
jobs:
jules:
runs-on: ubuntu-latest
steps:
- uses: google-labs-code/jules-invoke@v1
with:
prompt: |
Fix failing tests and open a PR.
jules_api_key: ${{ secrets.JULES_API_KEY }}Inputs:
prompt(required): the task for Julesjules_api_key(required): Jules API key (store as a GitHub Actions secret)- Optional:
starting_branch(defaultmain),include_last_commit(defaultfalse),include_commit_log(defaultfalse) [1]
To get an API key and connect repos, you sign into jules.google.com and connect your GitHub account/repos in Jules first. [1][2]
서드파티 액션은 태그 대신 커밋 SHA로 고정하세요.
google-labs-code/jules-invoke@v1는 가변 참조입니다. GitHub의 공식 보안 지침에 따르면, 서드파티 액션은 전체 커밋 SHA로 고정하는 것이 공급망 공격 위험을 줄이는 표준 방식입니다.
수정 예시
- uses: google-labs-code/jules-invoke@v1
+ uses: google-labs-code/jules-invoke@<verified-full-commit-sha>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.github/workflows/jules-code-review.yml around lines 18 - 20, The workflow
step "Trigger Jules Agent" currently uses a mutable tag 'uses:
google-labs-code/jules-invoke@v1'; replace that tag with the action's full
commit SHA to pin the third-party action (e.g., 'uses:
google-labs-code/jules-invoke@<full-commit-sha>') so the workflow references an
immutable commit; locate the step by the job/step name "Trigger Jules Agent" and
update the 'uses' value to the exact commit SHA from the action repository (you
can copy the SHA from the action's GitHub commits page or release) to comply
with supply-chain security guidance.
There was a problem hiding this comment.
Actionable comments posted: 10
♻️ Duplicate comments (4)
src/pages/content/TechPracticePage.jsx (1)
99-134:⚠️ Potential issue | 🟠 Major이전 리뷰에서 지적된 이름 기반 매칭 문제가 여전히 존재합니다.
loadCatalog가preferredJobName과preferredSkillName을 전체 목록에서 이름으로만 찾습니다. 동일 이름의 항목이 다른 계열/직무에 있으면 방금 생성한 항목 대신 첫 번째 동명 항목이 선택될 수 있습니다.매칭 시 이미 선택된 branch/job 범위 내로 제한해야 합니다:
🔧 제안된 수정 (skill 매칭 예시)
const normalizedPreferredSkillName = String(preferredSkillName || "").trim().toLowerCase(); if (normalizedPreferredSkillName) { - const preferredSkill = skills.find((skill) => String(skill?.name || "").trim().toLowerCase() === normalizedPreferredSkillName); + const preferredSkill = skills.find((skill) => { + if (String(skill?.name || "").trim().toLowerCase() !== normalizedPreferredSkillName) return false; + // nextJobFilter가 설정된 경우 해당 job의 자식만 매칭 + if (nextJobFilter && String(skill?.parentId || "") !== nextJobFilter) return false; + return true; + }); if (preferredSkill) {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/TechPracticePage.jsx` around lines 99 - 134, The name-based matching in loadCatalog (matching preferredJobName and preferredSkillName against jobs/skills) must be constrained to the currently selected branch/job context so duplicates in other categories don't get picked; update the find logic that computes matchedBranch/matchedJob and the skill lookup to first filter jobs by the active branchFilter (or branches by any other relevant scope) and skills by the active jobFilter/branchFilter before comparing normalized names, and only fall back to the global search if no match exists in the scoped list; ensure the updates affect the code paths that call setBranchFilter, setJobFilter, setCategoryQuery and setSelectedSkillId so the chosen item comes from the intended category.src/lib/authApi.js (1)
39-44:⚠️ Potential issue | 🟠 Major로그아웃 실패 경로에서는 프로필 캐시가 그대로 남습니다.
호출부들이
logout()오류를 무시하고 로그인 화면으로 이동하는 흐름이라, 여기서apiRequest가 throw 하면 캐시만 남고 세션은 종료된 것처럼 보입니다. 캐시 초기화는 성공 여부와 무관하게finally에서 실행하는 편이 안전합니다.수정 예시
export async function logout() { - const result = await apiRequest("/api/auth/logout", { - method: "POST", - }); - resetMyProfileCache(); - return result; + try { + return await apiRequest("/api/auth/logout", { + method: "POST", + }); + } finally { + resetMyProfileCache(); + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/authApi.js` around lines 39 - 44, The logout function currently calls apiRequest("/api/auth/logout") and then resetMyProfileCache(), which leaves the cache intact if apiRequest throws; change logout to ensure resetMyProfileCache() runs regardless of success by wrapping the apiRequest call in a try/finally (or using finally on the returned promise) so that resetMyProfileCache() is invoked in the finally block while returning or rethrowing the apiRequest result/error from logout; reference the logout function and resetMyProfileCache and ensure any returned result or thrown error from apiRequest is preserved.src/pages/content/QuestionBrowsePage.jsx (2)
214-220:⚠️ Potential issue | 🟠 Major
questionCount가 다시 0으로 저장될 수 있습니다.Line 96-111에서 세트별 문항 조회 실패를 빈 배열로 흡수하므로, 여기서는
setItem.questionCount가 없으면metadata.questionCount가0이 됩니다. 그러면 질문 세트 세션의 마지막 문항 계산과 종료 보호가 다시 깨질 수 있습니다. 시작 요청에 넘긴 값(5)이나 서버 응답의 총 문항 수를 최소 fallback으로 넣어 주세요.수정 예시
metadata: { apiBasePath: "/api/interview/tech", fromQuestionSet: true, saveHistory: false, categoryName: (Array.isArray(setItem.skillNames) ? setItem.skillNames.join(", ") : setItem.skillName) || null, jobName: primaryJobName, - questionCount: Number(setItem.questionCount || (Array.isArray(setItem.questions) ? setItem.questions.length : 0) || 0), + questionCount: Number( + setItem.questionCount || + (Array.isArray(setItem.questions) ? setItem.questions.length : 0) || + 5 + ), },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/QuestionBrowsePage.jsx` around lines 214 - 220, metadata.questionCount currently falls back to 0 when setItem.questionCount is missing because setItem.questions may have been swallowed to an empty array; update the assignment of metadata.questionCount (the Number(...) expression using setItem.questionCount and setItem.questions) to use a safer fallback such as the initial requested question count (the value you send on start, e.g., 5) and/or the server-provided total questions before defaulting to 0 — for example replace the current Number(...) expression with something like Math.max(Number(setItem.questionCount || (Array.isArray(setItem.questions) ? setItem.questions.length : 0) || 0), requestedStartCount, serverTotalCount) so questionCount never becomes 0 incorrectly.
83-91:⚠️ Potential issue | 🟠 Major세션 시작 중 SPA 이동은 아직 막히지 않습니다.
Line 83-91의
beforeunload는 새로고침/탭 종료만 막고, 브라우저 뒤로가기나 클라이언트 라우트 이동은 막지 못합니다. 이 상태에서 요청이 늦게 끝나면 현재 화면과 무관하게 세션 저장 후 인터뷰 화면으로 리다이렉트될 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/QuestionBrowsePage.jsx` around lines 83 - 91, The current effect only prevents refresh/close via beforeunload but doesn't block SPA/client-side navigation; when isStartingSetLaunch is true also intercept client-route changes and back button events: add a popstate listener (or, if using react-router, use the router's navigation blocker/Prompt API or a custom history.block) that prevents navigation when isStartingSetLaunch is true and shows a confirmation, implement a handler (e.g., handleClientNavigate or reuse handleBeforeUnload logic) to cancel/rollback the navigation, register it alongside the existing beforeunload listener in the useEffect, and ensure you remove the popstate/navigation blocker in the cleanup to avoid leaks.
🧹 Nitpick comments (6)
src/pages/content/InterviewSessionPage.jsx (2)
192-195: 결과 로딩 실패 시 재시도 방법이 없습니다.
loadSessionResults()가 실패하면sessionResults는null로 남지만, effect가 다시 트리거되지 않아 사용자가 결과를 다시 불러올 방법이 없습니다. 재시도 버튼 추가를 고려해보세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/InterviewSessionPage.jsx` around lines 192 - 195, The effect using useEffect checks completed and sessionResults and calls loadSessionResults() only once, so if loadSessionResults fails and sessionResults stays null the effect never retries; add a manual retry flow by exposing a retry handler (e.g., handleRetry) that calls loadSessionResults and set appropriate local state (e.g., isLoading/isError) so the UI can show a "Retry" button when sessionResults is null and completed is true; update the component rendering to show the Retry button wired to handleRetry and ensure loadSessionResults updates sessionResults or error state so subsequent clicks retry correctly.
289-300:isAlreadySavedQuestionError문자열 매칭이 광범위합니다.
savedQuestionUtils.js의message.includes("이미 저장된 질문")조건이 다른 에러 메시지에도 매칭될 수 있습니다. 실제 북마크 실패가 "이미 저장된 질문" 처리로 잘못 분류되어 조용히 무시될 가능성이 있습니다.현재 코드 자체는 문제없지만, 향후
savedQuestionUtils.js에서 에러 코드 기반 검증을 우선하고 문자열 매칭을 fallback으로만 사용하는 것을 권장합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/InterviewSessionPage.jsx` around lines 289 - 300, The isAlreadySavedQuestionError string match is too broad (savedQuestionUtils.js uses message.includes("이미 저장된 질문")), so update the savedQuestionUtils.js isAlreadySavedQuestionError implementation to first check a specific error code or a dedicated error property (e.g., error.code === 'ALREADY_SAVED_QUESTION' or error.type === 'AlreadySavedQuestion') and only use message.includes("이미 저장된 질문") as a fallback; ensure callers like the block in InterviewSessionPage.jsx that calls isAlreadySavedQuestionError(error) continue to work unchanged but now receive the more robust error check.src/pages/content/TechPracticePage.jsx (3)
201-205: 기술 중복 검사가 전역 범위로 수행됩니다.
canCreateCategory가skillCategories전체를 대상으로 중복을 검사하므로, 다른 직무 하위에 동일 이름의 기술이 이미 있으면 생성이 불가합니다. UI 메시지(Line 441)에서 이를 명시하고 있어 의도된 동작으로 보이나, 실제로 다른 직무에 동일 기술명이 필요한 경우가 있을 수 있습니다.의도한 설계라면 무시해도 됩니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/TechPracticePage.jsx` around lines 201 - 205, canCreateCategory currently checks duplicates across all skillCategories (using categoryQuery, jobFilter, and skillCategories) which prevents creating a category that has the same name under a different job; restrict the duplicate check to only categories for the current jobFilter by first filtering skillCategories by the job identifier used in your data model (e.g., jobFilter.id or jobFilter.name) and then perform the existing .some(...) comparison on that filtered list; update the predicate that builds canCreateCategory (and any related UI messages if necessary) to use the job-scoped list so same-name categories in other jobs do not block creation.
211-222: 검색 쿼리 변경 시 선택이 해제될 수 있습니다.
visibleJobs와visibleSkills가 검색 쿼리(jobQuery,categoryQuery)에 의해 필터링되므로, 검색어 입력 시 현재 선택된 항목이 검색 결과에 없으면 선택이 자동 해제됩니다.의도된 동작이라면 무시해도 되지만, 선택 상태와 검색 필터를 분리하면 더 나은 UX를 제공할 수 있습니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/TechPracticePage.jsx` around lines 211 - 222, The two useEffect hooks clear selection when filters change because they check against visibleJobs/visibleSkills which are filtered by search queries (jobQuery/categoryQuery); to fix, decouple selection from search by either (a) change the existence check inside the useEffect hooks to compare against the unfiltered source lists (e.g., allJobs/allSkills or the raw data used to create visibleJobs/visibleSkills) instead of visibleJobs/visibleSkills, or (b) only run the clearing logic when structural filters change (e.g., branchFilter/category selection) and skip it when jobQuery/categoryQuery changes; update the useEffect dependencies and keep calls to setJobFilter and setSelectedSkillId as-is so selection is only cleared for true category/branch removals, not on search input.
428-438: "기술 추가" 버튼이 두 번 렌더링됩니다.
canCreateCategory가 true일 때 Line 428과 Line 436에서 동일한handleCreateCategory핸들러를 호출하는 버튼이 두 개 표시됩니다. 의도된 UX(인라인 + 강조 카드)라면 문제없으나, 중복으로 보일 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/content/TechPracticePage.jsx` around lines 428 - 438, The UI renders two identical "기술 추가" buttons when canCreateCategory is true; remove the duplicate by keeping a single entry point for category creation (preferably the prominent button inside the dashed card) and delete or conditionally hide the inline button that also calls handleCreateCategory; ensure the remaining button still uses creatingCategory and onClick={handleCreateCategory} and that the category placeholder message continues to use categoryQuery.trim() so behavior and loading state remain correct.src/lib/userApi.js (1)
9-15: 프로필 payload 파서를 여기서 다시 정의하지 않는 편이 안전합니다.이 함수가
src/lib/profileUtils.js의 같은 역할 함수와 어긋나기 시작하면, 같은/api/users/me응답으로도 이미지 캐시와 화면 표시 정보가 서로 다른 객체를 기준으로 계산될 수 있습니다. 공통 유틸 하나로 맞춰 두는 편이 낫습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/userApi.js` around lines 9 - 15, The local extractProfile function duplicates parsing logic and risks divergence; remove this local extractProfile and instead import and use the shared profile parser from profileUtils (the same function used elsewhere) so all code uses one canonical parser; replace all uses of extractProfile in this module with the imported function and ensure the imported function preserves the original fallback semantics (object checks and returning payload if no nested object) so behavior stays consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.github/workflows/jules-code-review.yml:
- Around line 3-6: The workflow currently always calls
github.rest.pulls.createReview() (lines around the existing createReview usage)
which fails for fork/Dependabot PRs because GITHUB_TOKEN is read-only; update
the workflow to only attempt creating a review when the PR originates from the
same repository by checking github.event.pull_request.head.repo.full_name ===
github.repository before calling github.rest.pulls.createReview(), or move the
review/commenting logic into a separate pull_request_target workflow so it runs
with write permissions for repository-owned PRs; adjust any step names that
invoke createReview() accordingly so the conditional or separate workflow
prevents calls on external/forked PRs.
- Around line 31-60: The opening and closing multiline delimiter for the GitHub
Actions output must match: currently echo "body<<'EOF'" (prints body<<'EOF' with
quotes) does not match echo "EOF" (prints EOF without quotes), which breaks
$GITHUB_OUTPUT parsing; fix by making the delimiters identical (either both use
'EOF' or both use EOF) so the body<< delimiter and the terminating echo produce
the exact same token (e.g., change echo "body<<'EOF'" to echo "body<<EOF" or
change echo "EOF" to echo "'EOF'") and ensure the rest of the heredoc content
remains unchanged.
- Around line 25-41: The Build Review Summary step injects untrusted GitHub
context values directly into the inline bash (e.g., `${{
github.event.pull_request.base.ref }}`, `${{ github.event.pull_request.head.ref
}}`, `${{ github.event.pull_request.user.login }}`), which is unsafe; instead
add them to the step's env block (e.g., BASE_REF, HEAD_REF, PR_USER) and
reference the shell variables inside the run script ($BASE_REF, $HEAD_REF,
$PR_USER) so the values are passed safely as environment variables; update uses
in the script (git fetch, echo lines, and the git diff invocation) to use the
new shell variables rather than the inline `${{ }}` expressions.
In `@src/components/Sidebar.jsx`:
- Around line 52-55: The img currently receives profileImageUrl even when it's
an empty string, causing React warnings and unwanted requests; in the Sidebar
component guard against empty/falsey profileImageUrl by either conditionally
rendering the <img> only when profileImageUrl is a non-empty string or by
supplying a guaranteed placeholder URL (or rendering a placeholder element)
instead of passing "" to the src; update the logic around the img element (the
JSX that uses profileImageUrl and the key={profileImageUrl ||
"sidebar-profile-image"}) so that an empty string never becomes img.src and
ensure the key fallback still applies when you conditionally render a
placeholder.
In `@src/pages/content/AdminConsolePage.jsx`:
- Around line 292-300: When loading questions in loadSetQuestions, the previous
selectedSetQuestions can remain visible on failure; before starting the async
request and in the catch failure path (inside loadSetQuestions) call
setSelectedSetQuestions([]) to clear the array so the UI doesn't show the old
set, and keep existing setLoadingSetQuestions and setPageErrorMessage behavior
(i.e., clear selectedSetQuestions at request start and again on error before
setting the error message).
- Around line 262-271: The member detail state isn't cleared when a new load
starts or when the fetch fails, so the previous member stays visible; in
loadMemberDetail call setSelectedMemberDetail(null) at the start of the function
(after setLoadingMemberDetail(true)) and also setSelectedMemberDetail(null)
inside the catch branch before/when calling setPageErrorMessage to ensure the
right-hand panel is cleared on error or cancelled responses (refer to
loadMemberDetail, setSelectedMemberDetail, getAdminMemberDetail, cancelled).
In `@src/pages/content/InterviewSessionPage.jsx`:
- Around line 237-254: When completedResponse is true and loadSessionResults()
throws, finalizingSession remains true because the finally block only clears it
when !completedResponse; to fix this ensure finalizingSession is always reset:
wrap the loadSessionResults() call (or the whole completedResponse branch) in
its own try/finally that calls setFinalizingSession(false) in the finally, or
adjust the outer finally to always call setFinalizingSession(false) before
setSubmitting(false). Locate the logic around completedResponse,
setFinalizingSession, loadSessionResults, setSubmitting and
setSubmitErrorMessage in InterviewSessionPage.jsx and modify so
finalizingSession is cleared regardless of exceptions from loadSessionResults().
In `@src/pages/content/InterviewStartPage.jsx`:
- Around line 317-321: The effect using useEffect currently depends on
selectedCategoryIds.length which misses updates when the array contents change
but length stays the same; change the dependency to selectedCategoryIds (and
keep skillItems) so the effect reruns on any change to the selectedCategoryIds
array, and ensure the body still builds availableIds from skillItems and calls
setSelectedCategoryIds(prev => prev.filter(id => availableIds.has(String(id))))
to prune invalid ids (references: useEffect, selectedCategoryIds, skillItems,
setSelectedCategoryIds).
- Line 343: The code sets setBranchName by falling back to set.jobName (const
setBranchName = String(set.branchName || set.jobName ||
"").trim().toLowerCase()), which incorrectly mixes branch and job fields and
breaks filtering; change this to derive setBranchName solely from set.branchName
(e.g., String(set.branchName || "").trim().toLowerCase()) and keep set.jobName
handled independently (as done in SavedQuestionsPage.jsx) so branchName and
jobName remain distinct for accurate filtering.
In `@src/pages/content/QuestionBrowsePage.jsx`:
- Around line 421-432: The onKeyDown handler on the question card is reacting to
bubbled key events (so pressing Enter/Space on the Save button also triggers the
card's handler); update the onKeyDown in the card (the handler that calls
setSelectedQuestion with setId/setTitle/createdAt/categoryName) to only run when
the card itself is the focused/target element (for example, check
event.currentTarget === event.target or otherwise verify document.activeElement
is the card) before calling setSelectedQuestion, so key presses from child
controls like the Save button do not open the detail modal.
---
Duplicate comments:
In `@src/lib/authApi.js`:
- Around line 39-44: The logout function currently calls
apiRequest("/api/auth/logout") and then resetMyProfileCache(), which leaves the
cache intact if apiRequest throws; change logout to ensure resetMyProfileCache()
runs regardless of success by wrapping the apiRequest call in a try/finally (or
using finally on the returned promise) so that resetMyProfileCache() is invoked
in the finally block while returning or rethrowing the apiRequest result/error
from logout; reference the logout function and resetMyProfileCache and ensure
any returned result or thrown error from apiRequest is preserved.
In `@src/pages/content/QuestionBrowsePage.jsx`:
- Around line 214-220: metadata.questionCount currently falls back to 0 when
setItem.questionCount is missing because setItem.questions may have been
swallowed to an empty array; update the assignment of metadata.questionCount
(the Number(...) expression using setItem.questionCount and setItem.questions)
to use a safer fallback such as the initial requested question count (the value
you send on start, e.g., 5) and/or the server-provided total questions before
defaulting to 0 — for example replace the current Number(...) expression with
something like Math.max(Number(setItem.questionCount ||
(Array.isArray(setItem.questions) ? setItem.questions.length : 0) || 0),
requestedStartCount, serverTotalCount) so questionCount never becomes 0
incorrectly.
- Around line 83-91: The current effect only prevents refresh/close via
beforeunload but doesn't block SPA/client-side navigation; when
isStartingSetLaunch is true also intercept client-route changes and back button
events: add a popstate listener (or, if using react-router, use the router's
navigation blocker/Prompt API or a custom history.block) that prevents
navigation when isStartingSetLaunch is true and shows a confirmation, implement
a handler (e.g., handleClientNavigate or reuse handleBeforeUnload logic) to
cancel/rollback the navigation, register it alongside the existing beforeunload
listener in the useEffect, and ensure you remove the popstate/navigation blocker
in the cleanup to avoid leaks.
In `@src/pages/content/TechPracticePage.jsx`:
- Around line 99-134: The name-based matching in loadCatalog (matching
preferredJobName and preferredSkillName against jobs/skills) must be constrained
to the currently selected branch/job context so duplicates in other categories
don't get picked; update the find logic that computes matchedBranch/matchedJob
and the skill lookup to first filter jobs by the active branchFilter (or
branches by any other relevant scope) and skills by the active
jobFilter/branchFilter before comparing normalized names, and only fall back to
the global search if no match exists in the scoped list; ensure the updates
affect the code paths that call setBranchFilter, setJobFilter, setCategoryQuery
and setSelectedSkillId so the chosen item comes from the intended category.
---
Nitpick comments:
In `@src/lib/userApi.js`:
- Around line 9-15: The local extractProfile function duplicates parsing logic
and risks divergence; remove this local extractProfile and instead import and
use the shared profile parser from profileUtils (the same function used
elsewhere) so all code uses one canonical parser; replace all uses of
extractProfile in this module with the imported function and ensure the imported
function preserves the original fallback semantics (object checks and returning
payload if no nested object) so behavior stays consistent.
In `@src/pages/content/InterviewSessionPage.jsx`:
- Around line 192-195: The effect using useEffect checks completed and
sessionResults and calls loadSessionResults() only once, so if
loadSessionResults fails and sessionResults stays null the effect never retries;
add a manual retry flow by exposing a retry handler (e.g., handleRetry) that
calls loadSessionResults and set appropriate local state (e.g.,
isLoading/isError) so the UI can show a "Retry" button when sessionResults is
null and completed is true; update the component rendering to show the Retry
button wired to handleRetry and ensure loadSessionResults updates sessionResults
or error state so subsequent clicks retry correctly.
- Around line 289-300: The isAlreadySavedQuestionError string match is too broad
(savedQuestionUtils.js uses message.includes("이미 저장된 질문")), so update the
savedQuestionUtils.js isAlreadySavedQuestionError implementation to first check
a specific error code or a dedicated error property (e.g., error.code ===
'ALREADY_SAVED_QUESTION' or error.type === 'AlreadySavedQuestion') and only use
message.includes("이미 저장된 질문") as a fallback; ensure callers like the block in
InterviewSessionPage.jsx that calls isAlreadySavedQuestionError(error) continue
to work unchanged but now receive the more robust error check.
In `@src/pages/content/TechPracticePage.jsx`:
- Around line 201-205: canCreateCategory currently checks duplicates across all
skillCategories (using categoryQuery, jobFilter, and skillCategories) which
prevents creating a category that has the same name under a different job;
restrict the duplicate check to only categories for the current jobFilter by
first filtering skillCategories by the job identifier used in your data model
(e.g., jobFilter.id or jobFilter.name) and then perform the existing .some(...)
comparison on that filtered list; update the predicate that builds
canCreateCategory (and any related UI messages if necessary) to use the
job-scoped list so same-name categories in other jobs do not block creation.
- Around line 211-222: The two useEffect hooks clear selection when filters
change because they check against visibleJobs/visibleSkills which are filtered
by search queries (jobQuery/categoryQuery); to fix, decouple selection from
search by either (a) change the existence check inside the useEffect hooks to
compare against the unfiltered source lists (e.g., allJobs/allSkills or the raw
data used to create visibleJobs/visibleSkills) instead of
visibleJobs/visibleSkills, or (b) only run the clearing logic when structural
filters change (e.g., branchFilter/category selection) and skip it when
jobQuery/categoryQuery changes; update the useEffect dependencies and keep calls
to setJobFilter and setSelectedSkillId as-is so selection is only cleared for
true category/branch removals, not on search input.
- Around line 428-438: The UI renders two identical "기술 추가" buttons when
canCreateCategory is true; remove the duplicate by keeping a single entry point
for category creation (preferably the prominent button inside the dashed card)
and delete or conditionally hide the inline button that also calls
handleCreateCategory; ensure the remaining button still uses creatingCategory
and onClick={handleCreateCategory} and that the category placeholder message
continues to use categoryQuery.trim() so behavior and loading state remain
correct.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: ce952565-e806-4147-b227-1d6b150765f7
📒 Files selected for processing (12)
.github/workflows/jules-code-review.ymlsrc/components/Sidebar.jsxsrc/components/SupportReportModal.jsxsrc/lib/authApi.jssrc/lib/savedQuestionUtils.jssrc/lib/userApi.jssrc/pages/content/AdminConsolePage.jsxsrc/pages/content/InterviewSessionPage.jsxsrc/pages/content/InterviewStartPage.jsxsrc/pages/content/MyPage.jsxsrc/pages/content/QuestionBrowsePage.jsxsrc/pages/content/TechPracticePage.jsx
🚧 Files skipped from review as they are similar to previous changes (3)
- src/lib/savedQuestionUtils.js
- src/components/SupportReportModal.jsx
- src/pages/content/MyPage.jsx
| on: | ||
| pull_request: | ||
| branches: ["main", "develop"] | ||
| types: [opened, synchronize, reopened] |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cd .github/workflows && cat -n jules-code-review.yml | head -160Repository: rktclgh/VlaInter_FE
Length of output: 6900
포크/Dependabot PR에서 리뷰 생성이 실패합니다.
pull_request 이벤트로 실행되는 포크 PR과 Dependabot PR은 GITHUB_TOKEN이 읽기 전용입니다. 워크플로우는 146-156줄에서 항상 github.rest.pulls.createReview()를 호출하지만, 외부 기여자 PR에서는 pull-requests: write 권한이 거부되어 실패합니다.
해결책:
github.event.pull_request.head.repo.full_name === github.repository체크로 리뷰 생성을 조건부로 실행하거나- PR 코드를 실행하지 않는 별도
pull_request_target워크플로우로 댓글 기능을 분리하세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.github/workflows/jules-code-review.yml around lines 3 - 6, The workflow
currently always calls github.rest.pulls.createReview() (lines around the
existing createReview usage) which fails for fork/Dependabot PRs because
GITHUB_TOKEN is read-only; update the workflow to only attempt creating a review
when the PR originates from the same repository by checking
github.event.pull_request.head.repo.full_name === github.repository before
calling github.rest.pulls.createReview(), or move the review/commenting logic
into a separate pull_request_target workflow so it runs with write permissions
for repository-owned PRs; adjust any step names that invoke createReview()
accordingly so the conditional or separate workflow prevents calls on
external/forked PRs.
| - name: Build Review Summary | ||
| id: summary | ||
| shell: bash | ||
| run: | | ||
| git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1 | ||
|
|
||
| { | ||
| echo "body<<'EOF'" | ||
| echo "## 자동 PR 리뷰" | ||
| echo | ||
| echo "### 메타" | ||
| echo "- 대상 브랜치: \`${{ github.event.pull_request.base.ref }}\`" | ||
| echo "- 소스 브랜치: \`${{ github.event.pull_request.head.ref }}\`" | ||
| echo "- 작성자: @${{ github.event.pull_request.user.login }}" | ||
| echo | ||
| echo "### 변경 파일" | ||
| diff_output="$(git diff --name-status "origin/${{ github.event.pull_request.base.ref }}"...HEAD)" |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find the workflow file
find . -name "jules-code-review.yml" -type fRepository: rktclgh/VlaInter_FE
Length of output: 104
🏁 Script executed:
# List GitHub workflow files to confirm structure
ls -la .github/workflows/ 2>/dev/null || echo "Workflows directory might not exist"Repository: rktclgh/VlaInter_FE
Length of output: 316
🏁 Script executed:
# Read the specific file around lines 25-41
if [ -f ".github/workflows/jules-code-review.yml" ]; then
sed -n '20,45p' ".github/workflows/jules-code-review.yml" | cat -n
fiRepository: rktclgh/VlaInter_FE
Length of output: 1164
🌐 Web query:
GitHub Actions script injection security docs head_ref untrusted input environment variables
💡 Result:
GitHub’s guidance is that many github context values are attacker-controlled on PR-triggered workflows (including values that end in head_ref) and must be treated as untrusted input, because if they’re expanded into a run: script they can be interpreted as code (script/command injection). [1]
Why github.head_ref / GITHUB_HEAD_REF is risky
github.head_refis the source branch name forpull_request/pull_request_targetruns. [2]- Branch names are user-controlled, so using
${{ github.head_ref }}directly inside a shell script (or buildingbash,pwsh,python -c, etc. commands with it) can lead to injection. [1]
Recommended safe pattern (intermediate env var)
GitHub recommends assigning the untrusted context value to an environment variable, then reading it inside the script (with normal shell quoting) instead of embedding it directly into the script text. [3][4]
- name: Use head_ref safely
env:
HEAD_REF: ${{ github.head_ref }}
run: |
echo "PR branch is: $HEAD_REF"Environment-variable / environment-file gotchas
- Don’t take untrusted text (PR title/body/branch name/etc.) and write it into environment files (like
>> "$GITHUB_ENV") without proper handling, because newlines/delimiters can inject additional variables into later steps (a related injection class). GitHub’s security guidance on untrusted input covers this risk area and recommends defensive handling. [3]
Sources: GitHub Docs on script injections/untrusted contexts [1], GitHub Docs context reference for github.head_ref [2], GitHub Security Lab on untrusted input patterns [3], and GitHub Blog recommendations (intermediate env vars) [4].
References:
[1] https://docs.github.com/en/actions/concepts/security/script-injections
[2] https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs
[3] https://securitylab.github.com/resources/github-actions-untrusted-input/
[4] https://github.blog/security/supply-chain-security/four-tips-to-keep-your-github-actions-workflows-secure/
GitHub 컨텍스트 값들을 inline bash에 직접 삽입하면 안 됩니다.
이 워크플로우에서 ${{ github.event.pull_request.base.ref }}, ${{ github.event.pull_request.head.ref }}, ${{ github.event.pull_request.user.login }}을 inline script에 직접 삽입하고 있습니다. 이들은 모두 공격자가 제어할 수 있는 값들이며, 직접 삽입하면 브랜치명이나 사용자명에 포함된 셸 문법이 실행될 수 있습니다. GitHub은 이런 신뢰할 수 없는 입력값들을 env 변수로 먼저 전달한 뒤 스크립트 내에서 셸 변수로 읽도록 권장합니다.
수정 예시
- name: Build Review Summary
id: summary
+ env:
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
+ HEAD_REF: ${{ github.event.pull_request.head.ref }}
+ PR_AUTHOR: ${{ github.event.pull_request.user.login }}
shell: bash
run: |
- git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1
+ git fetch origin "${BASE_REF}" --depth=1
...
- echo "- 대상 브랜치: \`${{ github.event.pull_request.base.ref }}\`"
- echo "- 소스 브랜치: \`${{ github.event.pull_request.head.ref }}\`"
- echo "- 작성자: @${{ github.event.pull_request.user.login }}"
+ echo "- 대상 브랜치: \`${BASE_REF}\`"
+ echo "- 소스 브랜치: \`${HEAD_REF}\`"
+ echo "- 작성자: @${PR_AUTHOR}"
...
- diff_output="$(git diff --name-status "origin/${{ github.event.pull_request.base.ref }}"...HEAD)"
+ diff_output="$(git diff --name-status "origin/${BASE_REF}"...HEAD)"🧰 Tools
🪛 actionlint (1.7.11)
[error] 28-28: "github.event.pull_request.head.ref" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details
(expression)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.github/workflows/jules-code-review.yml around lines 25 - 41, The Build
Review Summary step injects untrusted GitHub context values directly into the
inline bash (e.g., `${{ github.event.pull_request.base.ref }}`, `${{
github.event.pull_request.head.ref }}`, `${{
github.event.pull_request.user.login }}`), which is unsafe; instead add them to
the step's env block (e.g., BASE_REF, HEAD_REF, PR_USER) and reference the shell
variables inside the run script ($BASE_REF, $HEAD_REF, $PR_USER) so the values
are passed safely as environment variables; update uses in the script (git
fetch, echo lines, and the git diff invocation) to use the new shell variables
rather than the inline `${{ }}` expressions.
| { | ||
| echo "body<<'EOF'" | ||
| echo "## 자동 PR 리뷰" | ||
| echo | ||
| echo "### 메타" | ||
| echo "- 대상 브랜치: \`${{ github.event.pull_request.base.ref }}\`" | ||
| echo "- 소스 브랜치: \`${{ github.event.pull_request.head.ref }}\`" | ||
| echo "- 작성자: @${{ github.event.pull_request.user.login }}" | ||
| echo | ||
| echo "### 변경 파일" | ||
| diff_output="$(git diff --name-status "origin/${{ github.event.pull_request.base.ref }}"...HEAD)" | ||
| if [ -n "${diff_output}" ]; then | ||
| echo '```text' | ||
| printf '%s\n' "${diff_output}" | ||
| echo '```' | ||
| else | ||
| echo "- 변경 파일을 찾지 못했습니다." | ||
| fi | ||
| echo | ||
| echo "### 리뷰 체크리스트" | ||
| echo "1. 보안 취약점: 하드코딩된 비밀번호, SQL 인젝션, XSS 등 위험 요소가 없는지 확인하세요." | ||
| echo "2. 성능 개선: 불필요한 루프, 메모리 누수, 과도한 네트워크 호출이 없는지 확인하세요." | ||
| echo "3. 버그 수정: 예외 처리 누락, 상태 동기화 실패, 회귀 가능성이 없는지 확인하세요." | ||
| echo "4. 오타 및 스타일: 오타, 변수명, 가독성, 일관성 문제를 확인하세요." | ||
| echo "5. 프롬프트 인젝션: AI 연동 코드가 있다면 입력 검증과 프롬프트 주입 가능성을 확인하세요." | ||
| echo | ||
| echo "### 참고" | ||
| echo "- 이 리뷰는 PR 수준의 자동 코멘트입니다." | ||
| echo "- 필요하면 inline review comment를 추가로 남겨 주세요." | ||
| echo "EOF" |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
GitHub Actions multiline output GITHUB_OUTPUT syntax delimiter
💡 Result:
To write a multiline step output via GITHUB_OUTPUT, use the environment-files delimiter form:
{name}<<{delimiter}
{value (can contain newlines)}
{delimiter}
Pick a delimiter (commonly EOF) that will not appear on a line by itself inside the value. [1]
Bash example:
{
echo 'MY_OUTPUT<<EOF'
printf '%s\n' "line 1" "line 2" "line 3"
echo 'EOF'
} >> "$GITHUB_OUTPUT"The output is then available as: ${{ steps.<id>.outputs.MY_OUTPUT }}. [1][2]
GitHub Actions 멀티라인 output delimiter가 일치하지 않습니다.
32행의 echo "body<<'EOF'"는 body<<'EOF'(따옴표 포함)를 출력하고, 60행의 echo "EOF"는 EOF(따옴표 없음)를 출력합니다. GitHub Actions 멀티라인 문법은 opening과 closing delimiter가 정확히 일치해야 하는데, 현재 'EOF'과 EOF가 다르므로 $GITHUB_OUTPUT이 정상 파싱되지 않습니다.
수정 예시
- echo "body<<'EOF'"
+ echo 'body<<EOF'
...
echo "EOF"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.github/workflows/jules-code-review.yml around lines 31 - 60, The opening
and closing multiline delimiter for the GitHub Actions output must match:
currently echo "body<<'EOF'" (prints body<<'EOF' with quotes) does not match
echo "EOF" (prints EOF without quotes), which breaks $GITHUB_OUTPUT parsing; fix
by making the delimiters identical (either both use 'EOF' or both use EOF) so
the body<< delimiter and the terminating echo produce the exact same token
(e.g., change echo "body<<'EOF'" to echo "body<<EOF" or change echo "EOF" to
echo "'EOF'") and ensure the rest of the heredoc content remains unchanged.
Summary by CodeRabbit
새 기능
기타