Compare commits

..

1 Commits

Author SHA1 Message Date
Naiyuan Qing
05e3552c6b fix(editor): keep code-block selection stable during background re-renders (MUL-3621)
Selecting text in a readonly code block (comment/issue markdown) lost the
selection within seconds, making copy impossible, whenever the surrounding
view re-rendered — most reliably while a sibling agent task streamed over
WebSocket (a re-render roughly every ~100ms).

Root cause: the `code` renderer emits highlighted HTML via
`dangerouslySetInnerHTML={{ __html }}`, a fresh prop object every render. Each
unrelated parent re-render re-ran react-markdown, and React rewrote the
`<code>` innerHTML even though the HTML string was byte-identical, tearing down
and rebuilding all 161 hljs `<span>` nodes. The native selection is anchored to
those nodes, so it collapsed.

Fix: memoize the entire `<ReactMarkdown>` subtree on its only real inputs
(`processed` + `components`). A stable element reference lets React bail out of
the subtree on unrelated re-renders, so the code-block DOM is never rebuilt
while content is unchanged. Confirmed via an instrumentation probe: zero
`<code>` DOM mutations during streaming after the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:42:17 +08:00
54 changed files with 67 additions and 3645 deletions

View File

@@ -489,25 +489,6 @@ VITE_API_URL=http://localhost:<backend-port>
VITE_WS_URL=ws://localhost:<backend-port>/ws
```
#### Running multiple worktrees side-by-side
`pnpm dev:desktop` auto-isolates a worktree so several worktrees can run their
own desktop dev instance at once — no extra setup. From a linked worktree it
derives, from the worktree path (same `cksum % 1000` offset as the backend /
frontend ports in `.env.worktree`):
- `DESKTOP_RENDERER_PORT` = `5174 + offset` — its own Vite dev server (`5174`
base leaves `5173` for the primary checkout, even when `offset` is `0`)
- `DESKTOP_APP_SUFFIX` = `<folder>-<offset>` — its own single-instance lock /
`userData`, and an app named `Multica Canary <folder>-<offset>` so it is
distinguishable in Cmd+Tab. The offset keeps it unique across worktrees that
share a folder name at different paths.
The primary checkout is left untouched (`5173`, `Multica Canary`). Set either
env var explicitly to override the derived value. Which backend each instance
talks to is still controlled only by `apps/desktop/.env*` above — point each
worktree's desktop at its own backend to also isolate the daemon profile.
### Isolation Guarantee
Nothing in this flow touches the system-installed `multica` or the default

View File

@@ -37,27 +37,6 @@ define REQUIRE_ENV
fi
endef
# Self-hosting requires Docker Compose v2 (the `docker compose` CLI plugin).
# The self-host compose files use compose-spec syntax (top-level `name:`, no
# `version:`) that the legacy v1 `docker-compose` standalone cannot parse, so we
# fail early with an actionable message instead of a cryptic CLI parse error
# (e.g. "unknown shorthand flag: 'f' in -f") when the plugin is missing or v1.
# Keep the message short and OS-agnostic: per-OS install steps belong in docs.
define REQUIRE_COMPOSE
@if ! $(COMPOSE) version >/dev/null 2>&1; then \
echo "Docker Compose v2 ('docker compose') was not found."; \
echo "Self-hosting requires the Compose v2 CLI plugin; legacy 'docker-compose' v1 is not supported."; \
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
exit 1; \
fi; \
if ! $(COMPOSE) version --short 2>/dev/null | grep -Eq '^v?2\.'; then \
echo "'$(COMPOSE)' is not Docker Compose v2."; \
echo "Self-hosting requires the Compose v2 CLI plugin; legacy 'docker-compose' v1 is not supported."; \
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
exit 1; \
fi
endef
# Default target changed from selfhost to help: bare `make` now prints this help
# instead of launching a full Docker Compose build, which is safer for onboarding.
.DEFAULT_GOAL := help
@@ -75,7 +54,6 @@ makehelp: help ## Alias for `make help`
##@ Self-hosting
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
$(REQUIRE_COMPOSE)
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -93,7 +71,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
fi
@echo "==> Pulling official Multica images..."
@if ! $(COMPOSE) -f docker-compose.selfhost.yml pull; then \
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
echo ""; \
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
echo "If this is before the first GHCR release, build from the current checkout:"; \
@@ -101,7 +79,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
exit 1; \
fi
@echo "==> Starting Multica via Docker Compose..."
$(COMPOSE) -f docker-compose.selfhost.yml up -d
docker compose -f docker-compose.selfhost.yml up -d
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@@ -127,11 +105,10 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " $(COMPOSE) -f docker-compose.selfhost.yml logs"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
$(REQUIRE_COMPOSE)
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
@@ -149,7 +126,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
fi
@echo "==> Building Multica from the current checkout..."
$(COMPOSE) -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@@ -175,13 +152,12 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " $(COMPOSE) -f docker-compose.selfhost.yml logs"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
selfhost-stop: ## Stop the self-hosted Docker Compose stack
$(REQUIRE_COMPOSE)
@echo "==> Stopping Multica services..."
$(COMPOSE) -f docker-compose.selfhost.yml down
docker compose -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------

View File

@@ -19,8 +19,8 @@
"scripts": {
"bundle-cli": "node scripts/bundle-cli.mjs",
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
"dev": "node scripts/dev.mjs",
"dev:staging": "node scripts/dev.mjs --mode staging",
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",

View File

@@ -9,10 +9,6 @@
// matches. The patch is isolated to this worktree's node_modules — we
// unlink the file before rewriting so we never mutate a pnpm-store inode
// shared with another project.
//
// In a worktree, scripts/dev.mjs sets DESKTOP_APP_SUFFIX so the name becomes
// "Multica Canary <suffix>" — distinguishable in Cmd+Tab and matching the app
// name src/main/index.ts derives from the same env var.
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
@@ -21,9 +17,7 @@ import { resolve } from "node:path";
if (process.platform !== "darwin") process.exit(0);
const DESIRED_NAME = process.env.DESKTOP_APP_SUFFIX
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
: "Multica Canary";
const DESIRED_NAME = "Multica Canary";
const require = createRequire(import.meta.url);
// `require('electron')` returns the path to the executable

View File

@@ -1,53 +0,0 @@
#!/usr/bin/env node
// Dev launcher for `pnpm dev:desktop`.
//
// Derives per-worktree isolation env (renderer port + app name) so multiple
// worktrees can run `pnpm dev:desktop` side-by-side, then runs the same chain
// as before — bundle the CLI, brand the dev Electron, start electron-vite —
// inheriting the augmented env. A plain `&&` chain in package.json can't do
// this: each `&&` step is its own process, so an env tweak in step 1 wouldn't
// reach electron-vite in step 3. Args (e.g. `--mode staging`) pass through to
// electron-vite.
import { spawnSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
applyWorktreeDevEnv,
repoRootFromScriptDir,
} from "./worktree-dev-env.mjs";
const here = dirname(fileURLToPath(import.meta.url));
applyWorktreeDevEnv(process.env, {
root: repoRootFromScriptDir(here),
log: true,
});
function run(command, args, { shell = false } = {}) {
const result = spawnSync(command, args, {
stdio: "inherit",
env: process.env,
shell,
});
if (result.error) {
console.error(`[dev:desktop] failed to run ${command}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) process.exit(result.status ?? 1);
}
const node = process.execPath;
run(node, [join(here, "bundle-cli.mjs")]);
run(node, [join(here, "brand-dev-electron.mjs")]);
const isWin = process.platform === "win32";
const electronVite = join(
here,
"..",
"node_modules",
".bin",
isWin ? "electron-vite.cmd" : "electron-vite",
);
run(electronVite, ["dev", ...process.argv.slice(2)], { shell: isWin });

View File

@@ -1,116 +0,0 @@
// Per-worktree dev isolation for `pnpm dev:desktop`.
//
// Two `pnpm dev:desktop` instances from two different git worktrees collide on
// the renderer Vite port (5173) and the single-instance lock / userData dir
// (keyed by the app name "Multica Canary"). The env hooks to override both
// already exist — electron.vite.config.ts reads DESKTOP_RENDERER_PORT and
// src/main/index.ts reads DESKTOP_APP_SUFFIX — but nothing derives unique
// values per worktree. This module does, mirroring the offset scheme that
// scripts/init-worktree-env.sh already uses for backend/frontend ports.
//
// Backend targeting is deliberately NOT touched here: which backend the desktop
// connects to stays driven by apps/desktop/.env* (VITE_API_URL / VITE_WS_URL),
// exactly as documented. This module only adds the two knobs needed for two
// Electron processes to coexist.
import { statSync } from "node:fs";
import { basename, join } from "node:path";
// Worktree renderer ports start at 5174 so they never reuse 5173 — the primary
// checkout's default — even when a worktree's offset is 0 (e.g. POSIX cksum of
// "/tmp/multica-3494" is 1189739000, and 1189739000 % 1000 === 0). Range 51746173.
const RENDERER_PORT_BASE = 5174;
const OFFSET_MODULO = 1000;
// POSIX cksum (CRC-32), kept byte-compatible with `cksum(1)` so the offset
// matches scripts/init-worktree-env.sh — a worktree's backend (18080+offset),
// frontend (13000+offset) and desktop renderer (5174+offset) ports all share
// one offset. Verified against coreutils: cksum of "/tmp/foo" → 427878967.
function cksumTable() {
const table = new Uint32Array(256);
const POLY = 0x04c11db7;
for (let i = 0; i < 256; i++) {
let crc = i << 24;
for (let bit = 0; bit < 8; bit++) {
crc = crc & 0x80000000 ? (crc << 1) ^ POLY : crc << 1;
}
table[i] = crc >>> 0;
}
return table;
}
const TABLE = cksumTable();
export function cksum(buf) {
let crc = 0;
for (const byte of buf) {
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ byte) & 0xff]) >>> 0;
}
// POSIX appends the byte length, least-significant byte first.
let len = buf.length;
while (len > 0) {
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ (len & 0xff)) & 0xff]) >>> 0;
len = Math.floor(len / 256);
}
return (~crc) >>> 0;
}
export function offsetForPath(path) {
return cksum(Buffer.from(path)) % OFFSET_MODULO;
}
export function rendererPortForPath(path) {
return RENDERER_PORT_BASE + offsetForPath(path);
}
// Worktree → a readable, unique, filesystem-safe suffix "<folder>-<offset>".
// The dev app then shows e.g. "Multica Canary mul-3724-194" in Cmd+Tab and gets
// its own userData / single-instance lock under that name. The offset is what
// makes the lock unique: the folder name alone collides for worktrees that share
// a basename at different paths (e.g. /a/multica vs /b/multica) or whose names
// slug to the same fallback — those would share one lock and the second Electron
// would still be blocked.
export function appSuffixForPath(path) {
const slug =
basename(path)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "worktree";
return `${slug}-${offsetForPath(path)}`;
}
// A linked git worktree has a `.git` FILE (a "gitdir:" pointer); the primary
// checkout has a `.git` DIRECTORY. We only auto-isolate linked worktrees, so
// the primary checkout keeps the unchanged 5173 / "Multica Canary" defaults.
export function isLinkedWorktree(root) {
try {
return statSync(join(root, ".git")).isFile();
} catch {
return false;
}
}
// scripts live at <root>/apps/desktop/scripts
export function repoRootFromScriptDir(scriptDir) {
return join(scriptDir, "..", "..", "..");
}
// Populate DESKTOP_RENDERER_PORT / DESKTOP_APP_SUFFIX on `env` for a worktree
// checkout, without overriding values the caller set explicitly. Returns `env`.
export function applyWorktreeDevEnv(env, { root, log = false } = {}) {
const hasPort = Boolean(env.DESKTOP_RENDERER_PORT);
const hasSuffix = Boolean(env.DESKTOP_APP_SUFFIX);
if (hasPort && hasSuffix) return env; // explicit overrides win outright
if (!isLinkedWorktree(root)) return env; // primary checkout → keep defaults
if (!hasPort) env.DESKTOP_RENDERER_PORT = String(rendererPortForPath(root));
if (!hasSuffix) env.DESKTOP_APP_SUFFIX = appSuffixForPath(root);
if (log) {
console.log(
`[dev:desktop] worktree isolation → renderer port ${env.DESKTOP_RENDERER_PORT}, ` +
`app "Multica Canary ${env.DESKTOP_APP_SUFFIX}"`,
);
}
return env;
}

View File

@@ -1,101 +0,0 @@
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
appSuffixForPath,
applyWorktreeDevEnv,
cksum,
offsetForPath,
rendererPortForPath,
} from "./worktree-dev-env.mjs";
const cleanups = [];
afterEach(() => {
while (cleanups.length) cleanups.pop()();
});
function tmpRoot(kind /* "file" | "dir" | "none" */) {
const root = mkdtempSync(join(tmpdir(), "wt-"));
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
if (kind === "file") writeFileSync(join(root, ".git"), "gitdir: /elsewhere\n");
else if (kind === "dir") mkdirSync(join(root, ".git"));
return root;
}
describe("worktree-dev-env", () => {
it("cksum is byte-compatible with coreutils cksum(1)", () => {
// `printf '%s' "/tmp/foo" | cksum` → 427878967 8
expect(cksum(Buffer.from("/tmp/foo"))).toBe(427878967);
// `printf '' | cksum` → 4294967295 0
expect(cksum(Buffer.from(""))).toBe(4294967295);
});
it("derives the offset from the path, mod 1000", () => {
expect(offsetForPath("/tmp/foo")).toBe(427878967 % 1000);
});
it("renderer port is 5174 + offset (5173 reserved for the primary checkout)", () => {
expect(rendererPortForPath("/tmp/foo")).toBe(5174 + (427878967 % 1000));
});
it("never reuses 5173 even when the offset is 0", () => {
// POSIX cksum("/tmp/multica-3494") === 1189739000, % 1000 === 0
expect(offsetForPath("/tmp/multica-3494")).toBe(0);
expect(rendererPortForPath("/tmp/multica-3494")).toBe(5174);
expect(rendererPortForPath("/tmp/multica-3494")).not.toBe(5173);
});
it("suffix is '<folder>-<offset>' so it stays recognizable and unique", () => {
expect(appSuffixForPath("/work/MUL-3724_Desktop")).toBe(
`mul-3724-desktop-${offsetForPath("/work/MUL-3724_Desktop")}`,
);
expect(appSuffixForPath("/work/feat/some thing")).toBe(
`some-thing-${offsetForPath("/work/feat/some thing")}`,
);
// empty/non-ascii slug falls back to "worktree", still disambiguated by offset
expect(appSuffixForPath("/work/___")).toBe(`worktree-${offsetForPath("/work/___")}`);
});
it("disambiguates worktrees that share a folder name at different paths", () => {
// Same basename "multica", different parent dirs → different offsets/suffixes,
// so each gets its own single-instance lock.
expect(offsetForPath("/tmp/a/multica")).not.toBe(offsetForPath("/tmp/b/multica"));
expect(appSuffixForPath("/tmp/a/multica")).not.toBe(
appSuffixForPath("/tmp/b/multica"),
);
});
it("auto-isolates a linked worktree (.git is a file)", () => {
const root = tmpRoot("file");
const env = {};
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe(String(rendererPortForPath(root)));
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
});
it("leaves the primary checkout untouched (.git is a dir)", () => {
const root = tmpRoot("dir");
const env = {};
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBeUndefined();
expect(env.DESKTOP_APP_SUFFIX).toBeUndefined();
});
it("respects explicit env overrides", () => {
const root = tmpRoot("file");
const env = { DESKTOP_RENDERER_PORT: "9999", DESKTOP_APP_SUFFIX: "manual" };
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
expect(env.DESKTOP_APP_SUFFIX).toBe("manual");
});
it("fills only the missing knob when one is set explicitly", () => {
const root = tmpRoot("file");
const env = { DESKTOP_RENDERER_PORT: "9999" };
applyWorktreeDevEnv(env, { root });
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
});
});

View File

