From 41d3a08c7c17228fcd95b28b05aa8c0eb37a3b80 Mon Sep 17 00:00:00 2001 From: bglglzd <248948303+bglglzd@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:14:46 +0300 Subject: [PATCH 1/3] fix: validate report recipes and surface delivery failures --- .github/workflows/checks.yml | 25 +++++ CHANGELOG.md | 15 +++ CONTRIBUTING.md | 20 ++++ INSTALL.md | 2 +- README.md | 32 ++++-- VALIDATION.md | 37 +++++++ commands/voice.md | 9 ++ docs/index.html | 4 +- recipes/cli-tool.md | 13 ++- recipes/email.md | 4 +- recipes/github-issues.md | 79 +++++--------- recipes/jsonl-file.md | 12 +- recipes/slack-discord.md | 66 +++++++---- recipes/telegram-bot.md | 172 +++++++++++++++++++---------- recipes/web-app-full.md | 67 ++++++++---- skills/adding-voice/SKILL.md | 18 +-- tests/helpers.mjs | 28 +++++ tests/jsonl-recipe.test.mjs | 24 ++++ tests/provider-contracts.test.mjs | 176 ++++++++++++++++++++++++++++++ tests/recipe-contracts.test.mjs | 84 ++++++++++++++ 20 files changed, 706 insertions(+), 181 deletions(-) create mode 100644 .github/workflows/checks.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 VALIDATION.md create mode 100644 commands/voice.md create mode 100644 tests/helpers.mjs create mode 100644 tests/provider-contracts.test.mjs create mode 100644 tests/recipe-contracts.test.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..44a90ab --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,25 @@ +name: Recipe checks +on: + push: + branches: [main] + pull_request: +permissions: + contents: read +concurrency: + group: recipes-${{ github.ref }} + cancel-in-progress: true +jobs: + recipes: + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - name: Execute recipe contracts without external delivery + run: node --test tests/*.test.mjs + - name: Verify patch whitespace + run: git diff --check diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a385208 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## Unreleased + +- Match install commands to the registered marketplace and provide the advertised + optional voice command. +- Preserve canonical report context, full IDs and storage-specific close hints. +- Validate Telegram callback authorization and delivery outcomes; send long + context as a file and retain real reasons for Won't Fix. +- Surface chat/email delivery rejection, split Slack text within its limit and + attach full Discord context with mentions disabled. +- Pass complete GitHub CLI bodies through stdin without a shell, preserve + finalized voice segments and fix picker coordinates after scrolling. +- Reject invalid terminal resolutions and propagate actual JSONL storage errors. +- Add executable recipe contracts and Linux/Windows CI with synthetic boundaries. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ed1546c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,20 @@ +# Contributing + +Keep the portable contract in `SPEC.md` compatible. Prefer the canonical context +formatter to a second, shorter copy. Describe which installing-project helpers +an example expects and which environment values stay on its server. + +For a behavioral recipe change, reproduce the problem and run: + +```bash +node --test tests/*.test.mjs +``` + +Use synthetic reports, temporary files and loopback services. Never run a test +against real recipients, provider credentials or a user's report store. Clearly +label mocked SDK/browser boundaries and document what still needs verification +in the installing project. Keep optional voice input optional; browser speech +may use a browser-provider service, while local Whisper is a separate setup. + +Keep PRs focused, retain licenses/attribution, and list the relevant checks and +limits. Do not commit local agent instructions, private reports or credentials. diff --git a/INSTALL.md b/INSTALL.md index 74a5df4..047e94a 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -12,7 +12,7 @@ > > - **Any AI agent that doesn't auto-fetch URLs:** copy the content below the `---` line into the chat directly. > -> **Claude Code users should NOT use this file.** Run `/plugin marketplace add bglglzd/bugkit`, `/plugin install bugkit@bglglzd-bugkit`, then `/bugkit:install` instead. +> **Claude Code users should NOT use this file.** Run `/plugin marketplace add bglglzd/bugkit`, `/plugin install bugkit@bugkit`, then `/bugkit:install` instead. --- diff --git a/README.md b/README.md index b4a4fb7..7d54746 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ # bugkit -**Users click. AI fixes. Done.** +**Bug reports with context your coding agent can use.** -*AI-friendly bug reports for any project. Paste the report into Claude Code, Cursor, or any AI agent โ€” and the fix ships in minutes.* +*Capture the page, selected element and errors with a report. Give the structured context to your coding agent, verify its patch, and record the resolution.* Illustrated workflow: report a problem, collect context, hand it to an agent, verify the fix @@ -25,10 +25,10 @@ ## Who this is for **๐Ÿ›  Building for a client, boss, or team lead** -Send them the staging link. They click the bug icon, pick what's broken, speak or type what they want. You paste into your AI agent. Fix ships the same day. +Send them the staging link. They select the affected element and type or speak a description. Copy the structured report to your agent, then verify and record the fix. **๐Ÿš€ Running a product with real users** -Users click the bug icon from inside your app. Reports arrive with full context (URL, selector, stack trace, voice transcript). Paste โ†’ AI fixes โ†’ user gets notified. No more "can you repro?" back-and-forth. +Reports can include the URL, selector, stack trace and voice transcript. Choose a recipe for storage/delivery and connect its status and notification flow to your application. --- @@ -47,7 +47,7 @@ Three commands. Paste each one separately and wait for it to finish before the n **2.** Install the plugin: ``` -/plugin install bugkit@bglglzd-bugkit +/plugin install bugkit@bugkit ``` **3.** Start the installer in your project: @@ -68,7 +68,9 @@ Install bugkit in this project from https://raw.githubusercontent.com/bglglzd/bu Either path detects your stack, asks **one** question (where should bug reports go โ€” DB, GitHub Issues, Telegram, JSONL, email, Slack, Discord), and implements the matching [recipe](./recipes/). Voice transcription is offered too โ€” ๐ŸŽค mic button next to the description field, Web Speech API by default, local Whisper as an offline upgrade, never paid APIs. -Full install takes about 5โ€“10 minutes for a web app. +This repository ships a specification, plugin prompts and integration recipes. +The installer adapts them to your project; it is not a hosted reporting service. +Verify the generated integration using that project's actual UI or CLI. --- @@ -77,9 +79,9 @@ Full install takes about 5โ€“10 minutes for a web app. | Traditional feedback form | bugkit | |---|---| | "Something is broken" | Selector, XPath, HTML, viewport, stack trace, voice transcript | -| Fix takes days | Fix ships in minutes | +| Rebuild context in every conversation | Copy one predictable context block | | Format differs per project | Standardized spec โ€” same ritual across every project | -| No closed-loop status | Reporter notified at each state change | +| No defined status flow | Status lifecycle and notification recipes | | Typing long descriptions is a chore | ๐ŸŽค press-to-speak, auto-transcribed | --- @@ -91,6 +93,20 @@ Full install takes about 5โ€“10 minutes for a web app. - **[INSTALL.md](./INSTALL.md)** โ€” the universal paste-in prompt for non-Claude-Code agents - **[Landing page](https://bglglzd.github.io/bugkit)** โ€” one-page tour with copy-ready install commands +## Verify the recipes + +With Node.js 24 LTS, no package installation is required: + +```bash +node --test tests/*.test.mjs +``` + +The checks execute documented snippets with real temporary JSONL files and +loopback HTTP services. Browser speech, provider SDKs and report stores used by +adapter tests are explicit synthetic fixtures. Tests do not contact Telegram, +Slack, Discord, email, GitHub Issues or a paid AI service. See +[validation boundaries](./VALIDATION.md) and [contributing](./CONTRIBUTING.md). + --- MIT โ€” use it anywhere. โญ Star or watch this repo for new releases. diff --git a/VALIDATION.md b/VALIDATION.md new file mode 100644 index 0000000..d0878ba --- /dev/null +++ b/VALIDATION.md @@ -0,0 +1,37 @@ +# Validation boundaries + +bugkit contains a portable report specification, plugin commands/skills and +recipes that an installing agent adapts to a project. It has no runtime package +manifest or hosted delivery service. Recipe-specific SDKs belong to the target +project; keep them on supported versions there. + +Run `node --test tests/*.test.mjs` with Node 24 LTS. CI runs the same checks on +Linux and Windows. Tests extract the actual documented code blocks, rather than +keeping a separate implementation of each adapter. + +The suite exercises: + +- Registered marketplace/install-command consistency and the advertised voice + command asset. +- Canonical SPEC context fields, section order and storage-specific close hints. +- Actual temporary JSONL create/list/show/copy/close, full UUID/numeric selection, + failure visibility and terminal resolution validation. +- Telegram complete IDs, webhook secret and chat/user authorization, reason + replies, large context files and delivery failures using loopback HTTP. +- Slack chunk reconstruction and Discord file delivery with long Unicode text, + controlled mentions, acknowledgement requests and rejected deliveries. +- Resend's resolved-promise error contract and GitHub issue-body reconstruction + with explicit fake SDKs. No actual email or issue is created. +- Literal CLI argv/stdin, synthetic speech-result sequences/restarts, and picker + coordinates with a synthetic scrolled browser object. + +These checks establish the supplied examples' contracts. They do not establish +live provider credentials, a generated project's framework integration, real +microphone recognition, email deliverability, rate-limit behavior or AI fix +quality. Plugin validation/clean local installation can be performed without an +inference request using an isolated Claude Code configuration directory. + +Store a report before mirroring it. Delivery errors must remain distinguishable +from a saved report; retrying a partially accepted multi-message operation may +duplicate a notification. Telegram pending-reason storage in the example is for +a single process; use durable storage for multiple workers/serverless handlers. diff --git a/commands/voice.md b/commands/voice.md new file mode 100644 index 0000000..7081bf2 --- /dev/null +++ b/commands/voice.md @@ -0,0 +1,9 @@ +--- +description: Add optional voice transcription to an existing bugkit web form. +--- + +The user wants optional voice input for their existing bugkit installation. +Invoke the `adding-voice` skill. Preserve typed input, explain the browser/local +transcription choice, and verify the supported path without paid API fallback. +If the project has no browser-facing form, explain that this command does not +apply to its sink; do not add a microphone flow to a CLI or bot. diff --git a/docs/index.html b/docs/index.html index 31ae076..e32fefe 100644 --- a/docs/index.html +++ b/docs/index.html @@ -80,8 +80,8 @@

Install โ€” pick your AI agent

Install the plugin
-
/plugin install bugkit@bglglzd-bugkit
- +
/plugin install bugkit@bugkit
+
diff --git a/recipes/cli-tool.md b/recipes/cli-tool.md index 2f8bc77..fa18184 100644 --- a/recipes/cli-tool.md +++ b/recipes/cli-tool.md @@ -164,10 +164,15 @@ If your CLI is open-source and you want reports to go to GitHub: ```ts async function createGhIssue(report: BugReport) { const body = buildAIContext(report) - const cmd = `gh issue create --title ${quote(report.title)} --body - --label bugkit,bug` - const child = spawn('sh', ['-c', cmd], { stdio: ['pipe', 'inherit', 'inherit'] }) - child.stdin.write(body) - child.stdin.end() + const child = spawn('gh', ['issue', 'create', '--title', report.title, '--body-file', '-', '--label', 'bugkit,bug'], { + shell: false, stdio: ['pipe', 'inherit', 'inherit'], + }) + await new Promise((resolve, reject) => { + child.once('error', reject) + child.stdin.on('error', reject) + child.once('close', code => code === 0 ? resolve() : reject(new Error(`GitHub issue creation failed (${code})`))) + child.stdin.end(body) + }) } ``` diff --git a/recipes/email.md b/recipes/email.md index 5521bd7..bc40f2d 100644 --- a/recipes/email.md +++ b/recipes/email.md @@ -60,13 +60,15 @@ const resend = new Resend(process.env.RESEND_API_KEY) export async function sendBugByEmail(report: BugReport) { const aiContext = buildAIContext(report) // SPEC.md ยง3 template - await resend.emails.send({ + const { data, error } = await resend.emails.send({ from: process.env.BUGKIT_EMAIL_FROM!, to: process.env.BUGKIT_EMAIL_TO!, subject: `[${report.type.toUpperCase()} #${report.id}] ${report.title}`, html: renderHtml(report, aiContext), text: renderPlainText(report, aiContext), // fallback }) + if (error || !data?.id) throw new Error('Email provider rejected delivery or returned no receipt') + return data } ``` diff --git a/recipes/github-issues.md b/recipes/github-issues.md index ab006d9..52b7402 100644 --- a/recipes/github-issues.md +++ b/recipes/github-issues.md @@ -28,55 +28,13 @@ No database, no admin UI. Bug reports open as GitHub Issues with a `bugkit` labe Since there's no admin UI to add a "Copy Context" button later, **write the AI context into the issue body at creation time** inside a collapsible `
` block. Admins click to expand, copy, paste into Claude. -Issue body template: - -```markdown -**Reporter:** @{reporter.name} -**Type:** {type} -**Page:** {page.url} -**Viewport:** {viewport} - -## Description -{description} - -
-๐Ÿค– Copy this into Claude Code - -``` -=== BUG REPORT #{issue_number} === -Type: {type} -Status: open -Reported: {created_at} by @{reporter.name} - -## Description -{description} - -## Page -URL: {page.url} -Viewport: {viewport} -User Agent: {user_agent} - -## Selected Element -Selector: {element.selector} -XPath: {element.xpath} -Tag: {element.tag} -Visible text: {element.text} -HTML: -{element.html} - -## Instructions -{Fix this bug | Implement this feature}. When done: -1. Summarize root cause and files changed. -2. Provide a short "how to verify" note. -3. Close this issue with a comment using `/resolved ` or via: - gh issue close {issue_number} --comment "" - (Or `/wontfix ` to refuse.) -``` - -
-``` - -**Note:** The `#{issue_number}` goes in AFTER GitHub assigns the number on creation. Easiest: create the issue with a placeholder `#PENDING`, then immediately edit the body substituting the real number. Or use the returned `issue.number` to construct a second request. +Use the canonical `buildAIContext` function from `web-app-full.md`, passing the +GitHub issue number and a GitHub-specific close command as `updateHint`. Do not +maintain a shorter second template: populated environment, page, element, error, +console and attachment sections must all remain available. The implementation +below chooses a code fence longer than any run inside the report, so report text +stays literal. After creation it rebuilds the context using the assigned issue +number rather than replacing matching text inside the user's description. --- @@ -102,13 +60,23 @@ Create these labels once during install. import { Octokit } from '@octokit/rest' const gh = new Octokit({ auth: process.env.GITHUB_TOKEN }) +function renderIssueBody(report: BugReport & { issue_number: number | string }) { + const context = buildAIContext({ ...report, id: report.issue_number }, `gh issue close ${report.issue_number} --comment ""`) + const fence = '`'.repeat(Math.max(3, ...[...(context.match(/`+/g) || [])].map(run => run.length + 1))) + return ['', '
', '๐Ÿค– Copy this into Claude Code', '', fence + 'text', context, fence, '', '
'].join('\n') +} + export async function createBugReport(input: BugReportInput) { + const report: BugReport = { + ...input, id: 'PENDING', bugkit_spec: '1', type: input.type || 'bug', + title: extractTitle(input.description), status: 'open', created_at: new Date().toISOString(), + } const issue = await gh.issues.create({ owner: process.env.GITHUB_OWNER!, repo: process.env.GITHUB_REPO!, - title: `[${input.type}] ${extractTitle(input.description)}`, - body: renderIssueBody({ ...input, issue_number: 'PENDING' }), - labels: ['bugkit', input.type === 'feature' ? 'feature' : 'bug'], + title: `[${report.type}] ${extractTitle(report.description)}`, + body: renderIssueBody({ ...report, issue_number: 'PENDING' }), + labels: ['bugkit', report.type === 'feature' ? 'feature' : 'bug'], }) // Second pass: replace PENDING with real issue number @@ -116,7 +84,7 @@ export async function createBugReport(input: BugReportInput) { owner: process.env.GITHUB_OWNER!, repo: process.env.GITHUB_REPO!, issue_number: issue.data.number, - body: issue.data.body!.replace(/#PENDING/g, `#${issue.data.number}`), + body: renderIssueBody({ ...report, issue_number: issue.data.number }), }) return { id: issue.data.number, url: issue.data.html_url } @@ -131,7 +99,10 @@ Admin workflow: 1. Triage in github.com โ†’ expand `๐Ÿค– Copy this into Claude Code` details โ†’ copy โ†’ paste into Claude Code 2. Claude fixes, commits, references the issue (`Fixes #42`) 3. Merging the PR auto-closes the issue -4. Reporter is notified by GitHub (they're subscribed automatically as the issue author) +4. Notify the app reporter through your application's status channel. A server + token makes the token owner/bot the GitHub issue author; the app reporter is + not automatically subscribed. GitHub notifications cover the actual author + and subscribers, or users you deliberately and correctly associate with it. Notifications are free โ€” GitHub handles them. diff --git a/recipes/jsonl-file.md b/recipes/jsonl-file.md index fdb14b0..406424e 100644 --- a/recipes/jsonl-file.md +++ b/recipes/jsonl-file.md @@ -39,11 +39,11 @@ const FILE = './.bugreports.jsonl' export async function createBugReport(input: BugReportInput) { const report = { + ...input, bugkit_spec: '1', id: randomUUID(), status: 'open', created_at: new Date().toISOString(), - ...input, title: extractTitle(input.description), } await appendFile(FILE, JSON.stringify(report) + '\n', 'utf8') @@ -57,7 +57,10 @@ export async function createBugReport(input: BugReportInput) { ```ts export async function listReports(filter?: { status?: string, type?: string }) { - const raw = await readFile(FILE, 'utf8').catch(() => '') + const raw = await readFile(FILE, 'utf8').catch(error => { + if (error.code === 'ENOENT') return '' + throw error + }) const reports = raw.split('\n').filter(Boolean).map(l => JSON.parse(l)) if (!filter) return reports.reverse() // newest first return reports.filter(r => @@ -159,11 +162,12 @@ else if (cmd === 'show') { } else if (cmd === 'copy') { const r = await findReport(args[0]) - process.stdout.write(buildAIContext(r)) + process.stdout.write(buildAIContext(r, `node scripts/bugkit.js close ${JSON.stringify(String(r.id))} ""`)) } else if (cmd === 'close') { const r = await findReport(args[0]) - const resolution = args.slice(1).join(' ') + const resolution = args.slice(1).join(' ').trim() + if (!resolution || resolution.length > 2000) { console.error('A resolution note of 1โ€“2000 characters is required'); process.exit(1) } await updateReport(r.id, { status: 'resolved', resolution, resolved_at: new Date().toISOString() }) console.log(`#${r.id} resolved.`) } diff --git a/recipes/slack-discord.md b/recipes/slack-discord.md index 9173da0..78ffa6b 100644 --- a/recipes/slack-discord.md +++ b/recipes/slack-discord.md @@ -80,43 +80,69 @@ Pick one or both. } ``` -Follow with a second message posted to the same channel containing `\`\`\`\n{SPEC.md ยง3 formatted}\n\`\`\`` so admins can copy-long-press. +The examples above are optional previews, not containers for the complete context. +Discord's content field is limited to 2,000 characters. The implementation below +uploads the full UTF-8 context as a file; Slack receives ordered plain-text blocks +under its 3,000-character limit. No context is silently truncated. --- ## Implementation (minimal) ```ts +function splitText(text: string, limit = 2800): string[] { + const chunks: string[] = [] + let current = '' + for (const character of text) { + if (current.length + character.length > limit) { chunks.push(current); current = '' } + current += character + } + if (current) chunks.push(current) + return chunks +} + export async function postToChat(report: BugReport) { const aiContext = buildAIContext(report) // SPEC.md ยง3 + if (!process.env.BUGKIT_SLACK_WEBHOOK && !process.env.BUGKIT_DISCORD_WEBHOOK) throw new Error('No chat destination configured') if (process.env.BUGKIT_SLACK_WEBHOOK) { - await fetch(process.env.BUGKIT_SLACK_WEBHOOK, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(buildSlackMessage(report, aiContext)), - }) + const chunks = splitText(aiContext) + for (let i = 0; i < chunks.length; i++) { + const response = await fetch(process.env.BUGKIT_SLACK_WEBHOOK, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + text: `bugkit report context (${i + 1}/${chunks.length})`, + blocks: [{ type: 'section', text: { type: 'plain_text', text: chunks[i], emoji: false } }], + unfurl_links: false, unfurl_media: false, + }), signal: AbortSignal.timeout(10000), + }) + if (!response.ok || (await response.text()).trim() !== 'ok') throw new Error(`Slack delivery failed (${response.status})`) + } } if (process.env.BUGKIT_DISCORD_WEBHOOK) { - const hook = process.env.BUGKIT_DISCORD_WEBHOOK - await fetch(hook, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(buildDiscordEmbed(report)), - }) - // Follow-up: full context in a code block for easy copy - await fetch(hook, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - content: '```\n' + aiContext + '\n```', - }), - }) + const hook = new URL(process.env.BUGKIT_DISCORD_WEBHOOK) + hook.searchParams.set('wait', 'true') // require Discord's delivery acknowledgement + const filename = `bug-${String(report.id).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64)}.txt` + const form = new FormData() + form.append('payload_json', JSON.stringify({ + content: 'bugkit report context attached', allowed_mentions: { parse: [] }, + attachments: [{ id: 0, filename }], + })) + form.append('files[0]', new Blob([aiContext], { type: 'text/plain;charset=utf-8' }), filename) + const response = await fetch(hook, { method: 'POST', body: form, signal: AbortSignal.timeout(10000) }) + if (!response.ok) throw new Error(`Discord delivery failed (${response.status})`) } } ``` +Use Node 24 LTS built-in fetch/FormData/Blob. The report store remains the source of +truth. Propagate delivery errors and expose partial delivery separately from a +stored report; retrying after a partially accepted multi-message delivery can +duplicate notifications. Configure request-size/rate limits for your application. +See [Slack text limits](https://docs.slack.dev/reference/block-kit/composition-objects/text-object/) +and [Discord webhook/file parameters](https://docs.discord.com/developers/resources/webhook#execute-webhook). + --- ## Status tracking diff --git a/recipes/telegram-bot.md b/recipes/telegram-bot.md index 85c9db2..7678ab8 100644 --- a/recipes/telegram-bot.md +++ b/recipes/telegram-bot.md @@ -10,9 +10,16 @@ Bug reports arrive as messages in a Telegram chat via a bot. Admin reads on thei - A Telegram bot token (get one from [@BotFather](https://t.me/botfather)) - The chat ID where reports should be posted (personal chat, group, or channel) - - For personal DM: send any message to your bot, then visit `https://api.telegram.org/bot/getUpdates` โ€” look for `message.chat.id` - - For a group: add the bot, send a message mentioning it, visit the same URL -- Stored as `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ADMIN_CHAT_ID` in env + - Send a test message to your bot (or mention it in the group), then read + `message.chat.id` using `getUpdates` in a server-side admin client with its + token supplied from the environment. Avoid token-bearing browser URLs. +- Store `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ADMIN_CHAT_ID` in server-only env. +- Pattern B also requires `BUGKIT_TELEGRAM_TRACKING=true`, a random + `TELEGRAM_WEBHOOK_SECRET` (32โ€“256 allowed characters), and comma-separated + numeric `TELEGRAM_ADMIN_USER_IDS`. A group chat alone is not authorization. +- The examples target Node 24 LTS built-in fetch, FormData and Blob. Reuse the canonical + `buildAIContext` from `web-app-full.md`; adapt `getReport` and `updateStatus` + to your store, comparing the complete string representation of IDs. --- @@ -26,7 +33,9 @@ No persistence. Each bug report just sends a formatted message to the admin chat ### B. Persist + mirror (recommended) -Store reports in a JSONL file or tiny SQLite DB so you have status tracking. Also send them to Telegram for notification. Admin can reply to the Telegram message with `/resolve 42 ` and the bot updates the JSONL/DB. +Store reports in JSONL or SQLite, then mirror them to Telegram. Authorized admin +buttons retrieve context or resolve a report; Won't Fix asks for an actual reason. +Without tracking, the example sends a notification and context file without buttons. Start with A. Upgrade to B if you have more than a handful of reports per week. @@ -52,7 +61,7 @@ Text: "Bookmark" ``` With inline keyboard: -- `๐Ÿค– Copy for AI` โ€” replies with the full AI context in a code block +- `๐Ÿค– Copy for AI` โ€” sends the full AI context as a UTF-8 file - `โœ… Mark Resolved` โ€” moves status (pattern B only) - `๐Ÿšซ Won't Fix` โ€” ditto - `๐Ÿ”— Open` โ€” link to the full detail (if you have a web admin page) @@ -62,40 +71,50 @@ With inline keyboard: ## Implementation sketch (Node) ```ts -import fetch from 'node-fetch' - const TG_API = `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}` const ADMIN_CHAT = process.env.TELEGRAM_ADMIN_CHAT_ID! +const TRACKING = process.env.BUGKIT_TELEGRAM_TRACKING === 'true' -export async function sendBugToTelegram(report: BugReport) { - const text = [ - `๐Ÿ› *Bug Report #${report.id}*`, - `โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”`, - `*Reporter:* @${report.reporter.name}`, - report.page_url ? `*Page:* ${report.page_url}` : '', - report.viewport ? `*Viewport:* ${report.viewport}` : '', - '', - report.description, - '', - report.element_context?.selector ? `*Element:* \`${report.element_context.selector}\`` : '', - ].filter(Boolean).join('\n') - - await fetch(`${TG_API}/sendMessage`, { +async function tg(method: string, payload: Record | FormData) { + const form = payload instanceof FormData + const response = await fetch(`${TG_API}/${method}`, { method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - chat_id: ADMIN_CHAT, - text, - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [[ - { text: '๐Ÿค– Copy for AI', callback_data: `ctx:${report.id}` }, - { text: 'โœ… Resolved', callback_data: `res:${report.id}` }, - { text: '๐Ÿšซ Won\'t Fix', callback_data: `wfx:${report.id}` }, - ]], - }, - }), + headers: form ? undefined : { 'content-type': 'application/json' }, + body: form ? payload : JSON.stringify(payload), + signal: AbortSignal.timeout(10000), + }) + const body = await response.json().catch(() => null) + if (!response.ok || body?.ok !== true) throw new Error(`Telegram delivery failed (${response.status})`) + return body.result +} + +async function sendContext(report: BugReport) { + const hint = TRACKING + ? `Use the authorized admin workflow for the full report ID ${report.id}` + : 'Notification-only sink: record the resolution in your project tracker' + const form = new FormData() + form.append('chat_id', ADMIN_CHAT) + const filename = `bug-${String(report.id).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64)}.txt` + form.append('document', new Blob([buildAIContext(report, hint)], { type: 'text/plain;charset=utf-8' }), filename) + return tg('sendDocument', form) +} + +export async function sendBugToTelegram(report: BugReport) { + if (!process.env.TELEGRAM_BOT_TOKEN || !ADMIN_CHAT) throw new Error('Telegram delivery is not configured') + const buttons = [ + { text: '๐Ÿค– Copy for AI', callback_data: `ctx:${report.id}` }, + { text: 'โœ… Resolved', callback_data: `res:${report.id}` }, + { text: "๐Ÿšซ Won't Fix", callback_data: `wfx:${report.id}` }, + ] + if (TRACKING && buttons.some(button => Buffer.byteLength(button.callback_data, 'utf8') > 64)) { + throw new Error('Report ID exceeds Telegram callback limit; use persisted opaque callback tokens') + } + await tg('sendMessage', { + chat_id: ADMIN_CHAT, + text: `๐Ÿ› Bug Report #${String(report.id).slice(0, 64)}\nReporter: ${String(report.reporter.name).slice(0, 80)}\n${report.description.slice(0, 700)}`, + ...(TRACKING ? { reply_markup: { inline_keyboard: [buttons] } } : {}), }) + await sendContext(report) } ``` @@ -103,7 +122,9 @@ For the "Copy for AI" button, Telegram doesn't have a native copy-to-clipboard. 1. **Post the context as a reply in a Markdown code block.** User long-presses the block on their phone to copy. 2. **Send as a `.txt` file attachment.** User taps โ†’ shares โ†’ pastes into Claude. -Option 1 is smoother on mobile. Build the AI context from `SPEC.md ยง3` and send it inside triple-backtick fences. +The example uses a UTF-8 `.txt` document, retaining the full context without +Telegram's message-size or Markdown-escaping limits. Preview text is deliberately +short; the context document contains the complete report. --- @@ -112,43 +133,76 @@ Option 1 is smoother on mobile. Build the AI context from `SPEC.md ยง3` and send You need a webhook endpoint to receive button clicks: ```ts +import { timingSafeEqual } from 'node:crypto' + +// A single-process sketch. Persist pending prompts for serverless/multi-worker +// deployments, keyed by chat/message/admin identity with the same expiry. +const pendingReasons = new Map() +const ADMIN_USERS = new Set((process.env.TELEGRAM_ADMIN_USER_IDS || '').split(',').map(id => id.trim()).filter(Boolean)) +function authorized(chat: unknown, user: unknown) { + return String(chat) === ADMIN_CHAT && ADMIN_USERS.has(String(user)) +} + // POST /api/telegram/webhook export async function handler(req: Request) { - const update = await req.json() + if (!TRACKING) return new Response('disabled', { status: 404 }) + const secret = process.env.TELEGRAM_WEBHOOK_SECRET || '' + if (!/^[a-zA-Z0-9_-]{32,256}$/.test(secret) || !ADMIN_CHAT || !ADMIN_USERS.size) return new Response('not configured', { status: 503 }) + const provided = Buffer.from(req.headers.get('x-telegram-bot-api-secret-token') || '') + const expected = Buffer.from(secret) + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return new Response('forbidden', { status: 403 }) + let update + try { update = await req.json() } catch { return new Response('invalid update', { status: 400 }) } + for (const [key, pending] of pendingReasons) if (pending.expires < Date.now()) pendingReasons.delete(key) const callback = update.callback_query - if (!callback) return new Response('ok') - - const [action, idStr] = callback.data.split(':') - const id = parseInt(idStr, 10) + if (!callback) { + const message = update.message + if (!message) return new Response('ok') + if (!authorized(message.chat?.id, message.from?.id)) return new Response('forbidden', { status: 403 }) + const key = `${ADMIN_CHAT}:${message.reply_to_message?.message_id}` + const pending = pendingReasons.get(key) + if (!pending || pending.user !== String(message.from.id)) return new Response('ok') + const reason = typeof message.text === 'string' ? message.text.trim() : '' + if (!reason || reason.length > 2000) return new Response('reason required (1โ€“2000 characters)', { status: 400 }) + await updateStatus(pending.id, 'wontfix', reason) + pendingReasons.delete(key) + await tg('sendMessage', { chat_id: ADMIN_CHAT, text: 'Reason saved.' }) + return new Response('ok') + } + if (!authorized(callback.message?.chat?.id, callback.from?.id)) return new Response('forbidden', { status: 403 }) + const data = typeof callback.data === 'string' ? callback.data : '' + const separator = data.indexOf(':') + const action = data.slice(0, separator), id = data.slice(separator + 1) + if (separator < 1 || !id || !['ctx', 'res', 'wfx'].includes(action) || Buffer.byteLength(data, 'utf8') > 64) return new Response('invalid callback', { status: 400 }) if (action === 'ctx') { const report = await getReport(id) - const ctx = buildAIContext(report) // SPEC.md ยง3 format - await tg('sendMessage', { - chat_id: callback.message.chat.id, - reply_to_message_id: callback.message.message_id, - text: '```\n' + ctx + '\n```', - parse_mode: 'MarkdownV2', - }) + if (!report) return new Response('not found', { status: 404 }) + await sendContext(report) + await tg('answerCallbackQuery', { callback_query_id: callback.id, text: 'Context sent as a file' }) } else if (action === 'res') { - await updateStatus(id, 'resolved', 'Fixed (via Telegram)') + await updateStatus(id, 'resolved', 'Marked resolved by an authorized administrator via Telegram') await tg('answerCallbackQuery', { callback_query_id: callback.id, text: 'Marked resolved' }) } else if (action === 'wfx') { - // Ask the admin to reply with a reason - await tg('sendMessage', { - chat_id: callback.message.chat.id, - reply_to_message_id: callback.message.message_id, - text: `Reply with reason for marking #${id} as won\\'t fix:`, + if (pendingReasons.size >= 1000) return new Response('too many pending prompts', { status: 503 }) + const prompt = await tg('sendMessage', { + chat_id: ADMIN_CHAT, + text: `Reply with a reason for marking #${id} as won't fix:`, reply_markup: { force_reply: true, selective: true }, }) - // Track the pending reply in memory/DB; on reply, call updateStatus(id, 'wontfix', reply.text) + if (!Number.isInteger(prompt?.message_id)) throw new Error('Telegram did not return a prompt ID') + pendingReasons.set(`${ADMIN_CHAT}:${prompt.message_id}`, { id, user: String(callback.from.id), expires: Date.now() + 15 * 60 * 1000 }) + await tg('answerCallbackQuery', { callback_query_id: callback.id, text: 'Reply with a reason' }) } return new Response('ok') } ``` -Register the webhook: `curl "https://api.telegram.org/bot/setWebhook?url=https://yourapp.com/api/telegram/webhook"`. +Register an HTTPS webhook with Telegram's `setWebhook` API and the same +`secret_token` stored in `TELEGRAM_WEBHOOK_SECRET`. Keep tokens out of shell +history/logs and source. Apply your framework's request-size and rate limits. +See the [official webhook contract](https://core.telegram.org/bots/api#setwebhook). --- @@ -168,8 +222,12 @@ If reporter submitted from a web app: reuse the web app's in-app notification sy ## Acceptance checklist -- [ ] Bot token + admin chat ID in env vars +- [ ] Server-only bot token and admin chat ID configured +- [ ] Tracking webhook requires its secret header plus allowed admin chat/user IDs +- [ ] Missing/incorrect authorization causes no storage read, mutation or delivery +- [ ] UUID IDs remain intact; callback data fits Telegram's 64-byte limit - [ ] Bug submission sends a formatted message to the admin chat -- [ ] Inline "Copy for AI" button replies with SPEC.md ยง3 formatted text in a code block +- [ ] Full SPEC.md ยง3 context arrives as a UTF-8 file, including long reports +- [ ] HTTP errors and Telegram `ok: false` reject the operation - [ ] Admin can mark reports resolved/wontfix via inline buttons - [ ] Webhook is registered and receives callback queries diff --git a/recipes/web-app-full.md b/recipes/web-app-full.md index 695b583..7d0bf16 100644 --- a/recipes/web-app-full.md +++ b/recipes/web-app-full.md @@ -79,8 +79,8 @@ function onMove(e: MouseEvent) { const rect = el.getBoundingClientRect() outline.value = { display: 'block', - top: rect.top + scrollY + 'px', - left: rect.left + scrollX + 'px', + top: rect.top + 'px', + left: rect.left + 'px', width: rect.width + 'px', height: rect.height + 'px', } @@ -110,7 +110,7 @@ function extractContext(el: Element) { html: el.outerHTML.slice(0, 1200), // truncate rect: { x: Math.round(rect.x), - y: Math.round(rect.y + scrollY), + y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height), }, @@ -197,7 +197,7 @@ Full report detail + resolution note (if status is terminal) + a link back to th This is the heart of bugkit. The output MUST match `SPEC.md ยง3` exactly. Rough TypeScript: ```ts -function buildAIContext(r: BugReport): string { +function buildAIContext(r: BugReport, updateHint = `PATCH /api/bug-reports/${encodeURIComponent(String(r.id))}`): string { const lines: string[] = [] lines.push(`=== BUG REPORT #${r.id} ===`) lines.push(`Type: ${r.type}`) @@ -207,15 +207,27 @@ function buildAIContext(r: BugReport): string { lines.push('## Description') lines.push(r.description) lines.push('') - if (r.page_url || r.viewport || r.user_agent) { + const environment = r.environment || {} + if (environment.app_version || environment.commit || environment.platform || environment.os) { + lines.push('## Environment') + if (environment.app_version || environment.commit) lines.push(`App: ${environment.app_version || ''} (commit ${environment.commit || ''})`) + if (environment.platform) lines.push(`Platform: ${environment.platform}`) + if (environment.os) lines.push(`OS: ${environment.os}`) + lines.push('') + } + // Canonical SPEC v1 fields; the fallback reads older flattened app records. + const page = r.page || { url: r.page_url } + const client = r.client || { viewport: r.viewport, user_agent: r.user_agent } + if (page.url || page.route || client.viewport || client.user_agent) { lines.push('## Page') - if (r.page_url) lines.push(`URL: ${r.page_url}`) - if (r.viewport) lines.push(`Viewport: ${r.viewport}`) - if (r.user_agent) lines.push(`User Agent: ${r.user_agent}`) + if (page.url) lines.push(`URL: ${page.url}`) + if (page.route) lines.push(`Route: ${page.route}`) + if (client.viewport) lines.push(`Viewport: ${client.viewport}`) + if (client.user_agent) lines.push(`User Agent: ${client.user_agent}`) lines.push('') } - if (r.element_context) { - const e = r.element_context + const e = r.element || r.element_context + if (e && Object.values(e).some(value => value !== undefined && value !== null && value !== '')) { lines.push('## Selected Element') if (e.selector) lines.push(`Selector: ${e.selector}`) if (e.xpath) lines.push(`XPath: ${e.xpath}`) @@ -227,13 +239,25 @@ function buildAIContext(r: BugReport): string { if (e.html) { lines.push('HTML:'); lines.push(e.html) } lines.push('') } + if (r.error?.message || r.error?.stack) { + lines.push('## Error') + if (r.error.message) lines.push(`Message: ${r.error.message}`) + if (r.error.stack) lines.push('Stack:', r.error.stack) + lines.push('') + } + if (r.logs?.console?.length) { + lines.push('## Recent Console', r.logs.console.join('\n'), '') + } + if (r.attachments?.length) { + lines.push('## Attachments', ...r.attachments.map(a => `- ${a.type}: ${a.url}`), '') + } lines.push('## Instructions') lines.push(`${r.type === 'bug' ? 'Fix this bug' : 'Implement this feature'}. When done:`) lines.push('1. Summarize root cause and files changed.') lines.push('2. Provide a short "how to verify" note.') - lines.push(`3. Update the report via: PATCH /api/bug-reports/${r.id}`) - lines.push(` With: { status: 'resolved', resolution: '', commit_sha: '' }`) - lines.push(` (Or status 'wontfix' / 'closed' with a reason.)`) + lines.push(`3. Update the report via: ${updateHint}`) + lines.push(' With: { status: "resolved", resolution: "", commit_sha: "" }') + lines.push(' (Or status "wontfix" / "closed" with a reason.)') return lines.join('\n') } @@ -321,16 +345,17 @@ function attachVoice(textarea: HTMLTextAreaElement, micBtn: HTMLButtonElement) { let base = '' rec.onresult = (e: any) => { - let interim = '', final = '' - for (let i = e.resultIndex; i < e.results.length; i++) { - const t = e.results[i][0].transcript - if (e.results[i].isFinal) final += t - else interim += t - } - textarea.value = (base + final + interim).trimStart() + const speech = Array.from(e.results, (result: any) => result[0].transcript.trim()).filter(Boolean).join(' ') + textarea.value = (base + speech).trimStart() + textarea.dispatchEvent(new Event('input', { bubbles: true })) } rec.onerror = () => { stop() } - rec.onend = () => { if (recording) rec.start() } + rec.onend = () => { + if (recording) { + base = textarea.value ? textarea.value + ' ' : '' + try { rec.start() } catch { stop() } + } + } function start() { base = textarea.value ? textarea.value + ' ' : '' diff --git a/skills/adding-voice/SKILL.md b/skills/adding-voice/SKILL.md index 62128da..007ef3c 100644 --- a/skills/adding-voice/SKILL.md +++ b/skills/adding-voice/SKILL.md @@ -40,17 +40,17 @@ function attachVoice(textarea: HTMLTextAreaElement, micButton: HTMLButtonElement let baseText = '' recognition.onresult = (e: any) => { - let interim = '' - let finalText = '' - for (let i = e.resultIndex; i < e.results.length; i++) { - const t = e.results[i][0].transcript - if (e.results[i].isFinal) finalText += t - else interim += t - } - textarea.value = (baseText + finalText + interim).trimStart() + const speech = Array.from(e.results, (result: any) => result[0].transcript.trim()).filter(Boolean).join(' ') + textarea.value = (baseText + speech).trimStart() + textarea.dispatchEvent(new Event('input', { bubbles: true })) } recognition.onerror = () => { stopRecording() } - recognition.onend = () => { if (recording) recognition.start() /* keep alive */ } + recognition.onend = () => { + if (recording) { + baseText = textarea.value ? textarea.value + ' ' : '' + try { recognition.start() } catch { stopRecording() } + } + } function startRecording() { baseText = textarea.value ? textarea.value + ' ' : '' diff --git a/tests/helpers.mjs b/tests/helpers.mjs new file mode 100644 index 0000000..4aa884a --- /dev/null +++ b/tests/helpers.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import {readFile} from 'node:fs/promises' +import {stripTypeScriptTypes} from 'node:module' + +export async function snippet(file, heading, index = 0) { + const text = await readFile(new URL(`../${file}`, import.meta.url), 'utf8') + const section = text.split(heading)[1]?.split(/\r?\n## /)[0] + const blocks = [...(section ?? '').matchAll(/^```ts\r?\n([\s\S]*?)^```\s*$/gm)] + assert.ok(blocks[index], `${file}: missing example under ${heading}`) + return stripTypeScriptTypes(blocks[index][1], {mode: 'strip'}) + .replace(/^import[^\n]*\n/gm, '').replace(/^export /gm, '') +} +export function load(code, names, globals = {}) { + const scope = {process: {env: {}}, fetch: () => { throw Error('External requests forbidden') }, ...globals} + return new Function(...Object.keys(scope), code + `\nreturn {${names.join(',')}}`)(...Object.values(scope)) +} +export const report = { + bugkit_spec: '1', id: 'synthetic-report', type: 'bug', status: 'open', + title: 'Synthetic report', description: 'Synthetic broken button report', + created_at: '2026-01-01T00:00:00Z', reporter: {id: 'fixture', name: 'fixture'}, + environment: {app_version: '1', commit: 'abc123', platform: 'web', os: 'Synthetic OS'}, + page: {url: 'https://example.invalid/problem', route: 'problem'}, + client: {viewport: '1200x800', user_agent: 'Synthetic UA'}, + element: {selector: '#fixture', xpath: '/html/body/button', tag: 'BUTTON', id: 'fixture', classes: 'fixture', text: 'Synthetic button', html: '', rect: {x: 1, y: 2, w: 3, h: 4}}, + error: {message: 'Synthetic error', stack: 'Synthetic stack'}, + logs: {console: ['Synthetic console', 'Second line']}, + attachments: [{type: 'screenshot', url: 'https://example.invalid/synthetic.png'}], +} diff --git a/tests/jsonl-recipe.test.mjs b/tests/jsonl-recipe.test.mjs index 1dcfe6c..1ef3cdb 100644 --- a/tests/jsonl-recipe.test.mjs +++ b/tests/jsonl-recipe.test.mjs @@ -98,3 +98,27 @@ test('a partial numeric ID cannot silently select a different report', async () assert.match(result.stderr, /Not found/) }) }) + +test('empty and oversized resolution notes fail without changing stored reports', async () => { + await fixture(async directory => { + const original = record('synthetic-close') + await writeFile(join(directory, '.bugreports.jsonl'), JSON.stringify(original) + '\n') + for (const note of ['', ' ', 'x'.repeat(2001)]) { + const result = spawnSync(process.execPath, [join(directory, 'bugkit.js'), 'close', original.id, note], {cwd: directory, encoding: 'utf8'}) + assert.equal(result.status, 1) + assert.match(result.stderr, /resolution note/) + assert.deepEqual(JSON.parse(cli(directory, 'show', original.id)), original) + } + }) +}) + +test('missing storage starts empty while real read errors are reported', async () => { + await fixture(async directory => { + assert.equal(cli(directory, 'list'), '') + const {mkdir} = await import('node:fs/promises') + await mkdir(join(directory, '.bugreports.jsonl')) + const result = spawnSync(process.execPath, [join(directory, 'bugkit.js'), 'list'], {cwd: directory, encoding: 'utf8'}) + assert.notEqual(result.status, 0) + assert.notEqual(result.stderr, '') + }) +}) diff --git a/tests/provider-contracts.test.mjs b/tests/provider-contracts.test.mjs new file mode 100644 index 0000000..bb527d0 --- /dev/null +++ b/tests/provider-contracts.test.mjs @@ -0,0 +1,176 @@ +// Actual recipe functions, loopback HTTP and explicitly fake SDK/store boundaries. +import assert from 'node:assert/strict' +import {createServer} from 'node:http' +import {timingSafeEqual} from 'node:crypto' +import test from 'node:test' +import {snippet, load, report} from './helpers.mjs' + +async function contextBuilder() { + return load(await snippet('recipes/web-app-full.md', '## The "Copy Context for AI" function'), ['buildAIContext']).buildAIContext +} +async function localService(run) { + const requests = [], mode = {httpError: false, telegramError: false, slackError: false} + const server = createServer(async (req, res) => { + let raw = ''; for await (const chunk of req) raw += chunk + requests.push({path: req.url, headers: req.headers, raw}) + if (mode.httpError) { res.writeHead(503); res.end('synthetic rejection'); return } + if (req.url.startsWith('/slack')) { res.end(mode.slackError ? 'invalid_payload' : 'ok'); return } + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify(req.url.startsWith('/telegram') + ? {ok: !mode.telegramError, result: {message_id: 501}} + : {id: 'synthetic-message'})) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const origin = `http://127.0.0.1:${server.address().port}` + const guardedFetch = (url, init) => { + const address = String(url) + if (address.startsWith(origin + '/')) return fetch(address, init) + const telegram = 'https://api.telegram.org/botsynthetic-token/' + if (address.startsWith(telegram)) return fetch(origin + '/telegram/' + address.slice(telegram.length), init) + throw Error('External provider request blocked by synthetic test') + } + try { await run({origin, guardedFetch, requests, mode}) } + finally { await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) } +} +const syntheticSecret = 'synthetic_webhook_secret_for_tests_only_1234' +const telegramEnv = { + TELEGRAM_BOT_TOKEN: 'synthetic-token', TELEGRAM_ADMIN_CHAT_ID: '-10042', + TELEGRAM_ADMIN_USER_IDS: '7,8', TELEGRAM_WEBHOOK_SECRET: syntheticSecret, + BUGKIT_TELEGRAM_TRACKING: 'true', +} +const callback = (data, chat = -10042, user = 7) => ({callback_query: {id: 'synthetic-callback', data, from: {id: user}, message: {chat: {id: chat}, message_id: 10}}}) +const webhookRequest = (body, secret = syntheticSecret) => new Request('http://127.0.0.1/synthetic-webhook', { + method: 'POST', headers: {'content-type': 'application/json', 'x-telegram-bot-api-secret-token': secret}, body: JSON.stringify(body), +}) +async function telegram(globals) { + const code = await snippet('recipes/telegram-bot.md', '## Implementation sketch (Node)') + '\n' + await snippet('recipes/telegram-bot.md', '## Webhook (for callback buttons)') + return load(code, ['handler', 'sendBugToTelegram'], {process: {env: telegramEnv}, timingSafeEqual, buildAIContext: await contextBuilder(), ...globals}) +} + +test('Telegram preserves full UUIDs and sends the complete long canonical context as a file', async () => { + await localService(async ({guardedFetch, requests}) => { + const id = '6588a8c7-9437-47cf-a30c-6dbe49797f08' + const input = {...report, id, description: 'Synthetic long description. '.repeat(220)} + let selected + const {handler, sendBugToTelegram} = await telegram({fetch: guardedFetch, getReport: async value => { selected = value; return input }, updateStatus: async () => { throw Error('Unexpected mutation') }}) + assert.equal((await handler(webhookRequest(callback(`ctx:${id}`)))).status, 200) + assert.equal(selected, id) + assert.ok(requests.some(request => request.path === '/telegram/sendDocument' && request.raw.includes(input.description))) + assert.ok(requests.some(request => request.raw.includes('## Environment') && request.raw.includes('## Attachments'))) + await sendBugToTelegram(input) + const notice = requests.filter(request => request.path === '/telegram/sendMessage').at(-1) + const message = JSON.parse(notice.raw) + assert.ok(message.text.length < 4096) + assert.equal(message.reply_markup.inline_keyboard[0][0].callback_data, `ctx:${id}`) + }) +}) + +test('Telegram webhook authorization denies disclosure/mutation before storage or delivery', async () => { + await localService(async ({guardedFetch, requests}) => { + let reads = 0, mutations = 0 + const {handler} = await telegram({fetch: guardedFetch, getReport: async () => { reads++; return report }, updateStatus: async () => { mutations++ }}) + for (const request of [ + webhookRequest(callback('ctx:synthetic'), ''), + webhookRequest(callback('res:synthetic'), 'synthetic_wrong_secret_123456789012345'), + webhookRequest(callback('ctx:synthetic', -99)), + webhookRequest(callback('res:synthetic', -10042, 999)), + ]) assert.equal((await handler(request)).status, 403) + const missing = await telegram({process: {env: {...telegramEnv, TELEGRAM_WEBHOOK_SECRET: ''}}, fetch: guardedFetch, getReport: async () => { reads++; return report }, updateStatus: async () => { mutations++ }}) + assert.equal((await missing.handler(webhookRequest(callback('ctx:synthetic')))).status, 503) + const disabled = await telegram({process: {env: {...telegramEnv, BUGKIT_TELEGRAM_TRACKING: 'false'}}, fetch: guardedFetch}) + assert.equal((await disabled.handler(webhookRequest(callback('ctx:synthetic')))).status, 404) + assert.equal(reads, 0); assert.equal(mutations, 0); assert.equal(requests.length, 0) + }) +}) + +test('Telegram authorized resolution and reason replies target the same complete ID', async () => { + await localService(async ({guardedFetch}) => { + const mutations = [] + const {handler} = await telegram({fetch: guardedFetch, getReport: async () => report, updateStatus: async (...args) => mutations.push(args)}) + const id = '6588a8c7-9437-47cf-a30c-6dbe49797f08' + assert.equal((await handler(webhookRequest(callback(`res:${id}`)))).status, 200) + assert.equal(mutations[0][0], id) + assert.equal((await handler(webhookRequest(callback(`wfx:${id}`)))).status, 200) + const reply = {message: {chat: {id: -10042}, from: {id: 7}, reply_to_message: {message_id: 501}, text: 'Synthetic explicit reason'}} + assert.equal((await handler(webhookRequest({...reply, message: {...reply.message, from: {id: 8}}}))).status, 200) + assert.equal(mutations.length, 1) + assert.equal((await handler(webhookRequest(reply))).status, 200) + assert.deepEqual(mutations[1], [id, 'wontfix', 'Synthetic explicit reason']) + }) +}) + +test('Telegram HTTP and application-level rejections reject instead of reporting success', async () => { + await localService(async ({guardedFetch, mode}) => { + const {sendBugToTelegram} = await telegram({fetch: guardedFetch}) + mode.httpError = true + await assert.rejects(sendBugToTelegram(report), /Telegram delivery failed/) + mode.httpError = false; mode.telegramError = true + await assert.rejects(sendBugToTelegram(report), /Telegram delivery failed/) + }) +}) + +test('Slack and Discord preserve large context within transport limits and suppress mentions', async () => { + await localService(async ({origin, guardedFetch, requests}) => { + const context = 'Synthetic <@everyone> ``` context ๐Ÿ˜€\n'.repeat(400) + const {postToChat} = load(await snippet('recipes/slack-discord.md', '## Implementation (minimal)'), ['postToChat'], { + process: {env: {BUGKIT_SLACK_WEBHOOK: origin + '/slack', BUGKIT_DISCORD_WEBHOOK: origin + '/discord'}}, + fetch: guardedFetch, buildAIContext: () => context, + }) + await postToChat(report) + const slack = requests.filter(request => request.path === '/slack').map(request => JSON.parse(request.raw)) + assert.ok(slack.length > 1) + assert.ok(slack.every(message => message.blocks[0].text.type === 'plain_text' && message.blocks[0].text.text.length <= 3000)) + assert.equal(slack.map(message => message.blocks[0].text.text).join(''), context) + const discord = requests.find(request => request.path === '/discord?wait=true') + assert.ok(discord.raw.includes(context)) + assert.ok(discord.raw.includes('"allowed_mentions":{"parse":[]}')) + assert.ok(discord.headers['content-type'].startsWith('multipart/form-data;')) + }) +}) + +for (const provider of ['Slack', 'Discord']) { + test(`${provider} delivery errors reject`, async () => { + await localService(async ({origin, guardedFetch, mode}) => { + const {postToChat} = load(await snippet('recipes/slack-discord.md', '## Implementation (minimal)'), ['postToChat'], { + process: {env: {[`BUGKIT_${provider.toUpperCase()}_WEBHOOK`]: origin + '/' + provider.toLowerCase()}}, + fetch: guardedFetch, buildAIContext: () => 'Synthetic context', + }) + mode.httpError = true + await assert.rejects(postToChat(report), /delivery failed/) + if (provider === 'Slack') { + mode.httpError = false; mode.slackError = true + await assert.rejects(postToChat(report), /delivery failed/) + } + }) + }) +} + +test('Resend SDK rejection is surfaced even when its promise resolves', async () => { + let rejected = true + class Resend { constructor() { this.emails = {send: async () => rejected ? {data: null, error: {message: 'synthetic error'}} : {data: {id: 'synthetic-mail'}, error: null}} } } + const {sendBugByEmail} = load(await snippet('recipes/email.md', '## Implementation sketch (Resend)'), ['sendBugByEmail'], { + Resend, buildAIContext: await contextBuilder(), renderHtml: () => '

Synthetic

', renderPlainText: () => 'Synthetic', + }) + await assert.rejects(sendBugByEmail(report), /Email provider rejected/) + rejected = false; assert.equal((await sendBugByEmail(report)).id, 'synthetic-mail') +}) + +test('GitHub issue rendering retains canonical context and assigns server IDs without rewriting user text', async () => { + const requests = [] + let rejectUpdate = false + class Octokit { constructor() { this.issues = { + create: async input => { requests.push(input); return {data: {number: 42, html_url: 'https://example.invalid/issues/42', body: input.body}} }, + update: async input => { if (rejectUpdate) throw Error('synthetic update rejection'); requests.push(input) }, + } } } + const {createBugReport} = load(await snippet('recipes/github-issues.md', '## Implementation sketch (Node)'), ['createBugReport'], { + Octokit, buildAIContext: await contextBuilder(), extractTitle: text => text.split('\n')[0].slice(0, 200), + }) + const input = {...report, description: 'Synthetic literal #PENDING ```\n
\nSecond line'} + assert.equal((await createBugReport(input)).id, 42) + const body = requests.at(-1).body + assert.ok(body.includes('=== BUG REPORT #42 ===') && body.includes(input.description)) + assert.ok(body.includes('gh issue close 42 --comment')) + assert.ok(body.includes('````text')) + for (const section of ['Environment', 'Selected Element', 'Error', 'Recent Console', 'Attachments']) assert.ok(body.includes('## ' + section)) + rejectUpdate = true; await assert.rejects(createBugReport(input), /synthetic update rejection/) +}) diff --git a/tests/recipe-contracts.test.mjs b/tests/recipe-contracts.test.mjs new file mode 100644 index 0000000..477e079 --- /dev/null +++ b/tests/recipe-contracts.test.mjs @@ -0,0 +1,84 @@ +// Execute the documented snippets with synthetic browser/SDK boundaries. +// Never contact a provider or send an actual report from these tests. +import assert from 'node:assert/strict' +import {readFile} from 'node:fs/promises' +import {EventEmitter} from 'node:events' +import test from 'node:test' + +import {snippet, load, report} from './helpers.mjs' + +test('install instructions name the registered marketplace and advertised voice command exists', async () => { + const marketplace = JSON.parse(await readFile(new URL('../.claude-plugin/marketplace.json', import.meta.url))) + for (const file of ['README.md', 'INSTALL.md', 'docs/index.html']) { + const text = await readFile(new URL(`../${file}`, import.meta.url), 'utf8') + assert.ok(text.includes(`bugkit@${marketplace.name}`), file) + assert.ok(!text.includes('bugkit@bglglzd-bugkit'), file) + } + assert.match(await readFile(new URL('../commands/voice.md', import.meta.url), 'utf8'), /adding-voice/) +}) + +test('canonical context preserves every populated SPEC section in order and sink-specific update hints', async () => { + const {buildAIContext} = load(await snippet('recipes/web-app-full.md', '## The "Copy Context for AI" function'), ['buildAIContext']) + const context = buildAIContext(report, 'node scripts/bugkit.js close synthetic-report ""') + const headings = ['Description', 'Environment', 'Page', 'Selected Element', 'Error', 'Recent Console', 'Attachments', 'Instructions'] + assert.deepEqual([...context.matchAll(/^## (.*)$/gm)].map(match => match[1]), headings) + for (const value of ['App: 1 (commit abc123)', 'Platform: web', 'OS: Synthetic OS', 'Route: problem', 'Viewport: 1200x800', 'User Agent: Synthetic UA', 'Position: x=1 y=2 w=3 h=4', 'Message: Synthetic error', 'Stack:\nSynthetic stack', 'Synthetic console\nSecond line', '- screenshot: https://example.invalid/synthetic.png']) assert.ok(context.includes(value), value) + assert.ok(context.includes('Update the report via: node scripts/bugkit.js close synthetic-report')) + assert.ok(!context.includes('PATCH /api/bug-reports/')) + const minimal = buildAIContext({id: 1, type: 'feature', status: 'open', created_at: 'synthetic', description: 'Synthetic feature', reporter: {name: 'fixture'}}) + assert.deepEqual([...minimal.matchAll(/^## (.*)$/gm)].map(match => match[1]), ['Description', 'Instructions']) + assert.match(minimal, /Implement this feature/) +}) + +for (const [file, heading] of [ + ['recipes/web-app-full.md', '### Web Speech path (default)'], + ['skills/adding-voice/SKILL.md', '### Mode 1: Web Speech API (default)'], +]) { + test(`${file}: voice retains finalized segments, interim updates and restarted sessions`, async () => { + let recognition + class SpeechRecognition { constructor() { recognition = this } start() {} stop() {} } + const clicks = new Map(), inputEvents = [] + const textarea = {value: 'Typed', addEventListener() {}, dispatchEvent: event => inputEvents.push(event.type)} + const button = {style: {}, classList: {add() {}, remove() {}}, addEventListener: (name, fn) => clicks.set(name, fn)} + const {attachVoice} = load(await snippet(file, heading), ['attachVoice'], {window: {SpeechRecognition}, navigator: {language: 'en-US'}, Event}) + attachVoice(textarea, button); clicks.get('click')() + const result = (text, final = true) => Object.assign([{transcript: text}], {isFinal: final}) + recognition.onresult({resultIndex: 0, results: [result('First')]}) + recognition.onresult({resultIndex: 1, results: [result('First'), result('Second', false)]}) + recognition.onresult({resultIndex: 1, results: [result('First'), result('Second')]}) + assert.equal(textarea.value, 'Typed First Second') + recognition.onend() + recognition.onresult({resultIndex: 0, results: [result('Third')]}) + assert.equal(textarea.value, 'Typed First Second Third') + assert.ok(inputEvents.every(type => type === 'input') && inputEvents.length === 4) + }) +} + +test('fixed picker outline and element coordinates share viewport space after scrolling', async () => { + const element = {closest: () => false, getBoundingClientRect: () => ({top: 30, left: 20, x: 20, y: 30, width: 50, height: 10}), tagName: 'BUTTON', id: 'fixture', className: '', innerText: 'Synthetic', outerHTML: ''} + const {onMove, outline, extractContext} = load(await snippet('recipes/web-app-full.md', '## Element picker'), ['onMove', 'outline', 'extractContext'], { + ref: value => ({value}), document: {elementFromPoint: () => element}, scrollY: 500, scrollX: 300, + buildSelector: () => '#fixture', buildXPath: () => '/button', + }) + onMove({clientX: 20, clientY: 30}) + assert.equal(outline.value.top, '30px'); assert.equal(outline.value.left, '20px') + assert.deepEqual(extractContext(element).rect, {x: 20, y: 30, w: 50, h: 10}) +}) + +test('GitHub CLI uses direct argv/body-file stdin, preserves literal title and propagates failure', async () => { + let code = 0, captured, body + const spawn = (command, args, options) => { + captured = {command, args, options} + const child = new EventEmitter(); child.stdin = new EventEmitter() + child.stdin.end = value => { body = value; queueMicrotask(() => child.emit('close', code)) } + return child + } + const {createGhIssue} = load(await snippet('recipes/cli-tool.md', '## Publishing via GitHub Issues instead'), ['createGhIssue'], {spawn, buildAIContext: () => 'Synthetic complete context'}) + const title = 'Synthetic $(literal) `title` "quoted"' + await createGhIssue({...report, title}) + assert.equal(captured.command, 'gh'); assert.equal(captured.options.shell, false) + assert.equal(captured.args[captured.args.indexOf('--title') + 1], title) + assert.equal(captured.args[captured.args.indexOf('--body-file') + 1], '-') + assert.equal(body, 'Synthetic complete context') + code = 1; await assert.rejects(createGhIssue(report), /failed/) +}) From e8dbef64402a9fbae9c513ff129f372ed141c609 Mon Sep 17 00:00:00 2001 From: bglglzd <248948303+bglglzd@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:29:35 +0300 Subject: [PATCH 2/3] fix: align secondary recipe paths with supported contracts --- CHANGELOG.md | 4 +++ VALIDATION.md | 6 +++- assets/og-image.svg | 4 +-- docs/assets/og-image.svg | 4 +-- docs/index.html | 22 +++++++------- recipes/cli-tool.md | 49 +++++++++---------------------- recipes/email.md | 9 ++++-- recipes/web-app-full.md | 29 +++++++++++++----- skills/adding-voice/SKILL.md | 30 ++++++++++++++----- tests/provider-contracts.test.mjs | 44 +++++++++++++++++++++++++++ tests/recipe-contracts.test.mjs | 21 ++++++++++++- 11 files changed, 151 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a385208..278f40d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,7 @@ finalized voice segments and fix picker coordinates after scrolling. - Reject invalid terminal resolutions and propagate actual JSONL storage errors. - Add executable recipe contracts and Linux/Windows CI with synthetic boundaries. +- Reuse the canonical CLI context, surface reporter-notification failures, handle + SVG picker classes and feature labels, and correct local Whisper setup/format + conversion instructions. Replace unsupported fix-time promises on the landing + page and share images with factual copy. diff --git a/VALIDATION.md b/VALIDATION.md index d0878ba..6a90430 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -23,7 +23,11 @@ The suite exercises: - Resend's resolved-promise error contract and GitHub issue-body reconstruction with explicit fake SDKs. No actual email or issue is created. - Literal CLI argv/stdin, synthetic speech-result sequences/restarts, and picker - coordinates with a synthetic scrolled browser object. + coordinates with synthetic scrolled HTML/SVG browser objects. +- Both Whisper proxy snippets' input validation, multipart byte forwarding and + upstream HTTP rejection using a synthetic loopback endpoint. Setup commands + are checked against the linked upstream source; no actual Whisper server or + model is executed by this suite. These checks establish the supplied examples' contracts. They do not establish live provider credentials, a generated project's framework integration, real diff --git a/assets/og-image.svg b/assets/og-image.svg index 651b244..38004d9 100644 --- a/assets/og-image.svg +++ b/assets/og-image.svg @@ -1,5 +1,5 @@ - + @@ -29,7 +29,7 @@ bugkit - Users click. AI fixes. Done. + Context for coding agents. AI-friendly bug reports for any project. diff --git a/docs/assets/og-image.svg b/docs/assets/og-image.svg index 651b244..38004d9 100644 --- a/docs/assets/og-image.svg +++ b/docs/assets/og-image.svg @@ -1,5 +1,5 @@ - + @@ -29,7 +29,7 @@ bugkit - Users click. AI fixes. Done. + Context for coding agents. AI-friendly bug reports for any project. diff --git a/docs/index.html b/docs/index.html index e32fefe..76a7196 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,10 +3,10 @@ - bugkit โ€” Users click. AI fixes. Done. - - - + bugkit โ€” reports with context for coding agents + + + @@ -19,9 +19,9 @@

bugkit

-
Users click. AI fixes. Done.
+
Reports with context for coding agents.

- AI-friendly bug reports for any project. Users report bugs with full context โ€” URL, element selector, stack trace, viewport, voice transcript. Admins hit one button. Paste into Claude Code or any AI coding agent. Fix ships in minutes. + AI-friendly bug reports for any project. Users report bugs with full context โ€” URL, element selector, stack trace, viewport, voice transcript. Admins hit one button. Paste into Claude Code or any AI coding agent. Verify the generated patch and record its resolution.

stars @@ -45,11 +45,11 @@

Who this is for

๐Ÿ›  Building for a client or boss

-

Send them the staging link. They click the bug icon, pick the broken element, speak or type what they want. You paste the output into your AI coding agent. Fix ships the same day โ€” exactly how they described it. No more "did they mean the header or the hero?"

+

Send them the staging link. They click the bug icon, pick the broken element, speak or type what they want. You paste the output into your AI coding agent. Verify the generated patch against their report. No more "did they mean the header or the hero?"

๐Ÿš€ Running a product with users

-

Users click the bug icon from inside your app. Reports arrive with full context โ€” selector, stack trace, viewport, voice transcript. Paste โ†’ AI fixes โ†’ user gets notified. Your product improves as fast as you can read reports.

+

Users click the bug icon from inside your app. Reports arrive with full context โ€” selector, stack trace, viewport, voice transcript. Copy context to your agent, verify its patch, and connect status notifications to your application.

@@ -131,7 +131,7 @@

How it works

โ†“ Admin marks report "resolved" with a short note + commit SHA โ†“ -Reporter gets notified
+Application sends its configured status notification

Why this beats a feedback form

@@ -141,9 +141,9 @@

Why this beats a feedback form

- + - +
"Something is broken"Selector, XPath, HTML, viewport, URL, stack trace, voice transcript
Admin reads, asks clarifying questionsAdmin pastes to AI, AI already has everything
Fix takes daysFix ships in minutes
Rebuild context in each conversationCopy one predictable context block
Format differs per projectStandardized spec โ€” same ritual across all your projects
No closed-loop statusReporter notified at each state change
No defined status flowStatus lifecycle and notification recipes
Typing long descriptions is a chore๐ŸŽค press-to-speak, auto-transcribed
diff --git a/recipes/cli-tool.md b/recipes/cli-tool.md index fa18184..3bfefb7 100644 --- a/recipes/cli-tool.md +++ b/recipes/cli-tool.md @@ -105,42 +105,21 @@ Use `readline` or a small prompts library. ## AI context format for CLI -Adapt SPEC.md ยง3 for CLI context (no DOM, no page, but commands and env instead): +Use the canonical `buildAIContext` function from `web-app-full.md`, unchanged. +Set `environment.platform` to `cli`, map tool version/commit/OS to the matching +SPEC fields, and put optional command/CWD/runtime details in `logs.console`. +Keep the standard labels and reporter prefix; omit empty web/DOM sections. +```ts +function buildCLIContext(report: BugReport, closeHint: string): string { + return buildAIContext(report, closeHint) +} ``` -=== BUG REPORT #{id} === -Type: {type} -Status: {status} -Reported: {created_at} by {reporter.name} - -## Description -{description} - -## Environment -Tool: mytool v{app_version} -Platform: {platform} / {os} -Node: {node} - -## Command -CWD: {cwd} -Invoked: mytool {argv} -Exit code: {exit_code} - -## Error -{error.message} - -Stack: -{error.stack} - -## Recent log -{last N lines of ~/.mytool/error.log} - -## Instructions -Fix this bug. When done: -1. Summarize root cause and files changed. -2. Provide a short "how to verify" note. -3. Close the report: mytool bug close {id} "" -``` + +Pass a close hint matching your actual CLI storage command. The JSONL recipe +shows its `node scripts/bugkit.js close REPORT_ID ""` path; a tool with its +own subcommand can supply that subcommand instead. Do not invent a web PATCH +endpoint for a file-backed CLI. --- @@ -164,7 +143,7 @@ If your CLI is open-source and you want reports to go to GitHub: ```ts async function createGhIssue(report: BugReport) { const body = buildAIContext(report) - const child = spawn('gh', ['issue', 'create', '--title', report.title, '--body-file', '-', '--label', 'bugkit,bug'], { + const child = spawn('gh', ['issue', 'create', '--title', report.title, '--body-file', '-', '--label', `bugkit,${report.type}`], { shell: false, stdio: ['pipe', 'inherit', 'inherit'], }) await new Promise((resolve, reject) => { diff --git a/recipes/email.md b/recipes/email.md index bc40f2d..9a337af 100644 --- a/recipes/email.md +++ b/recipes/email.md @@ -109,14 +109,17 @@ Only if they provided an email. Skip if not. ```ts async function notifyReporter(report: BugReport) { if (!report.reporter.email) return - await resend.emails.send({ + const escapeHtml = (value: string) => value.replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } as Record)[character]) + const { data, error } = await resend.emails.send({ from: process.env.BUGKIT_EMAIL_FROM!, to: report.reporter.email, subject: `Re: [${report.type.toUpperCase()} #${report.id}] ${report.title}`, - html: `

Your bug report has been marked ${report.status}.

- ${report.resolution ? `

${report.resolution}

` : ''} + html: `

Your bug report has been marked ${escapeHtml(report.status)}.

+ ${report.resolution ? `

${escapeHtml(report.resolution)}

` : ''}

bugkit ยท yourapp.com

`, }) + if (error || !data?.id) throw new Error('Reporter email delivery rejected or returned no receipt') + return data } ``` diff --git a/recipes/web-app-full.md b/recipes/web-app-full.md index 7d0bf16..cd4e297 100644 --- a/recipes/web-app-full.md +++ b/recipes/web-app-full.md @@ -105,7 +105,7 @@ function extractContext(el: Element) { xpath: buildXPath(el), tag: el.tagName, id: el.id, - classes: (el.className || '').trim(), + classes: (el.getAttribute('class') || '').trim(), text: (el as HTMLElement).innerText?.slice(0, 200) ?? '', html: el.outerHTML.slice(0, 1200), // truncate rect: { @@ -378,7 +378,9 @@ Wire it on modal mount. That's the whole thing for the default path. ### Local Whisper upgrade (optional) -If the user wants offline / Firefox / full privacy, add a `/api/bugkit/transcribe` endpoint that proxies to whisper.cpp: +For browsers with MediaRecorder (including Firefox), add a +`/api/bugkit/transcribe` endpoint that proxies to whisper.cpp. Transcription runs +offline after model download, on the machine hosting that server: ```ts // Next.js App Router โ€” adapt to your framework @@ -388,7 +390,7 @@ export async function POST(req: Request) { const url = process.env.BUGKIT_TRANSCRIBE_URL || 'http://localhost:9000/inference' const formData = await req.formData() const audio = formData.get('file') as Blob - if (!audio) return new Response('no audio', { status: 400 }) + if (!(audio instanceof Blob) || audio.size === 0) return new Response('no audio', { status: 400 }) const up = new FormData() up.append('file', audio, 'audio.webm') up.append('response_format', 'text') @@ -424,19 +426,30 @@ When `detectTranscribeEndpoint()` returns true, record with MediaRecorder and PO **Run whisper.cpp locally (one-time setup):** ```bash -git clone https://github.com/ggerganov/whisper.cpp -cd whisper.cpp && make +# Prerequisites: Git, CMake, a C++ compiler, curl and FFmpeg on PATH. +# POSIX shell; pin the source snapshot used by this recipe. +git clone https://github.com/ggml-org/whisper.cpp +cd whisper.cpp +git checkout d2c6f05a46dacf663e357bcf0ed40f542a72825d +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --config Release --target whisper-server -j 2 ./models/download-ggml-model.sh base.en -./server -m models/ggml-base.en.bin --port 9000 +./build/bin/whisper-server -m models/ggml-base.en.bin --host 127.0.0.1 --port 9000 --convert ``` -~150MB model download, CPU-only, works offline. Set `BUGKIT_TRANSCRIBE_URL=http://localhost:9000/inference` if you run it elsewhere. +The base.en model is about 150 MB. `--convert` requires FFmpeg for browser formats +such as WebM/Opus or MP4/AAC. Run the loopback-only example server as an ordinary +user in an isolated development environment. Set `BUGKIT_TRANSCRIBE_URL` to the +actual server URL if needed. See the [voice recipe](../skills/adding-voice/SKILL.md) +and [upstream server documentation](https://github.com/ggml-org/whisper.cpp/blob/d2c6f05a46dacf663e357bcf0ed40f542a72825d/examples/server/README.md). +The automated proxy checks use synthetic HTTP responses, not a Whisper model. ### Privacy line under the textarea While recording, show a small italic note: - Web Speech path: *"Listening via Web Speech API (Chrome may send audio to Google servers)"* -- Whisper path: *"Listening via local Whisper โ€” stays on your machine"* +- Whisper path: *"Transcribing on the configured Whisper server"* (identify its + host; audio leaves the browser for that server). ### Paid-API footnote diff --git a/skills/adding-voice/SKILL.md b/skills/adding-voice/SKILL.md index 007ef3c..23ebb02 100644 --- a/skills/adding-voice/SKILL.md +++ b/skills/adding-voice/SKILL.md @@ -78,7 +78,9 @@ Under the textarea, a small grey italic label: *"Listening via Web Speech API"* ### Mode 2: Local Whisper (opt-in upgrade) -Adds a thin backend proxy + uses MediaRecorder on the frontend. Works in every browser including Firefox, fully offline, no keys. +Adds a thin backend proxy + uses MediaRecorder on the frontend. Supports browsers +with MediaRecorder, including Firefox. After downloading the model, transcription +runs offline on the machine hosting Whisper; no API key is needed. **Backend proxy** โ€” add `/api/bugkit/transcribe`: @@ -90,7 +92,7 @@ export async function POST(req: Request) { const url = process.env.BUGKIT_TRANSCRIBE_URL || 'http://localhost:9000/inference' const formData = await req.formData() const audio = formData.get('file') as Blob - if (!audio) return new Response('no audio', { status: 400 }) + if (!(audio instanceof Blob) || audio.size === 0) return new Response('no audio', { status: 400 }) const upstream = new FormData() upstream.append('file', audio, 'audio.webm') @@ -130,13 +132,14 @@ async function recordAndTranscribe(textarea: HTMLTextAreaElement, micButton: HTM recorder.ondataavailable = (e) => chunks.push(e.data) recorder.onstop = async () => { stream.getTracks().forEach(t => t.stop()) - const blob = new Blob(chunks, { type: 'audio/webm' }) + const blob = new Blob(chunks, { type: recorder.mimeType }) const fd = new FormData() fd.append('file', blob, 'audio.webm') const r = await fetch('/api/bugkit/transcribe', { method: 'POST', body: fd }) if (r.ok) { const text = (await r.text()).trim() textarea.value = textarea.value ? textarea.value + ' ' + text : text + textarea.dispatchEvent(new Event('input', { bubbles: true })) } micButton.classList.remove('recording') } @@ -153,15 +156,25 @@ Modal init: `if (await detectTranscribeEndpoint()) { /* use Whisper path */ } el **Local Whisper setup** โ€” print this for the user to run once, outside the project: ```bash -# One-time: install whisper.cpp and download a ~150MB model -git clone https://github.com/ggerganov/whisper.cpp -cd whisper.cpp && make +# Prerequisites: Git, CMake, a C++ compiler, curl and FFmpeg on PATH. +# POSIX shell; one-time setup, with a pinned upstream source snapshot. +git clone https://github.com/ggml-org/whisper.cpp +cd whisper.cpp +git checkout d2c6f05a46dacf663e357bcf0ed40f542a72825d +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --config Release --target whisper-server -j 2 ./models/download-ggml-model.sh base.en # Each dev session: run the server -./server -m models/ggml-base.en.bin --port 9000 +./build/bin/whisper-server -m models/ggml-base.en.bin --host 127.0.0.1 --port 9000 --convert ``` +`--convert` uses FFmpeg for browser recordings such as WebM/Opus or MP4/AAC. +Keep this example server loopback-only and run it as an ordinary user in an +isolated development environment. [Upstream server documentation](https://github.com/ggml-org/whisper.cpp/blob/d2c6f05a46dacf663e357bcf0ed40f542a72825d/examples/server/README.md) +describes the conversion boundary. The recipe's automated checks use a synthetic +HTTP endpoint; they do not run an actual Whisper model. + Then set the env var (if different from default): ``` BUGKIT_TRANSCRIBE_URL=http://localhost:9000/inference @@ -197,7 +210,8 @@ If the user explicitly wants OpenAI Whisper or Groq or Deepgram, point `BUGKIT_T While recording, show a small grey italic note below the textarea: - Web Speech path: *"Listening via Web Speech API (Chrome may send audio to Google servers)"* -- Whisper path: *"Listening via local Whisper โ€” stays on your machine"* +- Whisper path: *"Transcribing on the configured Whisper server"* (identify that + server to the user; audio leaves the browser for its host). ## Verify diff --git a/tests/provider-contracts.test.mjs b/tests/provider-contracts.test.mjs index bb527d0..5abc509 100644 --- a/tests/provider-contracts.test.mjs +++ b/tests/provider-contracts.test.mjs @@ -38,6 +38,35 @@ const telegramEnv = { TELEGRAM_ADMIN_USER_IDS: '7,8', TELEGRAM_WEBHOOK_SECRET: syntheticSecret, BUGKIT_TELEGRAM_TRACKING: 'true', } + +for (const [file, heading] of [ + ['recipes/web-app-full.md', '### Local Whisper upgrade (optional)'], + ['skills/adding-voice/SKILL.md', '### Mode 2: Local Whisper (opt-in upgrade)'], +]) { + test(`${file}: Whisper proxy rejects missing audio and preserves browser bytes through loopback HTTP`, async () => { + await localService(async ({origin, guardedFetch, requests, mode}) => { + const {POST} = load(await snippet(file, heading), ['POST'], { + process: {env: {BUGKIT_TRANSCRIBE_URL: origin + '/synthetic-inference'}}, fetch: guardedFetch, + }) + const request = value => { + const form = new FormData() + if (value !== undefined) form.append('file', value) + return new Request(origin + '/proxy', {method: 'POST', body: form}) + } + for (const value of [undefined, 'not a file', new Blob([])]) { + assert.equal((await POST(request(value))).status, 400) + } + assert.equal(requests.length, 0) + const audio = new Blob(['synthetic browser audio bytes'], {type: 'audio/webm'}) + const response = await POST(request(audio)) + assert.equal(response.status, 200) + assert.ok(requests[0].raw.includes('synthetic browser audio bytes')) + assert.ok(requests[0].raw.includes('response_format')) + mode.httpError = true + assert.equal((await POST(request(audio))).status, 502) + }) + }) +} const callback = (data, chat = -10042, user = 7) => ({callback_query: {id: 'synthetic-callback', data, from: {id: user}, message: {chat: {id: chat}, message_id: 10}}}) const webhookRequest = (body, secret = syntheticSecret) => new Request('http://127.0.0.1/synthetic-webhook', { method: 'POST', headers: {'content-type': 'application/json', 'x-telegram-bot-api-secret-token': secret}, body: JSON.stringify(body), @@ -155,6 +184,21 @@ test('Resend SDK rejection is surfaced even when its promise resolves', async () rejected = false; assert.equal((await sendBugByEmail(report)).id, 'synthetic-mail') }) +test('reporter email notification skips absent addresses and propagates SDK rejection', async () => { + let rejected = true, calls = 0, payload + const resend = {emails: {send: async input => { + calls++; payload = input + return rejected ? {data: null, error: {message: 'synthetic failure'}} : {data: {id: 'synthetic-reporter-mail'}, error: null} + }}} + const {notifyReporter} = load(await snippet('recipes/email.md', '## Notify reporter on status change'), ['notifyReporter'], {resend}) + await notifyReporter(report); assert.equal(calls, 0) + const input = {...report, reporter: {...report.reporter, email: 'fixture@example.invalid'}, resolution: 'Synthetic literal note'} + await assert.rejects(notifyReporter(input), /Reporter email delivery rejected/) + rejected = false + assert.equal((await notifyReporter(input)).id, 'synthetic-reporter-mail') + assert.ok(payload.html.includes('<b>Synthetic literal note</b>')) +}) + test('GitHub issue rendering retains canonical context and assigns server IDs without rewriting user text', async () => { const requests = [] let rejectUpdate = false diff --git a/tests/recipe-contracts.test.mjs b/tests/recipe-contracts.test.mjs index 477e079..9a471cf 100644 --- a/tests/recipe-contracts.test.mjs +++ b/tests/recipe-contracts.test.mjs @@ -30,6 +30,21 @@ test('canonical context preserves every populated SPEC section in order and sink assert.match(minimal, /Implement this feature/) }) +test('CLI context reuses the canonical formatter instead of a second section layout', async () => { + const {buildAIContext} = load(await snippet('recipes/web-app-full.md', '## The "Copy Context for AI" function'), ['buildAIContext']) + const {buildCLIContext} = load(await snippet('recipes/cli-tool.md', '## AI context format for CLI'), ['buildCLIContext'], {buildAIContext}) + const input = {...report, environment: {...report.environment, platform: 'cli'}, logs: {console: ['Command: synthetic-tool check', 'CWD: /synthetic/project']}} + const hint = 'synthetic-tool bug close synthetic-report ""' + assert.equal(buildCLIContext(input, hint), buildAIContext(input, hint)) + assert.match(buildCLIContext(input, hint), /by @fixture/) +}) + +test('active landing copy and metadata make no minute or same-day fix promise', async () => { + const landing = await readFile(new URL('../docs/index.html', import.meta.url), 'utf8') + assert.doesNotMatch(landing, /Fix ships in minutes|Fix ships the same day|Reporter notified at each state change/) + assert.match(landing, /bugkit@bugkit/) +}) + for (const [file, heading] of [ ['recipes/web-app-full.md', '### Web Speech path (default)'], ['skills/adding-voice/SKILL.md', '### Mode 1: Web Speech API (default)'], @@ -55,7 +70,7 @@ for (const [file, heading] of [ } test('fixed picker outline and element coordinates share viewport space after scrolling', async () => { - const element = {closest: () => false, getBoundingClientRect: () => ({top: 30, left: 20, x: 20, y: 30, width: 50, height: 10}), tagName: 'BUTTON', id: 'fixture', className: '', innerText: 'Synthetic', outerHTML: ''} + const element = {closest: () => false, getBoundingClientRect: () => ({top: 30, left: 20, x: 20, y: 30, width: 50, height: 10}), tagName: 'BUTTON', id: 'fixture', getAttribute: () => '', innerText: 'Synthetic', outerHTML: ''} const {onMove, outline, extractContext} = load(await snippet('recipes/web-app-full.md', '## Element picker'), ['onMove', 'outline', 'extractContext'], { ref: value => ({value}), document: {elementFromPoint: () => element}, scrollY: 500, scrollX: 300, buildSelector: () => '#fixture', buildXPath: () => '/button', @@ -63,6 +78,8 @@ test('fixed picker outline and element coordinates share viewport space after sc onMove({clientX: 20, clientY: 30}) assert.equal(outline.value.top, '30px'); assert.equal(outline.value.left, '20px') assert.deepEqual(extractContext(element).rect, {x: 20, y: 30, w: 50, h: 10}) + const svg = {...element, tagName: 'svg', className: {baseVal: 'synthetic-icon'}, getAttribute: () => ' synthetic-icon '} + assert.equal(extractContext(svg).classes, 'synthetic-icon') }) test('GitHub CLI uses direct argv/body-file stdin, preserves literal title and propagates failure', async () => { @@ -80,5 +97,7 @@ test('GitHub CLI uses direct argv/body-file stdin, preserves literal title and p assert.equal(captured.args[captured.args.indexOf('--title') + 1], title) assert.equal(captured.args[captured.args.indexOf('--body-file') + 1], '-') assert.equal(body, 'Synthetic complete context') + await createGhIssue({...report, type: 'feature'}) + assert.equal(captured.args[captured.args.indexOf('--label') + 1], 'bugkit,feature') code = 1; await assert.rejects(createGhIssue(report), /failed/) }) From 181617b7b4ba49af3a39cf8f716d2088a1ffa913 Mon Sep 17 00:00:00 2001 From: bglglzd <248948303+bglglzd@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:33:36 +0300 Subject: [PATCH 3/3] fix: carry storage-specific resolution hints through delivery --- recipes/cli-tool.md | 9 +++++-- recipes/email.md | 11 ++++++--- recipes/slack-discord.md | 10 +++++--- tests/provider-contracts.test.mjs | 39 +++++++++++++++++++++++++++---- tests/recipe-contracts.test.mjs | 15 ++++++++---- 5 files changed, 66 insertions(+), 18 deletions(-) diff --git a/recipes/cli-tool.md b/recipes/cli-tool.md index 3bfefb7..7b12901 100644 --- a/recipes/cli-tool.md +++ b/recipes/cli-tool.md @@ -141,8 +141,9 @@ If your CLI is open-source and you want reports to go to GitHub: - Requires `gh` CLI installed; fall back to JSONL if not ```ts -async function createGhIssue(report: BugReport) { - const body = buildAIContext(report) +async function createGhIssue(report: BugReport, updateHint: string) { + if (!updateHint?.trim()) throw new Error('A storage-specific update hint is required') + const body = buildAIContext(report, updateHint) const child = spawn('gh', ['issue', 'create', '--title', report.title, '--body-file', '-', '--label', `bugkit,${report.type}`], { shell: false, stdio: ['pipe', 'inherit', 'inherit'], }) @@ -156,6 +157,10 @@ async function createGhIssue(report: BugReport) { ``` Fall back to JSONL if `gh` isn't available so users without it still get a working local log. +Pass an explicit hint: for an issue-only store, use +`gh issue close "" --comment ""`; for a JSONL +source of truth, use its actual local close command. A CLI must not inherit the +web reference's PATCH endpoint. --- diff --git a/recipes/email.md b/recipes/email.md index 9a337af..6d76353 100644 --- a/recipes/email.md +++ b/recipes/email.md @@ -57,8 +57,9 @@ Text: {element.text} import { Resend } from 'resend' const resend = new Resend(process.env.RESEND_API_KEY) -export async function sendBugByEmail(report: BugReport) { - const aiContext = buildAIContext(report) // SPEC.md ยง3 template +export async function sendBugByEmail(report: BugReport, updateHint: string) { + if (!updateHint?.trim()) throw new Error('A storage-specific update hint is required') + const aiContext = buildAIContext(report, updateHint) // SPEC.md ยง3 template const { data, error } = await resend.emails.send({ from: process.env.BUGKIT_EMAIL_FROM!, @@ -73,6 +74,10 @@ export async function sendBugByEmail(report: BugReport) { ``` Use any email library โ€” `nodemailer`, `@sendgrid/mail`, `mailgun.js`, whatever the project already has. +Pass the selected store's real update command. For JSONL, for example: +`sendBugByEmail(report, 'node scripts/bugkit.js close ' + report.id + ' ""')`. +For a notification-only inbox use an explicit instruction to record the +resolution in that inbox. Email delivery does not create a web PATCH endpoint. --- @@ -89,7 +94,7 @@ Just send. No status tracking. Admins reply to the user directly when fixed. Sim Combine this recipe with `jsonl-file.md`: 1. Store report in JSONL (the source of truth) 2. Also email the admin -3. Admin uses `bugkit close N ""` CLI to update status +3. Admin uses `node scripts/bugkit.js close N ""` to update status 4. Optionally, second email to the reporter when status changes (if they provided an email) ### C. Reply parsing (advanced) diff --git a/recipes/slack-discord.md b/recipes/slack-discord.md index 78ffa6b..3d5de25 100644 --- a/recipes/slack-discord.md +++ b/recipes/slack-discord.md @@ -101,8 +101,9 @@ function splitText(text: string, limit = 2800): string[] { return chunks } -export async function postToChat(report: BugReport) { - const aiContext = buildAIContext(report) // SPEC.md ยง3 +export async function postToChat(report: BugReport, updateHint: string) { + if (!updateHint?.trim()) throw new Error('A storage-specific update hint is required') + const aiContext = buildAIContext(report, updateHint) // SPEC.md ยง3 if (!process.env.BUGKIT_SLACK_WEBHOOK && !process.env.BUGKIT_DISCORD_WEBHOOK) throw new Error('No chat destination configured') if (process.env.BUGKIT_SLACK_WEBHOOK) { @@ -137,7 +138,10 @@ export async function postToChat(report: BugReport) { ``` Use Node 24 LTS built-in fetch/FormData/Blob. The report store remains the source of -truth. Propagate delivery errors and expose partial delivery separately from a +truth. Pass its actual update command, for example +`postToChat(report, 'node scripts/bugkit.js close ' + report.id + ' ""')` +for JSONL. For chat-only reports, pass an explicit instruction to record the +resolution in the report's thread. Propagate delivery errors and expose partial delivery separately from a stored report; retrying after a partially accepted multi-message delivery can duplicate notifications. Configure request-size/rate limits for your application. See [Slack text limits](https://docs.slack.dev/reference/block-kit/composition-objects/text-object/) diff --git a/tests/provider-contracts.test.mjs b/tests/provider-contracts.test.mjs index 5abc509..9805be2 100644 --- a/tests/provider-contracts.test.mjs +++ b/tests/provider-contracts.test.mjs @@ -8,6 +8,7 @@ import {snippet, load, report} from './helpers.mjs' async function contextBuilder() { return load(await snippet('recipes/web-app-full.md', '## The "Copy Context for AI" function'), ['buildAIContext']).buildAIContext } +const localCloseHint = 'node scripts/bugkit.js close synthetic-report ""' async function localService(run) { const requests = [], mode = {httpError: false, telegramError: false, slackError: false} const server = createServer(async (req, res) => { @@ -145,7 +146,7 @@ test('Slack and Discord preserve large context within transport limits and suppr process: {env: {BUGKIT_SLACK_WEBHOOK: origin + '/slack', BUGKIT_DISCORD_WEBHOOK: origin + '/discord'}}, fetch: guardedFetch, buildAIContext: () => context, }) - await postToChat(report) + await postToChat(report, localCloseHint) const slack = requests.filter(request => request.path === '/slack').map(request => JSON.parse(request.raw)) assert.ok(slack.length > 1) assert.ok(slack.every(message => message.blocks[0].text.type === 'plain_text' && message.blocks[0].text.text.length <= 3000)) @@ -165,10 +166,10 @@ for (const provider of ['Slack', 'Discord']) { fetch: guardedFetch, buildAIContext: () => 'Synthetic context', }) mode.httpError = true - await assert.rejects(postToChat(report), /delivery failed/) + await assert.rejects(postToChat(report, localCloseHint), /delivery failed/) if (provider === 'Slack') { mode.httpError = false; mode.slackError = true - await assert.rejects(postToChat(report), /delivery failed/) + await assert.rejects(postToChat(report, localCloseHint), /delivery failed/) } }) }) @@ -180,8 +181,36 @@ test('Resend SDK rejection is surfaced even when its promise resolves', async () const {sendBugByEmail} = load(await snippet('recipes/email.md', '## Implementation sketch (Resend)'), ['sendBugByEmail'], { Resend, buildAIContext: await contextBuilder(), renderHtml: () => '

