diff --git a/.github/ISSUE_TEMPLATE/gastown-bug.yml b/.github/ISSUE_TEMPLATE/gastown-bug.yml new file mode 100644 index 0000000000..d4c7abe4bb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/gastown-bug.yml @@ -0,0 +1,47 @@ +name: 'Gastown Bug Report' +description: 'Report a bug with Gastown by Kilo' +labels: ['gastown', 'bug'] +body: + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the bug. What did you expect to happen vs. what actually happened? + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - Mayor / Chat + - Terminal UI + - Bead Board / Dashboard + - Convoys + - Merge Queue / Refinery + - Agent Dispatch / Scheduling + - Container / Git + - Other + validations: + required: true + - type: input + id: town-id + attributes: + label: Town ID (if known) + description: Found in the URL or town settings + validations: + required: false + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: How can we reproduce this? + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant logs or screenshots + description: Paste any error messages, activity log entries, or screenshots + validations: + required: false diff --git a/cloudflare-gastown/container/plugin/mayor-tools.test.ts b/cloudflare-gastown/container/plugin/mayor-tools.test.ts index 7f7e299c7f..44a5bb1820 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.test.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { MayorGastownClient } from './client'; import type { Agent, @@ -411,6 +411,154 @@ describe('mayor tools', () => { }); }); + describe('gt_report_bug', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + GH_TOKEN: 'fake-gh-token', + GASTOWN_TOWN_ID: 'town-123', + GASTOWN_AGENT_ID: 'mayor-agent-1', + }; + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('returns fallback message when GH_TOKEN is not set', async () => { + delete process.env.GH_TOKEN; + const result = await tools.gt_report_bug.execute( + { + title: 'Test bug', + description: 'Something broke', + area: 'Mayor / Chat' as const, + }, + CTX + ); + expect(result).toContain('GH_TOKEN is not available'); + expect(result).toContain('github.com/Kilo-Org/cloud/issues/new'); + }); + + it('reports potential duplicates when search finds matches', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + number: 42, + title: 'Similar bug', + html_url: 'https://github.com/Kilo-Org/cloud/issues/42', + }, + ], + }), + { status: 200 } + ) + ); + + const result = await tools.gt_report_bug.execute( + { + title: 'Similar bug report', + description: 'Something broke', + area: 'Mayor / Chat' as const, + }, + CTX + ); + + expect(result).toContain('potentially related'); + expect(result).toContain('#42'); + expect(fetchSpy).toHaveBeenCalledOnce(); + }); + + it('creates issue when no duplicates found', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ number: 99, html_url: 'https://github.com/Kilo-Org/cloud/issues/99' }), + { status: 201 } + ) + ); + + const result = await tools.gt_report_bug.execute( + { + title: 'New bug', + description: 'Something broke', + area: 'Container / Git' as const, + rig_id: 'rig-5', + recent_errors: 'Error: connection refused', + }, + CTX + ); + + expect(result).toContain('#99'); + expect(result).toContain('Bug report filed'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + // Verify the create call body + const createCall = fetchSpy.mock.calls[1]; + const body = JSON.parse(createCall[1]?.body as string); + expect(body.title).toBe('[Gastown] New bug'); + expect(body.labels).toEqual(['bug', 'gt:mayor']); + expect(body.body).toContain('town-123'); + expect(body.body).toContain('rig-5'); + expect(body.body).toContain('connection refused'); + }); + + it('retries without labels on 422 (label permission error)', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 })) + .mockResolvedValueOnce( + new Response('Validation Failed: label permissions', { status: 422 }) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + number: 100, + html_url: 'https://github.com/Kilo-Org/cloud/issues/100', + }), + { status: 201 } + ) + ); + + const result = await tools.gt_report_bug.execute( + { title: 'Label bug', description: 'Labels fail', area: 'Other' as const }, + CTX + ); + + expect(result).toContain('#100'); + expect(result).toContain('Bug report filed'); + expect(fetchSpy).toHaveBeenCalledTimes(3); + + // Retry call should omit labels + const retryCall = fetchSpy.mock.calls[2]; + const retryBody = JSON.parse(retryCall[1]?.body as string); + expect(retryBody.labels).toBeUndefined(); + }); + + it('handles create failure gracefully', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 })) + .mockResolvedValueOnce(new Response('Forbidden', { status: 403 })); + + const result = await tools.gt_report_bug.execute( + { + title: 'Bug', + description: 'Broken', + area: 'Other' as const, + }, + CTX + ); + + expect(result).toContain('Failed to create issue'); + expect(result).toContain('403'); + }); + }); + describe('gt_nudge', () => { it('sends a nudge and returns the nudge_id', async () => { const result = await tools.gt_nudge.execute( diff --git a/cloudflare-gastown/container/plugin/mayor-tools.ts b/cloudflare-gastown/container/plugin/mayor-tools.ts index 24b0fb50eb..e8c9929de5 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -472,5 +472,134 @@ export function createMayorTools(client: MayorGastownClient) { return `Nudge queued: ${result.nudge_id} (mode: ${args.mode ?? 'wait-idle'})`; }, }), + + gt_report_bug: tool({ + description: + 'File a bug report on the Kilo-Org/cloud GitHub repo. ' + + 'Searches existing issues first to avoid duplicates. ' + + 'Use this when a user reports a bug or you encounter a repeating system error. ' + + 'Do NOT file bugs for user errors, expected behavior, or issues you can resolve yourself ' + + '(e.g. re-slinging a failed bead). Do NOT file bugs about yourself being unable to start.', + args: { + title: tool.schema.string().describe('Concise bug title'), + description: tool.schema + .string() + .describe('What happened vs. what was expected. Include error messages if available.'), + area: tool.schema + .enum([ + 'Mayor / Chat', + 'Terminal UI', + 'Bead Board / Dashboard', + 'Convoys', + 'Merge Queue / Refinery', + 'Agent Dispatch / Scheduling', + 'Container / Git', + 'Other', + ]) + .describe('Which area of Gastown is affected'), + rig_id: tool.schema + .string() + .describe('The rig ID where the bug was observed, if applicable') + .optional(), + recent_errors: tool.schema + .string() + .describe('Recent error messages or log snippets for context') + .optional(), + }, + async execute(args) { + const ghToken = process.env.GH_TOKEN; + if (!ghToken) { + return 'Cannot file bug report: GH_TOKEN is not available in this container. Ask the user to file manually at https://github.com/Kilo-Org/cloud/issues/new?template=gastown-bug.yml'; + } + + const repo = 'Kilo-Org/cloud'; + const headers = { + Authorization: `Bearer ${ghToken}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + }; + + // Search for potential duplicates (match both Mayor-filed and user-filed bug issues) + const searchKeywords = args.title.split(/\s+/).slice(0, 5).join(' '); + const searchQuery = encodeURIComponent( + `repo:${repo} is:issue is:open label:bug ${searchKeywords}` + ); + + let duplicates: Array<{ number: number; title: string; html_url: string }> = []; + try { + const searchRes = await fetch( + `https://api.github.com/search/issues?q=${searchQuery}&per_page=5`, + { headers } + ); + if (searchRes.ok) { + const searchData = (await searchRes.json()) as { + items: Array<{ number: number; title: string; html_url: string }>; + }; + duplicates = searchData.items; + } + } catch { + // Search failure is non-fatal — proceed to create + } + + if (duplicates.length > 0) { + const list = duplicates + .map(d => ` - #${d.number}: ${d.title} (${d.html_url})`) + .join('\n'); + return [ + `Found ${duplicates.length} potentially related open issue(s):`, + list, + '', + 'Review these before filing a new issue. If none match, call gt_report_bug again with a more specific title.', + ].join('\n'); + } + + // Build issue body with structured context + const townId = process.env.GASTOWN_TOWN_ID ?? 'unknown'; + const agentId = process.env.GASTOWN_AGENT_ID ?? 'unknown'; + const bodyParts = [ + `## What happened?\n\n${args.description}`, + `## Area\n\n${args.area}`, + `## Context\n\n- **Town ID:** ${townId}\n- **Agent:** Mayor (${agentId})`, + ]; + if (args.rig_id) { + bodyParts[bodyParts.length - 1] += `\n- **Rig ID:** ${args.rig_id}`; + } + if (args.recent_errors) { + bodyParts.push(`## Recent Errors\n\n\`\`\`\n${args.recent_errors}\n\`\`\``); + } + bodyParts.push('*Filed automatically by the Mayor via `gt_report_bug`.*'); + const body = bodyParts.join('\n\n'); + + const issuePayload = { + title: `[Gastown] ${args.title}`, + body, + labels: ['bug', 'gt:mayor'], + }; + + let createRes = await fetch(`https://api.github.com/repos/${repo}/issues`, { + method: 'POST', + headers, + body: JSON.stringify(issuePayload), + }); + + // If labeling failed (e.g. token lacks label permissions), retry without labels + if (!createRes.ok && createRes.status === 422) { + createRes = await fetch(`https://api.github.com/repos/${repo}/issues`, { + method: 'POST', + headers, + body: JSON.stringify({ title: issuePayload.title, body: issuePayload.body }), + }); + } + + if (!createRes.ok) { + const errText = await createRes.text(); + return `Failed to create issue (HTTP ${createRes.status}): ${errText}`; + } + + const issue = (await createRes.json()) as { number: number; html_url: string }; + return `Bug report filed: #${issue.number} — ${issue.html_url}`; + }, + }), }; } diff --git a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts index cf98c598b6..10ce483c4e 100644 --- a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts @@ -282,5 +282,25 @@ convoy, call gt_convoy_start with the convoy_id. For large convoys (>5 beads) where the decomposition is non-obvious, consider using staged=true by default to give the user a chance to review before agents -start spending compute.`; +start spending compute. + +## Bug Reporting + +If a user reports a bug or you encounter a repeating error, you can file a bug report +using gt_report_bug. Before filing: +1. Search existing issues to avoid duplicates (the tool does this automatically) +2. Include the town ID, what went wrong, and any error context +3. Share the issue URL with the user + +Only file bugs for genuine system problems — not for user errors or expected behavior. +Don't file bugs for issues you can resolve yourself (e.g. re-slinging a failed bead). +Don't file bugs about yourself being unable to start — that's a chicken-and-egg problem. + +The UI has a "Report a Bug" dropdown in the terminal bar with two options: +- **New GitHub Issue** — opens the structured bug report template +- **Discord Channel** — links to the Gastown bugs channel at https://discord.com/channels/1349288496988160052/1485796776635142174 + +If a user prefers to discuss a problem rather than file a formal issue, point them to the +Discord channel. For reproducible bugs with clear steps, prefer filing a GitHub issue via +gt_report_bug so it's tracked.`; } diff --git a/src/components/gastown/TerminalBar.tsx b/src/components/gastown/TerminalBar.tsx index 0e826331d6..284758cdf8 100644 --- a/src/components/gastown/TerminalBar.tsx +++ b/src/components/gastown/TerminalBar.tsx @@ -28,6 +28,9 @@ import { PanelTop, PanelLeft, PanelRight, + Bug, + Github, + MessageCircle, } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; @@ -425,19 +428,28 @@ function TabBar({ closeTab: (tabId: string) => void; }) { const [showPositionPicker, setShowPositionPicker] = useState(false); + const [showBugMenu, setShowBugMenu] = useState(false); const pickerRef = useRef(null); + const bugMenuRef = useRef(null); // Close picker on outside click useEffect(() => { - if (!showPositionPicker) return; + if (!showPositionPicker && !showBugMenu) return; const handler = (e: MouseEvent) => { - if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + if ( + showPositionPicker && + pickerRef.current && + !pickerRef.current.contains(e.target as Node) + ) { setShowPositionPicker(false); } + if (showBugMenu && bugMenuRef.current && !bugMenuRef.current.contains(e.target as Node)) { + setShowBugMenu(false); + } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); - }, [showPositionPicker]); + }, [showPositionPicker, showBugMenu]); const borderClass = horizontal ? 'border-b border-white/[0.06]' : 'border-r border-white/[0.06]'; @@ -522,6 +534,26 @@ function TabBar({ + {/* Bug report dropdown */} + {horizontal && ( +
+ + {showBugMenu && ( + setShowBugMenu(false)} + /> + )} +
+ )} + {/* Position picker */}