From 155a8dbab632d4e3b789b74328f3de23fecf8da1 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 19 Mar 2026 15:53:40 -0500 Subject: [PATCH 1/5] =?UTF-8?q?feat(gastown):=20add=20bug=20reporting=20?= =?UTF-8?q?=E2=80=94=20issue=20template,=20UI=20link,=20and=20Mayor=20gt?= =?UTF-8?q?=5Freport=5Fbug=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1248 - Add .github/ISSUE_TEMPLATE/gastown-bug.yml with structured fields - Add 'Report a Bug' link in the terminal bar (visible in both user and org layouts) - Add gt_report_bug Mayor tool that searches for duplicates via GitHub API before filing - Add bug reporting guidance to Mayor system prompt - Add tests for gt_report_bug (no token, duplicates, create, failure) --- .github/ISSUE_TEMPLATE/gastown-bug.yml | 47 +++++ .../container/plugin/mayor-tools.test.ts | 118 ++++++++++++- .../container/plugin/mayor-tools.ts | 165 ++++++++++++++++++ .../src/prompts/mayor-system.prompt.ts | 15 +- src/components/gastown/TerminalBar.tsx | 14 ++ 5 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/gastown-bug.yml 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..5e1805409f 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,122 @@ 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(['gastown', 'bug', 'reported-by-mayor']); + expect(body.body).toContain('town-123'); + expect(body.body).toContain('rig-5'); + expect(body.body).toContain('connection refused'); + }); + + 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..4218c89d36 100644 --- a/cloudflare-gastown/container/plugin/mayor-tools.ts +++ b/cloudflare-gastown/container/plugin/mayor-tools.ts @@ -472,5 +472,170 @@ 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 + const searchKeywords = args.title.split(/\s+/).slice(0, 5).join(' '); + const searchQuery = encodeURIComponent( + `repo:${repo} is:issue is:open label:gastown 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 createRes = await fetch(`https://api.github.com/repos/${repo}/issues`, { + method: 'POST', + headers, + body: JSON.stringify({ + title: `[Gastown] ${args.title}`, + body, + labels: ['gastown', 'bug', 'reported-by-mayor'], + }), + }); + + 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}`; + }, + }), + + gt_bead_add_dependency: tool({ + description: + 'Add a dependency between two beads. The bead at bead_id will be blocked by depends_on_bead_id — ' + + 'it will not be dispatched until the dependency is closed.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig the beads belong to'), + bead_id: tool.schema.string().describe('The UUID of the bead that should be blocked'), + depends_on_bead_id: tool.schema + .string() + .describe('The UUID of the bead that must close first'), + dependency_type: tool.schema + .enum(['blocks', 'parent-child']) + .describe('Type of dependency (default: blocks)') + .optional(), + }, + async execute(args) { + await client.addBeadDependency({ + rig_id: args.rig_id, + bead_id: args.bead_id, + depends_on_bead_id: args.depends_on_bead_id, + dependency_type: args.dependency_type ?? 'blocks', + }); + return `Dependency added: bead ${args.bead_id} now depends on ${args.depends_on_bead_id} (type: ${args.dependency_type ?? 'blocks'}).`; + }, + }), + + gt_bead_remove_dependency: tool({ + description: + 'Remove a dependency between two beads. If removing the dependency unblocks the bead, ' + + 'it will be dispatched automatically.', + args: { + rig_id: tool.schema.string().describe('The UUID of the rig the beads belong to'), + bead_id: tool.schema.string().describe('The UUID of the dependent bead'), + depends_on_bead_id: tool.schema + .string() + .describe('The UUID of the bead it currently depends on'), + }, + async execute(args) { + await client.removeBeadDependency({ + rig_id: args.rig_id, + bead_id: args.bead_id, + depends_on_bead_id: args.depends_on_bead_id, + }); + return `Dependency removed: bead ${args.bead_id} no longer depends on ${args.depends_on_bead_id}. If this was the last blocker, the bead will be dispatched automatically.`; + }, + }), }; } diff --git a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts index cf98c598b6..17c2fe43c3 100644 --- a/cloudflare-gastown/src/prompts/mayor-system.prompt.ts +++ b/cloudflare-gastown/src/prompts/mayor-system.prompt.ts @@ -282,5 +282,18 @@ 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" link in the terminal bar as a fallback when you're unavailable.`; } diff --git a/src/components/gastown/TerminalBar.tsx b/src/components/gastown/TerminalBar.tsx index 0e826331d6..e79e4763df 100644 --- a/src/components/gastown/TerminalBar.tsx +++ b/src/components/gastown/TerminalBar.tsx @@ -28,6 +28,7 @@ import { PanelTop, PanelLeft, PanelRight, + Bug, } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; @@ -522,6 +523,19 @@ function TabBar({ + {/* Bug report link */} + {horizontal && ( + + + Report a Bug + + )} + {/* Position picker */}
- {/* Bug report link */} + {/* Bug report dropdown */} {horizontal && ( - - - Report a Bug - +
+ + {showBugMenu && ( + setShowBugMenu(false)} + /> + )} +
)} {/* Position picker */} @@ -648,6 +666,89 @@ function PositionPicker({ ); } +// ── Bug Report Menu ────────────────────────────────────────────────────── + +const BUG_REPORT_OPTIONS = [ + { + label: 'New GitHub Issue', + href: 'https://github.com/Kilo-Org/cloud/issues/new?template=gastown-bug.yml&labels=gastown,bug', + Icon: Github, + }, + { + label: 'Discord Channel', + href: 'https://discord.com/channels/1349288496988160052/1485796776635142174', + Icon: MessageCircle, + }, +]; + +function BugReportMenu({ + position, + triggerRef, + onClose, +}: { + position: TerminalPosition; + triggerRef: React.RefObject; + onClose: () => void; +}) { + const popoverRef = useRef(null); + const [style, setStyle] = useState({ opacity: 0 }); + + useEffect(() => { + const trigger = triggerRef.current; + const popover = popoverRef.current; + if (!trigger || !popover) return; + const tr = trigger.getBoundingClientRect(); + const pr = popover.getBoundingClientRect(); + const gap = 4; + + let top: number; + let left: number; + + if (position === 'bottom') { + top = tr.top - pr.height - gap; + left = tr.left; + } else if (position === 'top') { + top = tr.bottom + gap; + left = tr.left; + } else if (position === 'left') { + top = tr.top; + left = tr.right + gap; + } else { + top = tr.top; + left = tr.left - pr.width - gap; + } + + top = Math.max(4, Math.min(top, window.innerHeight - pr.height - 4)); + left = Math.max(4, Math.min(left, window.innerWidth - pr.width - 4)); + + setStyle({ top, left, opacity: 1 }); + }, [position, triggerRef]); + + return ( +
+
+ {BUG_REPORT_OPTIONS.map(({ label, href, Icon }) => ( + + + {label} + + ))} +
+
+ ); +} + // ── Terminal Content Area ───────────────────────────────────────────────── function TerminalContent({