Skip to content

Commit 35fd955

Browse files
abrichrclaude
andcommitted
feat: add PR revision support, Claude-generated titles, plans & guides
PR revision support (3 methods): - Reply to any job message with feedback to revise its PR - /revise <job_id> <feedback> for explicit revision - /task <pr_url> <feedback> detects PR URLs and creates revision jobs Worker changes: - checkoutExistingBranch() for revision jobs (push to existing PR) - Claude generates PR titles via .wright-pr-title file - Skip PR creation for revision jobs (PR already exists) Shared types: add feature_branch and parent_job_id to Job Migration: add feature_branch and parent_job_id columns to job_queue Also includes: - macOS Desktop restore guide (docs/guides/) - Blog platform and CLI tool plans (docs/plans/) - Bot fly.toml fix for monorepo build context Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8b80701 commit 35fd955

10 files changed

Lines changed: 1565 additions & 33 deletions

File tree

apps/bot/fly.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Fly.io configuration for the wright Telegram bot
22
# Long-polling bot — no HTTP service needed, just a persistent process
3+
#
4+
# Deploy from repo root: fly deploy -c apps/bot/fly.toml
35

46
app = "wright-bot"
57
primary_region = "ord"

apps/bot/src/index.ts

Lines changed: 253 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,36 @@ function isValidRepoUrl(url: string): boolean {
164164
}
165165
}
166166

