mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 06:25:56 +02:00
Compare commits
5 Commits
v0.2.24
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8260e11c8a | ||
|
|
3dc3e49a47 | ||
|
|
ae9098637d | ||
|
|
cc94fbd305 | ||
|
|
a039c4d803 |
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
// vi.hoisted shared state — every store mock reads the same object so each
|
||||
// test can mutate it then re-render to drive the tracker.
|
||||
const state = vi.hoisted(() => ({
|
||||
user: null as { id: string } | null,
|
||||
overlay: null as { type: string; invitationId?: string } | null,
|
||||
activeWorkspaceSlug: null as string | null,
|
||||
byWorkspace: {} as Record<
|
||||
string,
|
||||
{ activeTabId: string; tabs: { id: string; path: string }[] }
|
||||
>,
|
||||
capturePageview: vi.fn<(path?: string) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/analytics", () => ({
|
||||
capturePageview: state.capturePageview,
|
||||
}));
|
||||
|
||||
// Auth store — single selector pattern (`s => s.user`).
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = (selector: (s: typeof state) => unknown) =>
|
||||
selector(state);
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
// Window overlay store — same shape.
|
||||
vi.mock("@/stores/window-overlay-store", () => {
|
||||
const useWindowOverlayStore = (selector: (s: typeof state) => unknown) =>
|
||||
selector(state);
|
||||
return { useWindowOverlayStore };
|
||||
});
|
||||
|
||||
// Tab store — selectors read activeWorkspaceSlug + byWorkspace. Also expose
|
||||
// getState() for the seed pass and the helpers the tracker imports
|
||||
// (useActiveTabIdentity, getActiveTab) so we don't have to re-import them
|
||||
// from the real store inside a mocked module.
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const useTabStore = Object.assign(
|
||||
(selector: (s: typeof state) => unknown) => selector(state),
|
||||
{ getState: () => state },
|
||||
);
|
||||
const getActiveTab = (s: typeof state) => {
|
||||
const slug = s.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = s.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
|
||||
};
|
||||
const useActiveTabIdentity = () => ({
|
||||
slug: state.activeWorkspaceSlug,
|
||||
tabId: state.activeWorkspaceSlug
|
||||
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
|
||||
: null,
|
||||
});
|
||||
return { useTabStore, getActiveTab, useActiveTabIdentity };
|
||||
});
|
||||
|
||||
import { PageviewTracker } from "./pageview-tracker";
|
||||
|
||||
function reset() {
|
||||
state.user = { id: "u1" };
|
||||
state.overlay = null;
|
||||
state.activeWorkspaceSlug = null;
|
||||
state.byWorkspace = {};
|
||||
state.capturePageview.mockClear();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
describe("PageviewTracker", () => {
|
||||
it("suppresses pageview when switching to a previously-visible tab on its existing path", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
// Initial mount on tA — seeded as observed, no pageview because both
|
||||
// tabs were already in the persisted store before the tracker mounted.
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Switch to tB (already-known tab on its already-known path).
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tB",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Switch back to tA — still no pageview.
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tC",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tC", path: "/acme/agents" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/acme/agents");
|
||||
});
|
||||
|
||||
it("fires pageview when switchWorkspace opens a new path in another workspace", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Cross-workspace navigation: switchWorkspace("butter", "/butter/inbox")
|
||||
// creates a fresh tab in the destination workspace and makes it active.
|
||||
state.byWorkspace = {
|
||||
acme: { activeTabId: "tA", tabs: [{ id: "tA", path: "/acme/issues" }] },
|
||||
butter: {
|
||||
activeTabId: "tD",
|
||||
tabs: [{ id: "tD", path: "/butter/inbox" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "butter";
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/butter/inbox");
|
||||
});
|
||||
|
||||
it("fires pageview on intra-tab navigation (path changes for the same tabId)", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues/123" }],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/acme/issues/123");
|
||||
});
|
||||
|
||||
it("fires overlay and login pageviews and suppresses re-entry into the same tab afterward", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Open onboarding overlay.
|
||||
state.overlay = { type: "onboarding" };
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).toHaveBeenLastCalledWith("/onboarding");
|
||||
|
||||
// Close overlay back to the tab — the tab is already observed on
|
||||
// /acme/issues so this is a re-activation, no pageview.
|
||||
state.capturePageview.mockClear();
|
||||
state.overlay = null;
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Logout fires /login.
|
||||
state.user = null;
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).toHaveBeenLastCalledWith("/login");
|
||||
});
|
||||
|
||||
it("suppresses on initial mount when the active tab was restored from persistence", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
render(<PageviewTracker />);
|
||||
// Restored tab — seeded, treated as a re-activation.
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import {
|
||||
getActiveTab,
|
||||
useActiveTabIdentity,
|
||||
useTabStore,
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes.
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes,
|
||||
* EXCEPT for re-activations of an already-known tab on its already-known
|
||||
* path.
|
||||
*
|
||||
* Desktop has three layers that can own the visible page:
|
||||
*
|
||||
@@ -17,10 +23,18 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
|
||||
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
|
||||
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
|
||||
*
|
||||
* The overlay takes precedence over the tab path because it is visually in
|
||||
* front of the tab system; the logged-out state shadows both because the
|
||||
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
|
||||
* with what the user actually sees.
|
||||
* Tab-switch suppression: re-activating an already-open tab surfaces a
|
||||
* previously-visited path under a `(workspace, tabId)` we have already
|
||||
* seen — the pageview was emitted when the user originally navigated
|
||||
* there, so re-emitting on every switch just inflates PostHog billing
|
||||
* without adding signal (real-data audit: desktop tab switches were
|
||||
* ~50% of all `$pageview` events).
|
||||
*
|
||||
* Newly opened tabs (`openInNewTab`, `addTab`) and cross-workspace
|
||||
* `switchWorkspace(slug, path)` to a previously-unseen tab still fire,
|
||||
* because their key is not in the observed map yet. The map is seeded
|
||||
* from the persisted tab store on first render so tabs restored from a
|
||||
* previous session don't all re-emit on first activation.
|
||||
*
|
||||
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
|
||||
* `initAnalytics`) so this component owns the event shape, matching the web
|
||||
@@ -29,34 +43,75 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
|
||||
export function PageviewTracker() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
const activeTabPath = useTabStore((s) => {
|
||||
const slug = s.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = s.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
|
||||
});
|
||||
const { slug: activeWorkspaceSlug, tabId: activeTabId } = useActiveTabIdentity();
|
||||
const activeTabPath = useTabStore((s) => getActiveTab(s)?.path ?? null);
|
||||
|
||||
const path = resolvePath(user, overlay, activeTabPath);
|
||||
// (slug:tabId) → last path observed while that tab was visible. Lets us
|
||||
// tell "re-activating a tab on a path we already saw" (suppress) apart
|
||||
// from "newly opened tab" or "intra-tab navigation" (fire). Seeded
|
||||
// synchronously on first render from the persisted tab store so
|
||||
// session-restored tabs don't re-emit on first click.
|
||||
const observedTabsRef = useRef<Map<string, string> | null>(null);
|
||||
if (observedTabsRef.current === null) {
|
||||
const seed = new Map<string, string>();
|
||||
for (const [slug, group] of Object.entries(useTabStore.getState().byWorkspace)) {
|
||||
for (const tab of group.tabs) {
|
||||
seed.set(`${slug}:${tab.id}`, tab.path);
|
||||
}
|
||||
}
|
||||
observedTabsRef.current = seed;
|
||||
}
|
||||
const lastSurfaceRef = useRef<{
|
||||
kind: "login" | "overlay" | "tab" | null;
|
||||
key: string | null;
|
||||
path: string | null;
|
||||
}>({ kind: null, key: null, path: null });
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) return;
|
||||
let kind: "login" | "overlay" | "tab";
|
||||
let path: string;
|
||||
let key: string | null = null;
|
||||
|
||||
if (!user) {
|
||||
kind = "login";
|
||||
path = "/login";
|
||||
} else if (overlay) {
|
||||
kind = "overlay";
|
||||
path = overlayPath(overlay);
|
||||
} else if (activeTabPath && activeTabId && activeWorkspaceSlug) {
|
||||
kind = "tab";
|
||||
key = `${activeWorkspaceSlug}:${activeTabId}`;
|
||||
path = activeTabPath;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const observed = observedTabsRef.current!;
|
||||
const last = lastSurfaceRef.current;
|
||||
const next = { kind, key, path };
|
||||
|
||||
if (kind === "tab" && key !== null) {
|
||||
const knownPath = observed.get(key);
|
||||
const isReactivation =
|
||||
last.key !== key && knownPath !== undefined && knownPath === path;
|
||||
observed.set(key, path);
|
||||
if (isReactivation) {
|
||||
lastSurfaceRef.current = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const unchanged =
|
||||
last.kind === kind && last.key === key && last.path === path;
|
||||
if (unchanged) return;
|
||||
|
||||
capturePageview(path);
|
||||
}, [path]);
|
||||
lastSurfaceRef.current = next;
|
||||
}, [user, overlay, activeWorkspaceSlug, activeTabId, activeTabPath]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePath(
|
||||
user: unknown,
|
||||
overlay: WindowOverlay | null,
|
||||
activeTabPath: string | null,
|
||||
): string | null {
|
||||
if (!user) return "/login";
|
||||
if (overlay) return overlayPath(overlay);
|
||||
return activeTabPath;
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src/renderer/src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],
|
||||
|
||||
84
packages/views/editor/extensions/mention-extension.test.ts
Normal file
84
packages/views/editor/extensions/mention-extension.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { BaseMentionExtension } from "./mention-extension";
|
||||
|
||||
const tokenizer = BaseMentionExtension.config.markdownTokenizer!;
|
||||
|
||||
// The tiptap MarkdownTokenizer/renderMarkdown types have broad signatures
|
||||
// (multi-arg overloads). Our extension always provides single-argument
|
||||
// implementations, so cast for test convenience.
|
||||
const startFn = tokenizer.start as (src: string) => number;
|
||||
const tokenizeFn = tokenizer.tokenize as (
|
||||
src: string,
|
||||
) => { type: string; raw: string; attributes: Record<string, string> } | undefined;
|
||||
const renderMarkdown = BaseMentionExtension.config.renderMarkdown as (
|
||||
node: { attrs: Record<string, string> },
|
||||
) => string;
|
||||
|
||||
function tokenize(src: string) {
|
||||
const start = startFn(src);
|
||||
if (start === -1) return undefined;
|
||||
return tokenizeFn(src.slice(start));
|
||||
}
|
||||
|
||||
describe("mention tokenizer", () => {
|
||||
it("parses a plain mention", () => {
|
||||
const token = tokenize("[@Alice](mention://member/aaa-bbb)");
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("Alice");
|
||||
expect(token!.attributes.type).toBe("member");
|
||||
expect(token!.attributes.id).toBe("aaa-bbb");
|
||||
});
|
||||
|
||||
it("parses a mention with escaped brackets (round-trip from renderMarkdown)", () => {
|
||||
// renderMarkdown escapes brackets: David[TF] → David\[TF\]
|
||||
const md = renderMarkdown({
|
||||
attrs: { id: "aaa-bbb", label: "David[TF]", type: "agent" },
|
||||
});
|
||||
expect(md).toBe("[@David\\[TF\\]](mention://agent/aaa-bbb)");
|
||||
|
||||
const token = tokenize(md);
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("David[TF]");
|
||||
expect(token!.attributes.type).toBe("agent");
|
||||
});
|
||||
|
||||
it("does not match an ordinary Markdown link before a mention", () => {
|
||||
const src =
|
||||
"Check [docs](https://example.com) - [@User](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)";
|
||||
|
||||
// start() must NOT land on the [docs] link at index 6
|
||||
const start = startFn(src);
|
||||
expect(start).toBeGreaterThan(6);
|
||||
|
||||
// tokenize from the correct start position
|
||||
const token = tokenizeFn(src.slice(start));
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("User");
|
||||
expect(token!.attributes.id).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
it("handles multiple ordinary links before a mention", () => {
|
||||
const src =
|
||||
"See [a](https://a.com) and [b](https://b.com) - [@Bot](mention://agent/abc-123)";
|
||||
const start = startFn(src);
|
||||
const token = tokenizeFn(src.slice(start));
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("Bot");
|
||||
});
|
||||
|
||||
it("round-trips an agent label with nested brackets", () => {
|
||||
const md = renderMarkdown({
|
||||
attrs: { id: "x-y-z", label: "Bot[v2][beta]", type: "agent" },
|
||||
});
|
||||
const token = tokenize(md);
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("Bot[v2][beta]");
|
||||
});
|
||||
|
||||
it("parses issue mentions without @ prefix", () => {
|
||||
const token = tokenize("[MUL-123](mention://issue/aaa-bbb)");
|
||||
expect(token).toBeDefined();
|
||||
expect(token!.attributes.label).toBe("MUL-123");
|
||||
expect(token!.attributes.type).toBe("issue");
|
||||
});
|
||||
});
|
||||
@@ -39,17 +39,25 @@ export const BaseMentionExtension = Mention.extend({
|
||||
name: "mention",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
return src.search(/\[@?[^\]]+\]\(mention:\/\//);
|
||||
// Accept escaped brackets (\\[ \\]) and non-] chars in the label.
|
||||
// This prevents matching ordinary Markdown links like [docs](url)
|
||||
// that appear before a mention on the same line.
|
||||
return src.search(/\[@?(?:\\.|[^\]])+\]\(mention:\/\//);
|
||||
},
|
||||
tokenize(src: string) {
|
||||
// Label accepts escaped chars (\\[ \\]) or any non-] character.
|
||||
// This prevents the label from crossing a ]( Markdown link boundary
|
||||
// while still supporting bracket-containing names like "David\[TF\]".
|
||||
const match = src.match(
|
||||
/^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
|
||||
/^\[@?((?:\\.|[^\]])+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
// Unescape backslash-escaped brackets that renderMarkdown may produce.
|
||||
const rawLabel = match[1]?.replace(/\\\[/g, "[").replace(/\\\]/g, "]");
|
||||
return {
|
||||
type: "mention",
|
||||
raw: match[0],
|
||||
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
|
||||
attributes: { label: rawLabel, type: match[2] ?? "member", id: match[3] },
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -59,6 +67,9 @@ export const BaseMentionExtension = Mention.extend({
|
||||
renderMarkdown: (node: any) => {
|
||||
const { id, label, type = "member" } = node.attrs || {};
|
||||
const prefix = type === "issue" ? "" : "@";
|
||||
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
|
||||
// Escape square brackets in the label so the markdown link syntax
|
||||
// is not broken when the name contains [ or ] (e.g. "David[TF]").
|
||||
const safeLabel = (label ?? id).replace(/\[/g, "\\[").replace(/\]/g, "\\]");
|
||||
return `[${prefix}${safeLabel}](mention://${type}/${id})`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -439,11 +439,19 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
|
||||
_ = excludeFromGit(worktreePath, pattern)
|
||||
}
|
||||
|
||||
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
|
||||
// Install or remove the Co-authored-by hook based on the workspace
|
||||
// setting. The hook lives in the bare repo's shared hooks dir, so we
|
||||
// must actively remove it when disabled — otherwise a previously
|
||||
// installed hook keeps appending the trailer to every commit even
|
||||
// after the user toggles the setting off.
|
||||
if params.CoAuthoredByEnabled {
|
||||
if err := installCoAuthoredByHook(worktreePath); err != nil {
|
||||
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
|
||||
}
|
||||
} else {
|
||||
if err := removeCoAuthoredByHook(worktreePath); err != nil {
|
||||
c.logger.Warn("repo checkout: remove co-authored-by hook failed (non-fatal)", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Info("repo checkout: existing worktree updated",
|
||||
@@ -471,11 +479,17 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
|
||||
_ = excludeFromGit(worktreePath, pattern)
|
||||
}
|
||||
|
||||
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
|
||||
// Install or remove the Co-authored-by hook based on the workspace
|
||||
// setting. See the existing-worktree branch above for why removal is
|
||||
// required when the setting is disabled.
|
||||
if params.CoAuthoredByEnabled {
|
||||
if err := installCoAuthoredByHook(worktreePath); err != nil {
|
||||
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
|
||||
}
|
||||
} else {
|
||||
if err := removeCoAuthoredByHook(worktreePath); err != nil {
|
||||
c.logger.Warn("repo checkout: remove co-authored-by hook failed (non-fatal)", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Info("repo checkout: worktree created",
|
||||
@@ -728,9 +742,29 @@ func bareHeadBranch(barePath string) string {
|
||||
return ref
|
||||
}
|
||||
|
||||
// multicaHookMarker is a sentinel comment embedded in every prepare-commit-msg
|
||||
// hook installed by the daemon. removeCoAuthoredByHook uses it to recognize
|
||||
// hooks it owns so it never deletes a hook installed by the user or another
|
||||
// tool. Do not change without bumping the recognition logic.
|
||||
const multicaHookMarker = "# multica:prepare-commit-msg:co-authored-by"
|
||||
|
||||
// daemonInstalledHookSignatures lists substrings that identify a
|
||||
// prepare-commit-msg hook as one the daemon installed. removeCoAuthoredByHook
|
||||
// treats a hook as Multica-owned if its content contains ANY of these
|
||||
// substrings. The list deliberately includes the legacy comment that the
|
||||
// daemon used before multicaHookMarker existed, so disabling the toggle on
|
||||
// existing installations still cleans up old hooks seeded by previous daemon
|
||||
// versions. Add to this list — never remove from it — so future tweaks to
|
||||
// prepareCommitMsgHook keep recognizing every previously-shipped variant.
|
||||
var daemonInstalledHookSignatures = []string{
|
||||
multicaHookMarker,
|
||||
"# Installed by the Multica daemon.",
|
||||
}
|
||||
|
||||
// prepareCommitMsgHook is the prepare-commit-msg hook script that appends a
|
||||
// Co-authored-by trailer for the Multica Agent to every commit message.
|
||||
const prepareCommitMsgHook = `#!/bin/sh
|
||||
# multica:prepare-commit-msg:co-authored-by
|
||||
# Multica: add Co-authored-by trailer for the Multica Agent.
|
||||
# Installed by the Multica daemon. Do not edit — it will be overwritten.
|
||||
|
||||
@@ -780,6 +814,55 @@ func installCoAuthoredByHook(worktreePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDaemonInstalledHook reports whether a prepare-commit-msg hook on disk was
|
||||
// installed by the Multica daemon (current or any previously released
|
||||
// version). It returns false for hooks that don't carry any known daemon
|
||||
// signature, so a user-installed hook at the same path is left alone.
|
||||
func isDaemonInstalledHook(contents []byte) bool {
|
||||
body := string(contents)
|
||||
for _, sig := range daemonInstalledHookSignatures {
|
||||
if strings.Contains(body, sig) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// removeCoAuthoredByHook removes the prepare-commit-msg hook installed by
|
||||
// installCoAuthoredByHook. It only deletes the file when the content matches
|
||||
// a known daemon signature (current marker or any previously released hook
|
||||
// content), so a user-installed prepare-commit-msg hook is never touched.
|
||||
// Returns nil when no hook is present or when an unrelated hook occupies
|
||||
// the path.
|
||||
func removeCoAuthoredByHook(worktreePath string) error {
|
||||
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-common-dir")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve git common dir: %w", err)
|
||||
}
|
||||
commonDir := strings.TrimSpace(string(out))
|
||||
if !filepath.IsAbs(commonDir) {
|
||||
commonDir = filepath.Join(worktreePath, commonDir)
|
||||
}
|
||||
|
||||
hookPath := filepath.Join(commonDir, "hooks", "prepare-commit-msg")
|
||||
contents, err := os.ReadFile(hookPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read prepare-commit-msg hook: %w", err)
|
||||
}
|
||||
if !isDaemonInstalledHook(contents) {
|
||||
// Unrelated hook (user or third-party): leave it alone.
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(hookPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove prepare-commit-msg hook: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// excludeFromGit adds a pattern to the worktree's .git/info/exclude file.
|
||||
func excludeFromGit(worktreePath, pattern string) error {
|
||||
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir")
|
||||
|
||||
@@ -1158,6 +1158,210 @@ func TestCoAuthoredByHookIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateWorktreeRemovesCoAuthoredByHookWhenDisabled verifies the toggle-off
|
||||
// path: a bare cache that already carries the Multica prepare-commit-msg hook
|
||||
// (e.g. from a prior worktree created with the setting on) must drop the hook
|
||||
// when the next CreateWorktree call passes CoAuthoredByEnabled=false.
|
||||
// Otherwise commits keep getting the trailer even after the user disables the
|
||||
// workspace setting.
|
||||
func TestCreateWorktreeRemovesCoAuthoredByHookWhenDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("sync failed: %v", err)
|
||||
}
|
||||
|
||||
// First worktree: setting enabled → hook installed in the bare cache's
|
||||
// shared hooks dir.
|
||||
workDir1 := t.TempDir()
|
||||
if _, err := cache.CreateWorktree(WorktreeParams{
|
||||
WorkspaceID: "ws-1",
|
||||
RepoURL: sourceRepo,
|
||||
WorkDir: workDir1,
|
||||
AgentName: "Test Agent",
|
||||
TaskID: "11111111-0000-0000-0000-000000000000",
|
||||
CoAuthoredByEnabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateWorktree (enabled) failed: %v", err)
|
||||
}
|
||||
|
||||
barePath := cache.Lookup("ws-1", sourceRepo)
|
||||
hookPath := filepath.Join(barePath, "hooks", "prepare-commit-msg")
|
||||
if _, err := os.Stat(hookPath); err != nil {
|
||||
t.Fatalf("precondition: expected hook to be installed at %s: %v", hookPath, err)
|
||||
}
|
||||
|
||||
// Second worktree on the same bare cache: setting disabled → hook must
|
||||
// be removed and a commit in the new worktree must NOT carry the
|
||||
// trailer.
|
||||
workDir2 := t.TempDir()
|
||||
result, err := cache.CreateWorktree(WorktreeParams{
|
||||
WorkspaceID: "ws-1",
|
||||
RepoURL: sourceRepo,
|
||||
WorkDir: workDir2,
|
||||
AgentName: "Test Agent",
|
||||
TaskID: "22222222-0000-0000-0000-000000000000",
|
||||
CoAuthoredByEnabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree (disabled) failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
|
||||
t.Errorf("expected hook to be removed at %s, stat err=%v", hookPath, err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
runGitAuthored(t, result.Path, "add", ".")
|
||||
runGitAuthored(t, result.Path, "commit", "-m", "test commit")
|
||||
|
||||
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git log failed: %v", err)
|
||||
}
|
||||
commitMsg := string(out)
|
||||
if strings.Contains(commitMsg, "Co-authored-by: multica-agent") {
|
||||
t.Errorf("commit unexpectedly carries the Co-authored-by trailer with setting disabled.\ngot:\n%s", commitMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateWorktreeRemovesLegacyCoAuthoredByHook verifies the migration
|
||||
// path: bare clones already on disk from previous daemon versions carry a
|
||||
// prepare-commit-msg hook that does NOT include the multicaHookMarker
|
||||
// sentinel — only the older `# Installed by the Multica daemon.` comment.
|
||||
// Toggling the workspace setting off must still remove those legacy hooks,
|
||||
// otherwise users who flip the toggle in production keep seeing the trailer
|
||||
// indefinitely (the exact bug reported in MUL-1704).
|
||||
func TestCreateWorktreeRemovesLegacyCoAuthoredByHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("sync failed: %v", err)
|
||||
}
|
||||
|
||||
// Seed the bare cache with the exact hook content shipped by the
|
||||
// previous daemon release (no multicaHookMarker line). Keeping a
|
||||
// verbatim copy here means the test fails if recognition logic ever
|
||||
// drifts away from what production hosts actually have on disk.
|
||||
const legacyHook = `#!/bin/sh
|
||||
# Multica: add Co-authored-by trailer for the Multica Agent.
|
||||
# Installed by the Multica daemon. Do not edit — it will be overwritten.
|
||||
|
||||
COMMIT_MSG_FILE="$1"
|
||||
COMMIT_SOURCE="$2"
|
||||
|
||||
# Skip merge and squash commits.
|
||||
case "$COMMIT_SOURCE" in
|
||||
merge|squash) exit 0 ;;
|
||||
esac
|
||||
|
||||
TRAILER="Co-authored-by: multica-agent <github@multica.ai>"
|
||||
|
||||
# Don't add if already present.
|
||||
if grep -qF "$TRAILER" "$COMMIT_MSG_FILE"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use git interpret-trailers for proper formatting.
|
||||
git interpret-trailers --in-place --trailer "$TRAILER" "$COMMIT_MSG_FILE"
|
||||
`
|
||||
|
||||
barePath := cache.Lookup("ws-1", sourceRepo)
|
||||
hooksDir := filepath.Join(barePath, "hooks")
|
||||
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
|
||||
t.Fatalf("create hooks dir: %v", err)
|
||||
}
|
||||
hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
|
||||
if err := os.WriteFile(hookPath, []byte(legacyHook), 0o755); err != nil {
|
||||
t.Fatalf("seed legacy hook: %v", err)
|
||||
}
|
||||
|
||||
workDir := t.TempDir()
|
||||
result, err := cache.CreateWorktree(WorktreeParams{
|
||||
WorkspaceID: "ws-1",
|
||||
RepoURL: sourceRepo,
|
||||
WorkDir: workDir,
|
||||
AgentName: "Test Agent",
|
||||
TaskID: "44444444-0000-0000-0000-000000000000",
|
||||
CoAuthoredByEnabled: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree (disabled) failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
|
||||
t.Errorf("expected legacy hook to be removed at %s, stat err=%v", hookPath, err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
runGitAuthored(t, result.Path, "add", ".")
|
||||
runGitAuthored(t, result.Path, "commit", "-m", "test commit")
|
||||
|
||||
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git log failed: %v", err)
|
||||
}
|
||||
if commitMsg := string(out); strings.Contains(commitMsg, "Co-authored-by: multica-agent") {
|
||||
t.Errorf("commit unexpectedly carries the Co-authored-by trailer after legacy hook removal.\ngot:\n%s", commitMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveCoAuthoredByHookPreservesUserHook verifies that the disable path
|
||||
// only deletes hooks installed by the daemon. A prepare-commit-msg hook
|
||||
// without the Multica marker (e.g. one a user added manually) must be left
|
||||
// untouched even when CoAuthoredByEnabled=false.
|
||||
func TestRemoveCoAuthoredByHookPreservesUserHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("sync failed: %v", err)
|
||||
}
|
||||
|
||||
barePath := cache.Lookup("ws-1", sourceRepo)
|
||||
hooksDir := filepath.Join(barePath, "hooks")
|
||||
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
|
||||
t.Fatalf("create hooks dir: %v", err)
|
||||
}
|
||||
hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
|
||||
userHook := "#!/bin/sh\n# user hook, not Multica\nexit 0\n"
|
||||
if err := os.WriteFile(hookPath, []byte(userHook), 0o755); err != nil {
|
||||
t.Fatalf("seed user hook: %v", err)
|
||||
}
|
||||
|
||||
workDir := t.TempDir()
|
||||
if _, err := cache.CreateWorktree(WorktreeParams{
|
||||
WorkspaceID: "ws-1",
|
||||
RepoURL: sourceRepo,
|
||||
WorkDir: workDir,
|
||||
AgentName: "Test Agent",
|
||||
TaskID: "33333333-0000-0000-0000-000000000000",
|
||||
CoAuthoredByEnabled: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateWorktree (disabled) failed: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(hookPath)
|
||||
if err != nil {
|
||||
t.Fatalf("user hook unexpectedly removed: %v", err)
|
||||
}
|
||||
if string(got) != userHook {
|
||||
t.Errorf("user hook contents changed.\nwant:\n%s\ngot:\n%s", userHook, string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRemoteDefaultBranchAmbiguousOriginReturnsEmpty verifies step 4's
|
||||
// safe-scan gating: when the cache has multiple refs/remotes/origin/*
|
||||
// entries, none match the common defaults, and none match the bare HEAD
|
||||
|
||||
@@ -10,7 +10,10 @@ type Mention struct {
|
||||
|
||||
// MentionRe matches [@Label](mention://type/id) or [Label](mention://issue/id) in markdown.
|
||||
// The @ prefix is optional to support issue mentions which use [MUL-123](mention://issue/...).
|
||||
var MentionRe = regexp.MustCompile(`\[@?[^\]]*\]\(mention://(member|agent|issue|all)/([0-9a-fA-F-]+|all)\)`)
|
||||
// Uses .+? (non-greedy) instead of [^\]]* so labels containing square brackets
|
||||
// (e.g. "David[TF]") are matched correctly — the ](mention:// anchor is specific
|
||||
// enough to prevent over-matching.
|
||||
var MentionRe = regexp.MustCompile(`\[@?(.+?)\]\(mention://(member|agent|issue|all)/([0-9a-fA-F-]+|all)\)`)
|
||||
|
||||
// IsMentionAll returns true if the mention is an @all mention.
|
||||
func (m Mention) IsMentionAll() bool {
|
||||
@@ -23,12 +26,12 @@ func ParseMentions(content string) []Mention {
|
||||
seen := make(map[string]bool)
|
||||
var result []Mention
|
||||
for _, m := range matches {
|
||||
key := m[1] + ":" + m[2]
|
||||
key := m[2] + ":" + m[3]
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
result = append(result, Mention{Type: m[1], ID: m[2]})
|
||||
result = append(result, Mention{Type: m[2], ID: m[3]})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
101
server/internal/util/mention_test.go
Normal file
101
server/internal/util/mention_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMentions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want []Mention
|
||||
}{
|
||||
{
|
||||
name: "simple agent mention",
|
||||
content: "[@Agent](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) please fix",
|
||||
want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
|
||||
},
|
||||
{
|
||||
name: "agent name with square brackets",
|
||||
content: "[@David[TF]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) please fix",
|
||||
want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
|
||||
},
|
||||
{
|
||||
name: "agent name with nested brackets",
|
||||
content: "[@Bot[v2][beta]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) help",
|
||||
want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
|
||||
},
|
||||
{
|
||||
name: "multiple mentions with brackets",
|
||||
content: "[@A[1]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) and [@B[2]](mention://agent/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb)",
|
||||
want: []Mention{
|
||||
{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"},
|
||||
{Type: "agent", ID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "issue mention without @",
|
||||
content: "[MUL-123](mention://issue/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) is related",
|
||||
want: []Mention{{Type: "issue", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
|
||||
},
|
||||
{
|
||||
name: "member mention",
|
||||
content: "[@Bob](mention://member/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) look",
|
||||
want: []Mention{{Type: "member", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
|
||||
},
|
||||
{
|
||||
name: "all mention",
|
||||
content: "[@All](mention://all/all) heads up",
|
||||
want: []Mention{{Type: "all", ID: "all"}},
|
||||
},
|
||||
{
|
||||
name: "deduplicate same mention",
|
||||
content: "[@A](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) and again [@A](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)",
|
||||
want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
|
||||
},
|
||||
{
|
||||
name: "no mentions",
|
||||
content: "just a plain comment",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "escaped brackets in label",
|
||||
content: `[@David\[TF\]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) hi`,
|
||||
want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParseMentions(tt.content)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("ParseMentions() returned %d mentions, want %d\ngot: %+v\nwant: %+v", len(got), len(tt.want), got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i].Type != tt.want[i].Type || got[i].ID != tt.want[i].ID {
|
||||
t.Errorf("mention[%d] = %+v, want %+v", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasMentionAll(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mentions []Mention
|
||||
want bool
|
||||
}{
|
||||
{"empty", nil, false},
|
||||
{"no all", []Mention{{Type: "agent", ID: "x"}}, false},
|
||||
{"has all", []Mention{{Type: "all", ID: "all"}}, true},
|
||||
{"mixed", []Mention{{Type: "agent", ID: "x"}, {Type: "all", ID: "all"}}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := HasMentionAll(tt.mentions); got != tt.want {
|
||||
t.Errorf("HasMentionAll() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user