@@ -11,7 +11,6 @@ import type { Metadata } from "next";
import { docsAlternates } from "@/lib/site";
import { i18n, type Lang } from "@/lib/i18n";
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
import { VideoEmbed } from "@/components/video-embed";
import { docsSlugStaticParams } from "@/lib/static-params";
function asLang(lang: string): Lang {
@@ -36,9 +35,7 @@ export default async function Page(props: {
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<DocsLocaleProvider lang={lang}>
<MDX
components={{ ...defaultMdxComponents, a: LocaleLink, VideoEmbed }}
/>
<MDX components={{ ...defaultMdxComponents, a: LocaleLink }} />
</DocsLocaleProvider>
</DocsBody>
</DocsPage>

View File

@@ -5,7 +5,6 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
import { DocsHero } from "@/components/hero";
import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/components/editorial";
import { VideoEmbed } from "@/components/video-embed";
import { i18n, type Lang } from "@/lib/i18n";
import { homeCopy } from "@/lib/translations";
import { docsAlternates } from "@/lib/site";
@@ -63,7 +62,6 @@ export default async function Page({
NumberedCard,
NumberedSteps,
Step,
VideoEmbed,
}}
/>
</DocsLocaleProvider>

View File

@@ -1,116 +0,0 @@
"use client";
import { useState } from "react";
import { Play } from "lucide-react";
/**
* VideoEmbed — provider-agnostic, click-to-load video embed for docs MDX.
*
* Renders a lightweight facade (no third-party iframe on first paint) and only
* mounts the real player after a user click, so the docs first paint never
* pays for an external player or its trackers. `provider` is abstracted so a
* future English-docs YouTube embed is a one-line MDX change, not a second
* component.
*
* Usage in MDX (registered in the docs MDX components map):
* <VideoEmbed provider="bilibili" id="BV1cv7Y6gEg7" title="Multica 介绍视频" />
*/
type Provider = "bilibili" | "youtube";
interface ProviderConfig {
/** Embeddable player URL. Autoplay is only requested after a user gesture. */
embedUrl: (id: string, autoplay: boolean) => string;
/** Canonical watch page — the load-failure / slow-network fallback link. */
watchUrl: (id: string) => string;
/** Human label for the fallback link ("在 Bilibili 观看"). */
siteName: string;
/** Validates the id shape so a typo renders a notice, not a broken frame. */
isValidId: (id: string) => boolean;
}
const PROVIDERS: Record<Provider, ProviderConfig> = {
bilibili: {
embedUrl: (id, autoplay) =>
`https://player.bilibili.com/player.html?bvid=${id}&autoplay=${autoplay ? 1 : 0}&high_quality=1&danmaku=0`,
watchUrl: (id) => `https://www.bilibili.com/video/${id}/`,
siteName: "Bilibili",
isValidId: (id) => /^BV[0-9A-Za-z]+$/.test(id),
},
// Reserved for a future English-docs YouTube embed. Not wired into any page
// yet, but kept here so the second provider is config, not a new component.
youtube: {
embedUrl: (id, autoplay) =>
`https://www.youtube-nocookie.com/embed/${id}?autoplay=${autoplay ? 1 : 0}&rel=0`,
watchUrl: (id) => `https://www.youtube.com/watch?v=${id}`,
siteName: "YouTube",
isValidId: (id) => /^[0-9A-Za-z_-]{11}$/.test(id),
},
};
export function VideoEmbed({
provider = "bilibili",
id,
title,
}: {
provider?: Provider;
id: string;
title?: string;
}) {
const [active, setActive] = useState(false);
const config = PROVIDERS[provider];
// Bad / missing id → a calm inline notice, never a broken or blank iframe.
if (!config || !id || !config.isValidId(id)) {
return (
<div className="not-prose my-7 rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
{title ? `${title}` : ""}
</div>
);
}
const watchUrl = config.watchUrl(id);
const label = title ?? "观看视频";
return (
<figure className="not-prose my-7">
<div className="relative aspect-video w-full overflow-hidden rounded-lg border border-border bg-muted/40">
{active ? (
<iframe
src={config.embedUrl(id, true)}
title={label}
loading="lazy"
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
allowFullScreen
className="absolute inset-0 size-full"
/>
) : (
<button
type="button"
onClick={() => setActive(true)}
aria-label={`播放:${label}`}
className="group absolute inset-0 flex size-full flex-col items-center justify-center gap-3 bg-gradient-to-b from-muted/20 to-muted/60 transition-colors hover:from-muted/30 hover:to-muted/70"
>
<span className="flex size-16 items-center justify-center rounded-full bg-[var(--primary)] text-[var(--primary-foreground)] shadow-lg transition-transform group-hover:scale-105">
<Play className="size-7 translate-x-0.5 fill-current" />
</span>
<span className="px-6 text-center text-sm font-medium text-foreground">
{label}
</span>
</button>
)}
</div>
<figcaption className="mt-2 text-xs text-muted-foreground">
<a
href={watchUrl}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
{config.siteName}
</a>
</figcaption>
</figure>
);
}

View File

@@ -7,8 +7,6 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在同一个 [工作区](/workspaces) 里共同工作。你可以像给同事派活一样,[把一个任务分配给智能体](/assigning-issues) ——由它去执行、汇报进展、在评论里回复你;也可以[打开聊天窗口直接和它对话](/chat),让它帮你起草任务、回答问题、或完成一次性请求。
<VideoEmbed provider="bilibili" id="BV1cv7Y6gEg7" title="Multica 中文介绍视频" />
这一页讲清楚智能体在哪里运行,以及你有哪几种方式开始使用 Multica。
## 智能体在哪里运行

View File

@@ -28,7 +28,6 @@ import type {
CreateRuntimeProfileRequest,
UpdateRuntimeProfileRequest,
InboxItem,
InboxWorkspaceUnread,
IssueSubscriber,
Comment,
CommentTriggerPreview,
@@ -206,8 +205,6 @@ import {
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
EMPTY_CANCEL_TASK_RESPONSE,
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -1478,17 +1475,6 @@ export class ApiClient {
return this.fetch("/api/inbox/unread-count");
}
// Cross-workspace unread summary: one entry per workspace the user belongs
// to that has unread inbox items. Backs the workspace-switcher dot for
// OTHER workspaces. Schema-guarded so a contract drift hides the dot rather
// than crashing the sidebar.
async getInboxUnreadSummary(): Promise<InboxWorkspaceUnread[]> {
const raw = await this.fetch<unknown>("/api/inbox/unread-summary");
return parseWithFallback(raw, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, {
endpoint: "GET /api/inbox/unread-summary",
});
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
}

View File

@@ -5,9 +5,7 @@ import {
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
DuplicateIssueErrorBodySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
EMPTY_USER,
InboxUnreadSummarySchema,
IssueTriggerPreviewSchema,
ListIssuesResponseSchema,
RuntimeHourlyActivityListSchema,
@@ -417,43 +415,3 @@ describe("AppConfigSchema cdn_signed drift", () => {
expect(parsed.cdn_signed).toBe(true);
});
});
describe("InboxUnreadSummarySchema", () => {
const ENDPOINT = { endpoint: "GET /api/inbox/unread-summary" };
it("parses a well-formed summary and tolerates extra fields", () => {
const parsed = parseWithFallback(
[
{ workspace_id: "ws-1", count: 2 },
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
],
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
ENDPOINT,
);
expect(parsed).toEqual([
{ workspace_id: "ws-1", count: 2 },
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
]);
});
it("returns the empty fallback (dot hidden) for a non-array body", () => {
expect(
parseWithFallback({ rows: [] }, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
expect(
parseWithFallback(null, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
});
it("returns the empty fallback when an entry has a wrong-typed count", () => {
expect(
parseWithFallback(
[{ workspace_id: "ws-1", count: "lots" }],
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
ENDPOINT,
),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
});
});

View File

@@ -15,7 +15,6 @@ import type {
CreateBillingCheckoutSessionResponse,
CreateBillingPortalSessionResponse,
GroupedIssuesResponse,
InboxWorkspaceUnread,
ListIssuesResponse,
ListWebhookDeliveriesResponse,
SearchIssuesResponse,
@@ -915,25 +914,6 @@ export const EMPTY_USER: User = {
updated_at: "",
};
// ---------------------------------------------------------------------------
// Cross-workspace unread inbox summary (`/api/inbox/unread-summary` GET).
// One entry per workspace the user belongs to that has unread items; the
// sidebar derives the workspace-switcher dot from it. Lenient per the usual
// rules so a future field addition can't blank the dot — on malformed JSON
// parseWithFallback returns the empty list, which simply hides the dot.
// ---------------------------------------------------------------------------
export const InboxUnreadSummarySchema = z.array(
z
.object({
workspace_id: z.string(),
count: z.number(),
})
.loose(),
);
export const EMPTY_INBOX_UNREAD_SUMMARY: InboxWorkspaceUnread[] = [];
// ---------------------------------------------------------------------------
// Billing schemas (cloud-billing proxy surface)
//

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { InboxItem, InboxWorkspaceUnread } from "../types";
import { deduplicateInboxItems, hasOtherWorkspaceUnread, inboxKeys, unreadWorkspaceIds } from "./queries";
import type { InboxItem } from "../types";
import { deduplicateInboxItems } from "./queries";
function item(overrides: Partial<InboxItem>): InboxItem {
return {
@@ -72,83 +72,3 @@ describe("deduplicateInboxItems", () => {
expect(merged[0]?.details?.comment_id).toBe("comment-2");
});
});
describe("hasOtherWorkspaceUnread", () => {
const summary = (entries: InboxWorkspaceUnread[]) => entries;
it("is true when a workspace other than the active one has unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-2", count: 3 }]),
"ws-1",
),
).toBe(true);
});
it("excludes the active workspace's own unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-1", count: 5 }]),
"ws-1",
),
).toBe(false);
});
it("ignores other workspaces whose count is zero", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-2", count: 0 }]),
"ws-1",
),
).toBe(false);
});
it("is true when at least one non-active workspace has unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([
{ workspace_id: "ws-1", count: 4 },
{ workspace_id: "ws-2", count: 1 },
]),
"ws-1",
),
).toBe(true);
});
it("is false for an empty summary", () => {
expect(hasOtherWorkspaceUnread([], "ws-1")).toBe(false);
});
it("counts every workspace as 'other' when there is no active workspace", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-1", count: 2 }]),
null,
),
).toBe(true);
});
});
describe("unreadWorkspaceIds", () => {
it("collects only workspaces with a non-zero count", () => {
const ids = unreadWorkspaceIds([
{ workspace_id: "ws-1", count: 0 },
{ workspace_id: "ws-2", count: 3 },
{ workspace_id: "ws-3", count: 1 },
]);
expect(ids.has("ws-1")).toBe(false);
expect(ids.has("ws-2")).toBe(true);
expect(ids.has("ws-3")).toBe(true);
expect(ids.size).toBe(2);
});
it("returns an empty set for an empty summary", () => {
expect(unreadWorkspaceIds([]).size).toBe(0);
});
});
describe("inboxKeys.unreadSummary", () => {
it("is a stable account-level key independent of any workspace", () => {
expect(inboxKeys.unreadSummary()).toEqual(["inbox", "unread-summary"]);
});
});

View File

@@ -1,13 +1,10 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { api } from "../api";
import type { InboxItem, InboxWorkspaceUnread } from "../types";
import type { InboxItem } from "../types";
export const inboxKeys = {
all: (wsId: string) => ["inbox", wsId] as const,
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
// Account-level (not workspace-scoped): a single shared cache entry that
// holds unread counts for every workspace the user belongs to.
unreadSummary: () => ["inbox", "unread-summary"] as const,
};
export function inboxListOptions(wsId: string) {
@@ -17,41 +14,6 @@ export function inboxListOptions(wsId: string) {
});
}
/**
* Cross-workspace unread inbox summary. One cache entry shared across all
* workspaces — the data is account-level, so switching workspaces does not
* refetch it; only the derived "is this for another workspace" view changes.
*/
export function inboxUnreadSummaryOptions() {
return queryOptions({
queryKey: inboxKeys.unreadSummary(),
queryFn: () => api.getInboxUnreadSummary(),
});
}
/**
* Whether any workspace OTHER than `currentWsId` has unread inbox items.
* Drives the workspace-switcher dot: the active workspace's own unread is
* already surfaced by the Inbox nav count, so it is excluded here to avoid a
* duplicate signal.
*/
export function hasOtherWorkspaceUnread(
summary: InboxWorkspaceUnread[],
currentWsId: string | null | undefined,
): boolean {
return summary.some((s) => s.workspace_id !== currentWsId && s.count > 0);
}
/**
* Set of workspace ids that have unread inbox items. Lets the workspace
* switcher dropdown mark WHICH workspace a pending message lives in (the
* aggregate switcher dot only says "somewhere else"). Workspaces with a zero
* count are excluded.
*/
export function unreadWorkspaceIds(summary: InboxWorkspaceUnread[]): Set<string> {
return new Set(summary.filter((s) => s.count > 0).map((s) => s.workspace_id));
}
/**
* Unread inbox count for the given workspace, aligned with what the inbox
* list UI renders: archived items excluded, then deduplicated by issue so a

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { onInboxIssueDeleted, onInboxIssueStatusChanged, onInboxSummaryInvalidate } from "./ws-updaters";
import { onInboxIssueDeleted, onInboxIssueStatusChanged } from "./ws-updaters";
import { inboxKeys } from "./queries";
import type { InboxItem } from "../types";
@@ -56,28 +56,6 @@ describe("onInboxIssueDeleted", () => {
});
});
describe("onInboxSummaryInvalidate", () => {
it("invalidates the account-level summary key regardless of active workspace", () => {
const qc = new QueryClient();
const spy = vi.spyOn(qc, "invalidateQueries");
onInboxSummaryInvalidate(qc);
expect(spy).toHaveBeenCalledWith({ queryKey: inboxKeys.unreadSummary() });
});
it("does not disturb a workspace-scoped inbox list cache", () => {
const qc = new QueryClient();
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), [makeItem("i1", "issue-a")]);
onInboxSummaryInvalidate(qc);
// The list cache entry is untouched (different key); only the summary
// query is marked stale.
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))?.[0]?.id).toBe("i1");
});
});
describe("onInboxIssueStatusChanged", () => {
it("updates issue_status only for items referencing the issue", () => {
const qc = new QueryClient();

View File

@@ -41,12 +41,3 @@ export function onInboxIssueDeleted(
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
}
// Refresh the cross-workspace unread summary (workspace-switcher dot). The
// summary spans every workspace, so it is invalidated on ANY inbox event
// regardless of which workspace the event came from — including read/archive
// events from a workspace other than the active one, which the workspace-
// scoped list invalidation cannot reach.
export function onInboxSummaryInvalidate(qc: QueryClient) {
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
}

View File

@@ -103,8 +103,8 @@ describe("useRealtimeSync — ws instance change", () => {
// Should have called invalidateQueries for all workspace-scoped keys
// (15 workspace-scoped + 6 per-issue prefixes + 1 workspaceKeys.list()
// + 1 cross-workspace inbox unread summary = 23 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(23);
// = 22 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(22);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {

View File

@@ -30,7 +30,7 @@ import {
onIssueLabelsChanged,
onIssueMetadataChanged,
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted, onInboxSummaryInvalidate } from "../inbox/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import {
notificationPreferenceOptions,
@@ -230,9 +230,6 @@ export async function handleInboxNew(
): Promise<void> {
const sourceWsId = item.workspace_id;
if (sourceWsId) onInboxNew(qc, sourceWsId, item);
// A new item in ANY workspace can light the workspace-switcher dot, so
// refresh the cross-workspace summary regardless of the active workspace.
onInboxSummaryInvalidate(qc);
// Fire a native OS notification only when the app isn't focused. When
// the user is already looking at Multica, the inbox sidebar's unread
// styling is enough — no need to interrupt with a banner. `desktopAPI`
@@ -323,9 +320,6 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
qc.invalidateQueries({ queryKey: chatKeys.all(wsId) });
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
}
// Cross-workspace, so outside the wsId guard: a reconnect may have missed
// inbox events from any workspace, so re-pull the switcher-dot summary.
onInboxSummaryInvalidate(qc);
// Per-issue caches are keyed without wsId, so the issueKeys.all(wsId)
// prefix above does not reach them. They rely entirely on WS events for
// freshness (staleTime: Infinity), so events missed while disconnected
@@ -400,12 +394,6 @@ export function useRealtimeSync(
inbox: () => {
const wsId = getCurrentWsId();
if (wsId) onInboxInvalidate(qc, wsId);
// inbox:read / inbox:archived / batch events arrive here. They can
// originate from a workspace other than the active one (personal
// events fan out to all the user's connections), so always refresh
// the cross-workspace summary — its dot must clear when another
// workspace's items are read/archived.
onInboxSummaryInvalidate(qc);
},
agent: () => {
const wsId = getCurrentWsId();

View File

@@ -22,17 +22,6 @@ export type InboxItemType =
| "quick_create_done"
| "quick_create_failed";
/**
* One workspace's unread inbox count in the cross-workspace summary
* (`GET /api/inbox/unread-summary`). The sidebar uses this to light a dot on
* the workspace switcher when a workspace OTHER than the active one has
* unread items.
*/
export interface InboxWorkspaceUnread {
workspace_id: string;
count: number;
}
export interface InboxItem {
id: string;
workspace_id: string;

View File

@@ -61,7 +61,7 @@ export type {
} from "./agent";
export { RUNTIME_PROFILE_PROTOCOL_FAMILIES } from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType, InboxWorkspaceUnread } from "./inbox";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
export type { Comment, CommentType, CommentAuthorType, CommentTriggerPreview, CommentTriggerPreviewAgent, CommentTriggerSource, Reaction } from "./comment";
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";

View File

