77
88import { Router } from 'express'
99import type { Request , Response } from 'express'
10+ import { execFile } from 'child_process'
11+ import { promisify } from 'util'
1012import { getWorkflowEngine } from './workflow-engine.js'
1113import {
1214 loadWorkflowConfig ,
@@ -20,6 +22,86 @@ import { syncCommitHooks } from './commit-event-hooks.js'
2022import type { CommitEventHandler } from './commit-event-handler.js'
2123import type { SessionManager } from './session-manager.js'
2224import { VALID_PROVIDERS } from './types.js'
25+ import {
26+ previewWebhookSetup ,
27+ createRepoWebhook ,
28+ updateRepoWebhook ,
29+ } from './webhook-github-setup.js'
30+ import { loadWebhookConfig , generateWebhookSecret , saveWebhookConfig } from './webhook-config.js'
31+
32+ const execFileAsync = promisify ( execFile )
33+
34+ /**
35+ * Parse a GitHub `owner/repo` slug from a git remote URL.
36+ * Supports SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git).
37+ * Returns null for non-GitHub remotes.
38+ */
39+ export function parseGitHubSlug ( remoteUrl : string ) : string | null {
40+ const match = remoteUrl . trim ( ) . match ( / g i t h u b \. c o m [: / ] ( [ ^ / ] + \/ [ ^ / ] + ?) (?: \. g i t ) ? $ / )
41+ return match ?. [ 1 ] ?? null
42+ }
43+
44+ /**
45+ * Derive the GitHub `owner/repo` slug from a local repo path
46+ * by parsing the git remote origin URL.
47+ */
48+ async function getGitHubSlug ( repoPath : string ) : Promise < string | null > {
49+ try {
50+ const { stdout } = await execFileAsync ( 'git' , [ '-C' , repoPath , 'remote' , 'get-url' , 'origin' ] , { timeout : 5000 } )
51+ return parseGitHubSlug ( stdout )
52+ } catch {
53+ return null
54+ }
55+ }
56+
57+ export interface WebhookSetupResult {
58+ status : 'created' | 'updated' | 'already_configured' | 'failed'
59+ message : string
60+ repo ?: string
61+ }
62+
63+ /**
64+ * Auto-setup the GitHub webhook for a PR review workflow.
65+ * Ensures the webhook config has a secret and is enabled, then
66+ * creates/updates the webhook on the GitHub repo.
67+ */
68+ async function autoSetupPrWebhook ( repoPath : string , webhookUrl : string ) : Promise < WebhookSetupResult > {
69+ const slug = await getGitHubSlug ( repoPath )
70+ if ( ! slug ) {
71+ return {
72+ status : 'failed' ,
73+ message : `Could not determine GitHub repository from ${ repoPath } . Please set up the webhook manually in Settings.` ,
74+ }
75+ }
76+
77+ const preview = await previewWebhookSetup ( slug , webhookUrl )
78+
79+ if ( preview . action === 'none' ) {
80+ return { status : 'already_configured' , message : 'GitHub webhook is already configured.' , repo : slug }
81+ }
82+
83+ // Ensure server webhook config has a secret and is enabled
84+ let config = loadWebhookConfig ( )
85+ const configUpdates : Record < string , unknown > = { }
86+ if ( ! config . secret ) configUpdates . secret = generateWebhookSecret ( )
87+ if ( ! config . enabled ) configUpdates . enabled = true
88+ if ( Object . keys ( configUpdates ) . length > 0 ) {
89+ saveWebhookConfig ( configUpdates )
90+ config = loadWebhookConfig ( )
91+ }
92+
93+ if ( preview . action === 'create' ) {
94+ await createRepoWebhook ( slug , webhookUrl , config . secret , preview . proposed . events )
95+ return { status : 'created' , message : `GitHub webhook created on ${ slug } .` , repo : slug }
96+ } else {
97+ await updateRepoWebhook ( slug , preview . existing ! . id , {
98+ events : preview . proposed . events ,
99+ active : true ,
100+ secret : config . secret ,
101+ } )
102+ return { status : 'updated' , message : `GitHub webhook updated on ${ slug } .` , repo : slug }
103+ }
104+ }
23105
24106type VerifyFn = ( token : string | undefined ) => boolean
25107type ExtractFn = ( req : Request ) => string | undefined
@@ -299,8 +381,9 @@ export function createWorkflowRouter(
299381 res . json ( { config : loadWorkflowConfig ( ) } )
300382 } )
301383
302- router . post ( '/config/repos' , ( req , res ) => {
303- const { id, name, repoPath, cronExpression, enabled, customPrompt, kind, model, provider } = req . body as Partial < ReviewRepoConfig >
384+ router . post ( '/config/repos' , async ( req , res ) => {
385+ const { id, name, repoPath, cronExpression, enabled, customPrompt, kind, model, provider, webhookUrl } =
386+ req . body as Partial < ReviewRepoConfig > & { webhookUrl ?: string }
304387 if ( ! id || ! name || ! repoPath || ! cronExpression ) {
305388 return res . status ( 400 ) . json ( { error : 'Missing required fields: id, name, repoPath, cronExpression' } )
306389 }
@@ -335,7 +418,25 @@ export function createWorkflowRouter(
335418 // Engine might not be ready yet
336419 }
337420
338- res . json ( { config } )
421+ // Auto-setup GitHub webhook for pr-review workflows
422+ let webhookSetup : WebhookSetupResult | undefined
423+ if ( kind === 'pr-review' && webhookUrl ) {
424+ try {
425+ webhookSetup = await autoSetupPrWebhook ( repoPath , webhookUrl )
426+ } catch ( err ) {
427+ webhookSetup = {
428+ status : 'failed' ,
429+ message : err instanceof Error ? err . message : 'Webhook setup failed. Please configure it manually in Settings.' ,
430+ }
431+ }
432+ } else if ( kind === 'pr-review' && ! webhookUrl ) {
433+ webhookSetup = {
434+ status : 'failed' ,
435+ message : 'Webhook URL not provided. Please configure the GitHub webhook manually in Settings.' ,
436+ }
437+ }
438+
439+ res . json ( { config, webhookSetup } )
339440 } )
340441
341442 router . patch ( '/config/repos/:id' , ( req , res ) => {
0 commit comments