blob: da7528412f6b771bcb92cd46120b48c7a548a7a5 [file] [log] [blame]
package commands
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"os"
"strings"
"github.com/iomodo/staff/git"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
var webhookCmd = &cobra.Command{
Use: "webhook",
Short: "Webhook management commands",
Long: `Manage GitHub webhooks for the Staff AI system.
Webhooks are used to automatically detect when Pull Requests are merged
and complete corresponding tasks in the Staff system.`,
}
var webhookConfigureCmd = &cobra.Command{
Use: "configure",
Short: "Configure GitHub webhook for PR merge events",
Long: `Configure a GitHub webhook to automatically detect when Pull Requests are merged.
This command will:
1. Generate a webhook secret if not already configured
2. Register a webhook on GitHub for pull_request events
3. Update the config.yaml file with the webhook secret
Examples:
staff webhook configure # Interactive setup
staff webhook configure --webhook-url https://example.com/webhooks/github
staff webhook configure --dry-run # Show what will be done
staff webhook configure --force # Force re-registration`,
RunE: runWebhookConfigure,
}
var (
webhookURL string
dryRun bool
force bool
)
func init() {
webhookConfigureCmd.Flags().StringVar(&webhookURL, "webhook-url", "", "Webhook URL (if not provided, will prompt)")
webhookConfigureCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be done without executing")
webhookConfigureCmd.Flags().BoolVar(&force, "force", false, "Force re-registration even if webhook exists")
webhookCmd.AddCommand(webhookConfigureCmd)
rootCmd.AddCommand(webhookCmd)
}
func runWebhookConfigure(cmd *cobra.Command, args []string) error {
ctx := context.Background()
// Validate GitHub configuration
if !cfg.HasGitHubConfig() {
return fmt.Errorf("GitHub configuration is required. Please configure github.token, github.owner, and github.repo in config.yaml")
}
// Get webhook URL if not provided
if webhookURL == "" {
var err error
webhookURL, err = promptWebhookURL()
if err != nil {
return fmt.Errorf("failed to get webhook URL: %w", err)
}
}
// Validate webhook URL
if err := validateWebhookURL(webhookURL); err != nil {
return fmt.Errorf("invalid webhook URL: %w", err)
}
// Generate webhook secret if not configured
secret := cfg.GitHub.WebhookSecret
if secret == "" {
var err error
secret, err = generateWebhookSecret()
if err != nil {
return fmt.Errorf("failed to generate webhook secret: %w", err)
}
fmt.Printf("Generated new webhook secret: %s\n", secret)
} else {
fmt.Printf("Using existing webhook secret: %s\n", secret)
}
if dryRun {
fmt.Printf("\nDry run mode - showing what would be done:\n")
fmt.Printf("- Webhook URL: %s\n", webhookURL)
fmt.Printf("- Events: [pull_request]\n")
fmt.Printf("- Secret: %s\n", secret)
fmt.Printf("- Repository: %s/%s\n", cfg.GitHub.Owner, cfg.GitHub.Repo)
if cfg.GitHub.WebhookSecret == "" {
fmt.Printf("- Would update config.yaml with webhook secret\n")
}
fmt.Printf("- Would register webhook on GitHub\n")
return nil
}
// Check existing webhooks
fmt.Printf("Checking existing webhooks...\n")
webhooks, err := gitInterface.ListWebhooks(ctx)
if err != nil {
return fmt.Errorf("failed to list existing webhooks: %w", err)
}
// Look for existing webhook with our URL
var existingWebhook *git.GitHubWebhookResponse
for i, webhook := range webhooks {
if webhook.Config.URL == webhookURL && webhook.Name == "web" {
existingWebhook = &webhooks[i]
break
}
}
if existingWebhook != nil {
if !force {
return fmt.Errorf("webhook already exists with URL %s (ID: %d). Use --force to update it", webhookURL, existingWebhook.ID)
}
fmt.Printf("Updating existing webhook (ID: %d)...\n", existingWebhook.ID)
updatedWebhook, err := gitInterface.UpdateWebhook(ctx, existingWebhook.ID, webhookURL, secret)
if err != nil {
return fmt.Errorf("failed to update webhook: %w", err)
}
fmt.Printf("Successfully updated webhook!\n")
fmt.Printf("- Webhook ID: %d\n", updatedWebhook.ID)
fmt.Printf("- URL: %s\n", updatedWebhook.Config.URL)
fmt.Printf("- Events: %v\n", updatedWebhook.Events)
fmt.Printf("- Secret: %s\n", secret)
} else {
fmt.Printf("Creating new webhook...\n")
newWebhook, err := gitInterface.CreateWebhook(ctx, webhookURL, secret)
if err != nil {
return fmt.Errorf("failed to create webhook: %w", err)
}
fmt.Printf("Successfully created webhook!\n")
fmt.Printf("- Webhook ID: %d\n", newWebhook.ID)
fmt.Printf("- URL: %s\n", newWebhook.Config.URL)
fmt.Printf("- Events: %v\n", newWebhook.Events)
fmt.Printf("- Secret: %s\n", secret)
}
// Update config file if webhook secret changed
if cfg.GitHub.WebhookSecret != secret {
fmt.Printf("Updating config.yaml with webhook secret...\n")
if err := updateConfigWebhookSecret(secret); err != nil {
return fmt.Errorf("failed to update config file: %w", err)
}
fmt.Printf("Config file updated successfully!\n")
}
fmt.Printf("\nWebhook configuration complete!\n")
fmt.Printf("\nWebhook Secret: %s\n", secret)
fmt.Printf("Please save this secret for your webhook endpoint configuration.\n")
fmt.Printf("\nYour Staff system will now automatically detect merged PRs and complete tasks.\n")
return nil
}
func promptWebhookURL() (string, error) {
reader := bufio.NewReader(os.Stdin)
// Show server listen address if configured
if cfg.Server.ListenAddress != "" {
fmt.Printf("Detected server listen address: %s\n", cfg.Server.ListenAddress)
fmt.Printf("Suggested webhook URL: http://%s/webhooks/github\n", cfg.Server.ListenAddress)
fmt.Printf("(Note: Use https:// and a public domain for production)\n")
}
fmt.Printf("Enter webhook URL (e.g., https://your-domain.com/webhooks/github): ")
url, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(url), nil
}
func validateWebhookURL(webhookURL string) error {
parsedURL, err := url.Parse(webhookURL)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("URL must use http or https scheme")
}
if parsedURL.Host == "" {
return fmt.Errorf("URL must include a host")
}
return nil
}
func generateWebhookSecret() (string, error) {
// Generate 32 bytes of random data
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Encode as hex string
return hex.EncodeToString(bytes), nil
}
func updateConfigWebhookSecret(secret string) error {
// Read current config file
configData, err := os.ReadFile("config.yaml")
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
// Parse YAML
var configMap map[string]interface{}
if err := yaml.Unmarshal(configData, &configMap); err != nil {
return fmt.Errorf("failed to parse config YAML: %w", err)
}
// Update webhook secret
github, ok := configMap["github"].(map[string]interface{})
if !ok {
github = make(map[string]interface{})
configMap["github"] = github
}
github["webhook_secret"] = secret
// Marshal back to YAML
updatedData, err := yaml.Marshal(configMap)
if err != nil {
return fmt.Errorf("failed to marshal config YAML: %w", err)
}
// Write back to file
if err := os.WriteFile("config.yaml", updatedData, 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}