merge: integrate hook-runner into webhook commands
CI / test (push) Failing after 2m6s Details

- 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
This commit is contained in:
赵昌 2026-06-04 17:33:26 +08:00
parent a115c97dde
commit e60e876fad
4 changed files with 134 additions and 440 deletions

View File

@ -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),
})
}

View File

@ -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")
}
}

View File

@ -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",

View File

@ -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)