| 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 |
| } |