@@ -1,8 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { TaskList } from "@tiptap/extension-list";
import { PatchedListItem, PatchedTaskItem } from "./list-item";
import { PatchedListItem } from "./list-item";
interface JsonNode {
type: string;
@@ -15,12 +14,7 @@ function makeEditor(content: JsonNode) {
document.body.appendChild(element);
return new Editor({
element,
extensions: [
StarterKit.configure({ listItem: false }),
PatchedListItem,
TaskList,
PatchedTaskItem,
],
extensions: [StarterKit.configure({ listItem: false }), PatchedListItem],
content,
});
}
@@ -43,100 +37,26 @@ function listItemTextPos(editor: Editor, index: number): number {
return pos;
}
/**
* Mimic an editor keymap by invoking a bound shortcut directly. We can't drive
* real key events reliably in jsdom, so we resolve the keymap an extension
* registers and call the entry for `key`. The shared list keymap closes over
* `editor` (not `this`), so the rebind only needs a faithful `this`.
*/
function pressShortcut(editor: Editor, extName: string, key: string): boolean {
const ext = editor.extensionManager.extensions.find((e) => e.name === extName);
if (!ext) throw new Error(`${extName} extension not registered`);
/** Mimic the editor's Enter keymap: invoke the bound Enter shortcut directly. */
function pressEnter(editor: Editor): boolean {
const listItemExt = editor.extensionManager.extensions.find(
(e) => e.name === "listItem",
);
if (!listItemExt) throw new Error("listItem extension not registered");
const shortcuts = (
ext.config.addKeyboardShortcuts as
listItemExt.config.addKeyboardShortcuts as
| (() => Record<string, () => boolean>)
| undefined
)?.bind({
editor,
name: extName,
options: ext.options,
type: editor.schema.nodes[extName],
storage: ext.storage,
name: "listItem",
options: listItemExt.options,
type: editor.schema.nodes.listItem,
storage: listItemExt.storage,
} as never)();
const fn = shortcuts?.[key];
if (!fn) throw new Error(`${key} shortcut not bound on ${extName}`);
return fn();
}
/** Mimic the editor's Enter keymap: invoke the bound Enter shortcut directly. */
function pressEnter(editor: Editor): boolean {
return pressShortcut(editor, "listItem", "Enter");
}
/** Indented bullet outline of the doc — nesting depth, item text only. */
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
const ITEM_TYPES = ["listItem", "taskItem"];
function outline(json: JsonNode): string {
const lines: string[] = [];
function rec(node: JsonNode, depth: number) {
for (const child of node.content ?? []) {
if (LIST_TYPES.includes(child.type)) {
rec(child, depth + 1);
} else if (ITEM_TYPES.includes(child.type)) {
const text = child.content?.[0]?.content?.[0]?.text ?? "";
lines.push(" ".repeat(Math.max(0, depth - 1)) + "- " + text);
for (const gc of child.content ?? []) {
if (LIST_TYPES.includes(gc.type)) rec(gc, depth + 1);
}
} else {
rec(child, depth);
}
}
}
rec(json, 0);
return lines.join("\n");
}
/** Inside-paragraph position of the index-th item of `typeName` (doc order). */
function itemPos(editor: Editor, typeName: string, index: number): number {
const positions: number[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name === typeName) positions.push(pos + 2);
return true;
});
const pos = positions[index];
if (pos === undefined) throw new Error(`no ${typeName} at index ${index}`);
return pos;
}
/** Select from the start of item `fromIdx`'s text to the end of item `toIdx`'s. */
function selectItemRange(
editor: Editor,
typeName: string,
fromIdx: number,
toIdx: number,
itemLen = 3,
) {
editor.commands.setTextSelection({
from: itemPos(editor, typeName, fromIdx),
to: itemPos(editor, typeName, toIdx) + itemLen,
});
}
/** A flat three-item list ("aaa","bbb","ccc") of the given node types. */
function flatList(listType: string, itemType: string): JsonNode {
return {
type: "doc",
content: [
{
type: listType,
content: ["aaa", "bbb", "ccc"].map((t) => ({
type: itemType,
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
})),
},
],
};
const enter = shortcuts?.Enter;
if (!enter) throw new Error("Enter shortcut not bound");
return enter();
}
describe("PatchedListItem Enter behaviour", () => {
@@ -259,171 +179,3 @@ describe("PatchedListItem Enter behaviour", () => {
expect(outerText).toBe("outer");
});
});
describe("PatchedListItem Tab indent (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
const pressTab = (e: Editor) => pressShortcut(e, "listItem", "Tab");
it("leaves the doc unchanged but swallows Tab in the first item (stay put, do not escape focus)", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
editor.commands.setTextSelection(itemPos(editor, "listItem", 0));
// Nothing to nest under, so the structural indent is a no-op — but the caret
// is in a list, so Tab is swallowed (true) instead of leaking to the browser
// and moving focus to other controls. The doc must be untouched.
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
it("indents a single non-first item under its predecessor", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
editor.commands.setTextSelection(itemPos(editor, "listItem", 1));
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n- ccc",
);
});
it("indents a whole-list selection (items 1..3): first stays, 2 and 3 nest", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
expect(pressTab(editor)).toBe(true);
// The reported bug: this used to be a no-op because range.startIndex === 0.
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
it("indents a mid-list selection (items 2..3) under the first", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 1, 2);
expect(pressTab(editor)).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
it("returns false cleanly when the selection is not in a list (C2: no range)", () => {
editor = makeEditor({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "plain" }] },
],
});
editor.commands.setTextSelection(3);
expect(pressTab(editor)).toBe(false);
});
it("indents in a single, undoable transaction (C3)", () => {
editor = makeEditor(flatList("bulletList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
const view = editor.view;
const original = view.dispatch.bind(view);
let dispatches = 0;
view.dispatch = (tr) => {
dispatches += 1;
original(tr);
};
try {
expect(pressTab(editor)).toBe(true);
} finally {
view.dispatch = original;
}
// One dispatch -> one transaction -> one undo step.
expect(dispatches).toBe(1);
editor.commands.undo();
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
});
describe("Tab indent across list types (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
it("indents an ordered-list whole selection (not only unordered)", () => {
editor = makeEditor(flatList("orderedList", "listItem"));
selectItemRange(editor, "listItem", 0, 2);
expect(pressShortcut(editor, "listItem", "Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
expect((editor.getJSON() as JsonNode).content?.[0]?.type).toBe(
"orderedList",
);
});
it("indents a task-list whole selection via the taskItem keymap", () => {
editor = makeEditor(flatList("taskList", "taskItem"));
selectItemRange(editor, "taskItem", 0, 2);
expect(pressShortcut(editor, "taskItem", "Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe(
"- aaa\n - bbb\n - ccc",
);
});
});
describe("Shift-Tab dedent regression (MUL-3697)", () => {
let editor: Editor | undefined;
afterEach(() => {
editor?.destroy();
editor = undefined;
document.body.innerHTML = "";
});
it("lifts a multi-item nested selection back to the top level (unchanged)", () => {
editor = makeEditor({
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "aaa" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "bbb" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "ccc" }],
},
],
},
],
},
],
},
],
},
],
});
// Select the two nested items (bbb, ccc) and dedent.
selectItemRange(editor, "listItem", 1, 2);
expect(pressShortcut(editor, "listItem", "Shift-Tab")).toBe(true);
expect(outline(editor.getJSON() as JsonNode)).toBe("- aaa\n- bbb\n- ccc");
});
});

View File

@@ -1,65 +1,5 @@
import { type Editor, InputRule } from "@tiptap/core";
import { ListItem, TaskItem } from "@tiptap/extension-list";
import { sinkListItem as pmSinkListItem } from "@tiptap/pm/schema-list";
import { type Command, TextSelection } from "@tiptap/pm/state";
import type { NodeType } from "@tiptap/pm/model";
/**
* Tab indent that also works for a multi-item selection whose first item is the
* first child of its (sub)list.
*
* Stock `sinkListItem` (prosemirror-schema-list) bails — returns false without
* dispatching — whenever `range.startIndex === 0`, because the first item has no
* preceding sibling to nest under. That is correct for a collapsed cursor in the
* first item, but it also kills the natural "select the whole list from the top
* and press Tab" gesture: the command sees the first item at index 0 and does
* nothing (MUL-3697).
*
* The structurally-correct behaviour in a nested-list model (matching Notion /
* GitHub) is: keep the first selected item as an anchor and sink the rest under
* it. We get that by re-running the *stock* command on a selection narrowed to
* start inside the SECOND selected item (so its `startIndex` becomes 1) while
* keeping the original `$to`. The narrowed selection is computed on a derived
* state and never dispatched on its own, so the whole operation is a single
* dispatch / single undo step.
*
* Shift-Tab / `liftListItem` has no equivalent limitation (it handles ranges and
* the first-item case correctly), so only Tab needs this wrapper.
*/
function sinkListItemRange(itemType: NodeType): Command {
return (state, dispatch) => {
// Normal path — cursor or range not starting at the first item. This also
// covers the genuine no-op for a collapsed cursor in the first item: stock
// returns false and the fallback guards below also return false.
if (pmSinkListItem(itemType)(state, dispatch)) return true;
const { $from, $to } = state.selection;
const range = $from.blockRange(
$to,
(node) => node.childCount > 0 && node.firstChild?.type === itemType,
);
// Clean false (no dispatch) when the fallback does not apply: no list range,
// the range does not start at the first item, fewer than two items are
// selected, or the item type does not match (C2).
if (!range) return false;
if (range.startIndex !== 0) return false;
if (range.endIndex - range.startIndex < 2) return false;
if (range.parent.child(range.startIndex).type !== itemType) return false;
// Move $from into the second selected item, keep $to in the last selected
// item (C1 — do not collapse onto the second item). +2 steps over the
// <listItem> + <paragraph> open tokens into inline content; `between` snaps
// to a valid text position if the item does not start with a paragraph.
const secondItemStart =
range.start + range.parent.child(range.startIndex).nodeSize + 2;
const narrowed = state.apply(
state.tr.setSelection(
TextSelection.between(state.doc.resolve(secondItemStart), $to),
),
);
return pmSinkListItem(itemType)(narrowed, dispatch);
};
}
/**
* Shared list keymap with proper "double-Enter exits list" behaviour.
@@ -79,16 +19,7 @@ function sinkListItemRange(itemType: NodeType): Command {
* empty items are unaffected because `splitListItem` handles them correctly
* and returns true.
*
* Tab indents the item(s) — see `sinkListItemRange` for the multi-item
* first-item handling. Shift-Tab dedents via the stock command.
*
* Whenever the caret is inside a list item, Tab is the list's indent control
* and must be swallowed even when the structural indent is a no-op (first child
* with nothing to nest under, or already at max depth). Otherwise the unhandled
* Tab falls through to the browser and moves focus out of the editor to the
* next control. So the return value tracks "is the caret in this list?"
* (`editor.isActive(name)`), NOT "did the indent move anything?": indent
* best-effort, then swallow while in a list, fall through (focus nav) when not.
* Tab / Shift-Tab indent / dedent the item.
*/
function listItemKeymap(editor: Editor, name: string) {
return {
@@ -97,14 +28,7 @@ function listItemKeymap(editor: Editor, name: string) {
() => commands.splitListItem(name),
() => commands.liftListItem(name),
]),
Tab: () => {
const itemType = editor.schema.nodes[name];
if (!itemType) return false;
sinkListItemRange(itemType)(editor.state, (tr) =>
editor.view.dispatch(tr),
);
return editor.isActive(name);
},
Tab: () => editor.commands.sinkListItem(name),
"Shift-Tab": () => editor.commands.liftListItem(name),
};
}

View File

