Skip to content

Commit 2d518a4

Browse files
authored
Add /investigate command for Discord issue reports (#368)
## Summary - Adds a `/investigate` Discord slash command that works in `#mezo-issue-reports` threads - Collects thread messages, summarizes them via Claude Haiku into a structured GitHub issue on `Mezo-org/web`, and triggers Claude Code to analyze and propose a fix - API responses (Claude + GitHub) validated with zod schemas per codebase conventions - 13 tests covering happy paths, HTTP errors, malformed responses, and edge cases - Reference workflow for `Mezo-org/web` stored in `reference/` (not `.github/`) ## Setup required after merge - Add a `github_issue_token` key to the `valkyrie-hubot` k8s secret (fine-grained GitHub token with Issues read/write on `Mezo-org/web`) - Copy `reference/mezo-web-claude-workflow.yml` to `Mezo-org/web/.github/workflows/claude.yml` and add an `ANTHROPIC_API_KEY` repo secret there - Add `discord-report` and `investigate` labels to `Mezo-org/web` ## Test plan - [x] `pnpm lint` passes - [x] `pnpm test` passes (67 tests, 13 new) - [ ] Deploy to staging and test `/investigate` in a `#mezo-issue-reports` thread - [ ] Verify GitHub issue is created with correct structure and `@claude` trigger 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents def6317 + 1a169bd commit 2d518a4

7 files changed

Lines changed: 664 additions & 29 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import {
2+
ActionRowBuilder,
3+
ButtonBuilder,
4+
ButtonStyle,
5+
Client,
6+
ComponentType,
7+
Message,
8+
} from "discord.js"
9+
import { Robot } from "hubot"
10+
import {
11+
buildThreadUrl,
12+
createGitHubIssue,
13+
summarizeForGitHubIssue,
14+
} from "../lib/issue-report.ts"
15+
16+
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY
17+
const GITHUB_ISSUE_TOKEN = process.env.GITHUB_ISSUE_TOKEN
18+
const MEZO_ISSUE_REPORTS_CHANNEL = "mezo-issue-reports"
19+
const GITHUB_REPO_OWNER = "Mezo-org"
20+
const GITHUB_REPO_NAME = "web"
21+
const COMMAND_NAME = "investigate"
22+
const MAX_DISCORD_MESSAGE_LENGTH = 2000
23+
24+
export default async function issueReportWorkflow(
25+
discordClient: Client,
26+
robot: Robot,
27+
) {
28+
if (!ANTHROPIC_API_KEY) {
29+
robot.logger.error(
30+
"ANTHROPIC_API_KEY is not set. Skipping issue report workflow setup.",
31+
)
32+
return
33+
}
34+
35+
if (!GITHUB_ISSUE_TOKEN) {
36+
robot.logger.error(
37+
"GITHUB_ISSUE_TOKEN is not set. Skipping issue report workflow setup.",
38+
)
39+
return
40+
}
41+
42+
const { application } = discordClient
43+
if (application === null) {
44+
robot.logger.error(
45+
"Failed to resolve Discord application, dropping issue report workflow.",
46+
)
47+
return
48+
}
49+
50+
const existingCommand = (await application.commands.fetch()).find(
51+
(command) => command.name === COMMAND_NAME,
52+
)
53+
54+
if (existingCommand === undefined) {
55+
robot.logger.info("No investigate command yet, creating it!")
56+
await application.commands.create({
57+
name: COMMAND_NAME,
58+
description:
59+
"Create a GitHub issue from this thread and trigger Claude Code to investigate",
60+
})
61+
robot.logger.info("Created investigate command.")
62+
}
63+
64+
discordClient.on("interactionCreate", async (interaction) => {
65+
if (
66+
!interaction.isChatInputCommand() ||
67+
interaction.commandName !== COMMAND_NAME ||
68+
interaction.channel === null ||
69+
interaction.channel.isDMBased()
70+
) {
71+
return
72+
}
73+
74+
if (!interaction.channel.isThread()) {
75+
await interaction.reply({
76+
content:
77+
"The `/investigate` command can only be used inside a thread.",
78+
ephemeral: true,
79+
})
80+
return
81+
}
82+
83+
const thread = interaction.channel
84+
const parentChannel = thread.parent
85+
86+
if (!parentChannel || parentChannel.name !== MEZO_ISSUE_REPORTS_CHANNEL) {
87+
await interaction.reply({
88+
content: `The \`/investigate\` command can only be used in threads within #${MEZO_ISSUE_REPORTS_CHANNEL}.`,
89+
ephemeral: true,
90+
})
91+
return
92+
}
93+
94+
const confirmRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
95+
new ButtonBuilder()
96+
.setCustomId("confirm_investigate")
97+
.setLabel("Create Issue & Investigate")
98+
.setStyle(ButtonStyle.Success),
99+
new ButtonBuilder()
100+
.setCustomId("cancel_investigate")
101+
.setLabel("Cancel")
102+
.setStyle(ButtonStyle.Secondary),
103+
)
104+
105+
await interaction.reply({
106+
content: `This will:\n1. Summarize this thread into a GitHub issue on **${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}**\n2. Trigger Claude Code to analyze the codebase and propose a fix\n\nProceed?`,
107+
components: [confirmRow],
108+
ephemeral: true,
109+
})
110+
111+
const confirmation = await interaction.channel
112+
?.awaitMessageComponent({
113+
componentType: ComponentType.Button,
114+
time: 30_000,
115+
filter: (buttonInteraction) =>
116+
buttonInteraction.user.id === interaction.user.id,
117+
})
118+
.catch(() => null)
119+
120+
if (!confirmation || confirmation.customId === "cancel_investigate") {
121+
await interaction.followUp({
122+
content: "Investigation cancelled.",
123+
ephemeral: true,
124+
})
125+
return
126+
}
127+
128+
await confirmation.update({
129+
content: "Collecting thread messages and creating GitHub issue...",
130+
components: [],
131+
})
132+
133+
try {
134+
const messages = await thread.messages.fetch({ limit: 100 })
135+
if (!messages.size) {
136+
await interaction.followUp({
137+
content: "No messages found in this thread.",
138+
ephemeral: true,
139+
})
140+
return
141+
}
142+
143+
const formattedMessages = messages
144+
.map(
145+
(m: Message) =>
146+
`${m.member?.displayName ?? m.author.displayName ?? m.author.username}: ${m.content}`,
147+
)
148+
.reverse()
149+
.join("\n")
150+
151+
const { title, body } = await summarizeForGitHubIssue(
152+
ANTHROPIC_API_KEY,
153+
thread.name,
154+
formattedMessages,
155+
)
156+
157+
const threadUrl = buildThreadUrl(thread.guildId, thread.id)
158+
const issue = await createGitHubIssue(
159+
GITHUB_ISSUE_TOKEN,
160+
title,
161+
body,
162+
threadUrl,
163+
)
164+
165+
const resultMessage = `GitHub issue created and Claude Code investigation triggered!\n${issue.html_url}`
166+
167+
if (resultMessage.length > MAX_DISCORD_MESSAGE_LENGTH) {
168+
await thread.send(
169+
resultMessage.substring(0, MAX_DISCORD_MESSAGE_LENGTH),
170+
)
171+
} else {
172+
await thread.send(resultMessage)
173+
}
174+
175+
await interaction.followUp({
176+
content: "Issue created and investigation started!",
177+
ephemeral: true,
178+
})
179+
} catch (error) {
180+
robot.logger.error("Failed to create investigation issue:", error)
181+
await interaction.followUp({
182+
content:
183+
"Failed to create the GitHub issue. Check the bot logs for details.",
184+
ephemeral: true,
185+
})
186+
}
187+
})
188+
189+
robot.logger.info("Issue report workflow script loaded.")
190+
}

