Skip to content

Commit 02b2e38

Browse files
alari76claude
andauthored
feat: auto-setup GitHub webhook for PR Review workflows (#391)
* feat: auto-setup GitHub webhook when creating PR Review workflow When a user creates a PR Review workflow, the server now automatically attempts to configure the GitHub webhook on the repo. The modal shows the setup result — success or manual setup instructions if it fails. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: improve webhook setup robustness and add parseGitHubSlug tests - PR Review modal now always shows result screen (fallback message if server returns no setup info), ensuring consistent UX - Extract parseGitHubSlug as a testable pure function with 8 unit tests covering SSH, HTTPS, dots in names, non-GitHub remotes, etc. - Fix regex to allow dots in repo names (e.g. my_repo.name) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 445bb49 commit 02b2e38

File tree

6 files changed

+329
-105
lines changed

6 files changed

+329
-105
lines changed

server/workflow-routes.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/** Tests for parseGitHubSlug — verifies GitHub slug extraction from various remote URL formats. */
2+
import { describe, it, expect } from 'vitest'
3+
import { parseGitHubSlug } from './workflow-routes.js'
4+
5+
describe('parseGitHubSlug', () => {
6+
it('parses HTTPS URL with .git suffix', () => {
7+
expect(parseGitHubSlug('https://github.com/Multiplier-Labs/codekin.git')).toBe('Multiplier-Labs/codekin')
8+
})
9+
10+
it('parses HTTPS URL without .git suffix', () => {
11+
expect(parseGitHubSlug('https://github.com/owner/repo')).toBe('owner/repo')
12+
})
13+
14+
it('parses SSH URL', () => {
15+
expect(parseGitHubSlug('git@github.com:owner/repo.git')).toBe('owner/repo')
16+
})
17+
18+
it('parses SSH URL without .git suffix', () => {
19+
expect(parseGitHubSlug('git@github.com:owner/repo')).toBe('owner/repo')
20+
})
21+
22+
it('handles trailing whitespace/newline', () => {
23+
expect(parseGitHubSlug('git@github.com:owner/repo.git\n')).toBe('owner/repo')
24+
})
25+
26+
it('returns null for non-GitHub remotes', () => {
27+
expect(parseGitHubSlug('https://gitlab.com/owner/repo.git')).toBeNull()
28+
})
29+
30+
it('returns null for empty string', () => {
31+
expect(parseGitHubSlug('')).toBeNull()
32+
})
33+
34+
it('handles repos with hyphens, underscores, and dots', () => {
35+
expect(parseGitHubSlug('git@github.com:my-org/my_repo.name.git')).toBe('my-org/my_repo.name')
36+
})
37+
})

server/workflow-routes.ts

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
import { Router } from 'express'
99
import type { Request, Response } from 'express'
10+
import { execFile } from 'child_process'
11+
import { promisify } from 'util'
1012
import { getWorkflowEngine } from './workflow-engine.js'
1113
import {
1214
loadWorkflowConfig,
@@ -20,6 +22,86 @@ import { syncCommitHooks } from './commit-event-hooks.js'
2022
import type { CommitEventHandler } from './commit-event-handler.js'
2123
import type { SessionManager } from './session-manager.js'
2224
import { 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(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/)
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

24106
type VerifyFn = (token: string | undefined) => boolean
25107
type 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

Comments
 (0)