From e60e876fad2c26c8a07f9ebed10efcc343c4ed35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=98=8C?= <392871505@qq.com> Date: Thu, 4 Jun 2026 17:33:26 +0800 Subject: [PATCH] merge: integrate hook-runner into webhook commands - Add webhook +failed: list failed deliveries - Add webhook +task-view: view task details - Enhance webhook +tasks: add --limit, show UUID - Remove standalone hook-runner package - Update register.go --- shortcuts/hookrunner/hookrunner.go | 201 ------------------------ shortcuts/hookrunner/hookrunner_test.go | 187 ---------------------- shortcuts/register.go | 3 - shortcuts/webhook/webhook.go | 183 +++++++++++++++------ 4 files changed, 134 insertions(+), 440 deletions(-) delete mode 100644 shortcuts/hookrunner/hookrunner.go delete mode 100644 shortcuts/hookrunner/hookrunner_test.go diff --git a/shortcuts/hookrunner/hookrunner.go b/shortcuts/hookrunner/hookrunner.go deleted file mode 100644 index 53f30c0..0000000 --- a/shortcuts/hookrunner/hookrunner.go +++ /dev/null @@ -1,201 +0,0 @@ -package hookrunner - -import ( - "fmt" - "strconv" - - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -// v1RepoPath returns the v1 API path prefix. -func v1RepoPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) -} - -// Shortcuts returns webhook delivery monitoring shortcuts. -func Shortcuts() []*common.Shortcut { - return []*common.Shortcut{ - { - Name: "list", - Description: "List webhook delivery tasks (latest first)", - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, - {Name: "limit", Short: "l", Usage: "Number of recent tasks to show", Default: "10"}, - }, - Run: runList, - }, - { - Name: "view", - Description: "View webhook delivery task details", - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, - {Name: "task-id", Usage: "Task ID", Required: true}, - }, - Run: runView, - }, - { - Name: "failed", - Description: "List failed webhook deliveries", - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, - {Name: "limit", Short: "l", Usage: "Number of recent failed tasks to show", Default: "10"}, - }, - Run: runFailed, - }, - } -} - -func runList(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s/hooktasks", v1RepoPath(ctx), id), nil) - if err != nil { - return err - } - - // Limit results if specified - limitStr := ctx.Arg("limit") - if limitStr != "" { - if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 { - raw, ok := env.Data.(map[string]interface{}) - if ok { - if tasks, ok := raw["hooktasks"].([]interface{}); ok { - if len(tasks) > limit { - raw["hooktasks"] = tasks[:limit] - raw["total_count"] = float64(len(tasks[:limit])) - } - } - } - } - } - - - // Simplify output for listing - only show essential fields - if raw, ok := env.Data.(map[string]interface{}); ok { - if tasks, ok := raw["hooktasks"].([]interface{}); ok { - for i, rawTask := range tasks { - if task, ok := rawTask.(map[string]interface{}); ok { - // Keep only essential fields for list view - simple := map[string]interface{}{ - "uuid": task["uuid"], - "event_type": task["event_type"], - "delivered_time": task["delivered_time"], - "is_succeed": task["is_succeed"], - "is_delivered": task["is_delivered"], - } - tasks[i] = simple - } - } - } - } - - return ctx.Output(env) -} - -func runView(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - taskID, err := ctx.RequireArg("task-id") - if err != nil { - return err - } - - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s/hooktasks", v1RepoPath(ctx), id), nil) - if err != nil { - return err - } - - // Find the specific task by ID - raw, ok := env.Data.(map[string]interface{}) - if !ok { - return fmt.Errorf("failed to parse hook tasks") - } - tasks, ok := raw["hooktasks"].([]interface{}) - if !ok { - return fmt.Errorf("no hook tasks found") - } - - taskNum, err := strconv.Atoi(taskID) - if err != nil { - return fmt.Errorf("invalid task-id: %s", taskID) - } - - for _, rawTask := range tasks { - task, ok := rawTask.(map[string]interface{}) - if !ok { - continue - } - if tid, ok := task["id"].(float64); ok && int(tid) == taskNum { - return ctx.OutputData(task) - } - } - - return fmt.Errorf("task %s not found", taskID) -} - -func runFailed(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s/hooktasks", v1RepoPath(ctx), id), nil) - if err != nil { - return err - } - - // Filter for failed deliveries - raw, ok := env.Data.(map[string]interface{}) - if !ok { - return ctx.Output(env) - } - tasks, ok := raw["hooktasks"].([]interface{}) - if !ok { - return ctx.OutputData(map[string]interface{}{ - "hooktasks": []interface{}{}, - "total_count": 0, - }) - } - - limit := 0 - if limitStr := ctx.Arg("limit"); limitStr != "" { - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { - limit = l - } - } - - failedTasks := make([]interface{}, 0) - for _, rawTask := range tasks { - task, ok := rawTask.(map[string]interface{}) - if !ok { - continue - } - if succeed, ok := task["is_succeed"].(bool); ok && !succeed { - task["status"] = "failed" - failedTasks = append(failedTasks, task) - if limit > 0 && len(failedTasks) >= limit { - break - } - } - } - - return ctx.OutputData(map[string]interface{}{ - "hooktasks": failedTasks, - "total_count": len(failedTasks), - }) -} - diff --git a/shortcuts/hookrunner/hookrunner_test.go b/shortcuts/hookrunner/hookrunner_test.go deleted file mode 100644 index 3bf9a21..0000000 --- a/shortcuts/hookrunner/hookrunner_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package hookrunner - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gitlink-org/gitlink-cli/internal/client" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" -) - -func runHookRunnerShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findHookRunnerShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "test-owner", - Repo: "test-repo", - Format: "json", - Args: args, - } - if ctx.Args == nil { - ctx.Args = map[string]string{} - } - return shortcut.Run(ctx) -} - -func findHookRunnerShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { - t.Helper() - return httptest.NewServer(handler) -} - -func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { - t.Helper() - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) - } -} - -// --- Tests --- - -func TestHookRunnerList(t *testing.T) { - server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{ - "total_count": 2, - "hooktasks": []interface{}{ - map[string]interface{}{ - "id": 1, "event_type": "push", "is_succeed": true, - }, - map[string]interface{}{ - "id": 2, "event_type": "push", "is_succeed": true, - }, - }, - }) - }) - defer server.Close() - - if err := runHookRunnerShortcut(t, server, "list", map[string]string{"id": "10"}); err != nil { - t.Fatalf("list failed: %v", err) - } -} - -func TestHookRunnerListRejectsMissingID(t *testing.T) { - server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("should not call API") - }) - defer server.Close() - - err := runHookRunnerShortcut(t, server, "list", nil) - if err == nil { - t.Fatal("expected error when --id is missing") - } -} - -func TestHookRunnerView(t *testing.T) { - server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{ - "total_count": 3, - "hooktasks": []interface{}{ - map[string]interface{}{"id": 1, "event_type": "push", "is_succeed": true}, - map[string]interface{}{"id": 2, "event_type": "issues_only", "is_succeed": false}, - map[string]interface{}{"id": 3, "event_type": "push", "is_succeed": true}, - }, - }) - }) - defer server.Close() - - if err := runHookRunnerShortcut(t, server, "view", map[string]string{ - "id": "10", - "task-id": "2", - }); err != nil { - t.Fatalf("view failed: %v", err) - } -} - -func TestHookRunnerViewNotFound(t *testing.T) { - server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{ - "hooktasks": []interface{}{ - map[string]interface{}{"id": 1, "event_type": "push"}, - }, - }) - }) - defer server.Close() - - err := runHookRunnerShortcut(t, server, "view", map[string]string{ - "id": "10", - "task-id": "999", - }) - if err == nil { - t.Fatal("expected error when task not found") - } -} - -func TestHookRunnerFailed(t *testing.T) { - server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{ - "hooktasks": []interface{}{ - map[string]interface{}{"id": 1, "event_type": "push", "is_succeed": true}, - map[string]interface{}{"id": 2, "event_type": "issues_only", "is_succeed": false}, - map[string]interface{}{"id": 3, "event_type": "push", "is_succeed": false}, - }, - }) - }) - defer server.Close() - - if err := runHookRunnerShortcut(t, server, "failed", map[string]string{"id": "10"}); err != nil { - t.Fatalf("failed command error: %v", err) - } -} - -func TestHookRunnerFailedAllSucceed(t *testing.T) { - server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{ - "hooktasks": []interface{}{ - map[string]interface{}{"id": 1, "is_succeed": true}, - map[string]interface{}{"id": 2, "is_succeed": true}, - }, - }) - }) - defer server.Close() - - if err := runHookRunnerShortcut(t, server, "failed", map[string]string{"id": "10"}); err != nil { - t.Fatalf("failed with all succeed error: %v", err) - } -} - -func TestHookRunnerFailedEmpty(t *testing.T) { - server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, map[string]interface{}{ - "hooktasks": []interface{}{}, - }) - }) - defer server.Close() - - if err := runHookRunnerShortcut(t, server, "failed", map[string]string{"id": "10"}); err != nil { - t.Fatalf("failed with empty error: %v", err) - } -} - -func TestHookRunnerViewRejectsMissingTaskID(t *testing.T) { - server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("should not call API") - }) - defer server.Close() - - err := runHookRunnerShortcut(t, server, "view", map[string]string{"id": "10"}) - if err == nil { - t.Fatal("expected error when --task-id is missing") - } -} diff --git a/shortcuts/register.go b/shortcuts/register.go index 8856624..adf5a57 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -11,7 +11,6 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" "github.com/gitlink-org/gitlink-cli/shortcuts/file" - "github.com/gitlink-org/gitlink-cli/shortcuts/hookrunner" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/label" "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" @@ -43,7 +42,6 @@ func RegisterAll(root *cobra.Command) { "search": search.Shortcuts(), "ci": ci.Shortcuts(), "webhook": webhook.Shortcuts(), - "hook-runner": hookrunner.Shortcuts(), "wiki": wiki.Shortcuts(), "snippet": snippet.Shortcuts(), "collaborator": collaborator.Shortcuts(), @@ -70,7 +68,6 @@ func RegisterAll(root *cobra.Command) { "search": "Search operations", "ci": "CI/CD operations", "webhook": "Webhook operations", - "hook-runner": "Webhook delivery monitoring", "wiki": "Wiki operations", "snippet": "Code snippet management", "collaborator": "Collaborator management", diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 5e2a398..604aab3 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -2,6 +2,7 @@ package webhook import ( "fmt" + "strconv" "strings" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -21,17 +22,11 @@ var allowedWebhookEvents = map[string]bool{ "pull_request_only": true, "pull_request_assign": true, "pull_request_comment": true, } -// Shortcuts returns webhook management shortcuts. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "list", Description: "List repository webhooks", - Long: `List repository webhooks. - -Returns all webhooks configured for the current repository.`, - Example: ` # List all webhooks - gitlink webhook +list`, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -46,12 +41,6 @@ Returns all webhooks configured for the current repository.`, { Name: "view", Description: "View webhook details", - Long: `View webhook details. - -Shows the full configuration of a specific webhook including -URL, events, type, and active status.`, - Example: ` # View a webhook - gitlink webhook +view --id 10`, Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, }, @@ -73,16 +62,6 @@ URL, events, type, and active status.`, { Name: "create", Description: "Create a repository webhook", - Long: `Create a repository webhook. - -Creates a new webhook that sends HTTP requests to the specified URL -when certain events occur. Supported types: gitea, slack, discord, -dingtalk, telegram, msteams, feishu, matrix, jianmu, softbot.`, - Example: ` # Create a push webhook - gitlink webhook +create --url https://example.com/hook --events push - - # Create a webhook for multiple events - gitlink webhook +create --url https://example.com/hook --events push,issues_only --secret mysecret`, Flags: []common.Flag{ {Name: "url", Short: "u", Usage: "Webhook target URL", Required: true}, {Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only", Required: true}, @@ -98,16 +77,6 @@ dingtalk, telegram, msteams, feishu, matrix, jianmu, softbot.`, { Name: "update", Description: "Update a repository webhook while preserving unspecified fields when available", - Long: `Update a repository webhook while preserving unspecified fields when available. - -Fetches the current webhook configuration, merges changes from the provided -flags, and updates the webhook. Fields not specified are preserved from the -current configuration.`, - Example: ` # Update a webhook URL - gitlink webhook +update --id 10 --url https://new.example.com/hook - - # Update webhook events - gitlink webhook +update --id 10 --events push,pull_request_only`, Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, {Name: "url", Short: "u", Usage: "Webhook target URL"}, @@ -124,11 +93,6 @@ current configuration.`, { Name: "delete", Description: "Delete a repository webhook", - Long: `Delete a repository webhook. - -Permanently removes the webhook from the repository.`, - Example: ` # Delete a webhook - gitlink webhook +delete --id 10`, Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, }, @@ -150,11 +114,6 @@ Permanently removes the webhook from the repository.`, { Name: "test", Description: "Trigger a test delivery for a webhook", - Long: `Trigger a test delivery for a webhook. - -Sends a test payload to the webhook URL to verify the configuration.`, - Example: ` # Test a webhook - gitlink webhook +test --id 10`, Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, }, @@ -176,14 +135,9 @@ Sends a test payload to the webhook URL to verify the configuration.`, { Name: "tasks", Description: "List webhook delivery tasks", - Long: `List webhook delivery tasks. - -Returns the history of webhook deliveries including status, -response code, and timing information.`, - Example: ` # List delivery tasks for a webhook - gitlink webhook +tasks --id 10`, Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "limit", Short: "l", Usage: "Number of recent tasks to show", Default: "10"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -197,9 +151,141 @@ response code, and timing information.`, if err != nil { return err } + + // Apply limit and simplify output to show UUID + if raw, ok := env.Data.(map[string]interface{}); ok { + if tasks, ok := raw["hooktasks"].([]interface{}); ok { + limitStr := ctx.Arg("limit") + if limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 && len(tasks) > limit { + tasks = tasks[:limit] + raw["hooktasks"] = tasks + raw["total_count"] = float64(len(tasks)) + } + } + for i, rawTask := range tasks { + if task, ok := rawTask.(map[string]interface{}); ok { + tasks[i] = map[string]interface{}{ + "uuid": task["uuid"], + "event_type": task["event_type"], + "delivered_time": task["delivered_time"], + "is_succeed": task["is_succeed"], + "is_delivered": task["is_delivered"], + } + } + } + } + } return ctx.Output(env) }, }, + { + Name: "failed", + Description: "List failed webhook deliveries", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "limit", Short: "l", Usage: "Number of recent failed tasks", Default: "10"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil) + if err != nil { + return err + } + raw, ok := env.Data.(map[string]interface{}) + if !ok { + return ctx.Output(env) + } + tasks, ok := raw["hooktasks"].([]interface{}) + if !ok { + return ctx.OutputData(map[string]interface{}{ + "hooktasks": []interface{}{}, + "total_count": 0, + }) + } + limit := 0 + if limitStr := ctx.Arg("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + } + failedTasks := make([]interface{}, 0) + for _, rawTask := range tasks { + task, ok := rawTask.(map[string]interface{}) + if !ok { + continue + } + if succeed, ok := task["is_succeed"].(bool); ok && !succeed { + failedTasks = append(failedTasks, map[string]interface{}{ + "uuid": task["uuid"], + "event_type": task["event_type"], + "delivered_time": task["delivered_time"], + "status": "failed", + }) + if limit > 0 && len(failedTasks) >= limit { + break + } + } + } + return ctx.OutputData(map[string]interface{}{ + "hooktasks": failedTasks, + "total_count": len(failedTasks), + }) + }, + }, + { + Name: "task-view", + Description: "View webhook delivery task details", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "task-id", Usage: "Task ID (numeric)", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + taskID, err := ctx.RequireArg("task-id") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil) + if err != nil { + return err + } + raw, ok := env.Data.(map[string]interface{}) + if !ok { + return fmt.Errorf("failed to parse hook tasks") + } + tasks, ok := raw["hooktasks"].([]interface{}) + if !ok { + return fmt.Errorf("no hook tasks found") + } + taskNum, err := strconv.Atoi(taskID) + if err != nil { + return fmt.Errorf("invalid task-id: %s", taskID) + } + for _, rawTask := range tasks { + task, ok := rawTask.(map[string]interface{}) + if !ok { + continue + } + if tid, ok := task["id"].(float64); ok && int(tid) == taskNum { + return ctx.OutputData(task) + } + } + return fmt.Errorf("task %s not found", taskID) + }, + }, } } @@ -226,7 +312,6 @@ func runUpdate(ctx *common.RuntimeContext) error { if err != nil { return err } - current, err := fetchWebhook(ctx, id) if err != nil { return fmt.Errorf("fetch webhook: %w", err)