Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/ISSUE_TEMPLATE/gastown-bug.yml
Original file line number Diff line number Diff line change
@@ -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
150 changes: 149 additions & 1 deletion cloudflare-gastown/container/plugin/mayor-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down
129 changes: 129 additions & 0 deletions cloudflare-gastown/container/plugin/mayor-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Comment thread
jrf0110 marked this conversation as resolved.
};

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}`;
},
}),
};
}
22 changes: 21 additions & 1 deletion cloudflare-gastown/src/prompts/mayor-system.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
}
Loading
Loading