167+
/**
168+
* Extract a job ID from a bot message (looks for patterns like "Job ID: <uuid>"
169+
* or "[<short-id>]" in the message text).
170+
*/
171+
function extractJobIdFromMessage(text: string): string | null {
172+
// Full UUID pattern: "Job ID: xxxxxxxx-xxxx-..."
173+
const fullMatch = text.match(/Job ID:\s*([0-9a-f-]{36})/i)
174+
if (fullMatch) return fullMatch[1]
175+
// Full UUID inside "Job xxxxxxxx" (short ID in status messages)
176+
const shortMatch = text.match(/Job\s+([0-9a-f]{8})\b/i)
177+
if (shortMatch) return shortMatch[1]
178+
// Bracket prefix: "[xxxxxxxx]"
179+
const bracketMatch = text.match(/\[([0-9a-f]{8})\]/)
180+
if (bracketMatch) return bracketMatch[1]
181+
return null
182+
}
183+
184+
/**
185+
* Parse a GitHub PR URL into its components.
186+
* Returns null if the URL is not a valid PR URL.
187+
*/
188+
function parsePrUrl(url: string): { repoUrl: string; prNumber: number } | null {
189+
const match = url.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/)
190+
if (!match) return null
191+
return {
192+
repoUrl: `https://github.com/${match[1]}`,
193+
prNumber: parseInt(match[2], 10),
194+
}
195+
}
196+
167197
/** Build PR approval inline keyboard. */
168198
function buildPrKeyboard(jobId: string, prUrl: string): InlineKeyboard {
169199
return new InlineKeyboard()
@@ -186,11 +216,15 @@ bot.command('start', async (ctx: Context) => {
186216
'',
187217
'<b>Commands:</b>',
188218
'/task &lt;repo_url&gt; &lt;description&gt; -- Submit a dev task',
219+
'/task &lt;pr_url&gt; &lt;feedback&gt; -- Revise an existing PR',
220+
'/revise &lt;job_id&gt; &lt;feedback&gt; -- Revise a job\'s PR',
189221
'/status &lt;job_id&gt; -- Check job status',
190222
'/cancel &lt;job_id&gt; -- Cancel a running job',
191223
'/verbose -- Enable all event notifications (default)',
192224
'/quiet -- Only send milestone notifications; silence noisy events',
193225
'',
226+
'You can also <b>reply</b> to any job message with feedback to revise its PR.',
227+
'',
194228
'When a PR is ready, I will send approve/reject buttons.',
195229
].join('\n'),
196230
{ parse_mode: 'HTML' },
@@ -213,6 +247,79 @@ bot.command('quiet', async (ctx: Context) => {
213247
)
214248
})
215249

250+
bot.command('revise', async (ctx: Context) => {
251+
const text = ctx.message?.text ?? ''
252+
const parts = text.split(/\s+/)
253+
254+
if (parts.length < 3) {
255+
await ctx.reply(
256+
'Usage: <code>/revise &lt;job_id&gt; &lt;feedback&gt;</code>\n\n'
257+
+ 'Pushes changes to the existing PR branch based on your feedback.',
258+
{ parse_mode: 'HTML' },
259+
)
260+
return
261+
}
262+
263+
const jobIdInput = parts[1]
264+
const feedback = parts.slice(2).join(' ')
265+
266+
try {
267+
// Look up the original job — support both full UUID and 8-char prefix
268+
const originalJob = await getJob(jobIdInput)
269+
if (!originalJob) {
270+
await ctx.reply(
271+
`No job found with ID <code>${escapeHtml(jobIdInput)}</code>.`,
272+
{ parse_mode: 'HTML' },
273+
)
274+
return
275+
}
276+
277+
if (!originalJob.pr_url) {
278+
await ctx.reply('That job has no PR yet. Cannot revise.')
279+
return
280+
}
281+
282+
// Determine the feature branch from the original job
283+
const featureBranch = originalJob.feature_branch || `wright/${originalJob.id.slice(0, 8)}`
284+
285+
const ack = await ctx.reply(
286+
`\u{1F504} Queuing revision for <code>${featureBranch}</code>...`,
287+
{ parse_mode: 'HTML' },
288+
)
289+
290+
const job = await insertJob({
291+
repoUrl: originalJob.repo_url,
292+
task: feedback,
293+
chatId: ctx.chat!.id,
294+
messageId: ack.message_id,
295+
githubToken: GITHUB_TOKEN,
296+
branch: originalJob.branch,
297+
featureBranch,
298+
parentJobId: originalJob.id,
299+
})
300+
301+
await ctx.reply(
302+
[
303+
'\u{1F504} <b>Revision queued!</b>',
304+
'',
305+
`<b>Job ID:</b> <code>${job.id}</code>`,
306+
`<b>Revising:</b> <code>${originalJob.id.slice(0, 8)}</code>`,
307+
`<b>Branch:</b> <code>${featureBranch}</code>`,
308+
`<b>Feedback:</b> ${escapeHtml(feedback)}`,
309+
'',
310+
'The worker will push changes to the existing PR branch.',
311+
].join('\n'),
312+
{ parse_mode: 'HTML' },
313+
)
314+
} catch (err) {
315+
const msg = err instanceof Error ? err.message : String(err)
316+
await ctx.reply(
317+
`\u{274C} Failed to queue revision: <code>${escapeHtml(msg)}</code>`,
318+
{ parse_mode: 'HTML' },
319+
)
320+
}
321+
})
322+
216323
bot.command('task', async (ctx: Context) => {
217324
const text = ctx.message?.text ?? ''
218325
// Parse: /task <repo_url> <description...>
@@ -230,9 +337,73 @@ bot.command('task', async (ctx: Context) => {
230337
return
231338
}
232339

233-
const repoUrl = parts[1]
340+
const urlArg = parts[1]
234341
const description = parts.slice(2).join(' ')
235342

343+
// Detect PR URL — treat as revision of that PR
344+
const prInfo = parsePrUrl(urlArg)
345+
if (prInfo) {
346+
if (description.length < 5) {
347+
await ctx.reply('Please provide feedback for the PR revision (at least 5 characters).')
348+
return
349+
}
350+
351+
const ack = await ctx.reply(
352+
`\u{1F504} Detected PR #${prInfo.prNumber}. Queuing revision...`,
353+
{ parse_mode: 'HTML' },
354+
)
355+
356+
try {
357+
// Look up the PR's head branch via GitHub API
358+
const { execFileSync } = await import('child_process')
359+
const env: Record<string, string> = {
360+
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
361+
HOME: process.env.HOME || '/home/wright',
362+
}
363+
if (GITHUB_TOKEN) env.GH_TOKEN = GITHUB_TOKEN
364+
365+
const nwo = prInfo.repoUrl.replace('https://github.com/', '')
366+
const headRef = execFileSync(
367+
'gh',
368+
['api', `repos/${nwo}/pulls/${prInfo.prNumber}`, '--jq', '.head.ref'],
369+
{ encoding: 'utf-8', env },
370+
).trim()
371+
372+
const job = await insertJob({
373+
repoUrl: prInfo.repoUrl,
374+
task: description,
375+
chatId: ctx.chat!.id,
376+
messageId: ack.message_id,
377+
githubToken: GITHUB_TOKEN,
378+
featureBranch: headRef,
379+
})
380+
381+
await ctx.reply(
382+
[
383+
'\u{1F504} <b>PR revision queued!</b>',
384+
'',
385+
`<b>Job ID:</b> <code>${job.id}</code>`,
386+
`<b>PR:</b> #${prInfo.prNumber}`,
387+
`<b>Branch:</b> <code>${headRef}</code>`,
388+
`<b>Feedback:</b> ${escapeHtml(description)}`,
389+
'',
390+
'The worker will push changes to the existing PR branch.',
391+
].join('\n'),
392+
{ parse_mode: 'HTML' },
393+
)
394+
} catch (err) {
395+
const msg = err instanceof Error ? err.message : String(err)
396+
await ctx.reply(
397+
`\u{274C} Failed to queue PR revision: <code>${escapeHtml(msg)}</code>`,
398+
{ parse_mode: 'HTML' },
399+
)
400+
}
401+
return
402+
}
403+
404+
// Normal task: repo URL + description
405+
const repoUrl = urlArg
406+
236407
if (!isValidRepoUrl(repoUrl)) {
237408
await ctx.reply(
238409
'That does not look like a valid repository URL. '
@@ -374,6 +545,87 @@ bot.command('cancel', async (ctx: Context) => {
374545
}
375546
})
376547

548+
// ---------------------------------------------------------------------------
549+
// Reply-based revision — reply to any job message to revise the PR
550+
// ---------------------------------------------------------------------------
551+
552+
bot.on('message:text', async (ctx, next) => {
553+
const reply = ctx.message.reply_to_message
554+
// Only handle replies to bot messages that contain a job ID
555+
if (!reply || reply.from?.id !== ctx.me.id) {
556+
return next()
557+
}
558+
559+
const replyText = reply.text || reply.caption || ''
560+
const jobIdPrefix = extractJobIdFromMessage(replyText)
561+
if (!jobIdPrefix) return next()
562+
563+
const feedback = ctx.message.text
564+
// Ignore commands — let command handlers take care of those
565+
if (feedback.startsWith('/')) return next()
566+
567+
if (feedback.length < 5) {
568+
await ctx.reply('Please provide more detailed feedback (at least 5 characters).')
569+
return
570+
}
571+
572+
try {
573+
// Look up the original job — try full ID first, then prefix match
574+
let originalJob = await getJob(jobIdPrefix)
575+
if (!originalJob) {
576+
// jobIdPrefix might be 8-char short ID — try looking up via Supabase LIKE
577+
// For now, reply with an error
578+
await ctx.reply(
579+
`Could not find job <code>${escapeHtml(jobIdPrefix)}</code>. `
580+
+ 'Use <code>/revise &lt;job_id&gt; &lt;feedback&gt;</code> with the full job ID.',
581+
{ parse_mode: 'HTML' },
582+
)
583+
return
584+
}
585+
586+
if (!originalJob.pr_url) {
587+
await ctx.reply('That job has no PR yet. Cannot revise.')
588+
return
589+
}
590+
591+
const featureBranch = originalJob.feature_branch || `wright/${originalJob.id.slice(0, 8)}`
592+
593+
const ack = await ctx.reply(
594+
`\u{1F504} Queuing revision for <code>${featureBranch}</code> based on your reply...`,
595+
{ parse_mode: 'HTML' },
596+
)
597+
598+
const job = await insertJob({
599+
repoUrl: originalJob.repo_url,
600+
task: feedback,
601+
chatId: ctx.chat!.id,
602+
messageId: ack.message_id,
603+
githubToken: GITHUB_TOKEN,
604+
branch: originalJob.branch,
605+
featureBranch,
606+
parentJobId: originalJob.id,
607+
})
608+
609+
await ctx.reply(
610+
[
611+
'\u{1F504} <b>Revision queued from reply!</b>',
612+
'',
613+
`<b>Job ID:</b> <code>${job.id}</code>`,
614+
`<b>Revising:</b> <code>${originalJob.id.slice(0, 8)}</code>`,
615+
`<b>Branch:</b> <code>${featureBranch}</code>`,
616+
`<b>Feedback:</b> ${escapeHtml(feedback.slice(0, 200))}`,
617+
].join('\n'),
618+
{ parse_mode: 'HTML' },
619+
)
620+
} catch (err) {
621+
const msg = err instanceof Error ? err.message : String(err)
622+
await ctx.reply(
623+
`\u{274C} Failed to queue revision: <code>${escapeHtml(msg)}</code>`,
624+
{ parse_mode: 'HTML' },
625+
)
626+
}
627+
})
628+
377629
// ---------------------------------------------------------------------------
378630
// Inline keyboard callback queries (approve / reject PRs)
379631
// ---------------------------------------------------------------------------

apps/bot/src/supabase.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ export interface InsertJobParams {
5050
branch?: string
5151
maxLoops?: number
5252
maxBudgetUsd?: number
53+
/** For revision jobs: the existing feature branch to push to */
54+
featureBranch?: string
55+
/** For revision jobs: the parent job ID being revised */
56+
parentJobId?: string
5357
}
5458

5559
/**
@@ -60,20 +64,24 @@ export interface InsertJobParams {
6064
export async function insertJob(params: InsertJobParams): Promise<Job> {
6165
const sb = getSupabase()
6266

67+
const row: Record<string, unknown> = {
68+
repo_url: params.repoUrl,
69+
task: params.task,
70+
branch: params.branch ?? 'main',
71+
max_loops: params.maxLoops ?? DEFAULT_MAX_LOOPS,
72+
max_budget_usd: params.maxBudgetUsd ?? DEFAULT_MAX_BUDGET_USD,
73+
status: JOB_STATUS.QUEUED,
74+
total_cost_usd: 0,
75+
github_token: params.githubToken,
76+
telegram_chat_id: params.chatId,
77+
telegram_message_id: params.messageId,
78+
}
79+
if (params.featureBranch) row.feature_branch = params.featureBranch
80+
if (params.parentJobId) row.parent_job_id = params.parentJobId
81+
6382
const { data, error } = await sb
6483
.from(TABLES.JOB_QUEUE)
65-
.insert({
66-
repo_url: params.repoUrl,
67-
task: params.task,
68-
branch: params.branch ?? 'main',
69-
max_loops: params.maxLoops ?? DEFAULT_MAX_LOOPS,
70-
max_budget_usd: params.maxBudgetUsd ?? DEFAULT_MAX_BUDGET_USD,
71-
status: JOB_STATUS.QUEUED,
72-
total_cost_usd: 0,
73-
github_token: params.githubToken,
74-
telegram_chat_id: params.chatId,
75-
telegram_message_id: params.messageId,
76-
})
84+
.insert(row)
7785
.select()
7886
.single()
7987

0 commit comments

Comments
 (0)