@@ -3,14 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@multica/core/api";
import { AppSidebar } from "./app-sidebar";
const { detail, deletePin, navigation, pins, summary, workspaces } = vi.hoisted(() => ({
const { detail, deletePin, navigation, pins } = vi.hoisted(() => ({
detail: { current: { isPending: false, isError: false, data: null as unknown, error: null as unknown } },
deletePin: vi.fn(),
navigation: { current: { pathname: "/acme/issues" } },
summary: { current: [] as { workspace_id: string; count: number }[] },
workspaces: {
current: [] as { id: string; name: string; slug: string; avatar_url: string | null }[],
},
pins: {
current: [
{
@@ -66,7 +62,7 @@ vi.mock("@multica/ui/components/ui/sidebar", () => ({
}));
vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: () => null,
DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <>{children}</>,
@@ -126,17 +122,7 @@ vi.mock("@multica/core/api", async (importOriginal) => {
},
};
});
vi.mock("@multica/core/inbox/queries", () => ({
deduplicateInboxItems: (items: unknown[]) => items,
inboxKeys: { list: () => ["inbox"], unreadSummary: () => ["inbox", "unread-summary"] },
inboxUnreadSummaryOptions: () => ({ queryKey: ["inbox", "unread-summary"] }),
hasOtherWorkspaceUnread: (
entries: { workspace_id: string; count: number }[],
currentWsId: string | null,
) => entries.some((s) => s.workspace_id !== currentWsId && s.count > 0),
unreadWorkspaceIds: (entries: { workspace_id: string; count: number }[]) =>
new Set(entries.filter((s) => s.count > 0).map((s) => s.workspace_id)),
}));
vi.mock("@multica/core/inbox/queries", () => ({ deduplicateInboxItems: (items: unknown[]) => items, inboxKeys: { list: () => ["inbox"] } }));
vi.mock("@multica/core/issues/queries", () => ({ issueDetailOptions: () => ({ queryKey: ["issue"] }) }));
vi.mock("@multica/core/issues/stores/create-mode-store", () => ({
useCreateModeStore: { getState: () => ({ lastMode: "agent" }) },
@@ -159,8 +145,6 @@ vi.mock("@tanstack/react-query", async (importOriginal) => ({
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
if (queryKey[0] === "pins") return { data: pins.current };
if (queryKey[0] === "issue") return detail.current;
if (queryKey[0] === "inbox" && queryKey[1] === "unread-summary") return { data: summary.current };
if (queryKey[0] === "workspaces") return { data: workspaces.current };
return { data: [] };
},
useQueryClient: () => ({ fetchQuery: vi.fn(), invalidateQueries: vi.fn() }),
@@ -171,8 +155,6 @@ describe("PinRow", () => {
deletePin.mockReset();
navigation.current.pathname = "/acme/issues";
detail.current = { isPending: false, isError: false, data: null, error: null };
summary.current = [];
workspaces.current = [];
});
it("unpins missing details", async () => {
@@ -212,70 +194,3 @@ describe("PinRow", () => {
expect(container.querySelector('button[data-href="/acme/issues"]')).not.toHaveAttribute("data-active");
});
});
describe("workspace-switcher unread dot", () => {
beforeEach(() => {
summary.current = [];
workspaces.current = [];
});
// The aggregate switcher dot is the only `.ring-sidebar` span in the tree
// (DraftDot is null when there's no draft, and there are no invitations).
const dot = (container: HTMLElement) => container.querySelector("span.bg-brand.ring-sidebar");
it("shows a dot when another workspace has unread inbox items", () => {
summary.current = [{ workspace_id: "ws-2", count: 3 }];
const { container } = render(<AppSidebar />);
expect(dot(container)).not.toBeNull();
});
it("does not show a dot when only the active workspace has unread", () => {
// Active workspace is ws-1 (see useCurrentWorkspace mock).
summary.current = [{ workspace_id: "ws-1", count: 3 }];
const { container } = render(<AppSidebar />);
expect(dot(container)).toBeNull();
});
it("does not show a dot when no workspace has unread", () => {
summary.current = [];
const { container } = render(<AppSidebar />);
expect(dot(container)).toBeNull();
});
});
describe("workspace-switcher dropdown per-workspace dot", () => {
beforeEach(() => {
summary.current = [];
// Active workspace is ws-1 (see useCurrentWorkspace mock); "Other" is ws-2.
workspaces.current = [
{ id: "ws-1", name: "Active WS", slug: "active", avatar_url: null },
{ id: "ws-2", name: "Other WS", slug: "other", avatar_url: null },
];
});
// Row dots are brand dots WITHOUT the aggregate avatar dot's `ring-sidebar`.
const rowDots = (container: HTMLElement) =>
container.querySelectorAll("span.bg-brand:not(.ring-sidebar)");
it("dots the specific other workspace that has unread", () => {
summary.current = [{ workspace_id: "ws-2", count: 3 }];
const { container } = render(<AppSidebar />);
// Exactly one row dot, sitting right after the "Other WS" name; the active
// row shows the check, not a dot.
expect(rowDots(container)).toHaveLength(1);
expect(screen.getByText("Other WS").nextElementSibling?.className).toContain("bg-brand");
expect(screen.getByText("Active WS").nextElementSibling?.className ?? "").not.toContain("bg-brand");
});
it("does not dot a workspace whose unread count is zero", () => {
summary.current = [{ workspace_id: "ws-2", count: 0 }];
const { container } = render(<AppSidebar />);
expect(rowDots(container)).toHaveLength(0);
});
it("never dots the active workspace even when it has unread", () => {
summary.current = [{ workspace_id: "ws-1", count: 5 }];
const { container } = render(<AppSidebar />);
expect(rowDots(container)).toHaveLength(0);
});
});

View File

@@ -70,7 +70,7 @@ import { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/pat
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { resolvePublicFileUrl } from "@multica/core/workspace/avatar-url";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems, inboxUnreadSummaryOptions, hasOtherWorkspaceUnread, unreadWorkspaceIds } from "@multica/core/inbox/queries";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api, ApiError } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useConfigStore } from "@multica/core/config";
@@ -101,7 +101,6 @@ const EMPTY_PINS: PinnedItem[] = [];
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
const EMPTY_INBOX_SUMMARY: Awaited<ReturnType<typeof api.getInboxUnreadSummary>> = [];
// Nav items reference WorkspacePaths method names so they can be resolved
// against the current workspace slug at render time (see AppSidebar body).
@@ -365,20 +364,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
// Cross-workspace unread summary backs the workspace-switcher dot. One
// shared cache entry across workspaces; gated on an active workspace since
// the endpoint resolves through the workspace-member middleware.
const { data: unreadSummary = EMPTY_INBOX_SUMMARY } = useQuery({
...inboxUnreadSummaryOptions(),
enabled: !!wsId,
});
const otherWorkspaceUnread = React.useMemo(
() => hasOtherWorkspaceUnread(unreadSummary, wsId),
[unreadSummary, wsId],
);
// Which workspaces have unread, so the switcher dropdown can point at the
// specific one(s) rather than just the aggregate avatar dot.
const unreadWsIds = React.useMemo(() => unreadWorkspaceIds(unreadSummary), [unreadSummary]);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = EMPTY_PINS } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
@@ -501,11 +486,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarMenuButton>
<span className="relative">
<WorkspaceAvatar name={workspace?.name ?? "M"} avatarUrl={workspace?.avatar_url} size="sm" />
{/* Shared brand dot: a pending invitation OR another
workspace with unread inbox items. The active
workspace's own unread stays on the Inbox nav count
(below), so it is deliberately excluded here. */}
{(myInvitations.length > 0 || otherWorkspaceUnread) && (
{myInvitations.length > 0 && (
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-brand ring-1 ring-sidebar" />
)}
</span>
@@ -552,14 +533,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
>
<WorkspaceAvatar name={ws.name} avatarUrl={ws.avatar_url} size="sm" />
<span className="flex-1 truncate">{ws.name}</span>
{/* Points at the specific workspace holding unread
inbox items. Sits in the same right-edge slot as the
active-workspace check; the active workspace is
excluded (its unread is the Inbox nav count), so dot
and check never collide on one row. */}
{ws.id !== workspace?.id && unreadWsIds.has(ws.id) && (
<span className="size-2 rounded-full bg-brand" />
)}
{ws.id === workspace?.id && (
<Check className="h-3.5 w-3.5 text-primary" />
)}

View File

@@ -793,127 +793,6 @@ func TestInboxThroughRouter(t *testing.T) {
}
}
func TestInboxUnreadSummaryThroughRouter(t *testing.T) {
ctx := context.Background()
// Seed one unread inbox item for the test user in the test workspace.
var itemID string
if err := testPool.QueryRow(ctx, `
INSERT INTO inbox_item (workspace_id, recipient_type, recipient_id, type, title)
VALUES ($1, 'member', $2, 'issue_assigned', 'Summary fixture')
RETURNING id
`, testWorkspaceID, testUserID).Scan(&itemID); err != nil {
t.Fatalf("failed to seed inbox item: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM inbox_item WHERE id = $1`, itemID)
})
resp := authRequest(t, "GET", "/api/inbox/unread-summary", nil)
if resp.StatusCode != 200 {
t.Fatalf("UnreadInboxSummary: expected 200, got %d", resp.StatusCode)
}
var summary []struct {
WorkspaceID string `json:"workspace_id"`
Count int64 `json:"count"`
}
readJSON(t, resp, &summary)
var found bool
for _, s := range summary {
if s.WorkspaceID == testWorkspaceID {
found = true
if s.Count < 1 {
t.Fatalf("expected unread count >= 1 for test workspace, got %d", s.Count)
}
}
}
if !found {
t.Fatalf("expected test workspace %s in unread summary, got %+v", testWorkspaceID, summary)
}
// After marking it read, the workspace should drop out of the summary.
if _, err := testPool.Exec(ctx, `UPDATE inbox_item SET read = true WHERE id = $1`, itemID); err != nil {
t.Fatalf("failed to mark item read: %v", err)
}
resp = authRequest(t, "GET", "/api/inbox/unread-summary", nil)
if resp.StatusCode != 200 {
t.Fatalf("UnreadInboxSummary (after read): expected 200, got %d", resp.StatusCode)
}
readJSON(t, resp, &summary)
for _, s := range summary {
if s.WorkspaceID == testWorkspaceID && s.Count > 0 {
t.Fatalf("expected no unread for test workspace after read, got count %d", s.Count)
}
}
}
// An issue's inbox notifications are deduplicated per issue: opening the issue
// marks only the NEWEST item read, leaving older siblings unread. The summary
// must mirror the inbox UI (issue is read when its newest item is read), so a
// read-newest / unread-older issue must NOT light the switcher dot (MUL-3695).
func TestInboxUnreadSummaryDedupesByIssue(t *testing.T) {
ctx := context.Background()
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, creator_type, creator_id)
VALUES ($1, 'Dedup fixture', 'member', $2)
RETURNING id
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
t.Fatalf("failed to seed issue: %v", err)
}
// Deleting the issue cascades to its inbox_item rows (FK ON DELETE CASCADE).
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
// Older sibling stays unread; newer sibling is read (the one "opening the
// issue" would have marked read).
if _, err := testPool.Exec(ctx, `
INSERT INTO inbox_item (workspace_id, recipient_type, recipient_id, type, title, issue_id, read, created_at)
VALUES
($1, 'member', $2, 'new_comment', 'older', $3, false, now() - interval '1 hour'),
($1, 'member', $2, 'status_changed', 'newer', $3, true, now())
`, testWorkspaceID, testUserID, issueID); err != nil {
t.Fatalf("failed to seed inbox items: %v", err)
}
resp := authRequest(t, "GET", "/api/inbox/unread-summary", nil)
if resp.StatusCode != 200 {
t.Fatalf("UnreadInboxSummary: expected 200, got %d", resp.StatusCode)
}
var summary []struct {
WorkspaceID string `json:"workspace_id"`
Count int64 `json:"count"`
}
readJSON(t, resp, &summary)
for _, s := range summary {
if s.WorkspaceID == testWorkspaceID && s.Count > 0 {
t.Fatalf("issue whose newest item is read must not count as unread, got count %d", s.Count)
}
}
// Now mark the newest item unread again → the issue becomes unread and the
// workspace reappears in the summary.
if _, err := testPool.Exec(ctx, `
UPDATE inbox_item SET read = false WHERE issue_id = $1 AND title = 'newer'
`, issueID); err != nil {
t.Fatalf("failed to flip newest item unread: %v", err)
}
resp = authRequest(t, "GET", "/api/inbox/unread-summary", nil)
readJSON(t, resp, &summary)
var found bool
for _, s := range summary {
if s.WorkspaceID == testWorkspaceID && s.Count >= 1 {
found = true
}
}
if !found {
t.Fatalf("expected workspace in summary once newest item is unread, got %+v", summary)
}
}
// ---- 404 for non-existent resources ----
func TestNonExistentResources(t *testing.T) {

View File

@@ -1066,9 +1066,6 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Route("/api/inbox", func(r chi.Router) {
r.Get("/", h.ListInbox)
r.Get("/unread-count", h.CountUnreadInbox)
// Cross-workspace unread summary: account-level, keyed on the
// user. Backs the workspace-switcher dot for OTHER workspaces.
r.Get("/unread-summary", h.UnreadInboxSummary)
r.Post("/mark-all-read", h.MarkAllInboxRead)
r.Post("/archive-all", h.ArchiveAllInbox)
r.Post("/archive-all-read", h.ArchiveAllReadInbox)

View File

@@ -10,7 +10,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/go-resty/resty/v2 v2.17.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@@ -62,7 +61,6 @@ require (
github.com/prometheus/procfs v0.16.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.35.0 // indirect
)

View File

@@ -57,8 +57,6 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -139,16 +137,12 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -887,21 +887,6 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
for _, c := range extractClosingIdentifiers(p.PullRequest.Title, p.PullRequest.Body) {
closingIdents[c] = struct{}{}
}
// qualifyingIdents are the identifiers that genuinely tie this PR to an
// issue: a title prefix, a branch-name reference, or a body closing
// keyword. Any identifier that is linked but NOT in this set was matched
// only by a bare mention in the PR body ("Related MUL-1", "Follow up in
// MUL-1"). Those links are still recorded (auto-link stays generous so
// close_intent can be tracked across edits) but are flagged
// reference_only and hidden from the issue's PR list — a passing mention
// should not surface the PR as a working PR for that issue (MUL-3739).
qualifyingIdents := map[string]struct{}{}
for _, id := range extractIdentifiers(p.PullRequest.Title, p.PullRequest.Head.Ref) {
qualifyingIdents[id] = struct{}{}
}
for c := range closingIdents {
qualifyingIdents[c] = struct{}{}
}
// close_intent should follow the PR title/body while the PR is still
// editable before its terminal close event. Once GitHub has delivered
// a terminal event, later edit/synchronize webhooks must not rewrite
@@ -925,13 +910,10 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
}
_, declared := closingIdents[id]
closeIntent := declared && !preserveCloseIntent
_, qualifies := qualifyingIdents[id]
referenceOnly := !qualifies
if err := h.Queries.LinkIssueToPullRequest(ctx, db.LinkIssueToPullRequestParams{
IssueID: issue.ID,
PullRequestID: pr.ID,
CloseIntent: closeIntent,
ReferenceOnly: referenceOnly,
PreserveCloseIntent: preserveCloseIntent,
LinkedByType: strToText("system"),
LinkedByID: pgtype.UUID{},

View File

@@ -852,36 +852,14 @@ func TestWebhook_MergedPR_OnlyClosesIdentifiersWithClosingKeyword(t *testing.T)
)
fireBareWebhook(t, secret, installationID, 1, title, body, "fix/login")
// The closing-keyword issue (also a bare title prefix) is a genuine target,
// so it shows in the PR list. The follow-up / unblocks issues are matched
// only by a bare body mention — auto-link still records the row (generous),
// but the link is reference_only and excluded from the issue's PR list
// (MUL-3739).
listed, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(closes.ID))
if err != nil {
t.Fatalf("ListPullRequestsByIssue(%s): %v", closes.Identifier, err)
}
if len(listed) != 1 {
t.Errorf("expected %s (closing keyword) to show in the PR list, got %d rows", closes.Identifier, len(listed))
}
for _, issue := range []IssueResponse{followUp, unblocks} {
listed, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(issue.ID))
// All three should be linked — auto-link layer is intentionally generous.
for _, issue := range []IssueResponse{closes, followUp, unblocks} {
linked, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(issue.ID))
if err != nil {
t.Fatalf("ListPullRequestsByIssue(%s): %v", issue.Identifier, err)
}
if len(listed) != 0 {
t.Errorf("expected %s (bare body mention) to be hidden from the PR list, got %d rows", issue.Identifier, len(listed))
}
// The link row still exists — flagged reference_only, not deleted — so
// close_intent stays trackable across later edits.
var refOnly bool
if err := testPool.QueryRow(ctx,
`SELECT reference_only FROM issue_pull_request WHERE issue_id = $1`, issue.ID,
).Scan(&refOnly); err != nil {
t.Fatalf("query reference_only(%s): %v", issue.Identifier, err)
}
if !refOnly {
t.Errorf("expected %s link to be reference_only, got false", issue.Identifier)
if len(linked) != 1 {
t.Errorf("expected %s to be linked to the PR, got %d link rows", issue.Identifier, len(linked))
}
}
@@ -1271,157 +1249,6 @@ func TestWebhook_LinkOnlySiblingMergeAfterCloseKeywordPR(t *testing.T) {
}
}
// TestWebhook_BareBodyMentionHiddenFromPRList is the regression guard for
// MUL-3739: a PR that only mentions an issue identifier in its body (no closing
// keyword, no title prefix, no branch reference) must not appear in that
// issue's PR list. Editing the body to add/remove a closing keyword flips the
// PR's visibility, because reference_only follows the live title/body parse
// while the PR is still open.
func TestWebhook_BareBodyMentionHiddenFromPRList(t *testing.T) {
if testHandler == nil {
t.Skip("handler test fixture not initialized (no DB?)")
}
ctx := context.Background()
secret := "bare-mention-secret"
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "mentioned in passing",
"status": "in_progress",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String())
}
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
const installationID int64 = 30264006
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
WorkspaceID: parseUUID(testWorkspaceID),
InstallationID: installationID,
AccountLogin: "bare-mention-acct",
AccountType: "User",
}); err != nil {
t.Fatalf("CreateGitHubInstallation: %v", err)
}
listLen := func() int {
t.Helper()
rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("ListPullRequestsByIssue: %v", err)
}
return len(rows)
}
// 1) Opened with only a bare body mention → hidden from the PR list.
firePRWebhook(t, secret, installationID, 1, "Unrelated cleanup", "Context for reviewers: see "+created.Identifier, "feat/cleanup", "opened")
if n := listLen(); n != 0 {
t.Errorf("bare body mention should be hidden from PR list, got %d rows", n)
}
// 2) Edited to declare closing intent → now a genuine target, shown.
firePRWebhook(t, secret, installationID, 1, "Unrelated cleanup", "Closes "+created.Identifier, "feat/cleanup", "edited")
if n := listLen(); n != 1 {
t.Errorf("after adding a closing keyword the PR should show, got %d rows", n)
}
// 3) Edited back to a bare mention → hidden again.
firePRWebhook(t, secret, installationID, 1, "Unrelated cleanup", "Reverting: just referencing "+created.Identifier, "feat/cleanup", "edited")
if n := listLen(); n != 0 {
t.Errorf("after removing the closing keyword the PR should be hidden again, got %d rows", n)
}
}
// TestWebhook_HiddenBodyMentionDoesNotBlockAutoAdvance guards the P1 the code
// review flagged on PR #4611: a reference_only link (a PR that only mentions the
// issue in its body) is hidden from the PR list, so it must not silently gate
// auto-advance either. Here PR B stays open with a bare body mention while PR A
// merges with a closing keyword — the issue must still reach `done`, because
// the invisible PR B is excluded from the close aggregate's open_count.
func TestWebhook_HiddenBodyMentionDoesNotBlockAutoAdvance(t *testing.T) {
if testHandler == nil {
t.Skip("handler test fixture not initialized (no DB?)")
}
ctx := context.Background()
secret := "hidden-mention-gate-secret"
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "closing PR plus invisible mention",
"status": "in_progress",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String())
}
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
const installationID int64 = 30264007
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
WorkspaceID: parseUUID(testWorkspaceID),
InstallationID: installationID,
AccountLogin: "hidden-mention-gate-acct",
AccountType: "User",
}); err != nil {
t.Fatalf("CreateGitHubInstallation: %v", err)
}
// PR B opens with only a bare body mention → reference_only, hidden, open.
firePRWebhook(t, secret, installationID, 1, "Unrelated cleanup", "Context: see "+created.Identifier, "feat/cleanup", "opened")
// PR A opens with a closing keyword → genuine closing PR.
firePRWebhook(t, secret, installationID, 2, "Primary work", "Closes "+created.Identifier, "feat/primary", "opened")
// Only PR A shows in the list; PR B is hidden.
listed, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("ListPullRequestsByIssue: %v", err)
}
if len(listed) != 1 {
t.Fatalf("expected only the closing PR to show, got %d rows", len(listed))
}
// Sanity: issue is still in_progress (PR A open).
got, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("GetIssue after open: %v", err)
}
if got.Status != "in_progress" {
t.Fatalf("after both PRs opened: status = %q, want in_progress", got.Status)
}
// PR A merges. PR B is still open but reference_only, so it must NOT count
// toward open_count — the issue should advance to done.
firePRWebhook(t, secret, installationID, 2, "Primary work", "Closes "+created.Identifier, "feat/primary", "merged")
got, err = testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("GetIssue after merge: %v", err)
}
if got.Status != "done" {
t.Errorf("closing PR merged while only a hidden body-only mention is open: status = %q, want done", got.Status)
}
}
// ── CI / mergeable_state tests ─────────────────────────────────────────────
func TestDerivePRMergeableState(t *testing.T) {

View File

@@ -195,42 +195,6 @@ func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]int64{"count": count})
}
// InboxWorkspaceUnreadResponse is one workspace's unread inbox count in the
// cross-workspace summary.
type InboxWorkspaceUnreadResponse struct {
WorkspaceID string `json:"workspace_id"`
Count int64 `json:"count"`
}
// UnreadInboxSummary returns per-workspace unread inbox counts across every
// workspace the user belongs to. The sidebar uses it to light a dot on the
// workspace switcher when a workspace OTHER than the active one has unread
// items, without fetching each workspace's full inbox list. It is
// account-level by nature: it ignores the active workspace and keys only on
// the authenticated user.
func (h *Handler) UnreadInboxSummary(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
rows, err := h.Queries.CountUnreadInboxByWorkspace(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to summarize unread inbox")
return
}
resp := make([]InboxWorkspaceUnreadResponse, len(rows))
for i, row := range rows {
resp[i] = InboxWorkspaceUnreadResponse{
WorkspaceID: uuidToString(row.WorkspaceID),
Count: row.Count,
}
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) MarkAllInboxRead(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {

View File

@@ -23,13 +23,11 @@ same gate and they read different fields.
**Linking** scans the PR **title, body, OR branch** for a routable issue key
(`PREFIX-NUMBER`, e.g. `MUL-2759`). Each match writes an issue ↔ PR link row.
This is the link that `multica issue pull-requests` reads back — but see the
reference-only rule below: a key that appears **only** as a bare mention in the
body is linked yet hidden from that list.
This is the link that `multica issue pull-requests` reads back.
```text
MUL-2759: add built-in issue working skill # title prefix → links, shown
agent/matt/mul-2759-working-on-issues # branch ref → links, shown
MUL-2759: add built-in issue working skill # title prefix → links
agent/matt/mul-2759-working-on-issues # branch ref → links
```
**Close intent** is stricter and is a separate scan over **title or body only —
@@ -50,20 +48,6 @@ close the issue on merge. A closing keyword immediately adjacent to the issue ke
records close intent; on merge, that close intent can move the linked issue to
`done`.
**Reference-only links (hidden from the PR list).** A key that appears **only**
as a bare mention in the body — no closing keyword, and not in the title or
branch — still writes a link row, but the row is flagged `reference_only` and
**excluded from `multica issue pull-requests`** (and the issue's right-side PR
list in the UI). This keeps passing mentions like `Related MUL-2759` or
`Follow up in MUL-2759` from surfacing an unrelated PR as if it were working on
that issue. To make a PR show up for an issue, put the key in the title, the
branch, or after a closing keyword in the body — not as a loose body reference.
```text
Closes MUL-2759 in the body # links and shown
Related to MUL-2759 in the body (no title/branch) # links but reference_only → hidden
```
### Default for code-changing issue work
When an issue run changes code in a checked-out GitHub repo, the default handoff
@@ -114,9 +98,7 @@ So "is it merged?" is `state == "merged"` (or `merged_at != null`); "is it still
a draft?" is `state == "draft"`; CI status is `checks_conclusion`.
If the command returns no linked PRs after a PR was opened, the link scanner did
not observe a routable issue key in the PR title/body/branch — or the only match
was a bare body mention, which links as `reference_only` and is hidden from this
list (see the reference-only rule above).
not observe a routable issue key in the PR title/body/branch.
## Metadata: high-signal keys only

View File

@@ -72,19 +72,6 @@ Every `PREFIX-NUMBER` mention in **title, body, or branch** resolves to an issue
in the workspace and writes a link row (`LinkIssueToPullRequest`, ~`github.go:762`).
This is what `multica issue pull-requests` later reads back.
**Reference-only flag (MUL-3739).** The link row carries a `reference_only`
boolean (`migrations/127_issue_pull_request_reference_only.up.sql`). The handler
computes a `qualifyingIdents` set = identifiers in **title or branch** (any
`extractIdentifiers` match) **body closing keywords** (`closingIdents`). A
linked identifier NOT in that set was matched only by a bare body mention, so its
row is written with `reference_only = true`. Both `ListPullRequestsByIssue` and
`GetIssuePullRequestCloseAggregate` filter `AND NOT reference_only`, so
reference-only links are hidden from the CLI / UI PR list **and** excluded from
the auto-advance gate (an open body-only mention must not silently block the
issue from reaching `done` while invisible in the list). The row still exists for
edit-time close-intent tracking. `reference_only` follows the same
`preserve_close_intent` terminal gate as `close_intent`.
Drifted from the prior skill's `github.go:727` citation, which pointed at the old
call-site location for the link logic.
@@ -106,10 +93,8 @@ deliberately excluded (function doc, `github.go:1044-1050`): a branch like
Drifted from the prior skill's `github.go:736` citation.
Net: a bare title prefix (`MUL-2759: ...`) or a branch ref links only (shown in
the PR list); `Closes MUL-2759` links **and** records close intent; a bare body
mention with no title/branch ref and no closing keyword links as `reference_only`
and is hidden from the PR list.
Net: a bare title prefix (`MUL-2759: ...`) or a branch ref links only;
`Closes MUL-2759` links **and** records close intent.
## Status side effects (enqueue contracts)
@@ -161,7 +146,6 @@ grep -n 'pull-requests <id>' cmd/multica/cmd_issue.go
grep -n 'ListPullRequestsForIssue' cmd/server/router.go internal/handler/github.go
grep -n 'func issuePullRequestRowToResponse\|type GitHubPullRequestResponse struct\|func derivePRState\|func extractIdentifiers\|func extractClosingIdentifiers\|closingIdentifierRe' internal/handler/github.go
grep -n 'extractIdentifiers(\|extractClosingIdentifiers(\|derivePRState(' internal/handler/github.go
grep -n 'qualifyingIdents\|reference_only\|ReferenceOnly' internal/handler/github.go pkg/db/queries/github.sql
grep -n 'prevIssue.Status == "backlog"\|func (h \*Handler) shouldEnqueueAgentTask' internal/handler/issue.go
grep -n 'func notifyParentOfChildDone' internal/handler/issue_child_done.go
```

View File

@@ -1,2 +0,0 @@
ALTER TABLE issue_pull_request
DROP COLUMN reference_only;

View File

@@ -1,12 +0,0 @@
-- Persist whether a PR ↔ issue link is justified ONLY by a bare mention of the
-- issue identifier in the PR description (body), with no closing keyword and no
-- reference in the PR title or branch name. The auto-link layer stays generous
-- and still records the link row (so close_intent can be tracked and downgraded
-- across edits), but a reference-only link is hidden from the issue's PR list:
-- a passing "Related MUL-1" / "Follow up in MUL-1" mention should not surface
-- the PR as if it were a working PR for that issue.
--
-- Defaults to FALSE so pre-existing links keep showing until their PR's next
-- webhook re-evaluates the reference.
ALTER TABLE issue_pull_request
ADD COLUMN reference_only BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -1,159 +0,0 @@
# composio
A small, standalone Go SDK for the [Composio v3.1 REST API](https://docs.composio.dev/api-reference).
This package is intentionally self-contained — its only third-party dependency
is [`github.com/go-resty/resty/v2`](https://github.com/go-resty/resty). It does
not import any other Multica package, so it can be reused by other services or
extracted into its own module unchanged.
## Scope (MVP)
Only the endpoints required by the first-stage Composio integration are wired
up. More surface (auth configs, triggers, proxy execute, etc.) can be added
later without changing existing types.
| Capability | Method | REST endpoint |
| --- | --- | --- |
| Create Connect Link (hosted auth flow) | `Client.CreateLink` | `POST /connected_accounts/link` |
| Create MCP / tool-router session | `Client.CreateSession` | `POST /tool_router/session` |
| List connected accounts (per user) | `Client.ListConnectedAccounts` | `GET /connected_accounts` |
| Revoke a connection at the provider | `Client.RevokeConnection` | `POST /connected_accounts/{id}/revoke` |
| Delete a connection record (idempotent) | `Client.DeleteConnectedAccount` | `DELETE /connected_accounts/{id}` |
| List toolkits | `Client.ListToolkits` | `GET /toolkits` |
| Get a toolkit by slug | `Client.GetToolkit` | `GET /toolkits/{slug}` |
| Execute a tool deterministically | `Client.ExecuteTool` | `POST /tools/execute/{slug}` |
| Verify a webhook delivery | `VerifyWebhook` / `VerifyHTTPRequest` | (offline) |
## Quick start
```go
import (
"context"
"os"
"github.com/multica-ai/multica/server/pkg/composio"
)
client, err := composio.NewClient(composio.Options{
APIKey: os.Getenv("COMPOSIO_API_KEY"),
})
if err != nil { /* ... */ }
// 1. Send a user to the hosted Connect Link
link, err := client.CreateLink(ctx, composio.CreateLinkRequest{
AuthConfigID: "ac_xxxxxxxx", // configured in the Composio dashboard
UserID: multicaUserID.String(), // your own user id
CallbackURL: "https://app.multica.ai/api/integrations/composio/callback",
})
// → http.Redirect(w, r, link.RedirectURL, http.StatusFound)
// 2. After Composio creates the account, fetch what the user has connected
accounts, err := client.ListConnectedAccounts(ctx, composio.ListConnectedAccountsRequest{
UserIDs: []string{multicaUserID.String()},
Statuses: []string{"ACTIVE"},
})
// 3. Open an MCP session for the agent runtime
session, err := client.CreateSession(ctx, composio.CreateSessionRequest{
UserID: multicaUserID.String(),
ManageConnections: &composio.ManageConnections{
CallbackURL: "https://app.multica.ai/settings/integrations",
},
})
mcpURL := session.MCP.URL
mcpHdr := client.MCPAuthHeaders() // {"x-api-key": "..."} attach to MCP client
// 4. Disconnect (idempotent — 404 returns nil)
_ = client.RevokeConnection(ctx, "ca_xxxxxxxx")
_ = client.DeleteConnectedAccount(ctx, "ca_xxxxxxxx")
```
## Webhook verification
```go
secret := os.Getenv("COMPOSIO_WEBHOOK_SECRET")
http.HandleFunc("/api/integrations/composio/webhook", func(w http.ResponseWriter, r *http.Request) {
body, err := composio.VerifyHTTPRequest(secret, r, composio.VerifyOptions{})
if err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
event, err := composio.ParseEvent(body)
if err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
switch event.Type {
case "composio.connected_account.expired":
// mark row as expired, notify the user, ...
}
w.WriteHeader(http.StatusOK)
})
```
`VerifyWebhook` enforces a 300-second replay tolerance by default (matching
Composio's official SDKs). Pass `VerifyOptions{Tolerance: ...}` to tune it, or
`-1` to disable the check entirely (only useful when replaying historical
deliveries in tests).
The `webhook-signature` header is parsed as a list of `<version>,<sig>` pairs
so future signing versions don't break verification.
## Errors
All non-2xx responses are returned as a `*composio.APIError` carrying the
upstream status, slug, and message:
```go
_, err := client.CreateLink(ctx, req)
var apiErr *composio.APIError
if errors.As(err, &apiErr) {
if apiErr.IsRateLimited() { /* back off */ }
log.Printf("composio: %d %s (%s) req=%s", apiErr.HTTPStatus, apiErr.Message, apiErr.Slug, apiErr.RequestID)
}
```
`DeleteConnectedAccount` deliberately swallows 404 so the operation is
idempotent — every other error is propagated unchanged.
## Testing
The SDK is exercised entirely against `httptest.NewServer` so unit tests run
offline. Run them with:
```
go test ./server/pkg/composio/...
```
Current coverage: **82.2 %**.
## Design notes
- **Standalone.** Zero coupling to Multica internals — depend on this package
from `server/internal/integrations/composio` (Stage 2 integration glue) or
anywhere else without circular-import risk.
- **`x-api-key`, not Bearer.** Composio's v3.1 REST API authenticates with an
`x-api-key` header. The SDK sets it on every request and exposes
`Client.APIKeyHeader()` / `Client.MCPAuthHeaders()` so callers know
which header to attach when they're reaching Composio outside the SDK
(e.g. the MCP streaming client in the agent runtime).
- **Loose typing for evolving fields.** Session request blocks (`toolkits`,
`auth_configs`, `tools`, `multi_account`, …) and tool execution arguments
use `map[string]any` because their nested schemas are large and likely to
evolve. The frequently-used `manage_connections` block has a typed
wrapper — extend the typed surface as more shapes stabilise.
- **Webhook signing matches the official SDKs.** HMAC-SHA256 over
`{id}.{timestamp}.{rawBody}`, base64-encoded, with a 300-second replay
window. See
[Composio webhook verification](https://docs.composio.dev/docs/setting-up-triggers/subscribing-to-events#verifying-signatures).
## Roadmap (out of scope for v1)
- Auth-config CRUD (`/auth_configs`)
- Triggers (`/triggers`)
- Proxy execute (`/tools/execute/proxy`)
- Session meta-tool / `attach` / `search` endpoints
- Pagination iterators
- Built-in retry middleware on 429 / 5xx

View File

@@ -1,165 +0,0 @@
package composio
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
// DefaultBaseURL is the canonical Composio v3.1 REST root.
const DefaultBaseURL = "https://backend.composio.dev/api/v3.1"
// DefaultUserAgent is sent on every request unless overridden via [Options.UserAgent].
const DefaultUserAgent = "multica-composio-go/0.1"
// DefaultTimeout is the per-request timeout applied to the underlying
// resty client when [Options.Timeout] is zero.
const DefaultTimeout = 30 * time.Second
// Options configures a [Client]. Only APIKey is required.
type Options struct {
// APIKey is the Composio project API key, sent as the `x-api-key` header.
APIKey string
// BaseURL overrides the API root. Mostly useful for tests against a
// httptest.Server. Defaults to [DefaultBaseURL].
BaseURL string
// UserAgent overrides the User-Agent header. Defaults to [DefaultUserAgent].
UserAgent string
// Timeout is the per-request timeout. Zero means [DefaultTimeout].
// A negative value disables the timeout entirely.
Timeout time.Duration
// HTTPClient lets callers inject a custom *http.Client (for example with
// a corporate transport, custom CookieJar, redirect policy, or
// observability instrumentation). When non-nil it is adopted in full
// via resty.NewWithClient — the caller's Transport, Jar, CheckRedirect,
// and built-in Timeout all carry through. If both this client's
// Timeout and [Options.Timeout] are set, [Options.Timeout] wins.
HTTPClient *http.Client
// RetryCount is the number of retries resty performs on transient
// failures. Zero means no retries (callers can layer their own).
RetryCount int
// RetryWaitTime is the base delay between retries when RetryCount > 0.
RetryWaitTime time.Duration
}
// Client is the Composio REST client.
//
// It is safe for concurrent use by multiple goroutines.
type Client struct {
rc *resty.Client
baseURL string
apiKey string
userAgent string
}
// NewClient constructs a Client from [Options]. It returns an error when the
// options are obviously broken (empty API key, malformed base URL).
func NewClient(opts Options) (*Client, error) {
if strings.TrimSpace(opts.APIKey) == "" {
return nil, errors.New("composio: APIKey is required")
}
baseURL := opts.BaseURL
if baseURL == "" {
baseURL = DefaultBaseURL
}
if _, err := url.Parse(baseURL); err != nil {
return nil, fmt.Errorf("composio: invalid BaseURL %q: %w", baseURL, err)
}
baseURL = strings.TrimRight(baseURL, "/")
ua := opts.UserAgent
if ua == "" {
ua = DefaultUserAgent
}
timeout := opts.Timeout
switch {
case timeout == 0:
timeout = DefaultTimeout
case timeout < 0:
timeout = 0 // resty treats 0 as "no timeout"
}
rc := newRestyClient(opts.HTTPClient).
SetBaseURL(baseURL).
SetHeader("Content-Type", "application/json").
SetHeader("Accept", "application/json").
SetHeader("User-Agent", ua).
SetHeader("x-api-key", opts.APIKey).
SetTimeout(timeout)
if opts.RetryCount > 0 {
rc = rc.SetRetryCount(opts.RetryCount)
if opts.RetryWaitTime > 0 {
rc = rc.SetRetryWaitTime(opts.RetryWaitTime)
}
}
return &Client{
rc: rc,
baseURL: baseURL,
apiKey: opts.APIKey,
userAgent: ua,
}, nil
}
// newRestyClient constructs a resty.Client honoring an injected *http.Client
// in full when one is provided. resty.NewWithClient adopts the caller's
// http.Client wholesale — so the caller's Transport, Jar, CheckRedirect,
// and Timeout all carry through — which matches the documented contract
// of [Options.HTTPClient].
func newRestyClient(hc *http.Client) *resty.Client {
if hc != nil {
return resty.NewWithClient(hc)
}
return resty.New()
}
// BaseURL returns the resolved API root after defaulting.
func (c *Client) BaseURL() string { return c.baseURL }
// APIKeyHeader returns the header pair callers should attach to MCP
// streaming clients or any other Composio request made outside the SDK.
//
// Returning a copy keeps the internal map immutable.
func (c *Client) APIKeyHeader() map[string]string {
return map[string]string{"x-api-key": c.apiKey}
}
// newRequest returns a resty.Request bound to the given context.
// All endpoint methods funnel through this helper.
func (c *Client) newRequest(ctx context.Context) *resty.Request {
return c.rc.R().SetContext(ctx)
}
// do executes a request and unmarshals a successful body into out.
// On non-2xx it returns a *APIError populated from the response body.
//
// out may be nil if the caller does not care about the body
// (e.g. DELETE / 204).
func (c *Client) do(req *resty.Request, method, path string, out any) error {
if out != nil {
req = req.SetResult(out)
}
resp, err := req.Execute(method, path)
if err != nil {
return fmt.Errorf("composio: %s %s: %w", method, path, err)
}
if resp.IsError() {
return parseAPIError(resp.StatusCode(), resp.Body())
}
return nil
}

View File

@@ -1,567 +0,0 @@
package composio_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/multica-ai/multica/server/pkg/composio"
)
// newTestServer wires up a httptest.Server with the provided handler and
// returns a composio.Client pointed at it.
func newTestServer(t *testing.T, h http.HandlerFunc) (*composio.Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c, err := composio.NewClient(composio.Options{
APIKey: "test-key",
BaseURL: srv.URL,
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
return c, srv
}
func readJSON(t *testing.T, r *http.Request, out any) {
t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if err := json.Unmarshal(body, out); err != nil {
t.Fatalf("unmarshal body %q: %v", string(body), err)
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, status int, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("write json: %v", err)
}
}
// ---------------------------------------------------------------------------
// Client construction
// ---------------------------------------------------------------------------
func TestNewClient_Defaults(t *testing.T) {
c, err := composio.NewClient(composio.Options{APIKey: "k"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := c.BaseURL(); got != composio.DefaultBaseURL {
t.Errorf("BaseURL = %q, want %q", got, composio.DefaultBaseURL)
}
}
func TestNewClient_RequiresAPIKey(t *testing.T) {
_, err := composio.NewClient(composio.Options{})
if err == nil {
t.Fatal("expected error when APIKey is empty")
}
}
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
c, err := composio.NewClient(composio.Options{APIKey: "k", BaseURL: "https://x.example.com/"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := c.BaseURL(), "https://x.example.com"; got != want {
t.Errorf("BaseURL = %q, want %q", got, want)
}
}
// recordingTransport observes whether it actually handled a request.
type recordingTransport struct {
mu sync.Mutex
calls int
last *http.Request
status int
}
func (rt *recordingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
rt.mu.Lock()
rt.calls++
rt.last = r
rt.mu.Unlock()
body := io.NopCloser(strings.NewReader(`{"items":[]}`))
return &http.Response{
StatusCode: rt.statusOr(200),
Body: body,
Header: http.Header{"Content-Type": []string{"application/json"}},
Request: r,
}, nil
}
func (rt *recordingTransport) statusOr(d int) int {
if rt.status == 0 {
return d
}
return rt.status
}
// TestNewClient_HonorsInjectedHTTPClient asserts that when Options.HTTPClient
// is non-nil the SDK actually routes requests through *that* client — full
// fidelity, not just transport+timeout. GPT-Boy's PR review against #4603
// caught the partial behavior; this test locks the fix in.
func TestNewClient_HonorsInjectedHTTPClient(t *testing.T) {
rt := &recordingTransport{}
hc := &http.Client{Transport: rt}
c, err := composio.NewClient(composio.Options{
APIKey: "k",
BaseURL: "https://api.example.invalid",
HTTPClient: hc,
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
if _, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{}); err != nil {
t.Fatalf("ListToolkits: %v", err)
}
rt.mu.Lock()
defer rt.mu.Unlock()
if rt.calls != 1 {
t.Fatalf("expected 1 call through injected transport, got %d", rt.calls)
}
if rt.last == nil || rt.last.URL.Host != "api.example.invalid" {
t.Errorf("request did not flow through injected client: %+v", rt.last)
}
if got := rt.last.Header.Get("x-api-key"); got != "k" {
t.Errorf("api key header lost in injected client: %q", got)
}
}
// ---------------------------------------------------------------------------
// Connect Link
// ---------------------------------------------------------------------------
func TestCreateLink_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/connected_accounts/link" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.Header.Get("x-api-key"); got != "test-key" {
t.Errorf("missing api key header, got %q", got)
}
var body composio.CreateLinkRequest
readJSON(t, r, &body)
if body.AuthConfigID != "ac_abc" || body.UserID != "u_1" {
t.Errorf("unexpected body: %+v", body)
}
writeJSON(t, w, http.StatusCreated, map[string]any{
"link_token": "ltok_xyz",
"redirect_url": "https://connect.composio.dev/ln_xyz",
"expires_at": "2026-12-31T00:00:00Z",
"connected_account_id": "ca_pending",
})
})
resp, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{
AuthConfigID: "ac_abc",
UserID: "u_1",
CallbackURL: "https://example.com/cb",
})
if err != nil {
t.Fatalf("CreateLink: %v", err)
}
if resp.RedirectURL == "" || resp.LinkToken != "ltok_xyz" || resp.ConnectedAccountID != "ca_pending" {
t.Errorf("unexpected response: %+v", resp)
}
}
func TestCreateLink_ValidatesInputs(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit when inputs are invalid")
})
if _, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{UserID: "u"}); err == nil {
t.Error("expected error when AuthConfigID is empty")
}
if _, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{AuthConfigID: "ac"}); err == nil {
t.Error("expected error when UserID is empty")
}
}
func TestCreateLink_APIError(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusBadRequest, map[string]any{
"error": map[string]any{
"message": "bad input",
"code": 400,
"slug": "INVALID_INPUT",
"request_id": "req_1",
},
})
})
_, err := c.CreateLink(context.Background(), composio.CreateLinkRequest{
AuthConfigID: "ac", UserID: "u",
})
if err == nil {
t.Fatal("expected error")
}
var apiErr *composio.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.HTTPStatus != http.StatusBadRequest || apiErr.Slug != "INVALID_INPUT" || apiErr.Message != "bad input" {
t.Errorf("unexpected APIError: %+v", apiErr)
}
}
// ---------------------------------------------------------------------------
// Connected accounts list / revoke / delete
// ---------------------------------------------------------------------------
func TestListConnectedAccounts_QueryString(t *testing.T) {
var seen *http.Request
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
seen = r
writeJSON(t, w, http.StatusOK, map[string]any{
"items": []map[string]any{
{"id": "ca_1", "user_id": "u_1", "status": "ACTIVE",
"toolkit": map[string]any{"slug": "notion"}},
},
"next_cursor": "cur_2",
})
})
resp, err := c.ListConnectedAccounts(context.Background(), composio.ListConnectedAccountsRequest{
UserIDs: []string{"u_1", "u_2"},
ToolkitSlugs: []string{"notion", "slack"},
AuthConfigIDs: []string{"ac_a"},
ConnectedAccountIDs: []string{"ca_x"},
Statuses: []string{"ACTIVE"},
OrderBy: "updated_at",
OrderDirection: "desc",
AccountType: "PRIVATE",
Limit: 25,
})
if err != nil {
t.Fatalf("ListConnectedAccounts: %v", err)
}
if len(resp.Items) != 1 || resp.Items[0].Toolkit.Slug != "notion" || resp.NextCursor != "cur_2" {
t.Errorf("unexpected response: %+v", resp)
}
q := seen.URL.Query()
// Per Composio v3.1 these are plural array params.
if got := q["user_ids"]; len(got) != 2 || got[0] != "u_1" || got[1] != "u_2" {
t.Errorf("user_ids = %v", got)
}
if got := q["toolkit_slugs"]; len(got) != 2 || got[0] != "notion" || got[1] != "slack" {
t.Errorf("toolkit_slugs = %v", got)
}
if got := q["auth_config_ids"]; len(got) != 1 || got[0] != "ac_a" {
t.Errorf("auth_config_ids = %v", got)
}
if got := q["connected_account_ids"]; len(got) != 1 || got[0] != "ca_x" {
t.Errorf("connected_account_ids = %v", got)
}
if got := q["statuses"]; len(got) != 1 || got[0] != "ACTIVE" {
t.Errorf("statuses = %v", got)
}
// Singular legacy keys must NOT appear — guard against regression.
if q.Has("user_id") || q.Has("auth_config_id") {
t.Errorf("legacy singular query keys leaked: %s", seen.URL.RawQuery)
}
if q.Get("order_by") != "updated_at" {
t.Errorf("order_by = %q", q.Get("order_by"))
}
if q.Get("order_direction") != "desc" {
t.Errorf("order_direction = %q", q.Get("order_direction"))
}
if q.Get("account_type") != "PRIVATE" {
t.Errorf("account_type = %q", q.Get("account_type"))
}
if q.Get("limit") != "25" {
t.Errorf("limit = %q", q.Get("limit"))
}
}
func TestRevokeConnection_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/connected_accounts/ca_42/revoke" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
})
if err := c.RevokeConnection(context.Background(), "ca_42"); err != nil {
t.Errorf("RevokeConnection: %v", err)
}
}
func TestRevokeConnection_RequiresID(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if err := c.RevokeConnection(context.Background(), ""); err == nil {
t.Error("expected error for empty id")
}
}
func TestDeleteConnectedAccount_IdempotentOn404(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("method = %s", r.Method)
}
writeJSON(t, w, http.StatusNotFound, map[string]any{
"error": map[string]any{"message": "not found", "status": 404, "slug": "NOT_FOUND"},
})
})
if err := c.DeleteConnectedAccount(context.Background(), "ca_gone"); err != nil {
t.Errorf("expected nil on 404, got %v", err)
}
}
func TestDeleteConnectedAccount_PropagatesOtherErrors(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusInternalServerError, map[string]any{
"error": map[string]any{"message": "boom", "status": 500, "slug": "INTERNAL"},
})
})
err := c.DeleteConnectedAccount(context.Background(), "ca_1")
if err == nil {
t.Fatal("expected error")
}
var apiErr *composio.APIError
if !errors.As(err, &apiErr) || apiErr.HTTPStatus != http.StatusInternalServerError {
t.Errorf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
func TestCreateSession_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/tool_router/session" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body composio.CreateSessionRequest
readJSON(t, r, &body)
if body.UserID != "u_1" {
t.Errorf("user_id = %q", body.UserID)
}
if body.ManageConnections == nil || body.ManageConnections.CallbackURL != "https://cb" {
t.Errorf("manage_connections = %+v", body.ManageConnections)
}
writeJSON(t, w, http.StatusCreated, map[string]any{
"session_id": "trs_1",
"mcp": map[string]any{"type": "http", "url": "https://mcp.example/trs_1"},
})
})
enable := true
resp, err := c.CreateSession(context.Background(), composio.CreateSessionRequest{
UserID: "u_1",
ManageConnections: &composio.ManageConnections{
Enable: &enable,
CallbackURL: "https://cb",
},
})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if resp.MCP.URL == "" || resp.SessionID != "trs_1" {
t.Errorf("unexpected response: %+v", resp)
}
hdr := c.MCPAuthHeaders()
if hdr["x-api-key"] != "test-key" {
t.Errorf("MCPAuthHeaders = %v", hdr)
}
}
func TestCreateSession_RequiresUserID(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if _, err := c.CreateSession(context.Background(), composio.CreateSessionRequest{}); err == nil {
t.Error("expected error for empty UserID")
}
}
// ---------------------------------------------------------------------------
// Toolkits / Tools
// ---------------------------------------------------------------------------
func TestListToolkits_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/toolkits" || r.URL.Query().Get("category") != "productivity" {
t.Errorf("unexpected request: %s ?%s", r.URL.Path, r.URL.RawQuery)
}
writeJSON(t, w, http.StatusOK, map[string]any{
"items": []map[string]any{
{"slug": "notion", "name": "Notion"},
{"slug": "slack", "name": "Slack"},
},
})
})
resp, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{Category: "productivity"})
if err != nil {
t.Fatalf("ListToolkits: %v", err)
}
if len(resp.Items) != 2 || resp.Items[0].Slug != "notion" {
t.Errorf("unexpected response: %+v", resp)
}
}
func TestGetToolkit_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/toolkits/notion" {
t.Errorf("path = %s", r.URL.Path)
}
writeJSON(t, w, http.StatusOK, map[string]any{"slug": "notion", "name": "Notion"})
})
tk, err := c.GetToolkit(context.Background(), "notion")
if err != nil {
t.Fatalf("GetToolkit: %v", err)
}
if tk.Slug != "notion" {
t.Errorf("slug = %q", tk.Slug)
}
}
func TestGetToolkit_RequiresSlug(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if _, err := c.GetToolkit(context.Background(), ""); err == nil {
t.Error("expected error for empty slug")
}
}
func TestExecuteTool_Success(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tools/execute/GITHUB_CREATE_ISSUE" {
t.Errorf("path = %s", r.URL.Path)
}
// Decode into a raw map first so we can assert wire keys directly.
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
var wire map[string]any
if err := json.Unmarshal(raw, &wire); err != nil {
t.Fatalf("unmarshal wire: %v", err)
}
if wire["user_id"] != "u_1" {
t.Errorf("user_id = %v", wire["user_id"])
}
// The spec field is `version`, not `toolkit_versions`.
if wire["version"] != "latest" {
t.Errorf("version = %v (want %q)", wire["version"], "latest")
}
if _, leaked := wire["toolkit_versions"]; leaked {
t.Errorf("legacy toolkit_versions field leaked to wire: %s", raw)
}
args, _ := wire["arguments"].(map[string]any)
if args["title"] != "hi" {
t.Errorf("arguments.title = %v", args["title"])
}
writeJSON(t, w, http.StatusOK, map[string]any{
"successful": true,
"data": map[string]any{"issue_number": float64(42)},
"log_id": "log_1",
})
})
resp, err := c.ExecuteTool(context.Background(), "GITHUB_CREATE_ISSUE", composio.ExecuteToolRequest{
UserID: "u_1",
Arguments: map[string]any{"title": "hi"},
Version: "latest",
})
if err != nil {
t.Fatalf("ExecuteTool: %v", err)
}
if !resp.Successful || resp.Data["issue_number"].(float64) != 42 || resp.LogID != "log_1" {
t.Errorf("unexpected response: %+v", resp)
}
}
// TestExecuteToolRequest_VersionSerialization locks in the json tag for the
// Version field — GPT-Boy's review against PR #4603 caught that the field
// used to serialize as `toolkit_versions`, which is not a v3.1 wire key.
func TestExecuteToolRequest_VersionSerialization(t *testing.T) {
req := composio.ExecuteToolRequest{
UserID: "u_1",
Version: "20251027_00",
}
b, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal: %v", err)
}
got := string(b)
if !strings.Contains(got, `"version":"20251027_00"`) {
t.Errorf("version not serialized as `version`: %s", got)
}
if strings.Contains(got, "toolkit_versions") {
t.Errorf("legacy toolkit_versions key leaked: %s", got)
}
// Zero-value Version must omit the field entirely (omitempty).
bEmpty, _ := json.Marshal(composio.ExecuteToolRequest{UserID: "u_1"})
if strings.Contains(string(bEmpty), "version") {
t.Errorf("empty Version should omit, got: %s", bEmpty)
}
}
func TestExecuteTool_ValidatesInputs(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("server should not be hit")
})
if _, err := c.ExecuteTool(context.Background(), "", composio.ExecuteToolRequest{UserID: "u"}); err == nil {
t.Error("expected error for empty tool slug")
}
if _, err := c.ExecuteTool(context.Background(), "X", composio.ExecuteToolRequest{}); err == nil {
t.Error("expected error when neither UserID nor ConnectedAccountID is set")
}
}
// ---------------------------------------------------------------------------
// Error parsing
// ---------------------------------------------------------------------------
func TestAPIError_FallbackOnNonJSONBody(t *testing.T) {
c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("<html>upstream down</html>"))
})
_, err := c.ListToolkits(context.Background(), composio.ListToolkitsRequest{})
if err == nil {
t.Fatal("expected error")
}
var apiErr *composio.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.HTTPStatus != http.StatusBadGateway {
t.Errorf("status = %d", apiErr.HTTPStatus)
}
if !strings.Contains(string(apiErr.RawBody), "upstream down") {
t.Errorf("raw body lost: %q", apiErr.RawBody)
}
}
func TestAPIError_HelperPredicates(t *testing.T) {
e := &composio.APIError{HTTPStatus: http.StatusNotFound}
if !e.IsNotFound() {
t.Error("IsNotFound() = false")
}
e2 := &composio.APIError{HTTPStatus: http.StatusUnauthorized}
if !e2.IsUnauthorized() {
t.Error("IsUnauthorized() = false")
}
e3 := &composio.APIError{HTTPStatus: http.StatusTooManyRequests}
if !e3.IsRateLimited() {
t.Error("IsRateLimited() = false")
}
}

View File

@@ -1,198 +0,0 @@
package composio
import (
"context"
"errors"
"net/http"
"net/url"
"strconv"
)
// --- Create link --------------------------------------------------------
// CreateLinkRequest is the body of POST /connected_accounts/link.
//
// Spec: https://docs.composio.dev/reference/api-reference/connected-accounts/postConnectedAccountsLink
type CreateLinkRequest struct {
// AuthConfigID is the `ac_…` id of an auth config registered in your
// Composio project (one per toolkit / OAuth client variant).
AuthConfigID string `json:"auth_config_id"`
// UserID is your own user identifier — Composio scopes the resulting
// connected account by it.
UserID string `json:"user_id"`
// CallbackURL is where Composio sends the user after they finish the
// hosted auth flow. Optional; Composio has a default landing page.
CallbackURL string `json:"callback_url,omitempty"`
// Alias is a human-readable label for the connection. Optional but useful
// when the same user connects multiple accounts of the same toolkit.
Alias string `json:"alias,omitempty"`
// ConnectionData lets the caller pre-fill connection fields with default
// values (per the Composio docs). Free-form to avoid coupling to the
// scheme-specific child schemas.
ConnectionData map[string]any `json:"connection_data,omitempty"`
}
// CreateLinkResponse is the body returned by POST /connected_accounts/link.
type CreateLinkResponse struct {
LinkToken string `json:"link_token"`
RedirectURL string `json:"redirect_url"`
ExpiresAt string `json:"expires_at"`
ConnectedAccountID string `json:"connected_account_id"`
}
// CreateLink starts a hosted Composio Connect Link session. The redirect URL
// is what the caller should send the user to (popup, redirect, or
// SFSafariViewController).
func (c *Client) CreateLink(ctx context.Context, req CreateLinkRequest) (*CreateLinkResponse, error) {
if req.AuthConfigID == "" {
return nil, errors.New("composio: CreateLink: AuthConfigID is required")
}
if req.UserID == "" {
return nil, errors.New("composio: CreateLink: UserID is required")
}
var out CreateLinkResponse
if err := c.do(c.newRequest(ctx).SetBody(req), http.MethodPost, "/connected_accounts/link", &out); err != nil {
return nil, err
}
return &out, nil
}
// --- List ---------------------------------------------------------------
// ListConnectedAccountsRequest collects the optional filters supported by
// GET /connected_accounts. Zero values are omitted from the query string.
//
// Per the Composio v3.1 spec all filters are plural array params: the SDK
// sends one query entry per slice element (`user_ids=u1&user_ids=u2`).
// Pass a single-element slice for the common "list by one user" case.
//
// Spec: https://docs.composio.dev/reference/api-reference/connected-accounts/getConnectedAccounts
type ListConnectedAccountsRequest struct {
UserIDs []string
ToolkitSlugs []string
AuthConfigIDs []string
ConnectedAccountIDs []string
Statuses []string // ACTIVE, EXPIRED, INACTIVE, …
OrderBy string // "created_at" (default) | "updated_at"
OrderDirection string // "asc" | "desc" (default)
AccountType string // experimental: PRIVATE | SHARED | ALL
Limit int // 0 = use upstream default
Cursor string
}
// ConnectedAccount mirrors a subset of the Composio response shape. Only the
// fields actually consumed by the MVP are typed; extras live in Extra so
// callers can read them without an SDK update.
type ConnectedAccount struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AuthConfigID string `json:"auth_config_id"`
Toolkit Toolkit `json:"toolkit"`
Status string `json:"status"`
StatusReason string `json:"status_reason,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
LastUsedAt string `json:"last_used_at,omitempty"`
Extra map[string]any `json:"-"`
}
// ListConnectedAccountsResponse is the typed paginated response.
type ListConnectedAccountsResponse struct {
Items []ConnectedAccount `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
TotalItems int `json:"total_items,omitempty"`
}
// ListConnectedAccounts returns the connections matching the supplied filters.
func (c *Client) ListConnectedAccounts(ctx context.Context, req ListConnectedAccountsRequest) (*ListConnectedAccountsResponse, error) {
q := url.Values{}
for _, v := range req.UserIDs {
if v != "" {
q.Add("user_ids", v)
}
}
for _, v := range req.ToolkitSlugs {
if v != "" {
q.Add("toolkit_slugs", v)
}
}
for _, v := range req.AuthConfigIDs {
if v != "" {
q.Add("auth_config_ids", v)
}
}
for _, v := range req.ConnectedAccountIDs {
if v != "" {
q.Add("connected_account_ids", v)
}
}
for _, v := range req.Statuses {
if v != "" {
q.Add("statuses", v)
}
}
if req.OrderBy != "" {
q.Set("order_by", req.OrderBy)
}
if req.OrderDirection != "" {
q.Set("order_direction", req.OrderDirection)
}
if req.AccountType != "" {
q.Set("account_type", req.AccountType)
}
if req.Limit > 0 {
q.Set("limit", strconv.Itoa(req.Limit))
}
if req.Cursor != "" {
q.Set("cursor", req.Cursor)
}
path := "/connected_accounts"
if encoded := q.Encode(); encoded != "" {
path += "?" + encoded
}
var out ListConnectedAccountsResponse
if err := c.do(c.newRequest(ctx), http.MethodGet, path, &out); err != nil {
return nil, err
}
return &out, nil
}
// --- Revoke / Delete ----------------------------------------------------
// RevokeConnection revokes the OAuth grant at the upstream provider but
// keeps the Composio record. Use this when the user disconnects and you
// want the provider-side tokens invalidated immediately.
func (c *Client) RevokeConnection(ctx context.Context, connectedAccountID string) error {
if connectedAccountID == "" {
return errors.New("composio: RevokeConnection: connectedAccountID is required")
}
return c.do(c.newRequest(ctx),
http.MethodPost, "/connected_accounts/"+url.PathEscape(connectedAccountID)+"/revoke", nil)
}
// DeleteConnectedAccount removes the connection record from Composio. The
// provider tokens are NOT revoked by this call — call [Client.RevokeConnection]
// first if you need them invalidated upstream.
//
// Returns nil for 404 so callers can treat the operation as idempotent.
func (c *Client) DeleteConnectedAccount(ctx context.Context, connectedAccountID string) error {
if connectedAccountID == "" {
return errors.New("composio: DeleteConnectedAccount: connectedAccountID is required")
}
err := c.do(c.newRequest(ctx),
http.MethodDelete, "/connected_accounts/"+url.PathEscape(connectedAccountID), nil)
if err == nil {
return nil
}
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.IsNotFound() {
return nil
}
return err
}

View File

@@ -1,56 +0,0 @@
// Package composio is a small, standalone Go SDK for the Composio v3.1 REST API.
//
// It is intentionally self-contained: the only third-party dependency is
// [github.com/go-resty/resty/v2]. It does not import any Multica-specific
// package, so it can be reused by other Go services or extracted into its
// own module unchanged.
//
// # MVP surface
//
// The SDK targets the surface required by the Composio integration MVP
// (see MUL-3715 / MUL-3720). It is deliberately minimal — only the
// endpoints actually used by the first-stage product are wired up:
//
// - Connect Link — POST /connected_accounts/link
// - MCP Session — POST /tool_router/session
// - Connected Accounts — GET /connected_accounts,
// POST /connected_accounts/{id}/revoke,
// DELETE /connected_accounts/{id}
// - Toolkits — GET /toolkits, GET /toolkits/{slug}
// - Tool Execute — POST /tools/execute/{tool_slug}
// - Webhook — HMAC-SHA256 signature verification
//
// More surface (auth configs, triggers, proxy execute, etc.) can be
// added later without changing the existing types.
//
// # Quick start
//
// client, err := composio.NewClient(composio.Options{
// APIKey: os.Getenv("COMPOSIO_API_KEY"),
// })
// if err != nil { return err }
//
// link, err := client.CreateLink(ctx, composio.CreateLinkRequest{
// AuthConfigID: "ac_abc",
// UserID: "u_123",
// CallbackURL: "https://app.example.com/composio/callback",
// })
// // redirect user to link.RedirectURL
//
// session, err := client.CreateSession(ctx, composio.CreateSessionRequest{
// UserID: "u_123",
// })
// // agent runtime now consumes session.MCP.URL + composio.MCPAuthHeaders(...)
//
// # Errors
//
// All non-2xx responses come back as a *APIError carrying the upstream
// status, slug, and message. Transport errors come back unwrapped from
// resty so callers can errors.Is/As as usual.
//
// # Webhook verification
//
// [VerifyWebhook] verifies the HMAC-SHA256 signature Composio attaches
// to every webhook delivery, with a configurable replay tolerance.
// See https://docs.composio.dev/docs/setting-up-triggers/subscribing-to-events#verifying-signatures
package composio

View File

@@ -1,88 +0,0 @@
package composio
import (
"encoding/json"
"fmt"
"net/http"
)
// APIError is the canonical error returned by the SDK when Composio responds
// with a non-2xx HTTP status.
//
// The Composio error envelope as of v3.1 looks like:
//
// {
// "error": {
// "message": "...",
// "code": 400,
// "slug": "INVALID_INPUT",
// "status": 400,
// "request_id": "req_...",
// "suggested_fix":"...",
// "errors": ["..."]
// }
// }
//
// HTTPStatus is the transport status as observed locally; the rest mirrors
// the body if Composio returned one. RawBody is preserved verbatim so
// callers can log the full upstream response for debugging.
type APIError struct {
HTTPStatus int `json:"-"`
Message string `json:"message,omitempty"`
Code int `json:"code,omitempty"`
Slug string `json:"slug,omitempty"`
Status int `json:"status,omitempty"`
RequestID string `json:"request_id,omitempty"`
SuggestedFix string `json:"suggested_fix,omitempty"`
Errors []string `json:"errors,omitempty"`
RawBody []byte `json:"-"`
}
// Error implements error. It surfaces the upstream status, slug, and message.
func (e *APIError) Error() string {
if e == nil {
return ""
}
msg := e.Message
if msg == "" {
msg = http.StatusText(e.HTTPStatus)
}
if e.Slug != "" {
return fmt.Sprintf("composio: %d %s (%s)", e.HTTPStatus, msg, e.Slug)
}
return fmt.Sprintf("composio: %d %s", e.HTTPStatus, msg)
}
// IsNotFound reports whether the error is an HTTP 404 — useful for idempotent
// delete/revoke flows.
func (e *APIError) IsNotFound() bool { return e != nil && e.HTTPStatus == http.StatusNotFound }
// IsUnauthorized reports whether the error is an HTTP 401.
func (e *APIError) IsUnauthorized() bool {
return e != nil && e.HTTPStatus == http.StatusUnauthorized
}
// IsRateLimited reports whether the error is an HTTP 429.
func (e *APIError) IsRateLimited() bool {
return e != nil && e.HTTPStatus == http.StatusTooManyRequests
}
// parseAPIError decodes Composio's `{"error": {...}}` envelope. If the body
// is not the expected shape it returns an APIError carrying just HTTPStatus
// and RawBody so callers still see something useful.
func parseAPIError(status int, body []byte) *APIError {
out := &APIError{HTTPStatus: status, RawBody: body}
if len(body) == 0 {
return out
}
var wire struct {
Error APIError `json:"error"`
}
if err := json.Unmarshal(body, &wire); err != nil {
// Body is not the expected envelope — leave RawBody set, message empty.
return out
}
wire.Error.HTTPStatus = status
wire.Error.RawBody = body
return &wire.Error
}

View File

@@ -1,99 +0,0 @@
package composio
import (
"context"
"errors"
"net/http"
)
// --- Session creation ---------------------------------------------------
// CreateSessionRequest is the body of POST /tool_router/session.
//
// The minimum required field is [UserID]. Everything else is optional and
// maps directly to the v3.1 wire schema:
// https://docs.composio.dev/reference/api-reference/tool-router/postToolRouterSession
//
// The schema is intentionally typed loosely (map-based) for the nested
// `toolkits`, `auth_configs`, `tools`, `tags`, `multi_account`, etc. fields
// because they carry many child attributes and are expected to evolve.
// Callers can still construct strongly typed wrappers on top.
type CreateSessionRequest struct {
UserID string `json:"user_id"`
Toolkits map[string]any `json:"toolkits,omitempty"`
AuthConfigs map[string]any `json:"auth_configs,omitempty"`
ConnectedAccounts map[string]any `json:"connected_accounts,omitempty"`
ManageConnections *ManageConnections `json:"manage_connections,omitempty"`
Tools map[string]any `json:"tools,omitempty"`
Tags any `json:"tags,omitempty"`
Workbench map[string]any `json:"workbench,omitempty"`
MultiAccount map[string]any `json:"multi_account,omitempty"`
Preload map[string]any `json:"preload,omitempty"`
Search map[string]any `json:"search,omitempty"`
Execute map[string]any `json:"execute,omitempty"`
Experimental map[string]any `json:"experimental,omitempty"`
}
// ManageConnections is the typed flavor of the `manage_connections` object —
// the field used most often by integrations.
type ManageConnections struct {
Enable *bool `json:"enable,omitempty"`
CallbackURL string `json:"callback_url,omitempty"`
EnableWaitForConnections *bool `json:"enable_wait_for_connections,omitempty"`
EnableConnectionRemoval *bool `json:"enable_connection_removal,omitempty"`
}
// MCPDescriptor is the streamable HTTP entrypoint for the session's MCP.
type MCPDescriptor struct {
Type string `json:"type"`
URL string `json:"url"`
}
// CreateSessionResponse mirrors the subset of the upstream response the SDK
// currently exposes typed. Additional fields can be added without breaking
// callers.
type CreateSessionResponse struct {
SessionID string `json:"session_id"`
MCP MCPDescriptor `json:"mcp"`
ToolRouterTools []string `json:"tool_router_tools,omitempty"`
Config map[string]any `json:"config,omitempty"`
ConfigVersion int `json:"config_version,omitempty"`
Experimental map[string]any `json:"experimental,omitempty"`
Warnings []SessionWarning `json:"warnings,omitempty"`
}
// SessionWarning is a non-fatal warning emitted at session creation time.
type SessionWarning struct {
Code string `json:"code"`
Message string `json:"message"`
}
// CreateSession opens a new tool-router (a.k.a. MCP) session for the given
// user. The returned [CreateSessionResponse.MCP.URL] is the URL an
// MCP-compatible client connects to.
//
// Use [Client.MCPAuthHeaders] to obtain the matching headers — the SDK
// returns these separately rather than baking them into the response so
// that callers don't accidentally leak the secret API key through logs.
func (c *Client) CreateSession(ctx context.Context, req CreateSessionRequest) (*CreateSessionResponse, error) {
if req.UserID == "" {
return nil, errors.New("composio: CreateSession: UserID is required")
}
var out CreateSessionResponse
if err := c.do(c.newRequest(ctx).SetBody(req), http.MethodPost, "/tool_router/session", &out); err != nil {
return nil, err
}
return &out, nil
}
// MCPAuthHeaders returns the headers an MCP client must send when connecting
// to a session URL produced by [Client.CreateSession].
//
// Composio authenticates MCP streaming the same way it authenticates the
// REST API — with the project's `x-api-key` header. Keeping this as a
// dedicated helper makes it explicit at the call site that bearer
// material is leaving the SDK boundary, so callers can route it through
// their secret-redact pipeline (see server/pkg/redact).
func (c *Client) MCPAuthHeaders() map[string]string {
return c.APIKeyHeader()
}

View File

@@ -1,78 +0,0 @@
package composio
import (
"context"
"errors"
"net/http"
"net/url"
"strconv"
)
// Toolkit is the minimal toolkit descriptor used as a nested field inside
// connected accounts, sessions, and the toolkit list endpoint. Only fields
// useful for UI / dispatch decisions are typed.
type Toolkit struct {
Slug string `json:"slug"`
Name string `json:"name,omitempty"`
LogoURL string `json:"logo,omitempty"`
Description string `json:"description,omitempty"`
Categories []string `json:"categories,omitempty"`
AuthSchemes []string `json:"auth_schemes,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}
// ListToolkitsRequest carries the optional filters of GET /toolkits.
type ListToolkitsRequest struct {
Category string
Limit int
Cursor string
// SortBy is the upstream sort order. Per the v3.1 spec the valid enum
// values are "usage" and "alphabetically".
SortBy string
}
// ListToolkitsResponse is the typed paginated response.
type ListToolkitsResponse struct {
Items []Toolkit `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
TotalItems int `json:"total_items,omitempty"`
}
// ListToolkits returns toolkits available to the project.
func (c *Client) ListToolkits(ctx context.Context, req ListToolkitsRequest) (*ListToolkitsResponse, error) {
q := url.Values{}
if req.Category != "" {
q.Set("category", req.Category)
}
if req.Limit > 0 {
q.Set("limit", strconv.Itoa(req.Limit))
}
if req.Cursor != "" {
q.Set("cursor", req.Cursor)
}
if req.SortBy != "" {
q.Set("sort_by", req.SortBy)
}
path := "/toolkits"
if encoded := q.Encode(); encoded != "" {
path += "?" + encoded
}
var out ListToolkitsResponse
if err := c.do(c.newRequest(ctx), http.MethodGet, path, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetToolkit fetches a single toolkit by its slug (e.g. "notion", "github").
func (c *Client) GetToolkit(ctx context.Context, slug string) (*Toolkit, error) {
if slug == "" {
return nil, errors.New("composio: GetToolkit: slug is required")
}
var out Toolkit
if err := c.do(c.newRequest(ctx),
http.MethodGet, "/toolkits/"+url.PathEscape(slug), &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@@ -1,74 +0,0 @@
package composio
import (
"context"
"errors"
"net/http"
"net/url"
)
// ExecuteToolRequest is the body for POST /tools/execute/{tool_slug}.
//
// Spec: https://docs.composio.dev/reference/api-reference/tools/postToolsExecuteByToolSlug
//
// Either ConnectedAccountID or (UserID + the tool's toolkit) is required so
// Composio knows which credential set to use. The SDK does not enforce that
// invariant up front; the upstream returns a 422 with a clear message when
// missing.
type ExecuteToolRequest struct {
// Arguments is the structured input to the tool. Shape varies per tool.
Arguments map[string]any `json:"arguments,omitempty"`
// ConnectedAccountID pins execution to a specific connected account.
ConnectedAccountID string `json:"connected_account_id,omitempty"`
// UserID lets Composio resolve the connected account by user when
// the caller does not have the explicit `ca_` id handy.
UserID string `json:"user_id,omitempty"`
// Version pins the tool definition version. Pass "latest" or a dated
// version like "20251027_00"; defaults to "00000000_00" upstream.
//
// The Composio docs note that manual tool execution requires an explicit
// version; setting this avoids unintended drift when Composio promotes
// a new latest.
Version string `json:"version,omitempty"`
// AllowTracing is the upstream-deprecated debug-tracing flag.
//
// Deprecated: marked deprecated on the Composio side (v3.1) — kept here
// only for backward compatibility with existing callers. Will be removed
// once Composio drops the field.
AllowTracing bool `json:"allow_tracing,omitempty"`
}
// ExecuteToolResponse is the typed result. The upstream wire shape varies by
// tool, so [Data] is intentionally generic; callers cast to whatever the
// tool's documented output schema looks like.
type ExecuteToolResponse struct {
Successful bool `json:"successful"`
Data map[string]any `json:"data,omitempty"`
Error string `json:"error,omitempty"`
LogID string `json:"log_id,omitempty"`
SessionInfo map[string]any `json:"session_info,omitempty"`
}
// ExecuteTool calls a Composio tool by its slug
// (SCREAMING_SNAKE_CASE, e.g. GITHUB_CREATE_ISSUE).
//
// This is the deterministic backend path — it skips MCP/session orchestration
// and is the right call for fixed flows like autopilots or built-in skills.
func (c *Client) ExecuteTool(ctx context.Context, toolSlug string, req ExecuteToolRequest) (*ExecuteToolResponse, error) {
if toolSlug == "" {
return nil, errors.New("composio: ExecuteTool: toolSlug is required")
}
if req.ConnectedAccountID == "" && req.UserID == "" {
return nil, errors.New("composio: ExecuteTool: either ConnectedAccountID or UserID must be set")
}
var out ExecuteToolResponse
if err := c.do(c.newRequest(ctx).SetBody(req),
http.MethodPost, "/tools/execute/"+url.PathEscape(toolSlug), &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@@ -1,191 +0,0 @@
package composio
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Webhook header names Composio sets on every delivery.
const (
HeaderWebhookID = "webhook-id"
HeaderWebhookTimestamp = "webhook-timestamp"
HeaderWebhookSignature = "webhook-signature"
)
// DefaultWebhookTolerance is the default replay window — matches the
// official Composio SDKs (300 s, see Composio webhook docs).
const DefaultWebhookTolerance = 300 * time.Second
// Sentinel errors returned by [VerifyWebhook] so callers can distinguish
// the failure mode with errors.Is.
var (
ErrMissingWebhookHeaders = errors.New("composio: missing webhook headers")
ErrInvalidWebhookSignature = errors.New("composio: invalid webhook signature")
ErrWebhookTimestampStale = errors.New("composio: webhook timestamp outside tolerance")
ErrWebhookSecretMissing = errors.New("composio: webhook secret is empty")
)
// WebhookHeaders carries the three headers that participate in the signature
// computation. Pass these straight from the inbound HTTP request.
type WebhookHeaders struct {
ID string
Timestamp string
Signature string
}
// HeadersFromHTTP pulls the three webhook headers off an http.Header.
// It is case-insensitive (http.Header normalizes its keys).
func HeadersFromHTTP(h http.Header) WebhookHeaders {
return WebhookHeaders{
ID: h.Get(HeaderWebhookID),
Timestamp: h.Get(HeaderWebhookTimestamp),
Signature: h.Get(HeaderWebhookSignature),
}
}
// VerifyOptions tweaks [VerifyWebhook]. Zero values mean defaults.
type VerifyOptions struct {
// Tolerance is how far the webhook-timestamp may drift from `now`.
// Zero means [DefaultWebhookTolerance]; a negative value disables the
// check entirely (useful only for replaying historical deliveries in
// tests).
Tolerance time.Duration
// Now overrides the wall clock used for the tolerance check.
// Tests use this; production should leave it nil.
Now func() time.Time
}
// VerifyWebhook checks the HMAC-SHA256 signature attached by Composio to
// every webhook delivery and enforces a replay-window tolerance.
//
// The signing string is constructed as
//
// "<webhook-id>.<webhook-timestamp>.<rawBody>"
//
// and HMAC-SHA256'd with secret. The result is base64 encoded.
//
// Composio's `webhook-signature` header is a comma-separated list of
// `<version>,<signature>` pairs (e.g. `v1,abc123…`); this function accepts
// any of them whose version starts with "v" so future-proofs work.
//
// secret must be the value from the matching webhook subscription —
// fetch via the Composio dashboard or the
// `GET /webhook_subscriptions/{id}` endpoint.
func VerifyWebhook(secret string, headers WebhookHeaders, rawBody []byte, opts VerifyOptions) error {
if secret == "" {
return ErrWebhookSecretMissing
}
if headers.ID == "" || headers.Timestamp == "" || headers.Signature == "" {
return ErrMissingWebhookHeaders
}
tolerance := opts.Tolerance
if tolerance == 0 {
tolerance = DefaultWebhookTolerance
}
if tolerance > 0 {
ts, err := strconv.ParseInt(headers.Timestamp, 10, 64)
if err != nil {
// Composio's docs show timestamps as Unix seconds, but allow a
// fallback in case future deliveries use RFC3339.
t, terr := time.Parse(time.RFC3339, headers.Timestamp)
if terr != nil {
return fmt.Errorf("composio: invalid webhook-timestamp %q: %w", headers.Timestamp, err)
}
ts = t.Unix()
}
now := time.Now().UTC()
if opts.Now != nil {
now = opts.Now().UTC()
}
delta := now.Sub(time.Unix(ts, 0))
if delta < 0 {
delta = -delta
}
if delta > tolerance {
return fmt.Errorf("%w: drift=%s tolerance=%s", ErrWebhookTimestampStale, delta, tolerance)
}
}
signingString := headers.ID + "." + headers.Timestamp + "." + string(rawBody)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(signingString))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Composio's header takes the form "v1,<sig>[ v2,<sig> ...]" — accept
// any version-tagged signature plus the bare-base64 form for forward-
// compat.
candidates := strings.Fields(strings.ReplaceAll(headers.Signature, ",", " "))
if len(candidates) == 0 {
return ErrInvalidWebhookSignature
}
want := []byte(expected)
for _, cand := range candidates {
// Skip version tags like "v1" / "v2".
if len(cand) <= 3 && strings.HasPrefix(cand, "v") {
continue
}
if hmac.Equal([]byte(cand), want) {
return nil
}
}
return ErrInvalidWebhookSignature
}
// VerifyHTTPRequest is a convenience wrapper that reads & verifies an
// inbound *http.Request in one call. It consumes the body and returns it
// to the caller so the handler can json-decode after a successful verify.
//
// On error the returned body slice is still populated (when read succeeded)
// so handlers can choose to log it.
func VerifyHTTPRequest(secret string, r *http.Request, opts VerifyOptions) ([]byte, error) {
if r == nil || r.Body == nil {
return nil, errors.New("composio: VerifyHTTPRequest: request body is nil")
}
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("composio: read webhook body: %w", err)
}
_ = r.Body.Close()
if verr := VerifyWebhook(secret, HeadersFromHTTP(r.Header), body, opts); verr != nil {
return body, verr
}
return body, nil
}
// --- Event envelope -----------------------------------------------------
// EventEnvelope is the V3 webhook payload as documented by Composio.
//
// Spec: https://docs.composio.dev/docs/setting-up-triggers/subscribing-to-events#webhook-payload-versions
//
// The `data` and `metadata` blocks vary per event; they stay as
// json.RawMessage so callers can decode into a strongly-typed struct
// matching whatever Type they care about.
type EventEnvelope struct {
ID string `json:"id"`
Type string `json:"type"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
// ParseEvent decodes a V3 envelope. It does NOT verify the signature —
// always call [VerifyWebhook] / [VerifyHTTPRequest] first.
func ParseEvent(rawBody []byte) (*EventEnvelope, error) {
var out EventEnvelope
if err := json.Unmarshal(rawBody, &out); err != nil {
return nil, fmt.Errorf("composio: parse webhook envelope: %w", err)
}
return &out, nil
}

View File

@@ -1,254 +0,0 @@
package composio_test
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/multica-ai/multica/server/pkg/composio"
)
// helper: produce a valid signature for the given inputs.
func sign(secret, id, ts, body string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(id + "." + ts + "." + body))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func TestVerifyWebhook_Success(t *testing.T) {
secret := "shh"
body := `{"id":"evt_1","type":"composio.connected_account.expired"}`
id := "msg_abc"
ts := strconv.FormatInt(time.Now().Unix(), 10)
sig := sign(secret, id, ts, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: "v1," + sig,
}, []byte(body), composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyWebhook: %v", err)
}
}
func TestVerifyWebhook_AcceptsBareSignature(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_b"
ts := strconv.FormatInt(time.Now().Unix(), 10)
sig := sign(secret, id, ts, body)
// No version prefix: just the raw base64
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: sig,
}, []byte(body), composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyWebhook bare: %v", err)
}
}
func TestVerifyWebhook_AcceptsMultipleVersions(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_c"
ts := strconv.FormatInt(time.Now().Unix(), 10)
good := sign(secret, id, ts, body)
bad := "AAAA" + good[4:]
// One bad sig, one good sig — verify should still pass.
hdr := "v2," + bad + " v1," + good
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: hdr,
}, []byte(body), composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyWebhook multi: %v", err)
}
}
func TestVerifyWebhook_RejectsTamperedBody(t *testing.T) {
secret := "shh"
body := `{"data":"original"}`
id := "msg_d"
ts := strconv.FormatInt(time.Now().Unix(), 10)
sig := sign(secret, id, ts, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: "v1," + sig,
}, []byte(`{"data":"tampered"}`), composio.VerifyOptions{})
if !errors.Is(err, composio.ErrInvalidWebhookSignature) {
t.Fatalf("expected ErrInvalidWebhookSignature, got %v", err)
}
}
func TestVerifyWebhook_RejectsStaleTimestamp(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_e"
old := time.Now().Add(-10 * time.Minute).Unix()
ts := strconv.FormatInt(old, 10)
sig := sign(secret, id, ts, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: "v1," + sig,
}, []byte(body), composio.VerifyOptions{Tolerance: 5 * time.Minute})
if !errors.Is(err, composio.ErrWebhookTimestampStale) {
t.Fatalf("expected ErrWebhookTimestampStale, got %v", err)
}
}
func TestVerifyWebhook_NegativeToleranceDisablesCheck(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_f"
ts := "1" // ancient
sig := sign(secret, id, ts, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: "v1," + sig,
}, []byte(body), composio.VerifyOptions{Tolerance: -1})
if err != nil {
t.Fatalf("VerifyWebhook negative tolerance: %v", err)
}
}
func TestVerifyWebhook_HonorsCustomNow(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_g"
ts := "1700000000"
sig := sign(secret, id, ts, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: ts, Signature: "v1," + sig,
}, []byte(body), composio.VerifyOptions{
Tolerance: 5 * time.Second,
Now: func() time.Time { return time.Unix(1700000003, 0) },
})
if err != nil {
t.Fatalf("expected fresh timestamp, got %v", err)
}
}
func TestVerifyWebhook_MissingHeaders(t *testing.T) {
err := composio.VerifyWebhook("shh", composio.WebhookHeaders{}, []byte(`{}`), composio.VerifyOptions{})
if !errors.Is(err, composio.ErrMissingWebhookHeaders) {
t.Fatalf("expected ErrMissingWebhookHeaders, got %v", err)
}
}
func TestVerifyWebhook_EmptySecret(t *testing.T) {
err := composio.VerifyWebhook("", composio.WebhookHeaders{
ID: "x", Timestamp: "1", Signature: "v1,xyz",
}, []byte(`{}`), composio.VerifyOptions{})
if !errors.Is(err, composio.ErrWebhookSecretMissing) {
t.Fatalf("expected ErrWebhookSecretMissing, got %v", err)
}
}
func TestVerifyWebhook_AcceptsRFC3339Timestamp(t *testing.T) {
secret := "shh"
body := `{}`
id := "msg_h"
now := time.Now().UTC().Format(time.RFC3339)
sig := sign(secret, id, now, body)
err := composio.VerifyWebhook(secret, composio.WebhookHeaders{
ID: id, Timestamp: now, Signature: "v1," + sig,
}, []byte(body), composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyWebhook rfc3339: %v", err)
}
}
func TestVerifyHTTPRequest_HappyPath(t *testing.T) {
secret := "shh"
body := `{"x":1}`
id := "msg_req"
ts := strconv.FormatInt(time.Now().Unix(), 10)
sig := sign(secret, id, ts, body)
r := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte(body)))
r.Header.Set(composio.HeaderWebhookID, id)
r.Header.Set(composio.HeaderWebhookTimestamp, ts)
r.Header.Set(composio.HeaderWebhookSignature, "v1,"+sig)
got, err := composio.VerifyHTTPRequest(secret, r, composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyHTTPRequest: %v", err)
}
if string(got) != body {
t.Errorf("body roundtrip mismatch: %q vs %q", got, body)
}
}
func TestVerifyHTTPRequest_ReturnsBodyOnFailure(t *testing.T) {
body := `{"x":1}`
r := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte(body)))
r.Header.Set(composio.HeaderWebhookID, "id")
r.Header.Set(composio.HeaderWebhookTimestamp, strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set(composio.HeaderWebhookSignature, "v1,deadbeef")
got, err := composio.VerifyHTTPRequest("shh", r, composio.VerifyOptions{})
if err == nil {
t.Fatal("expected error")
}
if string(got) != body {
t.Errorf("expected body returned for logging, got %q", got)
}
}
func TestVerifyHTTPRequest_NilBody(t *testing.T) {
r := &http.Request{}
_, err := composio.VerifyHTTPRequest("shh", r, composio.VerifyOptions{})
if err == nil {
t.Fatal("expected error for nil body")
}
}
// Sanity check: io.ReadAll still gets the same body bytes via our helper.
func TestVerifyHTTPRequest_BodyReadFully(t *testing.T) {
body := "{}"
r := httptest.NewRequest(http.MethodPost, "/", io.NopCloser(bytes.NewReader([]byte(body))))
ts := strconv.FormatInt(time.Now().Unix(), 10)
r.Header.Set(composio.HeaderWebhookID, "id")
r.Header.Set(composio.HeaderWebhookTimestamp, ts)
r.Header.Set(composio.HeaderWebhookSignature, "v1,"+sign("shh", "id", ts, body))
got, err := composio.VerifyHTTPRequest("shh", r, composio.VerifyOptions{})
if err != nil {
t.Fatalf("VerifyHTTPRequest: %v", err)
}
if string(got) != body {
t.Errorf("body = %q, want %q", got, body)
}
}
// ---------------------------------------------------------------------------
// Event envelope
// ---------------------------------------------------------------------------
func TestParseEvent_V3Envelope(t *testing.T) {
raw := []byte(`{
"id": "evt_1",
"type": "composio.connected_account.expired",
"metadata": {"project_id":"pr_a","user_id":"u_1"},
"data": {"id":"ca_1","status":"EXPIRED"},
"timestamp": "2026-02-06T12:00:00Z"
}`)
ev, err := composio.ParseEvent(raw)
if err != nil {
t.Fatalf("ParseEvent: %v", err)
}
if ev.ID != "evt_1" || ev.Type != "composio.connected_account.expired" {
t.Errorf("unexpected envelope: %+v", ev)
}
if !bytes.Contains(ev.Data, []byte(`"EXPIRED"`)) {
t.Errorf("data lost: %s", ev.Data)
}
}
func TestParseEvent_RejectsGarbage(t *testing.T) {
if _, err := composio.ParseEvent([]byte(`not-json`)); err == nil {
t.Error("expected error for non-JSON body")
}
}

