@loicngr/kobo 1.5.2 → 1.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/dist/server/index.js +2 -0
- package/dist/server/routes/sentry.js +20 -0
- package/dist/server/routes/settings.js +11 -0
- package/dist/server/routes/workspaces.js +93 -3
- package/dist/server/services/notion-service.js +45 -189
- package/dist/server/services/sentry-service.js +135 -0
- package/dist/server/services/settings-service.js +23 -0
- package/dist/server/utils/mcp-client.js +211 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-CyCjnWpd.js → ActivityFeed-DwLZQtds.js} +1 -1
- package/src/client/dist/spa/assets/CreatePage-DnV87-Ej.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-y7wOGccu.css +1 -0
- package/src/client/dist/spa/assets/{DiffViewer-CODOs-nD.js → DiffViewer-DGMt53xq.js} +2 -2
- package/src/client/dist/spa/assets/{MainLayout-gJB92Uex.js → MainLayout-liO7poC9.js} +2 -2
- package/src/client/dist/spa/assets/{QExpansionItem-GHHBo3vG.js → QExpansionItem-DG5mKIcR.js} +1 -1
- package/src/client/dist/spa/assets/{QList-D4El-L0w.js → QList-B7DWhM2q.js} +1 -1
- package/src/client/dist/spa/assets/{QMenu-DPjNAi13.js → QMenu-hku3VAGZ.js} +1 -1
- package/src/client/dist/spa/assets/{QPage-DyGpMmRB.js → QPage-BhzgHYFd.js} +1 -1
- package/src/client/dist/spa/assets/SettingsPage-C26BwQT7.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-LzfsgdZ9.js +1 -0
- package/src/client/dist/spa/assets/{WorkspacePage-DCIpeMKc.js → WorkspacePage-BaOtXfiF.js} +3 -3
- package/src/client/dist/spa/assets/{cssMode-Bs7Ir5HZ.js → cssMode-DiBSKuoC.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-Bx7Ah6pf.js → editor.api-hOnUFdOA.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-tO5BzTPC.js → editor.main-DNa8GpeG.js} +3 -3
- package/src/client/dist/spa/assets/{freemarker2-CDfily2B.js → freemarker2-Dj7P1Jpi.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-AwDxxENn.js → handlebars-DEMCvy8L.js} +1 -1
- package/src/client/dist/spa/assets/{html-BY9SWE1C.js → html-CPhzMOby.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-C6FDyX-w.js → htmlMode-DneB4Mlv.js} +1 -1
- package/src/client/dist/spa/assets/i18n-BYjjZp-H.js +1 -0
- package/src/client/dist/spa/assets/i18n-Dho6KmW_.js +1 -0
- package/src/client/dist/spa/assets/index-CFlp44pN.js +5 -0
- package/src/client/dist/spa/assets/{javascript-Blavvi4I.js → javascript-xA20aQhN.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-CsgNfmDe.js → jsonMode-DB1q7_RO.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-CeX-oFi-.js → liquid-DUaXf8Ca.js} +1 -1
- package/src/client/dist/spa/assets/{marked.esm-DqTghqFg.js → marked.esm-hG3KqOZk.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-CzdoKooW.js → mdx-DhSPjkRN.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-2VdGUl2G.js → monaco.contribution-BRz6c5Oe.js} +2 -2
- package/src/client/dist/spa/assets/{python-DfvLV8-T.js → python-Cf6SIZRF.js} +1 -1
- package/src/client/dist/spa/assets/{razor-CLVHHld4.js → razor-N0lXOpYt.js} +1 -1
- package/src/client/dist/spa/assets/{settings-CuK-S6HH.js → settings-CrHkitxT.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-B8UnweFF.js → tsMode-rGOAcVzO.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-BtDEyXr-.js → typescript-BDFRwi4p.js} +1 -1
- package/src/client/dist/spa/assets/{xml-BPbtYZUS.js → xml-Bu77_PI9.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-Ddz4CrJo.js → yaml-wD3nav-2.js} +1 -1
- package/src/client/dist/spa/index.html +3 -3
- package/src/client/dist/spa/assets/CreatePage-BKe7LN7v.js +0 -2
- package/src/client/dist/spa/assets/CreatePage-BTFc1WXO.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BG_7gLtm.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-ai7Q_1KB.css +0 -1
- package/src/client/dist/spa/assets/i18n-Ck0cyale.js +0 -1
- package/src/client/dist/spa/assets/i18n-NYEh-R2v.js +0 -1
- package/src/client/dist/spa/assets/index-C0rZED_M.js +0 -5
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
|
|
|
15
15
|
- **Live agent output** — stream `stdout`/`stderr` from Claude Code to the browser via WebSocket, with persisted event replay on reconnect
|
|
16
16
|
- **Task & acceptance criteria tracking** — the agent reports progress through a dedicated MCP server (`kobo-tasks`) that reads and updates tasks directly from the SQLite database
|
|
17
17
|
- **Notion integration** — pull workspace missions straight from Notion pages, extract markdown, and use it as the source of truth for acceptance criteria
|
|
18
|
+
- **Sentry integration** — paste a Sentry issue URL to spin up a dedicated "fix workspace" with the stacktrace, tags, and offending spans written to `.ai/thoughts/SENTRY-<id>.md`; the agent is primed with a TDD fix workflow and has access to the Sentry MCP tools for deeper digging
|
|
18
19
|
- **Per-workspace dev servers** — start/stop Docker or Node dev servers scoped to each branch, with log streaming
|
|
19
20
|
- **Conventional-commit enforcement** — project-level git conventions are written to `.ai/.git-conventions.md` inside every workspace so Claude follows them during commits
|
|
20
21
|
- **Pull request automation** — one-click `push`, `pull`, and `open-pr` endpoints integrate with the GitHub CLI, using a configurable prompt template
|
|
@@ -41,6 +42,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
|
|
|
41
42
|
- Optional: Docker (if you configure per-workspace dev servers)
|
|
42
43
|
- Optional: `gh` CLI (if you use the PR automation)
|
|
43
44
|
- Optional: a Notion integration token (only if you want to import workspace missions from Notion pages — see [Notion integration](#notion-integration))
|
|
45
|
+
- Optional: a Sentry auth token (only if you want to create fix workspaces from Sentry issue URLs — see [Sentry integration](#sentry-integration))
|
|
44
46
|
|
|
45
47
|
### Run via `npx` (recommended)
|
|
46
48
|
|
|
@@ -137,6 +139,51 @@ If you need to pin a specific version of the Notion MCP server, use a fork, or a
|
|
|
137
139
|
|
|
138
140
|
Without a valid token configured, the Notion import field in the workspace creation form will return an error when you click **Refresh** or submit a Notion URL — the rest of Kōbō (workspaces, agents, tasks, Git integration) keeps working independently.
|
|
139
141
|
|
|
142
|
+
## Sentry integration
|
|
143
|
+
|
|
144
|
+
Kōbō can turn a Sentry issue into a dedicated "fix workspace" — you paste the issue URL at workspace creation and Kōbō extracts the stacktrace, culprit, tags, offending spans and extra context, writes them as a local markdown file inside the worktree (`.ai/thoughts/SENTRY-<id>.md`), and primes the Claude agent with a TDD fix workflow that points at that file. The agent also keeps access to the Sentry MCP tools (`search_issue_events`, `get_issue_tag_values`, `get_sentry_resource`) so it can dig deeper on its own. **This feature is opt-in and reuses the Sentry MCP configuration you already have for Claude Code** — Kōbō does not manage a Sentry token separately.
|
|
145
|
+
|
|
146
|
+
Under the hood, Kōbō spawns the official [`@sentry/mcp-server`](https://www.npmjs.com/package/@sentry/mcp-server) as a child process using the exact `command`, `args`, and `env` from your `~/.claude.json`, then calls `get_sentry_resource` over stdio. No token handling inside Kōbō — if you change the token or the host in your Claude Code config, Kōbō follows automatically.
|
|
147
|
+
|
|
148
|
+
### Getting a Sentry auth token
|
|
149
|
+
|
|
150
|
+
1. In Sentry, go to **Settings → Developer Settings → Custom Integrations** (or **User Auth Tokens** for personal use)
|
|
151
|
+
2. Create a token with at least these scopes: `project:read`, `event:read`, `org:read`
|
|
152
|
+
3. Copy the token (format `sntryu_...` for user tokens)
|
|
153
|
+
|
|
154
|
+
### Configuring the Sentry MCP in Claude Code
|
|
155
|
+
|
|
156
|
+
The recommended setup is to register the Sentry MCP once in Claude Code — Kōbō picks it up automatically:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
claude mcp add sentry -s user \
|
|
160
|
+
-e SENTRY_ACCESS_TOKEN=sntryu_your_token_here \
|
|
161
|
+
-e SENTRY_HOST=your-org.sentry.io \
|
|
162
|
+
-- npx -y @sentry/mcp-server@latest
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
For self-hosted Sentry, set `SENTRY_HOST` to your Sentry hostname (e.g. `sentry.mycompany.com`).
|
|
166
|
+
|
|
167
|
+
### How Kōbō picks the entry
|
|
168
|
+
|
|
169
|
+
Kōbō reads `~/.claude.json` and uses the first entry under `mcpServers` whose key contains `sentry` (case-insensitive) **and is not disabled**. This means:
|
|
170
|
+
|
|
171
|
+
- A single `sentry` entry → used as-is
|
|
172
|
+
- Multiple entries whose key contains `sentry` → the first matching non-disabled key wins
|
|
173
|
+
- Toggle `"disabled": true` on an entry to make Kōbō skip it
|
|
174
|
+
|
|
175
|
+
### Usage
|
|
176
|
+
|
|
177
|
+
1. In the workspace creation form, click **Import Sentry**
|
|
178
|
+
2. Paste the issue URL (e.g. `https://your-org.sentry.io/issues/112081699`)
|
|
179
|
+
3. Submit — Kōbō extracts the issue, writes `.ai/thoughts/SENTRY-<numericId>.md`, creates a `Fix: <title>` task, and boots the agent with the fix workflow
|
|
180
|
+
|
|
181
|
+
The Sentry issue Short-ID (e.g. `ACME-API-3` — the canonical identifier Sentry assigns to each issue) is used as the ticket prefix for the working branch (e.g. `fix/ACME-API-3--slow-db-query` or `bugfix/ACME-API-3--slow-db-query`, depending on the branch prefix you chose at creation). The Short-ID is also what Sentry recognises in commit messages like `Fixes ACME-API-3` to auto-close the issue on merge. The local copy of the issue is written to `.ai/thoughts/SENTRY-<shortId>.md` (e.g. `SENTRY-ACME-API-3.md`). When Sentry is active, the description field becomes optional — the extracted context is enough to start work.
|
|
182
|
+
|
|
183
|
+
If the MCP server is slow to initialize (e.g. cold `npx` fetch, self-hosted host validation), bump the handshake timeout with `KOBO_MCP_INIT_TIMEOUT_MS` (default: `30000`).
|
|
184
|
+
|
|
185
|
+
Without a valid Sentry MCP configured in `~/.claude.json`, the Sentry import field returns a clear error when you submit — the rest of Kōbō keeps working.
|
|
186
|
+
|
|
140
187
|
## Recommended: Superpowers plugin for Claude Code
|
|
141
188
|
|
|
142
189
|
For the best experience, we recommend installing the [**superpowers**](https://github.com/obra/superpowers) plugin in Claude Code. Kōbō is designed to work well with it out of the box:
|
package/dist/server/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import gitRouter from './routes/git.js';
|
|
|
12
12
|
import imagesRouter from './routes/images.js';
|
|
13
13
|
import notionRouter from './routes/notion.js';
|
|
14
14
|
import plansRouter from './routes/plans.js';
|
|
15
|
+
import sentryRouter from './routes/sentry.js';
|
|
15
16
|
import settingsRouter from './routes/settings.js';
|
|
16
17
|
import templatesRouter from './routes/templates.js';
|
|
17
18
|
import workspacesRouter from './routes/workspaces.js';
|
|
@@ -47,6 +48,7 @@ app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion(
|
|
|
47
48
|
app.route('/api/workspaces', workspacesRouter);
|
|
48
49
|
app.route('/api/workspaces', imagesRouter);
|
|
49
50
|
app.route('/api/notion', notionRouter);
|
|
51
|
+
app.route('/api/sentry', sentryRouter);
|
|
50
52
|
app.route('/api/git', gitRouter);
|
|
51
53
|
app.route('/api/settings', settingsRouter);
|
|
52
54
|
app.route('/api/dev-server', devServerRouter);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { extractSentryIssue } from '../services/sentry-service.js';
|
|
3
|
+
/** Hono sub-router for Sentry issue extraction (preflight). */
|
|
4
|
+
const app = new Hono();
|
|
5
|
+
// POST /api/sentry/extract — extract a Sentry issue by URL
|
|
6
|
+
app.post('/extract', async (c) => {
|
|
7
|
+
try {
|
|
8
|
+
const body = await c.req.json();
|
|
9
|
+
if (!body.url) {
|
|
10
|
+
return c.json({ error: 'Missing required field: url' }, 400);
|
|
11
|
+
}
|
|
12
|
+
const content = await extractSentryIssue(body.url);
|
|
13
|
+
return c.json(content);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17
|
+
return c.json({ error: message }, 500);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
export default app;
|
|
@@ -24,6 +24,17 @@ app.get('/global', (c) => {
|
|
|
24
24
|
return c.json({ error: message }, 500);
|
|
25
25
|
}
|
|
26
26
|
});
|
|
27
|
+
// GET /api/settings/mcp-servers — list active MCP servers from Claude config
|
|
28
|
+
app.get('/mcp-servers', (c) => {
|
|
29
|
+
try {
|
|
30
|
+
const servers = settingsService.listActiveClaudeMcpServers();
|
|
31
|
+
return c.json(servers);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
35
|
+
return c.json({ error: message }, 500);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
27
38
|
// PUT /api/settings/global — update global settings
|
|
28
39
|
app.put('/global', async (c) => {
|
|
29
40
|
try {
|
|
@@ -9,6 +9,7 @@ import * as agentManager from '../services/agent-manager.js';
|
|
|
9
9
|
import * as devServerService from '../services/dev-server-service.js';
|
|
10
10
|
import * as notionService from '../services/notion-service.js';
|
|
11
11
|
import { renderPrTemplate } from '../services/pr-template-service.js';
|
|
12
|
+
import * as sentryService from '../services/sentry-service.js';
|
|
12
13
|
import * as settingsService from '../services/settings-service.js';
|
|
13
14
|
import { runSetupScript } from '../services/setup-script-service.js';
|
|
14
15
|
import * as terminalService from '../services/terminal-service.js';
|
|
@@ -52,6 +53,7 @@ app.post('/', async (c) => {
|
|
|
52
53
|
permissionMode: body.permissionMode || globalSettings.defaultPermissionMode || 'plan',
|
|
53
54
|
});
|
|
54
55
|
let notionContent = null;
|
|
56
|
+
let sentryContent = null;
|
|
55
57
|
// Extract Notion page content if a URL was provided
|
|
56
58
|
if (body.notionUrl) {
|
|
57
59
|
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
@@ -63,6 +65,23 @@ app.post('/', async (c) => {
|
|
|
63
65
|
console.error(`[workspaces] Failed to extract Notion page: ${message}`);
|
|
64
66
|
}
|
|
65
67
|
}
|
|
68
|
+
// Extract Sentry issue content if a URL was provided. Done early (before
|
|
69
|
+
// worktree creation) so the issue ID can be injected into the branch name.
|
|
70
|
+
if (body.sentryUrl) {
|
|
71
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
|
|
72
|
+
try {
|
|
73
|
+
sentryContent = await sentryService.extractSentryIssue(body.sentryUrl);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
77
|
+
console.error(`[workspaces] Failed to extract Sentry issue: ${message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Update workspace name with Sentry issue title if the user did not provide
|
|
81
|
+
// a custom name and Notion hasn't already filled it.
|
|
82
|
+
if (sentryContent?.title && !notionContent?.title && workspace.name === 'workspace') {
|
|
83
|
+
workspace = workspaceService.updateWorkspaceName(workspace.id, sentryContent.title);
|
|
84
|
+
}
|
|
66
85
|
// Create tasks from extracted Notion data
|
|
67
86
|
if (notionContent) {
|
|
68
87
|
let sortOrder = 0;
|
|
@@ -116,14 +135,16 @@ app.post('/', async (c) => {
|
|
|
116
135
|
// then falls back to a TK-XXXX pattern anywhere in the workspace name.
|
|
117
136
|
// The worktree has not been created yet, so a DB update is sufficient.
|
|
118
137
|
{
|
|
119
|
-
|
|
138
|
+
// Sentry's canonical identifier is the issue short-ID (e.g. "ACME-API-3"),
|
|
139
|
+
// which is what Sentry auto-close recognises in commit messages.
|
|
140
|
+
const detectedTicketId = notionContent?.ticketId || sentryContent?.issueId || workspace.name.match(/[A-Z]+-\d+/i)?.[0];
|
|
120
141
|
if (detectedTicketId && !workingBranch.toLowerCase().includes(detectedTicketId.toLowerCase())) {
|
|
121
142
|
const ticketPrefix = detectedTicketId.toUpperCase();
|
|
122
143
|
const slashIdx = workingBranch.indexOf('/');
|
|
123
144
|
const typePrefix = slashIdx >= 0 ? workingBranch.slice(0, slashIdx + 1) : 'feature/';
|
|
124
|
-
// Use Notion title or workspace name for the slug —
|
|
145
|
+
// Use Notion/Sentry title or workspace name for the slug — all have proper accented
|
|
125
146
|
// characters that NFD normalization can transliterate (é→e, ç→c, etc.)
|
|
126
|
-
const titleSource = notionContent?.title || workspace.name;
|
|
147
|
+
const titleSource = notionContent?.title || sentryContent?.title || workspace.name;
|
|
127
148
|
const titleSlug = titleSource
|
|
128
149
|
.normalize('NFD')
|
|
129
150
|
.replace(/[\u0300-\u036f]/g, '')
|
|
@@ -262,6 +283,57 @@ app.post('/', async (c) => {
|
|
|
262
283
|
console.error('[workspaces] Failed to save Notion content:', err);
|
|
263
284
|
}
|
|
264
285
|
}
|
|
286
|
+
// --- Sentry file + task (extraction already done before worktree creation) --
|
|
287
|
+
let sentryFilePath = null;
|
|
288
|
+
if (sentryContent) {
|
|
289
|
+
try {
|
|
290
|
+
const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts');
|
|
291
|
+
fs.mkdirSync(thoughtsDir, { recursive: true });
|
|
292
|
+
// File is named SENTRY-<shortId>.md (e.g. SENTRY-ACME-API-3.md) — the
|
|
293
|
+
// Short-ID is the canonical Sentry identifier. Falls back to the numeric
|
|
294
|
+
// ID if the Short-ID could not be parsed from the MCP response.
|
|
295
|
+
const idForFile = sentryContent.issueId || sentryContent.issueNumericId;
|
|
296
|
+
sentryFilePath = path.join(thoughtsDir, `SENTRY-${idForFile}.md`);
|
|
297
|
+
const today = new Date().toISOString().split('T')[0];
|
|
298
|
+
const tags = sentryContent.tags;
|
|
299
|
+
const env = tags.environment ?? 'unknown';
|
|
300
|
+
const tagsBlock = Object.entries(tags)
|
|
301
|
+
.map(([k, v]) => `- ${k}: ${v}`)
|
|
302
|
+
.join('\n') || '- (none)';
|
|
303
|
+
const spansBlock = sentryContent.offendingSpans.length > 0 ? sentryContent.offendingSpans.map((s) => `- ${s}`).join('\n') : 'N/A';
|
|
304
|
+
const extra = sentryContent.extraContext || 'N/A';
|
|
305
|
+
const md = `# Fix: ${sentryContent.title || sentryContent.issueId || sentryContent.issueNumericId}\n\n` +
|
|
306
|
+
`## Source\n` +
|
|
307
|
+
`- Sentry: ${body.sentryUrl}\n` +
|
|
308
|
+
`- Issue Short-ID: ${sentryContent.issueId} (use in commit messages for auto-close)\n` +
|
|
309
|
+
`- Issue numeric ID: ${sentryContent.issueNumericId}\n` +
|
|
310
|
+
`- Retrieved: ${today}\n\n` +
|
|
311
|
+
`## Summary\n` +
|
|
312
|
+
`- **Culprit**: ${sentryContent.culprit}\n` +
|
|
313
|
+
`- **Platform**: ${sentryContent.platform}\n` +
|
|
314
|
+
`- **Environment**: ${env}\n` +
|
|
315
|
+
`- **Occurrences**: ${sentryContent.occurrences} (first: ${sentryContent.firstSeen}, last: ${sentryContent.lastSeen})\n\n` +
|
|
316
|
+
`## Tags\n${tagsBlock}\n\n` +
|
|
317
|
+
`## Error Detail / Offending Spans\n${spansBlock}\n\n` +
|
|
318
|
+
`## Additional Context\n${extra}\n\n` +
|
|
319
|
+
`## MCP Tools for deeper analysis\n` +
|
|
320
|
+
`If you need more context, the following Sentry MCP tools are available:\n` +
|
|
321
|
+
`- \`mcp__sentry__get_sentry_resource(url, resourceType)\` — fetch the issue, breadcrumbs, replay, or trace\n` +
|
|
322
|
+
`- \`mcp__sentry__search_issue_events(organizationSlug, issueId='${sentryContent.issueId}')\` — recent events for this issue\n` +
|
|
323
|
+
`- \`mcp__sentry__get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key)\` — filter by tag (environment, user, browser, …)\n`;
|
|
324
|
+
fs.writeFileSync(sentryFilePath, md, 'utf-8');
|
|
325
|
+
workspaceService.createTask(workspace.id, {
|
|
326
|
+
title: `Fix: ${sentryContent.title || sentryContent.issueId || `Sentry #${sentryContent.issueNumericId}`}`,
|
|
327
|
+
isAcceptanceCriterion: false,
|
|
328
|
+
sortOrder: 9999,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
console.error('[workspaces] Failed to save Sentry content:', err);
|
|
333
|
+
sentryFilePath = null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// ------------------------------------------------------------------------
|
|
265
337
|
// Update Notion status if both property name and value are configured
|
|
266
338
|
const notionStatusProp = effectiveSettings.notionStatusProperty;
|
|
267
339
|
const notionTargetStatus = effectiveSettings.notionInProgressStatus;
|
|
@@ -299,6 +371,24 @@ app.post('/', async (c) => {
|
|
|
299
371
|
brainstormPrompt += `\nNotion ticket: ${body.notionUrl}`;
|
|
300
372
|
brainstormPrompt += `\nLocal copy: ${notionFilePath}\n`;
|
|
301
373
|
}
|
|
374
|
+
if (sentryFilePath && sentryContent) {
|
|
375
|
+
brainstormPrompt += `\nSentry issue: ${body.sentryUrl}`;
|
|
376
|
+
brainstormPrompt += `\nIssue Short-ID: ${sentryContent.issueId} (canonical, use in commit messages for auto-close)`;
|
|
377
|
+
brainstormPrompt += `\nIssue numeric ID: ${sentryContent.issueNumericId}`;
|
|
378
|
+
brainstormPrompt += `\nLocal copy: ${sentryFilePath}\n`;
|
|
379
|
+
brainstormPrompt +=
|
|
380
|
+
`\nFix workflow:\n` +
|
|
381
|
+
`1. Read the local Sentry file above for full context\n` +
|
|
382
|
+
`2. Locate the bug from the stacktrace / culprit\n` +
|
|
383
|
+
`3. Write a failing test that reproduces the bug (TDD)\n` +
|
|
384
|
+
`4. Implement the minimal fix\n` +
|
|
385
|
+
`5. Confirm the test passes, run related tests\n` +
|
|
386
|
+
`6. Commit referencing the Sentry Short-ID (e.g. "fix(scope): description (${sentryContent.issueId})") — Sentry auto-closes the issue when the commit is merged\n` +
|
|
387
|
+
`\nIf you need more context, Sentry MCP tools are available:\n` +
|
|
388
|
+
`- mcp__sentry__get_sentry_resource(url, resourceType) — fetch the issue, breadcrumbs, replay or trace\n` +
|
|
389
|
+
`- mcp__sentry__search_issue_events(organizationSlug, issueId='${sentryContent.issueId}') — recent events\n` +
|
|
390
|
+
`- mcp__sentry__get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key) — filter by tag\n`;
|
|
391
|
+
}
|
|
302
392
|
if (todos.length > 0) {
|
|
303
393
|
brainstormPrompt += `\nTasks:\n${todos.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
|
|
304
394
|
}
|
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { getPackageVersion } from '../utils/paths.js';
|
|
1
|
+
import { callMcpTool, initializeMcp, readClaudeMcpEntry, spawnMcpProcess, unwrapMcpResult, } from '../utils/mcp-client.js';
|
|
2
|
+
import { getGlobalSettings } from './settings-service.js';
|
|
4
3
|
// Gherkin keywords (French and English)
|
|
5
4
|
const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
|
|
6
5
|
// Keywords that start a NEW scenario and must flush the current block.
|
|
7
6
|
// NOTE: `Feature`/`Fonctionnalité` are top-level containers, not a new scenario,
|
|
8
7
|
// so they stay attached to the first scenario rather than triggering a split.
|
|
9
8
|
const SCENARIO_START_PATTERN = /^(Scénario|Scenario)/i;
|
|
10
|
-
const nextRpcId = (() => {
|
|
11
|
-
let counter = 1;
|
|
12
|
-
return () => counter++;
|
|
13
|
-
})();
|
|
14
9
|
/**
|
|
15
10
|
* Parse a Notion URL and extract the page_id in UUID format (with dashes).
|
|
16
11
|
* Handles:
|
|
@@ -35,192 +30,51 @@ export function parseNotionUrl(url) {
|
|
|
35
30
|
// Convert 32 hex chars to UUID format: 8-4-4-4-12
|
|
36
31
|
return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
|
|
37
32
|
}
|
|
38
|
-
/** Send a JSON-RPC request to the MCP process and read the response (30s timeout). */
|
|
39
|
-
export async function callMcpTool(mcpProcess, toolName, args) {
|
|
40
|
-
const id = nextRpcId();
|
|
41
|
-
const request = JSON.stringify({
|
|
42
|
-
jsonrpc: '2.0',
|
|
43
|
-
id,
|
|
44
|
-
method: 'tools/call',
|
|
45
|
-
params: {
|
|
46
|
-
name: toolName,
|
|
47
|
-
arguments: args,
|
|
48
|
-
},
|
|
49
|
-
});
|
|
50
|
-
return new Promise((resolve, reject) => {
|
|
51
|
-
if (!mcpProcess.stdin || !mcpProcess.stdout) {
|
|
52
|
-
reject(new Error('MCP process stdin/stdout not available'));
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
let buffer = '';
|
|
56
|
-
const timeout = setTimeout(() => {
|
|
57
|
-
mcpProcess.stdout?.removeListener('data', onData);
|
|
58
|
-
mcpProcess.stdout?.removeListener('error', onError);
|
|
59
|
-
mcpProcess.kill();
|
|
60
|
-
reject(new Error(`callMcpTool('${toolName}') timed out after 30s`));
|
|
61
|
-
}, 30_000);
|
|
62
|
-
const onData = (chunk) => {
|
|
63
|
-
buffer += chunk.toString();
|
|
64
|
-
// Try to parse complete JSON lines
|
|
65
|
-
const lines = buffer.split('\n');
|
|
66
|
-
// Keep the last (potentially incomplete) line in the buffer
|
|
67
|
-
buffer = lines.pop() ?? '';
|
|
68
|
-
for (const line of lines) {
|
|
69
|
-
const trimmed = line.trim();
|
|
70
|
-
if (!trimmed)
|
|
71
|
-
continue;
|
|
72
|
-
try {
|
|
73
|
-
const parsed = JSON.parse(trimmed);
|
|
74
|
-
if (parsed.id === id) {
|
|
75
|
-
clearTimeout(timeout);
|
|
76
|
-
mcpProcess.stdout?.removeListener('data', onData);
|
|
77
|
-
mcpProcess.stdout?.removeListener('error', onError);
|
|
78
|
-
if (parsed.error) {
|
|
79
|
-
reject(new Error(`MCP tool '${toolName}' error: ${parsed.error.message} (code: ${parsed.error.code})`));
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
resolve(parsed.result);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
// Ignore JSON parse errors for partial lines
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
const onError = (err) => {
|
|
92
|
-
clearTimeout(timeout);
|
|
93
|
-
mcpProcess.stdout?.removeListener('data', onData);
|
|
94
|
-
reject(err);
|
|
95
|
-
};
|
|
96
|
-
mcpProcess.stdout.on('data', onData);
|
|
97
|
-
mcpProcess.stdout.once('error', onError);
|
|
98
|
-
mcpProcess.stdin.write(`${request}\n`);
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
33
|
/**
|
|
102
|
-
* Read the Notion token from
|
|
34
|
+
* Read the Notion token from the user's Claude Code config as a fallback.
|
|
35
|
+
* Picks the first enabled `mcpServers` entry named `notion` (disabled entries
|
|
36
|
+
* are skipped). Returns an empty string when none is found.
|
|
103
37
|
*/
|
|
104
|
-
function
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
catch {
|
|
115
|
-
return '';
|
|
38
|
+
function readNotionMcpEntryFromClaudeConfig(preferredKey) {
|
|
39
|
+
const normalizedPreferred = preferredKey?.trim();
|
|
40
|
+
const match = normalizedPreferred
|
|
41
|
+
? readClaudeMcpEntry((k) => k === normalizedPreferred)
|
|
42
|
+
: readClaudeMcpEntry((k) => k === 'notion');
|
|
43
|
+
if (!match) {
|
|
44
|
+
if (normalizedPreferred) {
|
|
45
|
+
throw new Error(`Notion MCP key '${normalizedPreferred}' not found or disabled in ~/.claude.json (mcpServers section)`);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
116
48
|
}
|
|
49
|
+
return match;
|
|
117
50
|
}
|
|
118
|
-
function
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
const
|
|
51
|
+
export function buildNotionMcpConfig(preferredKey) {
|
|
52
|
+
const configEntry = readNotionMcpEntryFromClaudeConfig(preferredKey);
|
|
53
|
+
const configEnv = configEntry?.entry.env ?? {};
|
|
54
|
+
const notionToken = process.env.NOTION_API_TOKEN ??
|
|
55
|
+
process.env.NOTION_TOKEN ??
|
|
56
|
+
configEnv.NOTION_TOKEN ??
|
|
57
|
+
configEnv.NOTION_API_TOKEN ??
|
|
58
|
+
'';
|
|
59
|
+
const command = process.env.NOTION_MCP_COMMAND ?? configEntry?.entry.command ?? 'npx';
|
|
60
|
+
const args = process.env.NOTION_MCP_ARGS
|
|
122
61
|
? process.env.NOTION_MCP_ARGS.split(' ')
|
|
123
|
-
: ['-y', '@notionhq/notion-mcp-server'];
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
mcpProcess.stderr?.on('data', (data) => {
|
|
135
|
-
// Silently consume stderr to avoid cluttering logs
|
|
136
|
-
const text = data.toString();
|
|
137
|
-
if (process.env.DEBUG_NOTION_MCP) {
|
|
138
|
-
console.error('[notion-mcp stderr]', text);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
return mcpProcess;
|
|
142
|
-
}
|
|
143
|
-
/** Initialize the MCP server by sending an initialize handshake (10s timeout). */
|
|
144
|
-
async function initializeMcp(mcpProcess) {
|
|
145
|
-
const id = nextRpcId();
|
|
146
|
-
const request = JSON.stringify({
|
|
147
|
-
jsonrpc: '2.0',
|
|
148
|
-
id,
|
|
149
|
-
method: 'initialize',
|
|
150
|
-
params: {
|
|
151
|
-
protocolVersion: '2024-11-05',
|
|
152
|
-
capabilities: {},
|
|
153
|
-
clientInfo: { name: 'kobo', version: getPackageVersion() },
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
await new Promise((resolve, reject) => {
|
|
157
|
-
if (!mcpProcess.stdin || !mcpProcess.stdout) {
|
|
158
|
-
reject(new Error('MCP process not ready'));
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
let buffer = '';
|
|
162
|
-
const timeout = setTimeout(() => {
|
|
163
|
-
mcpProcess.stdout?.removeListener('data', onData);
|
|
164
|
-
mcpProcess.kill();
|
|
165
|
-
reject(new Error('initializeMcp timed out after 10s'));
|
|
166
|
-
}, 10_000);
|
|
167
|
-
const onData = (chunk) => {
|
|
168
|
-
buffer += chunk.toString();
|
|
169
|
-
const lines = buffer.split('\n');
|
|
170
|
-
buffer = lines.pop() ?? '';
|
|
171
|
-
for (const line of lines) {
|
|
172
|
-
const trimmed = line.trim();
|
|
173
|
-
if (!trimmed)
|
|
174
|
-
continue;
|
|
175
|
-
try {
|
|
176
|
-
const parsed = JSON.parse(trimmed);
|
|
177
|
-
if (parsed.id === id) {
|
|
178
|
-
clearTimeout(timeout);
|
|
179
|
-
mcpProcess.stdout?.removeListener('data', onData);
|
|
180
|
-
const initialized = JSON.stringify({
|
|
181
|
-
jsonrpc: '2.0',
|
|
182
|
-
method: 'notifications/initialized',
|
|
183
|
-
});
|
|
184
|
-
mcpProcess.stdin?.write(`${initialized}\n`);
|
|
185
|
-
resolve();
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
// ignore
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
const onError = (err) => {
|
|
194
|
-
clearTimeout(timeout);
|
|
195
|
-
mcpProcess.stdout?.removeListener('data', onData);
|
|
196
|
-
reject(err);
|
|
197
|
-
};
|
|
198
|
-
mcpProcess.stdout.on('data', onData);
|
|
199
|
-
mcpProcess.stdout.once('error', onError);
|
|
200
|
-
mcpProcess.stdin.write(`${request}\n`);
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* Unwrap MCP tool response.
|
|
205
|
-
* MCP returns { content: [{ type: "text", text: "..." }] }
|
|
206
|
-
* where text is a JSON-stringified API response.
|
|
207
|
-
*/
|
|
208
|
-
function unwrapMcpResult(result) {
|
|
209
|
-
if (result && typeof result === 'object') {
|
|
210
|
-
const obj = result;
|
|
211
|
-
if (Array.isArray(obj.content)) {
|
|
212
|
-
const first = obj.content[0];
|
|
213
|
-
if (first?.type === 'text' && first.text) {
|
|
214
|
-
try {
|
|
215
|
-
return JSON.parse(first.text);
|
|
216
|
-
}
|
|
217
|
-
catch {
|
|
218
|
-
return first.text;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
62
|
+
: (configEntry?.entry.args ?? ['-y', '@notionhq/notion-mcp-server']);
|
|
63
|
+
const env = {
|
|
64
|
+
...process.env,
|
|
65
|
+
...configEnv,
|
|
66
|
+
};
|
|
67
|
+
if (!env.OPENAPI_MCP_HEADERS && notionToken) {
|
|
68
|
+
env.OPENAPI_MCP_HEADERS = JSON.stringify({
|
|
69
|
+
Authorization: `Bearer ${notionToken}`,
|
|
70
|
+
'Notion-Version': '2022-06-28',
|
|
71
|
+
});
|
|
222
72
|
}
|
|
223
|
-
return
|
|
73
|
+
return { command, args, env };
|
|
74
|
+
}
|
|
75
|
+
function spawnNotionMcp(preferredKey) {
|
|
76
|
+
const { command, args, env } = buildNotionMcpConfig(preferredKey);
|
|
77
|
+
return spawnMcpProcess(command, args, env);
|
|
224
78
|
}
|
|
225
79
|
function extractTextFromRichText(richText) {
|
|
226
80
|
if (!Array.isArray(richText))
|
|
@@ -340,7 +194,8 @@ export function parseBlocks(blocks) {
|
|
|
340
194
|
*/
|
|
341
195
|
export async function extractNotionPage(notionUrl) {
|
|
342
196
|
const pageId = parseNotionUrl(notionUrl);
|
|
343
|
-
const
|
|
197
|
+
const global = getGlobalSettings();
|
|
198
|
+
const mcpProcess = spawnNotionMcp(global.notionMcpKey);
|
|
344
199
|
// Give the process a moment to start
|
|
345
200
|
await new Promise((resolve, reject) => {
|
|
346
201
|
const timeout = setTimeout(() => resolve(), 1000);
|
|
@@ -405,7 +260,8 @@ export async function extractNotionPage(notionUrl) {
|
|
|
405
260
|
/** Update a status property on a Notion page. Best-effort, does not throw. */
|
|
406
261
|
export async function updateNotionStatus(notionUrl, propertyName, statusValue) {
|
|
407
262
|
const pageId = parseNotionUrl(notionUrl);
|
|
408
|
-
const
|
|
263
|
+
const global = getGlobalSettings();
|
|
264
|
+
const mcpProcess = spawnNotionMcp(global.notionMcpKey);
|
|
409
265
|
try {
|
|
410
266
|
await new Promise((resolve, reject) => {
|
|
411
267
|
const timeout = setTimeout(() => resolve(), 1000);
|