mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-18 04:09:13 +02:00
Compare commits
19 Commits
feat/cli-v
...
fix/runtim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1ae10f50 | ||
|
|
67212b0bef | ||
|
|
c7e0863419 | ||
|
|
d7c83bc285 | ||
|
|
4285549381 | ||
|
|
9ed80120e0 | ||
|
|
ec586ebc25 | ||
|
|
ea8cb18f9e | ||
|
|
d011039c58 | ||
|
|
471d4a6838 | ||
|
|
bd42552854 | ||
|
|
31eeb00b59 | ||
|
|
d32c419b6d | ||
|
|
f31a322978 | ||
|
|
5bae3368d7 | ||
|
|
f100b5b707 | ||
|
|
701399536f | ||
|
|
4ca607f888 | ||
|
|
a35f71f65d |
@@ -22,6 +22,8 @@ MULTICA_CODEX_WORKDIR=
|
||||
MULTICA_CODEX_TIMEOUT=20m
|
||||
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
|
||||
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
|
||||
@@ -40,6 +42,10 @@ CLOUDFRONT_PRIVATE_KEY=
|
||||
CLOUDFRONT_DOMAIN=
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# Local file storage (fallback when S3_BUCKET is not set)
|
||||
LOCAL_UPLOAD_DIR=./data/uploads
|
||||
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
|
||||
39
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: "Bug Report"
|
||||
description: Report a bug — something that's broken, crashes, or behaves incorrectly.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Describe the bug and what you expected instead. Screenshots, error messages, or screen recordings are welcome.
|
||||
placeholder: |
|
||||
When I do X, Y happens. I expected Z instead.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: How can we trigger this bug?
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots (optional)
|
||||
description: If applicable, add screenshots or screen recordings to help explain the problem.
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context (optional)
|
||||
description: Environment info, logs, or anything else that might help.
|
||||
render: shell
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: "Feature Request"
|
||||
description: Suggest a new feature or improvement.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What do you want and why?
|
||||
description: Describe the problem you're trying to solve or the improvement you'd like to see.
|
||||
placeholder: |
|
||||
I'm trying to do X but there's no way to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution (optional)
|
||||
description: If you have an idea for how this should work, describe it here.
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots / mockups (optional)
|
||||
description: If applicable, add screenshots, mockups, or sketches to illustrate your idea.
|
||||
52
.github/PULL_REQUEST_TEMPLATE.md
vendored
52
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,34 +1,56 @@
|
||||
## What
|
||||
## What does this PR do?
|
||||
|
||||
<!-- What does this PR do? Keep it to 1-3 sentences. -->
|
||||
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Why is this change needed? Link the related issue. -->
|
||||
|
||||
Closes #<!-- issue number -->
|
||||
## Related Issue
|
||||
|
||||
<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->
|
||||
|
||||
Closes #
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor / code improvement
|
||||
- [ ] Documentation
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Refactor / code improvement (no behavior change)
|
||||
- [ ] Documentation update
|
||||
- [ ] Tests (adding or improving test coverage)
|
||||
- [ ] CI / infrastructure
|
||||
- [ ] Other (describe below)
|
||||
|
||||
## Changes Made
|
||||
|
||||
<!-- List the specific changes. Include file paths for code changes. -->
|
||||
|
||||
-
|
||||
|
||||
## How to Test
|
||||
|
||||
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
|
||||
<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I searched for [existing PRs](https://github.com/multica-ai/multica/pulls) to make sure this isn't a duplicate
|
||||
- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
|
||||
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
|
||||
- [ ] Changes follow existing code patterns and conventions
|
||||
- [ ] No unrelated changes included
|
||||
|
||||
## AI Disclosure (optional)
|
||||
## AI Disclosure
|
||||
|
||||
<!-- If AI tools were used: -->
|
||||
<!-- - Which tool? (e.g., Claude Code, Copilot, Cursor) -->
|
||||
<!-- - What prompt did you use? Sharing your prompt helps others learn and lets reviewers understand intent. -->
|
||||
<!-- Most PRs involve AI coding tools — that's totally fine! We're curious about your process. -->
|
||||
|
||||
**AI tool used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Multica Agent, N/A -->
|
||||
|
||||
**Prompt / approach:**
|
||||
<!-- How did you use AI to produce this code? Share your prompt, conversation link, or describe your approach. This helps the team learn from each other's AI workflows. -->
|
||||
|
||||
|
||||
## Screenshots (optional)
|
||||
|
||||
<!-- If applicable, add screenshots showing the change in action. -->
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -48,3 +48,5 @@ _features/
|
||||
*.dmg
|
||||
*.app
|
||||
server/server
|
||||
data/
|
||||
.kilo
|
||||
|
||||
@@ -18,7 +18,6 @@ The open-source managed agents platform.<br/>
|
||||
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/multica_hq) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/multica_hq) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
|
||||
@@ -77,6 +77,8 @@ brew install multica-ai/tap/multica
|
||||
You also need at least one AI agent CLI installed:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { LoginPage } from "@multica/views/auth";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const lastWorkspaceId = localStorage.getItem("multica_workspace_id");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Traffic light inset */}
|
||||
@@ -11,6 +13,7 @@ export function DesktopLoginPage() {
|
||||
/>
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
lastWorkspaceId={lastWorkspaceId}
|
||||
onSuccess={() => {
|
||||
// Auth store update triggers AppContent re-render → shows DesktopShell
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -52,6 +53,8 @@ export function LandingHero() {
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<InstallCommand />
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center justify-center gap-8">
|
||||
@@ -87,6 +90,64 @@ export function LandingHero() {
|
||||
);
|
||||
}
|
||||
|
||||
const INSTALL_COMMAND =
|
||||
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
|
||||
|
||||
function InstallCommand() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(INSTALL_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-6 max-w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
|
||||
>
|
||||
<span className="text-white/40">$</span>
|
||||
<span className="select-all">{INSTALL_COMMAND}</span>
|
||||
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
|
||||
{copied ? (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3.5 text-green-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3.5"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LandingBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
|
||||
13
e2e/env.ts
Normal file
13
e2e/env.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { config } from "dotenv";
|
||||
|
||||
const envCandidates = [".env.worktree", ".env"];
|
||||
|
||||
for (const filename of envCandidates) {
|
||||
const path = resolve(process.cwd(), filename);
|
||||
if (existsSync(path)) {
|
||||
config({ path });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
|
||||
*/
|
||||
|
||||
import "./env";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
|
||||
@@ -21,39 +22,43 @@ export class TestApiClient {
|
||||
private createdIssueIds: string[] = [];
|
||||
|
||||
async login(email: string, name: string) {
|
||||
// Step 1: Send verification code
|
||||
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!sendRes.ok) {
|
||||
// Rate limited — code already sent recently, read it from DB
|
||||
if (sendRes.status !== 429) {
|
||||
throw new Error(`send-code failed: ${sendRes.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Read code from database
|
||||
const client = new pg.Client(DATABASE_URL);
|
||||
await client.connect();
|
||||
try {
|
||||
// Keep each E2E login isolated so previous test runs do not trip the
|
||||
// per-email send-code rate limit.
|
||||
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
|
||||
|
||||
// Step 1: Send verification code
|
||||
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!sendRes.ok) {
|
||||
throw new Error(`send-code failed: ${sendRes.status}`);
|
||||
}
|
||||
|
||||
// Step 2: Read code from database
|
||||
const result = await client.query(
|
||||
"SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1",
|
||||
[email]
|
||||
[email],
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`No verification code found for ${email}`);
|
||||
}
|
||||
const code = result.rows[0].code;
|
||||
|
||||
// Step 3: Verify code to get JWT
|
||||
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, code }),
|
||||
body: JSON.stringify({ email, code: result.rows[0].code }),
|
||||
});
|
||||
if (!verifyRes.ok) {
|
||||
throw new Error(`verify-code failed: ${verifyRes.status}`);
|
||||
}
|
||||
const data = await verifyRes.json();
|
||||
|
||||
this.token = data.token;
|
||||
|
||||
// Update user name if needed
|
||||
@@ -64,6 +69,8 @@ export class TestApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
|
||||
|
||||
return data;
|
||||
} finally {
|
||||
await client.end();
|
||||
|
||||
@@ -11,11 +11,14 @@ test.describe("Issues", () => {
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup();
|
||||
if (api) {
|
||||
await api.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("issues page loads with board view", async ({ page }) => {
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
await api.createIssue("E2E Board View " + Date.now());
|
||||
await page.reload();
|
||||
|
||||
// Board columns should be visible
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
@@ -23,29 +26,36 @@ test.describe("Issues", () => {
|
||||
await expect(page.locator("text=In Progress")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can switch between board and list view", async ({ page }) => {
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
test("can switch from board to list view", async ({ page }) => {
|
||||
const title = "E2E List Switch " + Date.now();
|
||||
await api.createIssue(title);
|
||||
await page.reload();
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
|
||||
// Switch to list view
|
||||
await page.click("text=List");
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
|
||||
// Switch back to board view
|
||||
await page.click("text=Board");
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
});
|
||||
|
||||
test("can create a new issue", async ({ page }) => {
|
||||
await page.click("text=New Issue");
|
||||
const newIssueButton = page.getByRole("button", { name: "New Issue" });
|
||||
await expect(newIssueButton).toBeVisible();
|
||||
await newIssueButton.click();
|
||||
|
||||
const title = "E2E Created " + Date.now();
|
||||
await page.fill('input[placeholder="Issue title..."]', title);
|
||||
await page.click("text=Create");
|
||||
const titleInput = page.getByRole("textbox", { name: "Issue title" });
|
||||
await expect(titleInput).toBeVisible();
|
||||
await titleInput.fill(title);
|
||||
await page.getByRole("button", { name: "Create Issue" }).click();
|
||||
|
||||
// New issue should appear on the page
|
||||
await expect(page.locator(`text=${title}`).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Issue created")).toBeVisible({ timeout: 10000 });
|
||||
await expect(
|
||||
page.getByRole("region", { name: /Notifications/ }).getByText(title),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "View issue" }).click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
await expect(page.locator("text=Properties")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can navigate to issue detail page", async ({ page }) => {
|
||||
@@ -54,7 +64,6 @@ test.describe("Issues", () => {
|
||||
|
||||
// Reload to see the new issue
|
||||
await page.reload();
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
|
||||
// Navigate to the issue detail
|
||||
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
|
||||
@@ -71,18 +80,15 @@ test.describe("Issues", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("can cancel issue creation", async ({ page }) => {
|
||||
await page.click("text=New Issue");
|
||||
test("can dismiss issue creation", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "New Issue" }).click();
|
||||
|
||||
await expect(
|
||||
page.locator('input[placeholder="Issue title..."]'),
|
||||
).toBeVisible();
|
||||
const titleInput = page.getByRole("textbox", { name: "Issue title" });
|
||||
await expect(titleInput).toBeVisible();
|
||||
|
||||
await page.click("text=Cancel");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(
|
||||
page.locator('input[placeholder="Issue title..."]'),
|
||||
).not.toBeVisible();
|
||||
await expect(page.locator("text=New Issue")).toBeVisible();
|
||||
await expect(titleInput).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,6 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
|
||||
logout: () => {
|
||||
storage.removeItem("multica_token");
|
||||
storage.removeItem("multica_workspace_id");
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
onLogout?.();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { pinKeys } from "./queries";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import type { PinnedItem, PinnedItemType } from "../types";
|
||||
@@ -7,16 +8,17 @@ import type { PinnedItem, PinnedItemType } from "../types";
|
||||
export function useCreatePin() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const userId = useAuthStore((s) => s.user?.id ?? "");
|
||||
return useMutation({
|
||||
mutationFn: (data: { item_type: PinnedItemType; item_id: string }) =>
|
||||
api.createPin(data),
|
||||
onSuccess: (newPin) => {
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
|
||||
old ? [...old, newPin] : [newPin],
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -24,22 +26,23 @@ export function useCreatePin() {
|
||||
export function useDeletePin() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const userId = useAuthStore((s) => s.user?.id ?? "");
|
||||
return useMutation({
|
||||
mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) =>
|
||||
api.deletePin(itemType, itemId),
|
||||
onMutate: async ({ itemType, itemId }) => {
|
||||
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
|
||||
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
|
||||
old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old,
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -47,19 +50,20 @@ export function useDeletePin() {
|
||||
export function useReorderPins() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const userId = useAuthStore((s) => s.user?.id ?? "");
|
||||
return useMutation({
|
||||
mutationFn: (reorderedPins: PinnedItem[]) => {
|
||||
const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 }));
|
||||
return api.reorderPins({ items });
|
||||
},
|
||||
onMutate: async (reorderedPins) => {
|
||||
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), reorderedPins);
|
||||
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), reorderedPins);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const pinKeys = {
|
||||
all: (wsId: string) => ["pins", wsId] as const,
|
||||
list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const,
|
||||
all: (wsId: string, userId: string) => ["pins", wsId, userId] as const,
|
||||
list: (wsId: string, userId: string) => [...pinKeys.all(wsId, userId), "list"] as const,
|
||||
};
|
||||
|
||||
export function pinListOptions(wsId: string) {
|
||||
export function pinListOptions(wsId: string, userId: string) {
|
||||
return queryOptions({
|
||||
queryKey: pinKeys.list(wsId),
|
||||
queryKey: pinKeys.list(wsId, userId),
|
||||
queryFn: () => api.listPins(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,7 +102,8 @@ export function useRealtimeSync(
|
||||
},
|
||||
pin: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) });
|
||||
const userId = authStore.getState().user?.id;
|
||||
if (wsId && userId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId, userId) });
|
||||
},
|
||||
daemon: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
|
||||
@@ -53,7 +53,7 @@ function IssueMention({
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
const { openInNewTab } = useNavigation();
|
||||
const { push, openInNewTab } = useNavigation();
|
||||
const issue = issues.find((i) => i.id === issueId);
|
||||
|
||||
const issuePath = `/issues/${issueId}`;
|
||||
@@ -61,11 +61,13 @@ function IssueMention({
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (openInNewTab) {
|
||||
openInNewTab(issuePath, tabTitle);
|
||||
} else {
|
||||
window.open(issuePath, "_blank", "noopener,noreferrer");
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
if (openInNewTab) {
|
||||
openInNewTab(issuePath, tabTitle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
push(issuePath);
|
||||
};
|
||||
|
||||
const cardClass =
|
||||
|
||||
@@ -86,7 +86,7 @@ function urlTransform(url: string): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IssueMentionLink({ issueId, label }: { issueId: string; label?: string }) {
|
||||
const { openInNewTab } = useNavigation();
|
||||
const { push, openInNewTab } = useNavigation();
|
||||
const path = `/issues/${issueId}`;
|
||||
return (
|
||||
<span
|
||||
@@ -94,11 +94,13 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (openInNewTab) {
|
||||
openInNewTab(path, label);
|
||||
} else {
|
||||
window.open(path, "_blank", "noopener,noreferrer");
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
if (openInNewTab) {
|
||||
openInNewTab(path, label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
push(path);
|
||||
}}
|
||||
>
|
||||
<IssueMentionCard issueId={issueId} fallbackLabel={label} />
|
||||
|
||||
@@ -94,7 +94,10 @@ vi.mock("../../editor", () => ({
|
||||
ReadonlyContent: ({ content }: { content: string }) => (
|
||||
<div data-testid="readonly-content">{content}</div>
|
||||
),
|
||||
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
|
||||
ContentEditor: forwardRef(function MockContentEditor(
|
||||
{ defaultValue, onUpdate, placeholder }: any,
|
||||
ref: any,
|
||||
) {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -116,7 +119,10 @@ vi.mock("../../editor", () => ({
|
||||
/>
|
||||
);
|
||||
}),
|
||||
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
|
||||
TitleEditor: forwardRef(function MockTitleEditor(
|
||||
{ defaultValue, placeholder, onBlur, onChange }: any,
|
||||
ref: any,
|
||||
) {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
||||
@@ -195,6 +195,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const id = issueId;
|
||||
const router = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
|
||||
// Issue navigation — read from TQ list cache
|
||||
@@ -264,7 +265,10 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const { data: usage } = useQuery(issueUsageOptions(id));
|
||||
|
||||
// Pinned state
|
||||
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
|
||||
const { data: pinnedItems = [] } = useQuery({
|
||||
...pinListOptions(wsId, userId ?? ""),
|
||||
enabled: !!userId,
|
||||
});
|
||||
const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id);
|
||||
const createPin = useCreatePin();
|
||||
const deletePin = useDeletePin();
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
SidebarFooter,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@multica/ui/components/ui/sidebar";
|
||||
@@ -63,7 +64,7 @@ import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
|
||||
import { pinKeys } from "@multica/core/pins/queries";
|
||||
import { pinListOptions } from "@multica/core/pins/queries";
|
||||
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
|
||||
import type { PinnedItem } from "@multica/core/types";
|
||||
|
||||
@@ -93,7 +94,6 @@ function DraftDot() {
|
||||
function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname: string; onUnpin: () => void }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
|
||||
const wasDragged = useRef(false);
|
||||
const { push } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) wasDragged.current = true;
|
||||
@@ -114,12 +114,13 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
|
||||
>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
onClick={() => {
|
||||
render={<AppLink href={href} />}
|
||||
onClick={(event) => {
|
||||
if (wasDragged.current) {
|
||||
wasDragged.current = false;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
push(href);
|
||||
}}
|
||||
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
||||
>
|
||||
@@ -129,17 +130,17 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
|
||||
<FolderKanban className="size-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
<button
|
||||
className="ml-auto opacity-0 group-hover/pin:opacity-100 transition-opacity p-0.5 rounded hover:bg-accent shrink-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onUnpin();
|
||||
}}
|
||||
>
|
||||
<PinOff className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onUnpin();
|
||||
}}
|
||||
>
|
||||
<PinOff className="size-3 text-muted-foreground" />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +159,7 @@ interface AppSidebarProps {
|
||||
export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) {
|
||||
const { pathname, push } = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const authLogout = useAuthStore((s) => s.logout);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const workspaces = useWorkspaceStore((s) => s.workspaces);
|
||||
@@ -174,10 +176,9 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
[inboxItems],
|
||||
);
|
||||
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
|
||||
const { data: pinnedItems = [] } = useQuery<PinnedItem[]>({
|
||||
queryKey: wsId ? pinKeys.list(wsId) : ["pins", "disabled"],
|
||||
queryFn: () => api.listPins(),
|
||||
enabled: !!wsId,
|
||||
const { data: pinnedItems = [] } = useQuery({
|
||||
...pinListOptions(wsId ?? "", userId ?? ""),
|
||||
enabled: !!wsId && !!userId,
|
||||
});
|
||||
const deletePin = useDeletePin();
|
||||
const reorderPins = useReorderPins();
|
||||
|
||||
223
packages/views/modals/create-issue.test.tsx
Normal file
223
packages/views/modals/create-issue.test.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const mockPush = vi.hoisted(() => vi.fn());
|
||||
const mockCreateIssue = vi.hoisted(() => vi.fn());
|
||||
const mockSetDraft = vi.hoisted(() => vi.fn());
|
||||
const mockClearDraft = vi.hoisted(() => vi.fn());
|
||||
const mockToastCustom = vi.hoisted(() => vi.fn());
|
||||
const mockToastDismiss = vi.hoisted(() => vi.fn());
|
||||
const mockToastError = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockDraftStore = {
|
||||
draft: {
|
||||
title: "",
|
||||
description: "",
|
||||
status: "todo" as const,
|
||||
priority: "none" as const,
|
||||
assigneeType: undefined,
|
||||
assigneeId: undefined,
|
||||
dueDate: null,
|
||||
},
|
||||
setDraft: mockSetDraft,
|
||||
clearDraft: mockClearDraft,
|
||||
};
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace", () => ({
|
||||
useWorkspaceStore: Object.assign(
|
||||
(selector?: (state: { workspace: { name: string } }) => unknown) => {
|
||||
const state = { workspace: { name: "Test Workspace" } };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ workspace: { name: "Test Workspace" } }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/stores/draft-store", () => ({
|
||||
useIssueDraftStore: Object.assign(
|
||||
(selector?: (state: typeof mockDraftStore) => unknown) =>
|
||||
(selector ? selector(mockDraftStore) : mockDraftStore),
|
||||
{ getState: () => mockDraftStore },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/mutations", () => ({
|
||||
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks/use-file-upload", () => ({
|
||||
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {},
|
||||
}));
|
||||
|
||||
vi.mock("../editor", () => ({
|
||||
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
|
||||
FileDropOverlay: () => null,
|
||||
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onUpdate?.(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
TitleEditor: ({ defaultValue, placeholder, onChange, onSubmit }: any) => {
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSubmit?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../issues/components", () => ({
|
||||
StatusIcon: ({ status }: { status: string }) => <span data-testid="status-icon">{status}</span>,
|
||||
StatusPicker: () => <div data-testid="status-picker" />,
|
||||
PriorityPicker: () => <div data-testid="priority-picker" />,
|
||||
AssigneePicker: () => <div data-testid="assignee-picker" />,
|
||||
DueDatePicker: () => <div data-testid="due-date-picker" />,
|
||||
}));
|
||||
|
||||
vi.mock("../projects/components/project-picker", () => ({
|
||||
ProjectPicker: () => <div data-testid="project-picker" />,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => <div data-testid="dialog-root">{children}</div>,
|
||||
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
type = "button",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
type?: "button" | "submit" | "reset";
|
||||
}) => (
|
||||
<button type={type} disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
|
||||
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
|
||||
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
|
||||
Upload file
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/lib/utils", () => ({
|
||||
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
custom: mockToastCustom,
|
||||
dismiss: mockToastDismiss,
|
||||
error: mockToastError,
|
||||
},
|
||||
}));
|
||||
|
||||
import { CreateIssueModal } from "./create-issue";
|
||||
|
||||
describe("CreateIssueModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateIssue.mockResolvedValue({
|
||||
id: "issue-123",
|
||||
identifier: "TES-123",
|
||||
title: "Ship create issue regression coverage",
|
||||
status: "todo",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success feedback with a direct path to the new issue", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(<CreateIssueModal onClose={onClose} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Issue title"), " Ship create issue regression coverage ");
|
||||
await user.click(screen.getByRole("button", { name: "Create Issue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: "Ship create issue regression coverage",
|
||||
description: undefined,
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assignee_type: undefined,
|
||||
assignee_id: undefined,
|
||||
due_date: undefined,
|
||||
attachment_ids: undefined,
|
||||
parent_issue_id: undefined,
|
||||
project_id: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockClearDraft).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(mockToastCustom).toHaveBeenCalledTimes(1);
|
||||
|
||||
const renderToast = mockToastCustom.mock.calls[0]?.[0];
|
||||
expect(typeof renderToast).toBe("function");
|
||||
|
||||
render(renderToast("toast-1"));
|
||||
|
||||
expect(screen.getByText("Issue created")).toBeInTheDocument();
|
||||
expect(screen.getByText(/TES-123/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Ship create issue regression coverage/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "View issue" }));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/issues/issue-123");
|
||||
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import type { Issue, IssueStatus, ProjectStatus, ProjectPriority } from "@multica/core/types";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { projectDetailOptions } from "@multica/core/projects/queries";
|
||||
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
|
||||
import { pinListOptions } from "@multica/core/pins";
|
||||
@@ -193,6 +194,7 @@ function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
|
||||
export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
const wsId = useWorkspaceId();
|
||||
const router = useNavigation();
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
|
||||
const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId));
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
@@ -201,7 +203,10 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
const { getActorName } = useActorName();
|
||||
const updateProject = useUpdateProject();
|
||||
const deleteProject = useDeleteProject();
|
||||
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
|
||||
const { data: pinnedItems = [] } = useQuery({
|
||||
...pinListOptions(wsId, userId ?? ""),
|
||||
enabled: !!userId,
|
||||
});
|
||||
const isPinned = pinnedItems.some((p) => p.item_type === "project" && p.item_id === projectId);
|
||||
const createPin = useCreatePin();
|
||||
const deletePinMut = useDeletePin();
|
||||
|
||||
@@ -228,7 +228,7 @@ export function SearchCommand() {
|
||||
setOpen(false);
|
||||
push(href);
|
||||
},
|
||||
[push],
|
||||
[push, setOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./e2e/env";
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
@@ -56,9 +56,21 @@ func allowedOrigins() []string {
|
||||
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
|
||||
queries := db.New(pool)
|
||||
emailSvc := service.NewEmailService()
|
||||
|
||||
// Initialize storage with S3 as primary, fallback to local
|
||||
var store storage.Storage
|
||||
s3 := storage.NewS3StorageFromEnv()
|
||||
if s3 != nil {
|
||||
store = s3
|
||||
} else {
|
||||
local := storage.NewLocalStorageFromEnv()
|
||||
if local != nil {
|
||||
store = local
|
||||
}
|
||||
}
|
||||
|
||||
cfSigner := auth.NewCloudFrontSignerFromEnv()
|
||||
h := handler.New(queries, pool, hub, bus, emailSvc, s3, cfSigner)
|
||||
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -87,6 +99,14 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
realtime.HandleWebSocket(hub, mc, pr, w, r)
|
||||
})
|
||||
|
||||
// Local file serving (when using local storage)
|
||||
if local, ok := store.(*storage.LocalStorage); ok {
|
||||
r.Get("/uploads/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
file := strings.TrimPrefix(r.URL.Path, "/uploads/")
|
||||
local.ServeFile(w, r, file)
|
||||
})
|
||||
}
|
||||
|
||||
// Auth (public)
|
||||
r.Post("/auth/send-code", h.SendCode)
|
||||
r.Post("/auth/verify-code", h.VerifyCode)
|
||||
|
||||
@@ -134,12 +134,36 @@ func broadcastFailedTasks(ctx context.Context, queries *db.Queries, bus *events.
|
||||
}
|
||||
|
||||
affectedAgents := make(map[string]pgtype.UUID)
|
||||
processedIssues := make(map[string]bool)
|
||||
|
||||
for _, ft := range items {
|
||||
// Look up workspace ID from the issue so the event reaches the right WS room.
|
||||
workspaceID := ""
|
||||
if issue, err := queries.GetIssue(ctx, ft.IssueID); err == nil {
|
||||
workspaceID = util.UUIDToString(issue.WorkspaceID)
|
||||
// If the issue is still in_progress and no other active tasks remain,
|
||||
// reset it back to todo so the daemon can pick it up again.
|
||||
issueKey := util.UUIDToString(ft.IssueID)
|
||||
if issue.Status == "in_progress" && !processedIssues[issueKey] {
|
||||
processedIssues[issueKey] = true
|
||||
hasActive, checkErr := queries.HasActiveTaskForIssue(ctx, ft.IssueID)
|
||||
if checkErr != nil {
|
||||
slog.Warn("runtime sweeper: failed to check active tasks for issue",
|
||||
"issue_id", issueKey,
|
||||
"error", checkErr,
|
||||
)
|
||||
} else if !hasActive {
|
||||
if _, updateErr := queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
|
||||
ID: ft.IssueID,
|
||||
Status: "todo",
|
||||
}); updateErr != nil {
|
||||
slog.Warn("runtime sweeper: failed to reset stuck issue to todo",
|
||||
"issue_id", issueKey,
|
||||
"error", updateErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bus.Publish(events.Event{
|
||||
|
||||
@@ -300,6 +300,168 @@ func TestSweepDispatchedStaleTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepResetsInProgressIssueToTodo verifies the core fix: when the sweeper
|
||||
// force-fails a stale task whose issue is still in_progress (because the daemon
|
||||
// crashed mid-run), the issue is reset back to todo so the daemon can re-queue it.
|
||||
//
|
||||
// Without this fix the issue stays in_progress permanently — the agent never runs
|
||||
// to update the status because it was never dispatched.
|
||||
func TestSweepResetsInProgressIssueToTodo(t *testing.T) {
|
||||
if testPool == nil {
|
||||
t.Skip("no database connection")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Use the same agent/runtime as the other sweeper tests.
|
||||
var agentID, runtimeID string
|
||||
err := testPool.QueryRow(ctx, `
|
||||
SELECT a.id, a.runtime_id FROM agent a
|
||||
JOIN member m ON m.workspace_id = a.workspace_id
|
||||
JOIN "user" u ON u.id = m.user_id
|
||||
WHERE u.email = $1
|
||||
LIMIT 1
|
||||
`, integrationTestEmail).Scan(&agentID, &runtimeID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find test agent: %v", err)
|
||||
}
|
||||
|
||||
// Create an issue already in in_progress (simulates a daemon crash mid-run).
|
||||
var issueID string
|
||||
err = testPool.QueryRow(ctx, `
|
||||
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id)
|
||||
SELECT $1, 'Stuck in_progress issue', 'in_progress', 'none', 'member', m.user_id, 'agent', $2
|
||||
FROM member m WHERE m.workspace_id = $1 LIMIT 1
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID).Scan(&issueID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test issue: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
// Create a stale running task for the issue (3 hours old — beyond any timeout).
|
||||
var taskID string
|
||||
err = testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, dispatched_at, started_at)
|
||||
VALUES ($1, $2, $3, 'running', 0, now() - interval '3 hours', now() - interval '3 hours')
|
||||
RETURNING id
|
||||
`, agentID, runtimeID, issueID).Scan(&taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create stale task: %v", err)
|
||||
}
|
||||
|
||||
queries := db.New(testPool)
|
||||
bus := events.New()
|
||||
|
||||
// Fail the stale task (running timeout of 1 second — our task is 3 hours old).
|
||||
failedTasks, err := queries.FailStaleTasks(ctx, db.FailStaleTasksParams{
|
||||
DispatchTimeoutSecs: 300.0,
|
||||
RunningTimeoutSecs: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FailStaleTasks failed: %v", err)
|
||||
}
|
||||
|
||||
// Confirm our task was swept.
|
||||
found := false
|
||||
for _, ft := range failedTasks {
|
||||
if ft.ID.Bytes == parseUUIDBytes(taskID) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected task %s to be in failed tasks, got %v", taskID, failedTasks)
|
||||
}
|
||||
|
||||
// This is what we're testing: issue must be reset from in_progress → todo.
|
||||
broadcastFailedTasks(ctx, queries, bus, failedTasks)
|
||||
|
||||
var issueStatus string
|
||||
err = testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&issueStatus)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query issue status: %v", err)
|
||||
}
|
||||
if issueStatus != "todo" {
|
||||
t.Fatalf("expected issue status 'todo' after sweep, got '%s' — issue is stuck", issueStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepDoesNotResetIssueAlreadyInReview verifies that the sweeper only resets
|
||||
// issues that are truly stuck in in_progress — it must not clobber issues whose
|
||||
// agents already moved them forward (e.g. to in_review) before the task timed out.
|
||||
func TestSweepDoesNotResetIssueAlreadyInReview(t *testing.T) {
|
||||
if testPool == nil {
|
||||
t.Skip("no database connection")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var agentID, runtimeID string
|
||||
err := testPool.QueryRow(ctx, `
|
||||
SELECT a.id, a.runtime_id FROM agent a
|
||||
JOIN member m ON m.workspace_id = a.workspace_id
|
||||
JOIN "user" u ON u.id = m.user_id
|
||||
WHERE u.email = $1
|
||||
LIMIT 1
|
||||
`, integrationTestEmail).Scan(&agentID, &runtimeID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find test agent: %v", err)
|
||||
}
|
||||
|
||||
// Issue already advanced to in_review by the agent before the task timed out.
|
||||
var issueID string
|
||||
err = testPool.QueryRow(ctx, `
|
||||
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id)
|
||||
SELECT $1, 'Already in_review issue', 'in_review', 'none', 'member', m.user_id, 'agent', $2
|
||||
FROM member m WHERE m.workspace_id = $1 LIMIT 1
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID).Scan(&issueID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test issue: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
var taskID string
|
||||
err = testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, dispatched_at, started_at)
|
||||
VALUES ($1, $2, $3, 'running', 0, now() - interval '3 hours', now() - interval '3 hours')
|
||||
RETURNING id
|
||||
`, agentID, runtimeID, issueID).Scan(&taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create stale task: %v", err)
|
||||
}
|
||||
|
||||
queries := db.New(testPool)
|
||||
bus := events.New()
|
||||
|
||||
failedTasks, err := queries.FailStaleTasks(ctx, db.FailStaleTasksParams{
|
||||
DispatchTimeoutSecs: 300.0,
|
||||
RunningTimeoutSecs: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FailStaleTasks failed: %v", err)
|
||||
}
|
||||
|
||||
broadcastFailedTasks(ctx, queries, bus, failedTasks)
|
||||
|
||||
// Issue should remain in_review — the sweeper must not clobber agent progress.
|
||||
var issueStatus string
|
||||
err = testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&issueStatus)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query issue status: %v", err)
|
||||
}
|
||||
if issueStatus != "in_review" {
|
||||
t.Fatalf("expected issue status 'in_review' to be preserved, got '%s'", issueStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// parseUUIDBytes converts a UUID string to the 16-byte array used by pgtype.UUID.
|
||||
func parseUUIDBytes(s string) [16]byte {
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
|
||||
@@ -241,6 +241,7 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.EmailService.SendVerificationCode(email, code); err != nil {
|
||||
slog.Error("failed to send verification code", "email", email, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to send verification code")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
@@ -19,6 +18,7 @@ import (
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
"github.com/multica-ai/multica/server/internal/storage"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
type txStarter interface {
|
||||
@@ -41,11 +41,11 @@ type Handler struct {
|
||||
EmailService *service.EmailService
|
||||
PingStore *PingStore
|
||||
UpdateStore *UpdateStore
|
||||
Storage *storage.S3Storage
|
||||
Storage storage.Storage
|
||||
CFSigner *auth.CloudFrontSigner
|
||||
}
|
||||
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, s3 *storage.S3Storage, cfSigner *auth.CloudFrontSigner) *Handler {
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner) *Handler {
|
||||
var executor dbExecutor
|
||||
if candidate, ok := txStarter.(dbExecutor); ok {
|
||||
executor = candidate
|
||||
@@ -61,7 +61,7 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
|
||||
EmailService: emailService,
|
||||
PingStore: NewPingStore(),
|
||||
UpdateStore: NewUpdateStore(),
|
||||
Storage: s3,
|
||||
Storage: store,
|
||||
CFSigner: cfSigner,
|
||||
}
|
||||
}
|
||||
@@ -77,14 +77,14 @@ func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
}
|
||||
|
||||
// Thin wrappers around util functions (preserve existing handler code unchanged).
|
||||
func parseUUID(s string) pgtype.UUID { return util.ParseUUID(s) }
|
||||
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
|
||||
func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) }
|
||||
func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
|
||||
func strToText(s string) pgtype.Text { return util.StrToText(s) }
|
||||
func parseUUID(s string) pgtype.UUID { return util.ParseUUID(s) }
|
||||
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
|
||||
func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) }
|
||||
func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
|
||||
func strToText(s string) pgtype.Text { return util.StrToText(s) }
|
||||
func timestampToString(t pgtype.Timestamptz) string { return util.TimestampToString(t) }
|
||||
func timestampToPtr(t pgtype.Timestamptz) *string { return util.TimestampToPtr(t) }
|
||||
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
|
||||
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
|
||||
|
||||
// publish sends a domain event through the event bus.
|
||||
func (h *Handler) publish(eventType, workspaceID, actorType, actorID string, payload any) {
|
||||
|
||||
@@ -135,16 +135,17 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
limit := int32(90)
|
||||
if l := r.URL.Query().Get("days"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 365 {
|
||||
limit = int32(parsed)
|
||||
days := 90
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
since := pgtype.Date{Time: time.Now().AddDate(0, 0, -days), Valid: true}
|
||||
|
||||
rows, err := h.Queries.ListRuntimeUsage(r.Context(), db.ListRuntimeUsageParams{
|
||||
RuntimeID: parseUUID(runtimeID),
|
||||
Limit: limit,
|
||||
Since: since,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list usage")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package sanitize
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
@@ -11,11 +13,6 @@ var httpURL = regexp.MustCompile(`^https?://`)
|
||||
|
||||
// policy is a shared bluemonday policy that allows safe Markdown HTML while
|
||||
// stripping dangerous elements (script, iframe, object, embed, style, on*).
|
||||
//
|
||||
// Note: bluemonday operates on raw text, so HTML inside Markdown code blocks
|
||||
// (e.g. ```<script>```) will also be stripped. This is an acceptable trade-off
|
||||
// for defense-in-depth — the primary sanitization happens in the frontend via
|
||||
// rehype-sanitize which understands the Markdown AST.
|
||||
var policy *bluemonday.Policy
|
||||
|
||||
func init() {
|
||||
@@ -28,8 +25,47 @@ func init() {
|
||||
policy.AllowAttrs("class").OnElements("code", "div", "span", "pre")
|
||||
}
|
||||
|
||||
// fencedCodeBlock matches ``` or ~~~ fenced code blocks (with optional language tag).
|
||||
var fencedCodeBlock = regexp.MustCompile("(?m)^(```|~~~)[^\n]*\n[\\s\\S]*?\n(```|~~~)[ \t]*$")
|
||||
|
||||
// inlineCode matches backtick-delimited inline code spans.
|
||||
// Ordered longest-delimiter-first so triple backticks match before doubles/singles.
|
||||
var inlineCode = regexp.MustCompile("```[^`]+```|``[^`]+``|`[^`]+`")
|
||||
|
||||
// HTML sanitizes user-provided HTML/Markdown content, stripping dangerous
|
||||
// tags (script, iframe, object, embed, etc.) and event-handler attributes.
|
||||
//
|
||||
// Code blocks and inline code spans are preserved verbatim so that bluemonday
|
||||
// does not HTML-escape their contents (e.g. && → &&).
|
||||
func HTML(input string) string {
|
||||
return policy.Sanitize(input)
|
||||
// 1. Extract fenced code blocks, replacing with unique placeholders.
|
||||
var blocks []string
|
||||
placeholder := func(i int) string { return fmt.Sprintf("\x00CODEBLOCK_%d\x00", i) }
|
||||
result := fencedCodeBlock.ReplaceAllStringFunc(input, func(m string) string {
|
||||
idx := len(blocks)
|
||||
blocks = append(blocks, m)
|
||||
return placeholder(idx)
|
||||
})
|
||||
|
||||
// 2. Extract inline code spans.
|
||||
var inlines []string
|
||||
inlinePH := func(i int) string { return fmt.Sprintf("\x00INLINE_%d\x00", i) }
|
||||
result = inlineCode.ReplaceAllStringFunc(result, func(m string) string {
|
||||
idx := len(inlines)
|
||||
inlines = append(inlines, m)
|
||||
return inlinePH(idx)
|
||||
})
|
||||
|
||||
// 3. Sanitize the non-code portions.
|
||||
result = policy.Sanitize(result)
|
||||
|
||||
// 4. Restore inline code spans, then fenced code blocks.
|
||||
for i, code := range inlines {
|
||||
result = strings.Replace(result, inlinePH(i), code, 1)
|
||||
}
|
||||
for i, block := range blocks {
|
||||
result = strings.Replace(result, placeholder(i), block, 1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -80,6 +80,52 @@ func TestHTML(t *testing.T) {
|
||||
input: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
|
||||
want: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
|
||||
},
|
||||
// Code block preservation — entities must NOT be escaped inside code.
|
||||
{
|
||||
name: "fenced code block preserves ampersands",
|
||||
input: "```\na && b\n```",
|
||||
want: "```\na && b\n```",
|
||||
},
|
||||
{
|
||||
name: "fenced code block preserves angle brackets",
|
||||
input: "```html\n<div class=\"x\">hello</div>\n```",
|
||||
want: "```html\n<div class=\"x\">hello</div>\n```",
|
||||
},
|
||||
{
|
||||
name: "inline code preserves ampersands",
|
||||
input: "run `a && b` in shell",
|
||||
want: "run `a && b` in shell",
|
||||
},
|
||||
{
|
||||
name: "inline code preserves angle brackets",
|
||||
input: "use `x < y && y > z`",
|
||||
want: "use `x < y && y > z`",
|
||||
},
|
||||
{
|
||||
name: "double backtick inline code preserved",
|
||||
input: "use ``a && b`` here",
|
||||
want: "use ``a && b`` here",
|
||||
},
|
||||
{
|
||||
name: "script in fenced code block preserved",
|
||||
input: "```\n<script>alert(1)</script>\n```",
|
||||
want: "```\n<script>alert(1)</script>\n```",
|
||||
},
|
||||
{
|
||||
name: "script outside code block still stripped",
|
||||
input: "hello <script>alert(1)</script> world",
|
||||
want: "hello world",
|
||||
},
|
||||
{
|
||||
name: "mixed code and non-code",
|
||||
input: "text `a && b` more <script>x</script> end",
|
||||
want: "text `a && b` more end",
|
||||
},
|
||||
{
|
||||
name: "tilde fenced code block preserves content",
|
||||
input: "~~~\na && b\n~~~",
|
||||
want: "~~~\na && b\n~~~",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
114
server/internal/storage/local.go
Normal file
114
server/internal/storage/local.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LocalStorage struct {
|
||||
uploadDir string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewLocalStorageFromEnv creates a LocalStorage from environment variables.
|
||||
// Returns nil if upload directory cannot be created.
|
||||
//
|
||||
// Environment variables:
|
||||
// - LOCAL_UPLOAD_DIR (default: "./data/uploads")
|
||||
// - LOCAL_UPLOAD_BASE_URL (optional, e.g., "http://localhost:8080")
|
||||
func NewLocalStorageFromEnv() *LocalStorage {
|
||||
uploadDir := os.Getenv("LOCAL_UPLOAD_DIR")
|
||||
if uploadDir == "" {
|
||||
uploadDir = "./data/uploads"
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
slog.Error("failed to create upload directory", "dir", uploadDir, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(os.Getenv("LOCAL_UPLOAD_BASE_URL"), "/")
|
||||
|
||||
slog.Info("local storage initialized", "dir", uploadDir, "baseURL", baseURL)
|
||||
return &LocalStorage{
|
||||
uploadDir: uploadDir,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LocalStorage) KeyFromURL(rawURL string) string {
|
||||
if s.baseURL != "" && strings.HasPrefix(rawURL, s.baseURL) {
|
||||
rawURL = strings.TrimPrefix(rawURL, s.baseURL)
|
||||
}
|
||||
|
||||
prefix := "/uploads/"
|
||||
if strings.HasPrefix(rawURL, prefix) {
|
||||
filename := strings.TrimPrefix(rawURL, prefix)
|
||||
if i := strings.LastIndex(filename, "/"); i >= 0 {
|
||||
return filename[i+1:]
|
||||
}
|
||||
return filename
|
||||
}
|
||||
if i := strings.LastIndex(rawURL, "/"); i >= 0 {
|
||||
return rawURL[i+1:]
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Delete(ctx context.Context, key string) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
filePath := filepath.Join(s.uploadDir, key)
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
slog.Error("local storage Delete failed", "key", key, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LocalStorage) DeleteKeys(ctx context.Context, keys []string) {
|
||||
for _, key := range keys {
|
||||
s.Delete(ctx, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) {
|
||||
dest := filepath.Join(s.uploadDir, key)
|
||||
if err := os.WriteFile(dest, data, 0644); err != nil {
|
||||
return "", fmt.Errorf("local storage WriteFile: %w", err)
|
||||
}
|
||||
|
||||
if s.baseURL != "" {
|
||||
return fmt.Sprintf("%s/uploads/%s", s.baseURL, key), nil
|
||||
}
|
||||
return fmt.Sprintf("/uploads/%s", key), nil
|
||||
}
|
||||
|
||||
func (s *LocalStorage) GetFilePath(key string) string {
|
||||
return filepath.Join(s.uploadDir, key)
|
||||
}
|
||||
|
||||
func (s *LocalStorage) ServeFile(w http.ResponseWriter, r *http.Request, filename string) {
|
||||
filePath := filepath.Join(s.uploadDir, filename)
|
||||
slog.Info("serving file", "filename", filename, "filepath", filePath)
|
||||
|
||||
// Use http.ServeFile which has built-in path traversal protection
|
||||
// It sanitizes the path and prevents access outside the directory
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
|
||||
func (s *LocalStorage) UploadFromReader(ctx context.Context, key string, reader io.Reader, contentType string, filename string) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("local storage ReadAll: %w", err)
|
||||
}
|
||||
|
||||
return s.Upload(ctx, key, data, contentType, filename)
|
||||
}
|
||||
214
server/internal/storage/local_test.go
Normal file
214
server/internal/storage/local_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalStorage_Upload(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
||||
os.Unsetenv("LOCAL_UPLOAD_BASE_URL")
|
||||
// No LOCAL_UPLOAD_BASE_URL set - should return relative path
|
||||
|
||||
store := NewLocalStorageFromEnv()
|
||||
if store == nil {
|
||||
t.Fatal("NewLocalStorageFromEnv returned nil")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
data := []byte("hello world")
|
||||
contentType := "text/plain"
|
||||
filename := "test.txt"
|
||||
|
||||
link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Upload failed: %v", err)
|
||||
}
|
||||
|
||||
expectedLink := "/uploads/test-key.txt"
|
||||
if link != expectedLink {
|
||||
t.Errorf("link = %q, want %q", link, expectedLink)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(tmpDir, "test-key.txt")
|
||||
stored, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read uploaded file: %v", err)
|
||||
}
|
||||
if string(stored) != string(data) {
|
||||
t.Errorf("stored data = %q, want %q", stored, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorage_Upload_WithBaseURL(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
||||
t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080")
|
||||
|
||||
store := NewLocalStorageFromEnv()
|
||||
if store == nil {
|
||||
t.Fatal("NewLocalStorageFromEnv returned nil")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
data := []byte("hello world")
|
||||
contentType := "text/plain"
|
||||
filename := "test.txt"
|
||||
|
||||
link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Upload failed: %v", err)
|
||||
}
|
||||
|
||||
// When LOCAL_UPLOAD_BASE_URL is set, should return full URL
|
||||
expectedLink := "http://localhost:8080/uploads/test-key.txt"
|
||||
if link != expectedLink {
|
||||
t.Errorf("link = %q, want %q", link, expectedLink)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(tmpDir, "test-key.txt")
|
||||
stored, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read uploaded file: %v", err)
|
||||
}
|
||||
if string(stored) != string(data) {
|
||||
t.Errorf("stored data = %q, want %q", stored, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorage_Delete(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
||||
|
||||
store := NewLocalStorageFromEnv()
|
||||
if store == nil {
|
||||
t.Fatal("NewLocalStorageFromEnv returned nil")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
data := []byte("hello world")
|
||||
|
||||
_, err := store.Upload(ctx, "delete-me.txt", data, "text/plain", "delete-me.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Upload failed: %v", err)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(tmpDir, "delete-me.txt")
|
||||
if _, err := os.ReadFile(filePath); err != nil {
|
||||
t.Fatalf("file should exist: %v", err)
|
||||
}
|
||||
|
||||
store.Delete(ctx, "delete-me.txt")
|
||||
|
||||
if _, err := os.ReadFile(filePath); !os.IsNotExist(err) {
|
||||
t.Errorf("file should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorage_KeyFromURL(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
||||
// No baseURL set
|
||||
|
||||
store := NewLocalStorageFromEnv()
|
||||
if store == nil {
|
||||
t.Fatal("NewLocalStorageFromEnv returned nil")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
expected string
|
||||
}{
|
||||
{"local URL format", "/uploads/abc123.png", "abc123.png"},
|
||||
{"local URL with subdir", "/uploads/2024/01/image.jpg", "image.jpg"},
|
||||
{"just filename", "abc123.png", "abc123.png"},
|
||||
{"full path", "/some/path/to/file.pdf", "file.pdf"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := store.KeyFromURL(tc.rawURL)
|
||||
if got != tc.expected {
|
||||
t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorage_KeyFromURL_WithBaseURL(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
||||
t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080")
|
||||
|
||||
store := NewLocalStorageFromEnv()
|
||||
if store == nil {
|
||||
t.Fatal("NewLocalStorageFromEnv returned nil")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
expected string
|
||||
}{
|
||||
{"full URL format", "http://localhost:8080/uploads/abc123.png", "abc123.png"},
|
||||
{"full URL with subdir", "http://localhost:8080/uploads/2024/01/image.jpg", "image.jpg"},
|
||||
{"local URL format still works", "/uploads/abc123.png", "abc123.png"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := store.KeyFromURL(tc.rawURL)
|
||||
if got != tc.expected {
|
||||
t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorage_DeleteKeys(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
||||
|
||||
store := NewLocalStorageFromEnv()
|
||||
if store == nil {
|
||||
t.Fatal("NewLocalStorageFromEnv returned nil")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
data := []byte("hello world")
|
||||
|
||||
keys := []string{"file1.txt", "file2.txt", "file3.txt"}
|
||||
for _, key := range keys {
|
||||
_, err := store.Upload(ctx, key, data, "text/plain", key)
|
||||
if err != nil {
|
||||
t.Fatalf("Upload %s failed: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
store.DeleteKeys(ctx, keys)
|
||||
|
||||
for _, key := range keys {
|
||||
filePath := filepath.Join(tmpDir, key)
|
||||
if _, err := os.ReadFile(filePath); !os.IsNotExist(err) {
|
||||
t.Errorf("file %s should be deleted", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorage_KeyFromURL_Empty(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
|
||||
|
||||
store := NewLocalStorageFromEnv()
|
||||
if store == nil {
|
||||
t.Fatal("NewLocalStorageFromEnv returned nil")
|
||||
}
|
||||
|
||||
if got := store.KeyFromURL(""); got != "" {
|
||||
t.Errorf("KeyFromURL(\"\") = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ type S3Storage struct {
|
||||
func NewS3StorageFromEnv() *S3Storage {
|
||||
bucket := os.Getenv("S3_BUCKET")
|
||||
if bucket == "" {
|
||||
slog.Info("S3_BUCKET not set, file upload disabled")
|
||||
slog.Info("S3_BUCKET not set, cloud upload disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -88,21 +88,6 @@ func (s *S3Storage) storageClass() types.StorageClass {
|
||||
return types.StorageClassIntelligentTiering
|
||||
}
|
||||
|
||||
// sanitizeFilename removes characters that could cause header injection in Content-Disposition.
|
||||
func sanitizeFilename(name string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(name))
|
||||
for _, r := range name {
|
||||
// Strip control chars, newlines, null bytes, quotes, semicolons, backslashes
|
||||
if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' {
|
||||
b.WriteRune('_')
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// KeyFromURL extracts the S3 object key from a CDN or bucket URL.
|
||||
// e.g. "https://multica-static.copilothub.ai/abc123.png" → "abc123.png"
|
||||
func (s *S3Storage) KeyFromURL(rawURL string) string {
|
||||
@@ -150,16 +135,6 @@ func (s *S3Storage) DeleteKeys(ctx context.Context, keys []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// isInlineContentType returns true for media types that browsers should
|
||||
// display inline (images, video, audio, PDF). Everything else triggers a
|
||||
// download via Content-Disposition: attachment.
|
||||
func isInlineContentType(ct string) bool {
|
||||
return strings.HasPrefix(ct, "image/") ||
|
||||
strings.HasPrefix(ct, "video/") ||
|
||||
strings.HasPrefix(ct, "audio/") ||
|
||||
ct == "application/pdf"
|
||||
}
|
||||
|
||||
func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) {
|
||||
safe := sanitizeFilename(filename)
|
||||
disposition := "attachment"
|
||||
|
||||
12
server/internal/storage/storage.go
Normal file
12
server/internal/storage/storage.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error)
|
||||
Delete(ctx context.Context, key string)
|
||||
DeleteKeys(ctx context.Context, keys []string)
|
||||
KeyFromURL(rawURL string) string
|
||||
}
|
||||
30
server/internal/storage/util.go
Normal file
30
server/internal/storage/util.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sanitizeFilename removes characters that could cause header injection in Content-Disposition.
|
||||
func sanitizeFilename(name string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(name))
|
||||
for _, r := range name {
|
||||
// Strip control chars, newlines, null bytes, quotes, semicolons, backslashes
|
||||
if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' {
|
||||
b.WriteRune('_')
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isInlineContentType returns true for media types that browsers should
|
||||
// display inline (images, video, audio, PDF). Everything else triggers a
|
||||
// download via Content-Disposition: attachment.
|
||||
func isInlineContentType(ct string) bool {
|
||||
return strings.HasPrefix(ct, "image/") ||
|
||||
strings.HasPrefix(ct, "video/") ||
|
||||
strings.HasPrefix(ct, "audio/") ||
|
||||
ct == "application/pdf"
|
||||
}
|
||||
@@ -95,17 +95,17 @@ func (q *Queries) GetRuntimeUsageSummary(ctx context.Context, runtimeID pgtype.U
|
||||
const listRuntimeUsage = `-- name: ListRuntimeUsage :many
|
||||
SELECT id, runtime_id, date, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at, updated_at FROM runtime_usage
|
||||
WHERE runtime_id = $1
|
||||
AND date >= $2
|
||||
ORDER BY date DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListRuntimeUsageParams struct {
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Since pgtype.Date `json:"since"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListRuntimeUsage(ctx context.Context, arg ListRuntimeUsageParams) ([]RuntimeUsage, error) {
|
||||
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Limit)
|
||||
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ DO UPDATE SET
|
||||
-- name: ListRuntimeUsage :many
|
||||
SELECT * FROM runtime_usage
|
||||
WHERE runtime_id = $1
|
||||
ORDER BY date DESC
|
||||
LIMIT $2;
|
||||
AND date >= $2
|
||||
ORDER BY date DESC;
|
||||
|
||||
-- name: GetRuntimeUsageSummary :many
|
||||
SELECT provider, model,
|
||||
|
||||
Reference in New Issue
Block a user