View File

@@ -259,7 +259,7 @@ SELECT
COALESCE(SUM(CASE WHEN pr.state = 'merged' AND ipr.close_intent THEN 1 ELSE 0 END), 0)::bigint AS merged_with_close_intent_count
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
WHERE ipr.issue_id = $1 AND NOT ipr.reference_only
WHERE ipr.issue_id = $1
`
type GetIssuePullRequestCloseAggregateRow struct {
@@ -275,13 +275,6 @@ type GetIssuePullRequestCloseAggregateRow struct {
// (with close_intent) are persisted before this query runs, so the result
// is event-agnostic — a link-only sibling closing after a closing-keyword
// PR has already merged still resolves the issue.
//
// reference_only links (a PR that merely mentions the issue identifier in its
// body) are excluded: they are hidden from the issue PR list, so they must not
// silently gate auto-advance either. An open body-only mention would otherwise
// keep open_count > 0 and block the issue from advancing while being invisible
// in the UI. (reference_only rows never carry close_intent, so excluding them
// does not change merged_with_close_intent_count.)
func (q *Queries) GetIssuePullRequestCloseAggregate(ctx context.Context, issueID pgtype.UUID) (GetIssuePullRequestCloseAggregateRow, error) {
row := q.db.QueryRow(ctx, getIssuePullRequestCloseAggregate, issueID)
var i GetIssuePullRequestCloseAggregateRow
@@ -310,18 +303,14 @@ func (q *Queries) GetPendingGitHubInstallation(ctx context.Context, installation
const linkIssueToPullRequest = `-- name: LinkIssueToPullRequest :exec
INSERT INTO issue_pull_request (
issue_id, pull_request_id, linked_by_type, linked_by_id, close_intent, reference_only
issue_id, pull_request_id, linked_by_type, linked_by_id, close_intent
) VALUES (
$1, $2, $4, $5, $3, $6
$1, $2, $4, $5, $3
)
ON CONFLICT (issue_id, pull_request_id) DO UPDATE SET
close_intent = CASE
WHEN $7 THEN issue_pull_request.close_intent
WHEN $6 THEN issue_pull_request.close_intent
ELSE EXCLUDED.close_intent
END,
reference_only = CASE
WHEN $7 THEN issue_pull_request.reference_only
ELSE EXCLUDED.reference_only
END
`
@@ -331,7 +320,6 @@ type LinkIssueToPullRequestParams struct {
CloseIntent bool `json:"close_intent"`
LinkedByType pgtype.Text `json:"linked_by_type"`
LinkedByID pgtype.UUID `json:"linked_by_id"`
ReferenceOnly bool `json:"reference_only"`
PreserveCloseIntent bool `json:"preserve_close_intent"`
}
@@ -343,11 +331,6 @@ type LinkIssueToPullRequestParams struct {
// the current title/body parse result so authors can remove a closing keyword
// before merge. Post-terminal edits can opt into preserving the stored value,
// keeping the merge-time decision stable.
//
// reference_only marks a link justified ONLY by a bare body mention (no closing
// keyword, no title/branch reference). It follows the same preserve gate as
// close_intent so a post-terminal edit can't retroactively hide a PR that did
// the work. The issue's PR list filters these out (see ListPullRequestsByIssue).
func (q *Queries) LinkIssueToPullRequest(ctx context.Context, arg LinkIssueToPullRequestParams) error {
_, err := q.db.Exec(ctx, linkIssueToPullRequest,
arg.IssueID,
@@ -355,7 +338,6 @@ func (q *Queries) LinkIssueToPullRequest(ctx context.Context, arg LinkIssueToPul
arg.CloseIntent,
arg.LinkedByType,
arg.LinkedByID,
arg.ReferenceOnly,
arg.PreserveCloseIntent,
)
return err
@@ -431,7 +413,7 @@ WITH issue_prs AS (
SELECT pr.id, pr.head_sha
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
WHERE ipr.issue_id = $1 AND NOT ipr.reference_only
WHERE ipr.issue_id = $1
),
per_app_latest AS (
SELECT DISTINCT ON (cs.pr_id, cs.app_id)
@@ -470,7 +452,7 @@ SELECT
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
LEFT JOIN checks c ON c.pr_id = pr.id
WHERE ipr.issue_id = $1 AND NOT ipr.reference_only
WHERE ipr.issue_id = $1
ORDER BY pr.pr_created_at DESC
`
@@ -512,9 +494,7 @@ type ListPullRequestsByIssueRow struct {
// selected so a single app firing multiple suites on the same head doesn't
// get counted N times. Late-arriving suites for an OLD head are stored but
// excluded by the head_sha filter, so they can't override the new head's
// pending view. reference_only links (a PR that merely mentions the issue
// identifier in its body, with no closing keyword and no title/branch
// reference) are filtered out — they are not working PRs for this issue.
// pending view.
func (q *Queries) ListPullRequestsByIssue(ctx context.Context, issueID pgtype.UUID) ([]ListPullRequestsByIssueRow, error) {
rows, err := q.db.Query(ctx, listPullRequestsByIssue, issueID)
if err != nil {

View File

@@ -175,57 +175,6 @@ func (q *Queries) CountUnreadInbox(ctx context.Context, arg CountUnreadInboxPara
return count, err
}
const countUnreadInboxByWorkspace = `-- name: CountUnreadInboxByWorkspace :many
SELECT newest.workspace_id, count(*) AS count
FROM (
SELECT DISTINCT ON (i.workspace_id, COALESCE(i.issue_id, i.id))
i.workspace_id, i.read
FROM inbox_item i
JOIN member m ON m.workspace_id = i.workspace_id AND m.user_id = i.recipient_id
WHERE i.recipient_type = 'member'
AND i.recipient_id = $1
AND i.archived = false
ORDER BY i.workspace_id, COALESCE(i.issue_id, i.id), i.created_at DESC
) newest
WHERE newest.read = false
GROUP BY newest.workspace_id
`
type CountUnreadInboxByWorkspaceRow struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Count int64 `json:"count"`
}
// Per-workspace unread inbox counts for a recipient member, matching the
// inbox UI's deduplicated view: notifications are grouped per issue
// (Linear-style, one row per issue) and an issue counts as unread only when
// its NEWEST non-archived item is unread. Opening an issue marks just that
// newest item read, so counting raw unread rows would keep older siblings
// alive and light the switcher dot for a workspace whose inbox the user sees
// as empty (MUL-3695). Items without an issue group on their own id. The
// member join keeps counts scoped to workspaces the user still belongs to,
// so a stale item left behind in a workspace the user has since left cannot
// light the dot.
func (q *Queries) CountUnreadInboxByWorkspace(ctx context.Context, recipientID pgtype.UUID) ([]CountUnreadInboxByWorkspaceRow, error) {
rows, err := q.db.Query(ctx, countUnreadInboxByWorkspace, recipientID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CountUnreadInboxByWorkspaceRow{}
for rows.Next() {
var i CountUnreadInboxByWorkspaceRow
if err := rows.Scan(&i.WorkspaceID, &i.Count); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const createInboxItem = `-- name: CreateInboxItem :one
INSERT INTO inbox_item (
workspace_id, recipient_type, recipient_id,

View File

@@ -502,7 +502,6 @@ type IssuePullRequest struct {
LinkedByID pgtype.UUID `json:"linked_by_id"`
LinkedAt pgtype.Timestamptz `json:"linked_at"`
CloseIntent bool `json:"close_intent"`
ReferenceOnly bool `json:"reference_only"`
}
type IssueReaction struct {

View File

@@ -120,14 +120,12 @@ WHERE workspace_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $
-- selected so a single app firing multiple suites on the same head doesn't
-- get counted N times. Late-arriving suites for an OLD head are stored but
-- excluded by the head_sha filter, so they can't override the new head's
-- pending view. reference_only links (a PR that merely mentions the issue
-- identifier in its body, with no closing keyword and no title/branch
-- reference) are filtered out — they are not working PRs for this issue.
-- pending view.
WITH issue_prs AS (
SELECT pr.id, pr.head_sha
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
WHERE ipr.issue_id = sqlc.arg('issue_id') AND NOT ipr.reference_only
WHERE ipr.issue_id = sqlc.arg('issue_id')
),
per_app_latest AS (
SELECT DISTINCT ON (cs.pr_id, cs.app_id)
@@ -166,7 +164,7 @@ SELECT
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
LEFT JOIN checks c ON c.pr_id = pr.id
WHERE ipr.issue_id = sqlc.arg('issue_id') AND NOT ipr.reference_only
WHERE ipr.issue_id = sqlc.arg('issue_id')
ORDER BY pr.pr_created_at DESC;
-- name: ListIssueIDsForPullRequest :many
@@ -182,19 +180,12 @@ WHERE pull_request_id = $1;
-- (with close_intent) are persisted before this query runs, so the result
-- is event-agnostic — a link-only sibling closing after a closing-keyword
-- PR has already merged still resolves the issue.
--
-- reference_only links (a PR that merely mentions the issue identifier in its
-- body) are excluded: they are hidden from the issue PR list, so they must not
-- silently gate auto-advance either. An open body-only mention would otherwise
-- keep open_count > 0 and block the issue from advancing while being invisible
-- in the UI. (reference_only rows never carry close_intent, so excluding them
-- does not change merged_with_close_intent_count.)
SELECT
COALESCE(SUM(CASE WHEN pr.state IN ('open', 'draft') THEN 1 ELSE 0 END), 0)::bigint AS open_count,
COALESCE(SUM(CASE WHEN pr.state = 'merged' AND ipr.close_intent THEN 1 ELSE 0 END), 0)::bigint AS merged_with_close_intent_count
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
WHERE ipr.issue_id = $1 AND NOT ipr.reference_only;
WHERE ipr.issue_id = $1;
-- =====================
-- GitHub PR check suite
@@ -271,24 +262,15 @@ RETURNING suite_id, head_sha, app_id, conclusion, status, suite_updated_at;
-- the current title/body parse result so authors can remove a closing keyword
-- before merge. Post-terminal edits can opt into preserving the stored value,
-- keeping the merge-time decision stable.
--
-- reference_only marks a link justified ONLY by a bare body mention (no closing
-- keyword, no title/branch reference). It follows the same preserve gate as
-- close_intent so a post-terminal edit can't retroactively hide a PR that did
-- the work. The issue's PR list filters these out (see ListPullRequestsByIssue).
INSERT INTO issue_pull_request (
issue_id, pull_request_id, linked_by_type, linked_by_id, close_intent, reference_only
issue_id, pull_request_id, linked_by_type, linked_by_id, close_intent
) VALUES (
$1, $2, sqlc.narg('linked_by_type'), sqlc.narg('linked_by_id'), $3, sqlc.arg('reference_only')
$1, $2, sqlc.narg('linked_by_type'), sqlc.narg('linked_by_id'), $3
)
ON CONFLICT (issue_id, pull_request_id) DO UPDATE SET
close_intent = CASE
WHEN sqlc.arg('preserve_close_intent') THEN issue_pull_request.close_intent
ELSE EXCLUDED.close_intent
END,
reference_only = CASE
WHEN sqlc.arg('preserve_close_intent') THEN issue_pull_request.reference_only
ELSE EXCLUDED.reference_only
END;
-- name: UnlinkIssueFromPullRequest :exec

View File

@@ -45,31 +45,6 @@ RETURNING recipient_type, recipient_id;
SELECT count(*) FROM inbox_item
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND read = false AND archived = false;
-- name: CountUnreadInboxByWorkspace :many
-- Per-workspace unread inbox counts for a recipient member, matching the
-- inbox UI's deduplicated view: notifications are grouped per issue
-- (Linear-style, one row per issue) and an issue counts as unread only when
-- its NEWEST non-archived item is unread. Opening an issue marks just that
-- newest item read, so counting raw unread rows would keep older siblings
-- alive and light the switcher dot for a workspace whose inbox the user sees
-- as empty (MUL-3695). Items without an issue group on their own id. The
-- member join keeps counts scoped to workspaces the user still belongs to,
-- so a stale item left behind in a workspace the user has since left cannot
-- light the dot.
SELECT newest.workspace_id, count(*) AS count
FROM (
SELECT DISTINCT ON (i.workspace_id, COALESCE(i.issue_id, i.id))
i.workspace_id, i.read
FROM inbox_item i
JOIN member m ON m.workspace_id = i.workspace_id AND m.user_id = i.recipient_id
WHERE i.recipient_type = 'member'
AND i.recipient_id = $1
AND i.archived = false
ORDER BY i.workspace_id, COALESCE(i.issue_id, i.id), i.created_at DESC
) newest
WHERE newest.read = false
GROUP BY newest.workspace_id;
-- name: MarkAllInboxRead :execrows
UPDATE inbox_item SET read = true
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false AND read = false;

View File

@@ -12,8 +12,7 @@
"COMPOSE_PROJECT_NAME",
"POSTGRES_DB",
"POSTGRES_PORT",
"DESKTOP_RENDERER_PORT",
"DESKTOP_APP_SUFFIX"
"DESKTOP_RENDERER_PORT"
],
"tasks": {
"build": {