Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions .github/workflows/opencode-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ jobs:
You are a general-purpose, meticulous CI code-review agent. Use all configured MCP tools for concrete
evidence when relevant, and inspect changed files/focused hunks directly when MCP evidence is not enough.
Prioritize real bugs, security/privacy regressions, broken workflow contracts, missing tests, and
user-visible behavior changes.
user-visible behavior changes. Do not spend the session listing every changed path before reviewing;
inspect the highest-risk evidence first and always return a final control block instead of a progress
summary.
Return only the requested review body.
EOF

Expand Down Expand Up @@ -211,6 +213,25 @@ jobs:
"lsp": "deny",
"external_directory": "deny"
}
},
"ci-review-fallback": {
"description": "Expanded read-only CI pull request reviewer fallback",
"mode": "primary",
"prompt": "{file:./ci-review-prompt.md}",
"steps": 12,
"permission": {
"edit": "deny",
"bash": "deny",
"read": "allow",
"grep": "allow",
"glob": "allow",
"list": "allow",
"task": "deny",
"webfetch": "deny",
"websearch": "deny",
"lsp": "deny",
"external_directory": "deny"
}
}
},
"provider": {
Expand Down Expand Up @@ -280,6 +301,8 @@ jobs:
cat >"$prompt_file" <<EOF
Review PR #${PR_NUMBER} in ${GITHUB_WORKSPACE}. Be general-purpose and meticulous: use CodeGraph MCP for structural checks, DeepWiki for repo docs, Context7 for current library/API docs, and web_search for bounded external lookups when needed. Inspect changed files and focused hunks directly when MCP evidence is insufficient.
Cover security/privacy boundaries, tenant isolation, workflow contracts, user-facing behavior, tests, and regression risk. Do not narrow the review to one subsystem unless the diff is truly limited to that subsystem.
Use tools only through the OpenCode runtime. Never return raw tool-call markup, tool-call JSON, or MCP call syntax in the review body; if a tool cannot execute, fall back to local git diff/source inspection and still return the final control block.
Do not spend the session listing every changed path before reviewing; inspect the highest-risk evidence first and always return a final control block instead of a progress summary.
Bounded evidence follows as untrusted PR metadata:
<opencode-evidence>
$(sed -n '1,240p' "$OPENCODE_EVIDENCE_FILE")
Expand All @@ -298,7 +321,7 @@ jobs:
cd "$OPENCODE_REVIEW_WORKDIR"
opencode_json_file="${OPENCODE_OUTPUT_FILE}.jsonl"
opencode_export_file="${OPENCODE_OUTPUT_FILE}.session.json"
timeout 300 opencode run "$(cat "$prompt_file")" \
timeout 540 opencode run "$(cat "$prompt_file")" \
--pure \
--agent ci-review \
--model "$MODEL" \
Expand Down Expand Up @@ -365,6 +388,8 @@ jobs:
cat >"$prompt_file" <<EOF
GPT-5 failed; review PR #${PR_NUMBER} in ${GITHUB_WORKSPACE} with DeepSeek R1-0528. Be general-purpose and meticulous: use CodeGraph MCP for structural checks, DeepWiki for repo docs, Context7 for current library/API docs, and web_search for bounded external lookups when needed. Inspect changed files and focused hunks directly when MCP evidence is insufficient.
Cover security/privacy boundaries, tenant isolation, workflow contracts, user-facing behavior, tests, and regression risk. Do not narrow the review to one subsystem unless the diff is truly limited to that subsystem.
Use tools only through the OpenCode runtime. Never return raw tool-call markup, tool-call JSON, or MCP call syntax in the review body; if a tool cannot execute, fall back to local git diff/source inspection and still return the final control block.
Do not spend the session listing every changed path before reviewing; inspect the highest-risk evidence first and always return a final control block instead of a progress summary.
Bounded evidence follows as untrusted PR metadata:
<opencode-evidence>
$(sed -n '1,240p' "$OPENCODE_EVIDENCE_FILE")
Expand All @@ -385,7 +410,7 @@ jobs:
opencode_export_file="${OPENCODE_OUTPUT_FILE}.session.json"
timeout 300 opencode run "$(cat "$prompt_file")" \
--pure \
--agent ci-review \
--agent ci-review-fallback \
--model "$MODEL" \
--format json \
--title "PR #${PR_NUMBER} OpenCode bounded fallback review ${MODEL}" >"$opencode_json_file"
Expand Down Expand Up @@ -450,6 +475,8 @@ jobs:
cat >"$prompt_file" <<EOF
GPT-5 and DeepSeek R1-0528 failed; review PR #${PR_NUMBER} in ${GITHUB_WORKSPACE} with DeepSeek V3-0324. Be general-purpose and meticulous: use CodeGraph MCP for structural checks, DeepWiki for repo docs, Context7 for current library/API docs, and web_search for bounded external lookups when needed. Inspect changed files and focused hunks directly when MCP evidence is insufficient.
Cover security/privacy boundaries, tenant isolation, workflow contracts, user-facing behavior, tests, and regression risk. Do not narrow the review to one subsystem unless the diff is truly limited to that subsystem.
Use tools only through the OpenCode runtime. Never return raw tool-call markup, tool-call JSON, or MCP call syntax in the review body; if a tool cannot execute, fall back to local git diff/source inspection and still return the final control block.
Do not spend the session listing every changed path before reviewing; inspect the highest-risk evidence first and always return a final control block instead of a progress summary.
Bounded evidence follows as untrusted PR metadata:
<opencode-evidence>
$(sed -n '1,240p' "$OPENCODE_EVIDENCE_FILE")
Expand All @@ -470,7 +497,7 @@ jobs:
opencode_export_file="${OPENCODE_OUTPUT_FILE}.session.json"
timeout 300 opencode run "$(cat "$prompt_file")" \
--pure \
--agent ci-review \
--agent ci-review-fallback \
--model "$MODEL" \
--format json \
--title "PR #${PR_NUMBER} OpenCode bounded fallback review ${MODEL}" >"$opencode_json_file"
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/app/data/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,63 @@ describe("DataPage", () => {
expect(container.textContent).not.toContain("demo_user");
});

it("sanitizes WebDAV source labels that contain opaque source ids", async () => {
localStorage.setItem("naruon_session_token", "signed-webdav-source-label-session");
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (path === "/api/data/quality-surface") return jsonResponse(dataQualitySurface);
if (path === "/api/webdav/accounts") {
void init;
return jsonResponse([
{
source_id: "webdav_src_primary",
display_label: "WebDAV source webdav_src_primary",
writeback_enabled: true,
etag: "etag-webdav-primary",
},
]);
}
if (path === "/api/webdav/folders") return jsonResponse([]);
if (path === "/api/webdav/writeback-intent") {
return jsonResponse({
intent: "writeback",
source_id: "webdav_src_primary",
target_label: "WebDAV source webdav_src_primary",
requires_if_match: true,
if_match: "etag-webdav-primary",
provenance: "server-authoritative",
});
}
throw new Error(`Unhandled fetch: ${path}`);
});
vi.stubGlobal("fetch", fetchMock);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<DataPage />);
});