Synthetic

', renderPlainText: () => 'Synthetic', }) - await assert.rejects(sendBugByEmail(report), /Email provider rejected/) - rejected = false; assert.equal((await sendBugByEmail(report)).id, 'synthetic-mail') + await assert.rejects(sendBugByEmail(report, localCloseHint), /Email provider rejected/) + rejected = false; assert.equal((await sendBugByEmail(report, localCloseHint)).id, 'synthetic-mail') +}) + +test('email and chat payloads propagate the actual JSONL close command and reject an omitted hint', async () => { + const buildAIContext = await contextBuilder() + const expected = buildAIContext(report, localCloseHint) + let payload, sends = 0 + class Resend { constructor() { this.emails = {send: async input => { payload = input; sends++; return {data: {id: 'synthetic-mail'}, error: null} }} } } + const {sendBugByEmail} = load(await snippet('recipes/email.md', '## Implementation sketch (Resend)'), ['sendBugByEmail'], { + Resend, buildAIContext, renderHtml: (_report, context) => context, renderPlainText: (_report, context) => context, + }) + await assert.rejects(sendBugByEmail(report), /update hint is required/) + assert.equal(sends, 0) + await sendBugByEmail(report, localCloseHint) + assert.equal(payload.text, expected); assert.equal(payload.html, expected) + assert.ok(!payload.text.includes('PATCH /api/bug-reports/')) + await localService(async ({origin, guardedFetch, requests}) => { + const {postToChat} = load(await snippet('recipes/slack-discord.md', '## Implementation (minimal)'), ['postToChat'], { + process: {env: {BUGKIT_SLACK_WEBHOOK: origin + '/slack', BUGKIT_DISCORD_WEBHOOK: origin + '/discord'}}, + fetch: guardedFetch, buildAIContext, + }) + await assert.rejects(postToChat(report), /update hint is required/) + assert.equal(requests.length, 0) + await postToChat(report, localCloseHint) + const slack = requests.filter(request => request.path === '/slack').map(request => JSON.parse(request.raw).blocks[0].text.text).join('') + assert.equal(slack, expected) + assert.ok(requests.find(request => request.path.startsWith('/discord')).raw.includes(expected)) + assert.ok(!slack.includes('PATCH /api/bug-reports/')) + }) }) test('reporter email notification skips absent addresses and propagates SDK rejection', async () => { diff --git a/tests/recipe-contracts.test.mjs b/tests/recipe-contracts.test.mjs index 9a471cf..82a22f8 100644 --- a/tests/recipe-contracts.test.mjs +++ b/tests/recipe-contracts.test.mjs @@ -90,14 +90,19 @@ test('GitHub CLI uses direct argv/body-file stdin, preserves literal title and p child.stdin.end = value => { body = value; queueMicrotask(() => child.emit('close', code)) } return child } - const {createGhIssue} = load(await snippet('recipes/cli-tool.md', '## Publishing via GitHub Issues instead'), ['createGhIssue'], {spawn, buildAIContext: () => 'Synthetic complete context'}) + const {buildAIContext} = load(await snippet('recipes/web-app-full.md', '## The "Copy Context for AI" function'), ['buildAIContext']) + const {createGhIssue} = load(await snippet('recipes/cli-tool.md', '## Publishing via GitHub Issues instead'), ['createGhIssue'], {spawn, buildAIContext}) const title = 'Synthetic $(literal) `title` "quoted"' - await createGhIssue({...report, title}) + const hint = 'gh issue close "" --comment ""' + await assert.rejects(createGhIssue(report), /update hint is required/) + assert.equal(captured, undefined) + await createGhIssue({...report, title}, hint) assert.equal(captured.command, 'gh'); assert.equal(captured.options.shell, false) assert.equal(captured.args[captured.args.indexOf('--title') + 1], title) assert.equal(captured.args[captured.args.indexOf('--body-file') + 1], '-') - assert.equal(body, 'Synthetic complete context') - await createGhIssue({...report, type: 'feature'}) + assert.equal(body, buildAIContext({...report, title}, hint)) + assert.ok(!body.includes('PATCH /api/bug-reports/')) + await createGhIssue({...report, type: 'feature'}, hint) assert.equal(captured.args[captured.args.indexOf('--label') + 1], 'bugkit,feature') - code = 1; await assert.rejects(createGhIssue(report), /failed/) + code = 1; await assert.rejects(createGhIssue(report, hint), /failed/) })