Add Github wehbook management
Change-Id: I4b7a23a77838f345d65adf51877fab5978bd6055
diff --git a/server/cmd/commands/root.go b/server/cmd/commands/root.go
index e75e134..7a4de91 100644
--- a/server/cmd/commands/root.go
+++ b/server/cmd/commands/root.go
@@ -21,6 +21,7 @@
agentManager *agent.Manager
taskManager tm.TaskManager
cfg *config.Config
+ gitInterface git.GitInterface
)
// Run function starts the application
@@ -75,7 +76,7 @@
Level: slog.LevelInfo,
}))
- gitInterface := git.New(cfg, logger)
+ gitInterface = git.New(cfg, logger)
taskManager = git_tm.NewGitTaskManager(gitInterface, cfg, logger)
// Initialize agent manager
diff --git a/server/cmd/commands/webhook.go b/server/cmd/commands/webhook.go
new file mode 100644
index 0000000..e62d1ce
--- /dev/null
+++ b/server/cmd/commands/webhook.go
@@ -0,0 +1,251 @@
+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
+}
\ No newline at end of file