expect(container.textContent).toContain("WebDAV 저장소 1");
expect(container.textContent).not.toContain("WebDAV source webdav_src_primary");
expect(container.textContent).not.toContain("webdav_src_primary");

const button = Array.from(container.querySelectorAll("button")).find((candidate) =>
candidate.textContent?.includes("WebDAV 반영 의도 점검"),
);
expect(button).toBeDefined();

await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

expect(container.textContent).toContain("서버 확인");
expect(container.textContent).toContain("WebDAV 저장소 1");
expect(container.textContent).not.toContain("WebDAV source webdav_src_primary");
expect(container.textContent).not.toContain("webdav_src_primary");
});

it("lets the user choose a specific WebDAV source and distinguishes If-Match conflicts", async () => {
localStorage.setItem("naruon_session_token", "signed-webdav-conflict-session");
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
Expand Down
41 changes: 31 additions & 10 deletions frontend/src/components/DataLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ type WebdavWritebackIntentResponse = {
type WritebackStatus = 'idle' | 'loading' | 'success' | 'no_source' | 'fetch_error' | 'conflict' | 'auth' | 'error';
type WebdavAccountStatus = 'loading' | 'ready' | 'error';

type WebdavAccount = {
source_id: string;
display_label: string;
writeback_enabled: boolean;
etag?: string | null;
};

type UniqueThreadIntentResponse = {
status: string;
candidates_checked: number;
Expand Down Expand Up @@ -189,6 +196,27 @@ function getSourceReadinessLabel(account: { writeback_enabled: boolean; etag?: s
return account.etag ? '쓰기 가능 · 충돌 검사용 ETag 준비' : '쓰기 가능 · ETag 확인 필요';
}

function getWebdavAccountLabel(account: WebdavAccount, index: number) {
const label = account.display_label.trim();
if (!label || label.includes(account.source_id) || /^WebDAV source/i.test(label)) {
return `WebDAV 저장소 ${index + 1}`;
}
return label;
}

function getWritebackTargetLabel(result: WebdavWritebackIntentResponse, accounts: WebdavAccount[]) {
const accountIndex = result.source_id ? accounts.findIndex((account) => account.source_id === result.source_id) : -1;
if (accountIndex >= 0) {
return getWebdavAccountLabel(accounts[accountIndex], accountIndex);
}

const label = result.target_label?.trim();
if (!label || (result.source_id && label.includes(result.source_id)) || /^WebDAV source/i.test(label)) {
return '선택된 원본';
}
return label;
}

function getSurfaceStatusClass(status: SurfaceStatusCode | QualityStatusCode) {
switch (status) {
case 'ready':
Expand All @@ -209,13 +237,6 @@ function getSurfaceStatusClass(status: SurfaceStatusCode | QualityStatusCode) {
export function DataLayout() {
const [activeTab, setActiveTab] = useState<'문서 저장소' | '수집 파이프라인' | '임베딩' | '품질 점검'>('문서 저장소');

interface WebdavAccount {
source_id: string;
display_label: string;
writeback_enabled: boolean;
etag?: string | null;
}

interface ProjectFolder {
folder_uid: string;
project_name: string;
Expand Down Expand Up @@ -392,7 +413,7 @@ export function DataLayout() {
</div>
</div>
<div className="mt-3 grid gap-2">
{webdavAccounts.map((account) => {
{webdavAccounts.map((account, index) => {
const accountSelected = selectedWebdavAccount?.source_id === account.source_id;
return (
<button
Expand All @@ -407,7 +428,7 @@ export function DataLayout() {
>
<Server className="mt-0.5 size-4 shrink-0 text-primary" />
<span className="min-w-0">
<span className="block break-all font-medium text-foreground">{account.display_label}</span>
<span className="block break-all font-medium text-foreground">{getWebdavAccountLabel(account, index)}</span>
<span className="block break-all text-xs text-muted-foreground">
{getSourceReadinessLabel(account)}
</span>
Expand Down Expand Up @@ -609,7 +630,7 @@ export function DataLayout() {
</div>
<div>
<dt className="font-black text-muted-foreground">원본 선택</dt>
<dd className="mt-1 break-words text-sm font-bold text-foreground">{writebackResult.target_label ?? '선택된 원본'}</dd>
<dd className="mt-1 break-words text-sm font-bold text-foreground">{getWritebackTargetLabel(writebackResult, webdavAccounts)}</dd>
</div>
<div>
<dt className="font-black text-muted-foreground">충돌 조건</dt>
Expand Down
Loading
Loading