@loicngr/kobo 1.5.2 → 1.5.3
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/workspaces.js +93 -3
- package/dist/server/services/notion-service.js +24 -186
- package/dist/server/services/sentry-service.js +130 -0
- package/dist/server/utils/mcp-client.js +191 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-CyCjnWpd.js → ActivityFeed-C7knqOOL.js} +1 -1
- package/src/client/dist/spa/assets/CreatePage-Owxm6Tpi.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-mLBk-G-D.js} +2 -2
- package/src/client/dist/spa/assets/{MainLayout-gJB92Uex.js → MainLayout-z-GTmsNk.js} +2 -2
- package/src/client/dist/spa/assets/{QExpansionItem-GHHBo3vG.js → QExpansionItem-BoxRv2sW.js} +1 -1
- package/src/client/dist/spa/assets/{QList-D4El-L0w.js → QList-C0MyMgKh.js} +1 -1
- package/src/client/dist/spa/assets/{QMenu-DPjNAi13.js → QMenu-DayfIdY0.js} +1 -1
- package/src/client/dist/spa/assets/{QPage-DyGpMmRB.js → QPage-D7SSe7S1.js} +1 -1
- package/src/client/dist/spa/assets/{SettingsPage-BG_7gLtm.js → SettingsPage-BzJ9LphQ.js} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-DCIpeMKc.js → WorkspacePage-Dv_vuDsw.js} +3 -3
- package/src/client/dist/spa/assets/{cssMode-Bs7Ir5HZ.js → cssMode-4TH_2zaD.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-Bx7Ah6pf.js → editor.api-CyJb27tS.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-tO5BzTPC.js → editor.main-oZnnOWQQ.js} +3 -3
- package/src/client/dist/spa/assets/{freemarker2-CDfily2B.js → freemarker2-QspagBXp.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-AwDxxENn.js → handlebars-DNK4bfk9.js} +1 -1
- package/src/client/dist/spa/assets/{html-BY9SWE1C.js → html-DoAFaOJE.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-C6FDyX-w.js → htmlMode-Dd2XBcML.js} +1 -1
- package/src/client/dist/spa/assets/i18n-NIfrKJms.js +1 -0
- package/src/client/dist/spa/assets/i18n-ckoKODmn.js +1 -0
- package/src/client/dist/spa/assets/index-B4-QwpHI.js +5 -0
- package/src/client/dist/spa/assets/{javascript-Blavvi4I.js → javascript-C8gxtWJG.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-CsgNfmDe.js → jsonMode-BW-w1oEn.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-CeX-oFi-.js → liquid-CQDlB55c.js} +1 -1
- package/src/client/dist/spa/assets/{marked.esm-DqTghqFg.js → marked.esm-CGMwQXW0.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-CzdoKooW.js → mdx-DUS2o1l2.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-2VdGUl2G.js → monaco.contribution-BSsdGujG.js} +2 -2
- package/src/client/dist/spa/assets/{python-DfvLV8-T.js → python-DVWLF-An.js} +1 -1
- package/src/client/dist/spa/assets/{razor-CLVHHld4.js → razor-bodgahcD.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-B8UnweFF.js → tsMode-C7oNyY1g.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-BtDEyXr-.js → typescript-C_X20eQu.js} +1 -1
- package/src/client/dist/spa/assets/{xml-BPbtYZUS.js → xml-BVbLd8T0.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-Ddz4CrJo.js → yaml-D5WOobuH.js} +1 -1
- package/src/client/dist/spa/index.html +2 -2
- 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/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;
|
|
@@ -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,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { getPackageVersion } from '../utils/paths.js';
|
|
1
|
+
import { callMcpTool, initializeMcp, readClaudeMcpEntry, spawnMcpProcess, unwrapMcpResult, } from '../utils/mcp-client.js';
|
|
4
2
|
// Gherkin keywords (French and English)
|
|
5
3
|
const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
|
|
6
4
|
// Keywords that start a NEW scenario and must flush the current block.
|
|
7
5
|
// NOTE: `Feature`/`Fonctionnalité` are top-level containers, not a new scenario,
|
|
8
6
|
// so they stay attached to the first scenario rather than triggering a split.
|
|
9
7
|
const SCENARIO_START_PATTERN = /^(Scénario|Scenario)/i;
|
|
10
|
-
const nextRpcId = (() => {
|
|
11
|
-
let counter = 1;
|
|
12
|
-
return () => counter++;
|
|
13
|
-
})();
|
|
14
8
|
/**
|
|
15
9
|
* Parse a Notion URL and extract the page_id in UUID format (with dashes).
|
|
16
10
|
* Handles:
|
|
@@ -35,192 +29,36 @@ export function parseNotionUrl(url) {
|
|
|
35
29
|
// Convert 32 hex chars to UUID format: 8-4-4-4-12
|
|
36
30
|
return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
|
|
37
31
|
}
|
|
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
32
|
/**
|
|
102
|
-
* Read the Notion token from
|
|
33
|
+
* Read the Notion token from the user's Claude Code config as a fallback.
|
|
34
|
+
* Picks the first enabled `mcpServers` entry named `notion` (disabled entries
|
|
35
|
+
* are skipped). Returns an empty string when none is found.
|
|
103
36
|
*/
|
|
104
37
|
function readNotionTokenFromClaudeConfig() {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const configPath = `${homedir}/.claude.json`;
|
|
108
|
-
const raw = readFileSync(configPath, 'utf-8');
|
|
109
|
-
const config = JSON.parse(raw);
|
|
110
|
-
const mcpServers = config.mcpServers;
|
|
111
|
-
const notionServer = mcpServers?.notion;
|
|
112
|
-
return notionServer?.env?.NOTION_TOKEN ?? notionServer?.env?.NOTION_API_TOKEN ?? '';
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
38
|
+
const match = readClaudeMcpEntry((k) => k === 'notion');
|
|
39
|
+
if (!match)
|
|
115
40
|
return '';
|
|
116
|
-
}
|
|
41
|
+
const env = match.entry.env ?? {};
|
|
42
|
+
return env.NOTION_TOKEN ?? env.NOTION_API_TOKEN ?? '';
|
|
117
43
|
}
|
|
118
|
-
function
|
|
44
|
+
function buildNotionMcpConfig() {
|
|
119
45
|
const notionToken = process.env.NOTION_API_TOKEN ?? process.env.NOTION_TOKEN ?? readNotionTokenFromClaudeConfig();
|
|
120
|
-
const
|
|
121
|
-
const
|
|
46
|
+
const command = process.env.NOTION_MCP_COMMAND ?? 'npx';
|
|
47
|
+
const args = process.env.NOTION_MCP_ARGS
|
|
122
48
|
? process.env.NOTION_MCP_ARGS.split(' ')
|
|
123
49
|
: ['-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;
|
|
50
|
+
const env = {
|
|
51
|
+
...process.env,
|
|
52
|
+
OPENAPI_MCP_HEADERS: JSON.stringify({
|
|
53
|
+
Authorization: `Bearer ${notionToken}`,
|
|
54
|
+
'Notion-Version': '2022-06-28',
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
return { command, args, env };
|
|
142
58
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return result;
|
|
59
|
+
function spawnNotionMcp() {
|
|
60
|
+
const { command, args, env } = buildNotionMcpConfig();
|
|
61
|
+
return spawnMcpProcess(command, args, env);
|
|
224
62
|
}
|
|
225
63
|
function extractTextFromRichText(richText) {
|
|
226
64
|
if (!Array.isArray(richText))
|
|
@@ -340,7 +178,7 @@ export function parseBlocks(blocks) {
|
|
|
340
178
|
*/
|
|
341
179
|
export async function extractNotionPage(notionUrl) {
|
|
342
180
|
const pageId = parseNotionUrl(notionUrl);
|
|
343
|
-
const mcpProcess =
|
|
181
|
+
const mcpProcess = spawnNotionMcp();
|
|
344
182
|
// Give the process a moment to start
|
|
345
183
|
await new Promise((resolve, reject) => {
|
|
346
184
|
const timeout = setTimeout(() => resolve(), 1000);
|
|
@@ -405,7 +243,7 @@ export async function extractNotionPage(notionUrl) {
|
|
|
405
243
|
/** Update a status property on a Notion page. Best-effort, does not throw. */
|
|
406
244
|
export async function updateNotionStatus(notionUrl, propertyName, statusValue) {
|
|
407
245
|
const pageId = parseNotionUrl(notionUrl);
|
|
408
|
-
const mcpProcess =
|
|
246
|
+
const mcpProcess = spawnNotionMcp();
|
|
409
247
|
try {
|
|
410
248
|
await new Promise((resolve, reject) => {
|
|
411
249
|
const timeout = setTimeout(() => resolve(), 1000);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { callMcpTool, initializeMcp, readClaudeMcpEntry, spawnMcpProcess, unwrapMcpResult, } from '../utils/mcp-client.js';
|
|
2
|
+
// ─── parseSentryUrl ───────────────────────────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* Parse a Sentry issue URL and extract the numeric issue ID.
|
|
5
|
+
* Accepts variations with trailing slash, query string, fragment, and
|
|
6
|
+
* self-hosted /organizations/<org>/issues/<id>/ paths.
|
|
7
|
+
*/
|
|
8
|
+
export function parseSentryUrl(url) {
|
|
9
|
+
const match = url.match(/\/issues\/(\d+)/);
|
|
10
|
+
if (!match) {
|
|
11
|
+
throw new Error(`Could not extract issue ID from Sentry URL: ${url}`);
|
|
12
|
+
}
|
|
13
|
+
return match[1];
|
|
14
|
+
}
|
|
15
|
+
const SENTRY_CONFIG_ERROR = "Sentry MCP server not configured in ~/.claude.json — add an enabled 'sentry' entry under mcpServers";
|
|
16
|
+
/**
|
|
17
|
+
* Return the first enabled (`disabled !== true`) MCP server entry from
|
|
18
|
+
* `~/.claude.json` whose key contains "sentry" (case-insensitive). The full
|
|
19
|
+
* `entry.env` is merged onto `process.env` so the spawned process has every
|
|
20
|
+
* configured variable available (SENTRY_ACCESS_TOKEN, SENTRY_HOST, etc.).
|
|
21
|
+
*
|
|
22
|
+
* Throws with a clear setup message when no enabled Sentry entry exists.
|
|
23
|
+
*/
|
|
24
|
+
export function readSentryMcpConfig() {
|
|
25
|
+
const match = readClaudeMcpEntry((k) => /sentry/i.test(k));
|
|
26
|
+
if (!match) {
|
|
27
|
+
throw new Error(SENTRY_CONFIG_ERROR);
|
|
28
|
+
}
|
|
29
|
+
const { entry } = match;
|
|
30
|
+
return {
|
|
31
|
+
command: entry.command ?? 'npx',
|
|
32
|
+
args: entry.args ?? [],
|
|
33
|
+
env: {
|
|
34
|
+
...process.env,
|
|
35
|
+
...(entry.env ?? {}),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function matchField(md, label) {
|
|
40
|
+
const re = new RegExp(`^\\*\\*${label}\\*\\*:\\s*(.+)$`, 'm');
|
|
41
|
+
const m = md.match(re);
|
|
42
|
+
return m ? m[1].trim() : '';
|
|
43
|
+
}
|
|
44
|
+
function matchSection(md, heading) {
|
|
45
|
+
// No 'm' flag: $ anchors to end of full string, not each line.
|
|
46
|
+
// Stops at the next heading of any level (#, ##, ### …) or end of string,
|
|
47
|
+
// so trailing top-level sections like "# Using this information" appended
|
|
48
|
+
// by the Sentry MCP response are NOT swallowed into a previous ### section.
|
|
49
|
+
const re = new RegExp(`###\\s+${heading}[\\r\\n]+((?:.|\\n)*?)(?=\\n#+\\s|$)`);
|
|
50
|
+
const m = md.match(re);
|
|
51
|
+
return m ? m[1].trim() : '';
|
|
52
|
+
}
|
|
53
|
+
function parseTagsBlock(block) {
|
|
54
|
+
const tags = {};
|
|
55
|
+
const lines = block.split('\n');
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
const m = line.match(/^\*\*([^*]+)\*\*:\s*(.+)$/);
|
|
58
|
+
if (m) {
|
|
59
|
+
tags[m[1].trim()] = m[2].trim();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return tags;
|
|
63
|
+
}
|
|
64
|
+
function parseOffendingSpans(md) {
|
|
65
|
+
const re = /\*\*Offending Spans:\*\*\s*\n([\s\S]*?)(?=\n\n|\n###|\n\*\*|$)/;
|
|
66
|
+
const m = md.match(re);
|
|
67
|
+
if (!m)
|
|
68
|
+
return [];
|
|
69
|
+
return m[1]
|
|
70
|
+
.split('\n')
|
|
71
|
+
.map((l) => l.trim())
|
|
72
|
+
.filter((l) => l.length > 0);
|
|
73
|
+
}
|
|
74
|
+
/** Parse the markdown response from `get_sentry_resource` into a structured object. */
|
|
75
|
+
export function parseSentryResponse(markdown, numericId) {
|
|
76
|
+
// Short-ID comes from the first heading: "# Issue ACME-API-3 in org"
|
|
77
|
+
const issueIdMatch = markdown.match(/^# Issue\s+(\S+)/m);
|
|
78
|
+
const issueId = issueIdMatch ? issueIdMatch[1] : '';
|
|
79
|
+
const occurrencesRaw = matchField(markdown, 'Occurrences');
|
|
80
|
+
const occurrences = occurrencesRaw ? parseInt(occurrencesRaw, 10) || 0 : 0;
|
|
81
|
+
const tagsBlock = matchSection(markdown, 'Tags');
|
|
82
|
+
const tags = tagsBlock ? parseTagsBlock(tagsBlock) : {};
|
|
83
|
+
const extraData = matchSection(markdown, 'Extra Data');
|
|
84
|
+
const additionalContext = matchSection(markdown, 'Additional Context');
|
|
85
|
+
const extraContext = [extraData, additionalContext].filter((s) => s.length > 0).join('\n\n');
|
|
86
|
+
return {
|
|
87
|
+
issueId,
|
|
88
|
+
issueNumericId: numericId,
|
|
89
|
+
title: matchField(markdown, 'Description'),
|
|
90
|
+
culprit: matchField(markdown, 'Location'),
|
|
91
|
+
url: matchField(markdown, 'URL'),
|
|
92
|
+
platform: matchField(markdown, 'Platform'),
|
|
93
|
+
firstSeen: matchField(markdown, 'First Seen'),
|
|
94
|
+
lastSeen: matchField(markdown, 'Last Seen'),
|
|
95
|
+
occurrences,
|
|
96
|
+
tags,
|
|
97
|
+
offendingSpans: parseOffendingSpans(markdown),
|
|
98
|
+
extraContext,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// ─── extractSentryIssue ───────────────────────────────────────────────────────
|
|
102
|
+
/**
|
|
103
|
+
* Extract a Sentry issue's full context via the user's configured Sentry MCP server.
|
|
104
|
+
*/
|
|
105
|
+
export async function extractSentryIssue(url) {
|
|
106
|
+
const numericId = parseSentryUrl(url);
|
|
107
|
+
const config = readSentryMcpConfig();
|
|
108
|
+
const mcpProcess = spawnMcpProcess(config.command, config.args, config.env);
|
|
109
|
+
try {
|
|
110
|
+
// Give the process a moment to start; reject if it errors immediately.
|
|
111
|
+
await new Promise((resolve, reject) => {
|
|
112
|
+
const timeout = setTimeout(() => resolve(), 1000);
|
|
113
|
+
mcpProcess.on('error', (err) => {
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
reject(new Error(`Failed to start Sentry MCP server: ${err.message}`));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
await initializeMcp(mcpProcess);
|
|
119
|
+
const raw = await callMcpTool(mcpProcess, 'get_sentry_resource', { url });
|
|
120
|
+
const markdown = unwrapMcpResult(raw);
|
|
121
|
+
if (typeof markdown !== 'string') {
|
|
122
|
+
throw new Error('Unexpected non-string response from get_sentry_resource');
|
|
123
|
+
}
|
|
124
|
+
return parseSentryResponse(markdown, numericId);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
mcpProcess.stdin?.end();
|
|
128
|
+
mcpProcess.kill();
|
|
129
|
+
}
|
|
130
|
+
}
|