@@ -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 ( / J o b I D : \s * ( [ 0 - 9 a - 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 ( / J o b \s + ( [ 0 - 9 a - f ] { 8 } ) \b / i)
177+ if ( shortMatch ) return shortMatch [ 1 ]
178+ // Bracket prefix: "[xxxxxxxx]"
179+ const bracketMatch = text . match ( / \[ ( [ 0 - 9 a - 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 ( / ^ h t t p s ? : \/ \/ g i t h u b \. c o m \/ ( [ ^ / ] + \/ [ ^ / ] + ) \/ p u l l \/ ( \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. */
168198function 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 <repo_url> <description> -- Submit a dev task' ,
219+ '/task <pr_url> <feedback> -- Revise an existing PR' ,
220+ '/revise <job_id> <feedback> -- Revise a job\'s PR' ,
189221 '/status <job_id> -- Check job status' ,
190222 '/cancel <job_id> -- 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 <job_id> <feedback></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+
216323bot . 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 <job_id> <feedback></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// ---------------------------------------------------------------------------
0 commit comments