Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions server/workflow-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/** Tests for parseGitHubSlug — verifies GitHub slug extraction from various remote URL formats. */
import { describe, it, expect } from 'vitest'
import { parseGitHubSlug } from './workflow-routes.js'

describe('parseGitHubSlug', () => {
it('parses HTTPS URL with .git suffix', () => {
expect(parseGitHubSlug('https://github.com/Multiplier-Labs/codekin.git')).toBe('Multiplier-Labs/codekin')
})

it('parses HTTPS URL without .git suffix', () => {
expect(parseGitHubSlug('https://github.com/owner/repo')).toBe('owner/repo')
})

it('parses SSH URL', () => {
expect(parseGitHubSlug('git@github.com:owner/repo.git')).toBe('owner/repo')
})

it('parses SSH URL without .git suffix', () => {
expect(parseGitHubSlug('git@github.com:owner/repo')).toBe('owner/repo')
})

it('handles trailing whitespace/newline', () => {
expect(parseGitHubSlug('git@github.com:owner/repo.git\n')).toBe('owner/repo')
})

it('returns null for non-GitHub remotes', () => {
expect(parseGitHubSlug('https://gitlab.com/owner/repo.git')).toBeNull()
})

it('returns null for empty string', () => {
expect(parseGitHubSlug('')).toBeNull()
})

it('handles repos with hyphens, underscores, and dots', () => {
expect(parseGitHubSlug('git@github.com:my-org/my_repo.name.git')).toBe('my-org/my_repo.name')
})
})
107 changes: 104 additions & 3 deletions server/workflow-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import { Router } from 'express'
import type { Request, Response } from 'express'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { getWorkflowEngine } from './workflow-engine.js'
import {
loadWorkflowConfig,
Expand All @@ -20,6 +22,86 @@ import { syncCommitHooks } from './commit-event-hooks.js'
import type { CommitEventHandler } from './commit-event-handler.js'
import type { SessionManager } from './session-manager.js'
import { VALID_PROVIDERS } from './types.js'
import {
previewWebhookSetup,
createRepoWebhook,
updateRepoWebhook,
} from './webhook-github-setup.js'
import { loadWebhookConfig, generateWebhookSecret, saveWebhookConfig } from './webhook-config.js'

const execFileAsync = promisify(execFile)

/**
* Parse a GitHub `owner/repo` slug from a git remote URL.
* Supports SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git).
* Returns null for non-GitHub remotes.
*/
export function parseGitHubSlug(remoteUrl: string): string | null {
const match = remoteUrl.trim().match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/)
return match?.[1] ?? null
}

/**
* Derive the GitHub `owner/repo` slug from a local repo path
* by parsing the git remote origin URL.
*/
async function getGitHubSlug(repoPath: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { timeout: 5000 })
return parseGitHubSlug(stdout)
} catch {
return null
}
}

export interface WebhookSetupResult {
status: 'created' | 'updated' | 'already_configured' | 'failed'
message: string
repo?: string
}

/**
* Auto-setup the GitHub webhook for a PR review workflow.
* Ensures the webhook config has a secret and is enabled, then
* creates/updates the webhook on the GitHub repo.
*/
async function autoSetupPrWebhook(repoPath: string, webhookUrl: string): Promise<WebhookSetupResult> {
const slug = await getGitHubSlug(repoPath)
if (!slug) {
return {
status: 'failed',
message: `Could not determine GitHub repository from ${repoPath}. Please set up the webhook manually in Settings.`,
}
}

const preview = await previewWebhookSetup(slug, webhookUrl)

if (preview.action === 'none') {
return { status: 'already_configured', message: 'GitHub webhook is already configured.', repo: slug }
}

// Ensure server webhook config has a secret and is enabled
let config = loadWebhookConfig()
const configUpdates: Record<string, unknown> = {}
if (!config.secret) configUpdates.secret = generateWebhookSecret()
if (!config.enabled) configUpdates.enabled = true
if (Object.keys(configUpdates).length > 0) {
saveWebhookConfig(configUpdates)
config = loadWebhookConfig()
}

if (preview.action === 'create') {
await createRepoWebhook(slug, webhookUrl, config.secret, preview.proposed.events)
return { status: 'created', message: `GitHub webhook created on ${slug}.`, repo: slug }
} else {
await updateRepoWebhook(slug, preview.existing!.id, {
events: preview.proposed.events,
active: true,
secret: config.secret,
})
return { status: 'updated', message: `GitHub webhook updated on ${slug}.`, repo: slug }
}
}

type VerifyFn = (token: string | undefined) => boolean
type ExtractFn = (req: Request) => string | undefined
Expand Down Expand Up @@ -299,8 +381,9 @@ export function createWorkflowRouter(
res.json({ config: loadWorkflowConfig() })
})

router.post('/config/repos', (req, res) => {
const { id, name, repoPath, cronExpression, enabled, customPrompt, kind, model, provider } = req.body as Partial<ReviewRepoConfig>
router.post('/config/repos', async (req, res) => {
const { id, name, repoPath, cronExpression, enabled, customPrompt, kind, model, provider, webhookUrl } =
req.body as Partial<ReviewRepoConfig> & { webhookUrl?: string }
if (!id || !name || !repoPath || !cronExpression) {
return res.status(400).json({ error: 'Missing required fields: id, name, repoPath, cronExpression' })
}
Expand Down Expand Up @@ -335,7 +418,25 @@ export function createWorkflowRouter(
// Engine might not be ready yet
}

res.json({ config })
// Auto-setup GitHub webhook for pr-review workflows
let webhookSetup: WebhookSetupResult | undefined
if (kind === 'pr-review' && webhookUrl) {
try {
webhookSetup = await autoSetupPrWebhook(repoPath, webhookUrl)
} catch (err) {
webhookSetup = {
status: 'failed',
message: err instanceof Error ? err.message : 'Webhook setup failed. Please configure it manually in Settings.',
}
}
} else if (kind === 'pr-review' && !webhookUrl) {
webhookSetup = {
status: 'failed',
message: 'Webhook URL not provided. Please configure the GitHub webhook manually in Settings.',
}
}

res.json({ config, webhookSetup })
})

router.patch('/config/repos/:id', (req, res) => {
Expand Down
Loading
Loading