infrastructure/kube/thesis-ops/valkyrie-hubot-deployment.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ spec:
132132
secretKeyRef:
133133
name: valkyrie-hubot
134134
key: zoom_api_secret
135+
- name: GITHUB_ISSUE_TOKEN
136+
valueFrom:
137+
secretKeyRef:
138+
name: valkyrie-hubot
139+
key: github_issue_token
135140
- name: ZOOM_EXPECTED_MEETING_DURATION
136141
value: "60"
137142
ports:

lib/issue-report.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { z } from "zod/v4"
2+
3+
const GITHUB_REPO_OWNER = "Mezo-org"
4+
const GITHUB_REPO_NAME = "web"
5+
6+
const claudeMessageResponseSchema = z.object({
7+
content: z
8+
.array(
9+
z.object({
10+
type: z.literal("text"),
11+
text: z.string(),
12+
}),
13+
)
14+
.min(1),
15+
})
16+
17+
const issueSummarySchema = z.object({
18+
title: z.string().min(1),
19+
body: z.string().min(1),
20+
})
21+
22+
const gitHubIssueResponseSchema = z.object({
23+
html_url: z.string().url(),
24+
number: z.number().int(),
25+
})
26+
27+
type IssueSummary = z.infer<typeof issueSummarySchema>
28+
type GitHubIssueResponse = z.infer<typeof gitHubIssueResponseSchema>
29+
30+
export async function summarizeForGitHubIssue(
31+
anthropicApiKey: string,
32+
threadTitle: string,
33+
messagesText: string,
34+
): Promise<IssueSummary> {
35+
const response = await fetch("https://api.anthropic.com/v1/messages", {
36+
method: "POST",
37+
headers: {
38+
"Content-Type": "application/json",
39+
"x-api-key": anthropicApiKey,
40+
"anthropic-version": "2023-06-01",
41+
},
42+
body: JSON.stringify({
43+
model: "claude-haiku-4-5",
44+
max_tokens: 4096,
45+
messages: [
46+
{
47+
role: "user",
48+
content: `You are converting a Discord issue-report thread into a GitHub issue for an engineering team. The thread comes from a #mezo-issue-reports channel.
49+
50+
Produce a JSON object with two fields:
51+
- "title": A concise GitHub issue title (under 80 characters) that captures the core problem.
52+
- "body": A well-structured GitHub issue body in markdown with these sections:
53+
54+
## Issue Report (from Discord)
55+
Summarize what was reported, including any reproduction steps or context.
56+
57+
## Observed Behavior
58+
What the reporter(s) described happening.
59+
60+
## Expected Behavior
61+
What should happen instead (infer from context if not stated).
62+
63+
## Additional Context
64+
Any relevant details from the thread (screenshots mentioned, links, environment info, etc.).
65+
66+
Here is the Discord thread to convert:
67+
68+
**Thread title:** ${threadTitle}
69+
70+
**Messages:**
71+
${messagesText}
72+
73+
Respond ONLY with the JSON object, no other text.`,
74+
},
75+
],
76+
}),
77+
})
78+
79+
if (!response.ok) {
80+
const errorText = await response.text()
81+
throw new Error(
82+
`Claude API request failed: ${response.status} ${response.statusText} - ${errorText}`,
83+
)
84+
}
85+
86+
const data = claudeMessageResponseSchema.parse(await response.json())
87+
const textResponse = data.content[0].text
88+
89+
const jsonMatch = textResponse.match(/\{[\s\S]*\}/)
90+
if (!jsonMatch) {
91+
throw new Error("Could not parse JSON from Claude response")
92+
}
93+
94+
return issueSummarySchema.parse(JSON.parse(jsonMatch[0]))
95+
}
96+
97+
export async function createGitHubIssue(
98+
githubToken: string,
99+
title: string,
100+
body: string,
101+
threadUrl: string,
102+
): Promise<GitHubIssueResponse> {
103+
const issueBody = `${body}
104+
105+
---
106+
107+
> **Source:** [Discord thread in #mezo-issue-reports](${threadUrl})
108+
109+
@claude Analyze this issue report against the codebase. Summarize the likely root cause, identify the relevant files and code paths, and propose a fix with code changes. If you can confidently produce a fix, open a pull request.`
110+
111+
const response = await fetch(
112+
`https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/issues`,
113+
{
114+
method: "POST",
115+
headers: {
116+
Accept: "application/vnd.github+json",
117+
Authorization: `Bearer ${githubToken}`,
118+
"X-GitHub-Api-Version": "2022-11-28",
119+
},
120+
body: JSON.stringify({
121+
title,
122+
body: issueBody,
123+
labels: ["discord-report", "investigate"],
124+
}),
125+
},
126+
)
127+
128+
if (!response.ok) {
129+
const errorText = await response.text()
130+
throw new Error(
131+
`GitHub API request failed: ${response.status} ${response.statusText} - ${errorText}`,
132+
)
133+
}
134+
135+
return gitHubIssueResponseSchema.parse(await response.json())
136+
}
137+
138+
export function buildThreadUrl(guildId: string, threadId: string): string {
139+
return `https://discord.com/channels/${guildId}/${threadId}`
140+
}

0 commit comments

Comments
 (0)