From adccd6f6f72ff637a0acbd35b9ff5fe48fe71c5b Mon Sep 17 00:00:00 2001 From: sid Date: Sun, 19 Apr 2026 22:01:29 -0600 Subject: [PATCH] feat: add webhook, repo delete/search, admin, pr clean/resolve/review-comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass of tea-parity work: - fgj webhook {list,create,update,delete}: full CRUD over repo webhooks. Create supports all standard hook types (gitea, slack, discord, etc.), event selection, content type, secret, branch filter, auth header. Update is partial — flags you omit leave existing config unchanged. - fgj repo delete: type-to-confirm deletion; --yes skips for scripts; refuses without a TTY unless --yes is passed. - fgj repo search: SDK SearchRepos with query, topic/description, private/archived, --type (source/fork/mirror), owner, sort/order. - fgj admin user list: admin-gated user enumeration. - fgj pr clean: delete the local branch from 'pr checkout'. Refuses if the PR is still open (use --force) or if the branch is currently checked out. - fgj pr review-comments: list inline review comments across every review on a PR (ListPullReviews + ListPullReviewComments per review). - fgj pr resolve / unresolve: mark review comments as (un)resolved. Uses raw POST since SDK v0.22.1 predates these endpoints; requires Forgejo 8.x+ / Gitea 1.22+ server-side. All share the standard parseRepo + config.Load + NewClientFromConfig pattern; list commands support --json / --jq. --- cmd/admin.go | 85 +++++++++++ cmd/pr_clean.go | 98 +++++++++++++ cmd/pr_review_comments.go | 171 ++++++++++++++++++++++ cmd/repo_delete.go | 78 ++++++++++ cmd/repo_search.go | 146 +++++++++++++++++++ cmd/webhook.go | 293 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 871 insertions(+) create mode 100644 cmd/admin.go create mode 100644 cmd/pr_clean.go create mode 100644 cmd/pr_review_comments.go create mode 100644 cmd/repo_delete.go create mode 100644 cmd/repo_search.go create mode 100644 cmd/webhook.go diff --git a/cmd/admin.go b/cmd/admin.go new file mode 100644 index 0000000..cbc390e --- /dev/null +++ b/cmd/admin.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" + "github.com/spf13/cobra" +) + +var adminCmd = &cobra.Command{ + Use: "admin", + Aliases: []string{"a"}, + Short: "Operations requiring admin access", + Long: "Administrative operations on the current host. These require an admin-scoped token.", +} + +var adminUserCmd = &cobra.Command{ + Use: "user", + Aliases: []string{"users", "u"}, + Short: "Manage users on the host", + Long: "Admin-scoped user management.", +} + +var adminUserListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List all users on the host", + Example: ` # List users + fgj admin user list + + # Limit and output as JSON + fgj admin user list --limit 100 --json`, + RunE: runAdminUserList, +} + +func init() { + rootCmd.AddCommand(adminCmd) + adminCmd.AddCommand(adminUserCmd) + adminUserCmd.AddCommand(adminUserListCmd) + + adminUserListCmd.Flags().IntP("limit", "L", 50, "Maximum number of users to list") + addJSONFlags(adminUserListCmd, "Output as JSON") +} + +func runAdminUserList(cmd *cobra.Command, args []string) error { + client, err := loadClient() + if err != nil { + return err + } + + limit, _ := cmd.Flags().GetInt("limit") + if limit <= 0 { + limit = 50 + } + + users, _, err := client.AdminListUsers(gitea.AdminListUsersOptions{ + ListOptions: gitea.ListOptions{PageSize: limit}, + }) + if err != nil { + return fmt.Errorf("failed to list users (admin token required): %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, users) + } + + if len(users) == 0 { + fmt.Fprintln(ios.Out, "No users found.") + return nil + } + + tp := ios.NewTablePrinter() + tp.AddHeader("LOGIN", "FULL NAME", "EMAIL", "ADMIN", "ACTIVE") + for _, u := range users { + admin, active := "", "yes" + if u.IsAdmin { + admin = "yes" + } + if !u.IsActive { + active = "no" + } + tp.AddRow(u.UserName, u.FullName, u.Email, admin, active) + } + return tp.Render() +} diff --git a/cmd/pr_clean.go b/cmd/pr_clean.go new file mode 100644 index 0000000..3b678c0 --- /dev/null +++ b/cmd/pr_clean.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "fmt" + "os/exec" + "strings" + + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/git" + "github.com/spf13/cobra" +) + +var prCleanCmd = &cobra.Command{ + Use: "clean ", + Short: "Delete the local branch created by 'pr checkout'", + Long: `Remove the local branch that was checked out for a pull request. + +For safety, the PR must be closed (merged or declined). If the branch is +currently checked out, switch away first — this command refuses to delete +your active branch. + +Pass --force to delete the local branch even if the PR is still open.`, + Example: ` # Clean up after a merged PR + fgj pr clean 42 + + # Force-delete local branch for an open PR + fgj pr clean 42 --force`, + Args: cobra.ExactArgs(1), + RunE: runPRClean, +} + +func init() { + prCmd.AddCommand(prCleanCmd) + addRepoFlags(prCleanCmd) + prCleanCmd.Flags().Bool("force", false, "Delete the local branch even if the PR is still open") +} + +func runPRClean(cmd *cobra.Command, args []string) error { + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + repoFlag, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repoFlag) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return err + } + + force, _ := cmd.Flags().GetBool("force") + + pr, _, err := client.GetPullRequest(owner, name, prNumber) + if err != nil { + return fmt.Errorf("failed to get pull request: %w", err) + } + + if !force && string(pr.State) == "open" { + return fmt.Errorf("PR #%d is still open; refuse to delete local branch without --force", prNumber) + } + + headBranch := pr.Head.Ref + if headBranch == "" { + return fmt.Errorf("PR #%d has no head branch to clean (it may have been deleted already)", prNumber) + } + + // Refuse to delete the current branch. + current, err := git.GetCurrentBranch() + if err == nil && current == headBranch { + return fmt.Errorf("branch %q is currently checked out; switch to another branch first (e.g. 'git switch main')", headBranch) + } + + // Check local branch exists. + if out, _ := exec.Command("git", "rev-parse", "--verify", "--quiet", "refs/heads/"+headBranch).Output(); len(strings.TrimSpace(string(out))) == 0 { + fmt.Fprintf(ios.ErrOut, "Local branch %q not found; nothing to clean.\n", headBranch) + return nil + } + + delCmd := exec.Command("git", "branch", "-D", headBranch) + delCmd.Stdout = ios.Out + delCmd.Stderr = ios.ErrOut + if err := delCmd.Run(); err != nil { + return fmt.Errorf("failed to delete local branch %q: %w", headBranch, err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Deleted local branch %q\n", cs.SuccessIcon(), headBranch) + return nil +} diff --git a/cmd/pr_review_comments.go b/cmd/pr_review_comments.go new file mode 100644 index 0000000..c577d59 --- /dev/null +++ b/cmd/pr_review_comments.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "fmt" + "strings" + + "code.gitea.io/sdk/gitea" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "github.com/spf13/cobra" +) + +var prReviewCommentsCmd = &cobra.Command{ + Use: "review-comments ", + Aliases: []string{"rc"}, + Short: "List review comments on a pull request", + Long: "List all review comments (inline code comments) across every review on a PR.", + Example: ` # List review comments on PR #42 + fgj pr review-comments 42 + + # As JSON + fgj pr review-comments 42 --json`, + Args: cobra.ExactArgs(1), + RunE: runPRReviewComments, +} + +var prResolveCmd = &cobra.Command{ + Use: "resolve ", + Short: "Resolve a PR review comment", + Long: `Mark a pull request review comment as resolved. Comment IDs are shown +in the output of 'fgj pr review-comments'. + +Requires Forgejo 8.x+ / Gitea 1.22+.`, + Args: cobra.ExactArgs(1), + RunE: runPRResolveComment(true), +} + +var prUnresolveCmd = &cobra.Command{ + Use: "unresolve ", + Short: "Unresolve a PR review comment", + Long: "Reopen a previously-resolved pull request review comment.", + Args: cobra.ExactArgs(1), + RunE: runPRResolveComment(false), +} + +func init() { + prCmd.AddCommand(prReviewCommentsCmd) + prCmd.AddCommand(prResolveCmd) + prCmd.AddCommand(prUnresolveCmd) + + addRepoFlags(prReviewCommentsCmd) + addJSONFlags(prReviewCommentsCmd, "Output as JSON") + + addRepoFlags(prResolveCmd) + addRepoFlags(prUnresolveCmd) +} + +func runPRReviewComments(cmd *cobra.Command, args []string) error { + prNumber, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid pull request number: %w", err) + } + + client, owner, name, err := newPRCommentClient(cmd) + if err != nil { + return err + } + + reviews, _, err := client.ListPullReviews(owner, name, prNumber, gitea.ListPullReviewsOptions{}) + if err != nil { + return fmt.Errorf("failed to list reviews: %w", err) + } + + var all []*gitea.PullReviewComment + for _, r := range reviews { + comments, _, err := client.ListPullReviewComments(owner, name, prNumber, r.ID) + if err != nil { + return fmt.Errorf("failed to list comments for review %d: %w", r.ID, err) + } + all = append(all, comments...) + } + + if wantJSON(cmd) { + return outputJSON(cmd, all) + } + + if len(all) == 0 { + fmt.Fprintln(ios.Out, "No review comments on this PR.") + return nil + } + + tp := ios.NewTablePrinter() + tp.AddHeader("ID", "REVIEWER", "PATH", "LINE", "RESOLVED", "BODY") + for _, c := range all { + reviewer := "" + if c.Reviewer != nil { + reviewer = c.Reviewer.UserName + } + resolved := "" + if c.Resolver != nil { + resolved = "yes" + } + // Collapse multi-line bodies for table view. + body := strings.ReplaceAll(c.Body, "\n", " ") + if len(body) > 80 { + body = body[:77] + "..." + } + tp.AddRow( + fmt.Sprintf("%d", c.ID), + reviewer, + c.Path, + fmt.Sprintf("%d", c.LineNum), + resolved, + body, + ) + } + return tp.Render() +} + +// runPRResolveComment returns a RunE closure that either resolves or unresolves +// a review comment, depending on the `resolve` flag. The underlying SDK +// (v0.22.1) doesn't expose these endpoints yet, so we call them raw. +func runPRResolveComment(resolve bool) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + id, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid comment id: %w", err) + } + + client, owner, name, err := newPRCommentClient(cmd) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/comments/%d", owner, name, id) + action := "unresolve" + if resolve { + action = "resolve" + } + endpoint := path + "/" + action + + if err := client.PostJSON(endpoint, map[string]any{}, nil); err != nil { + return fmt.Errorf("failed to %s comment %d: %w", action, id, err) + } + + cs := ios.ColorScheme() + verb := "Resolved" + if !resolve { + verb = "Unresolved" + } + fmt.Fprintf(ios.Out, "%s %s comment %d\n", cs.SuccessIcon(), verb, id) + return nil + } +} + +func newPRCommentClient(cmd *cobra.Command) (*api.Client, string, string, error) { + repoFlag, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repoFlag) + if err != nil { + return nil, "", "", err + } + cfg, err := config.Load() + if err != nil { + return nil, "", "", err + } + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return nil, "", "", err + } + return client, owner, name, nil +} diff --git a/cmd/repo_delete.go b/cmd/repo_delete.go new file mode 100644 index 0000000..5cabe72 --- /dev/null +++ b/cmd/repo_delete.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "fmt" + + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "github.com/spf13/cobra" +) + +var repoDeleteCmd = &cobra.Command{ + Use: "delete [owner/name]", + Aliases: []string{"rm"}, + Short: "Delete a repository", + Long: `Delete a repository. This is irreversible and removes all issues, PRs, +wikis, and release artifacts. + +For safety, you must either pass -y/--yes, or type the full owner/name +string when prompted.`, + Example: ` # Delete a repository (prompted confirmation) + fgj repo delete owner/name + + # Delete without confirmation (scripts) + fgj repo delete owner/name --yes`, + Args: cobra.MaximumNArgs(1), + RunE: runRepoDelete, +} + +func init() { + repoCmd.AddCommand(repoDeleteCmd) + repoDeleteCmd.Flags().BoolP("yes", "y", false, "Skip the type-to-confirm prompt") +} + +func runRepoDelete(cmd *cobra.Command, args []string) error { + var target string + if len(args) == 1 { + target = args[0] + } + owner, name, err := parseRepo(target) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return err + } + + slug := fmt.Sprintf("%s/%s", owner, name) + skipConfirm, _ := cmd.Flags().GetBool("yes") + + if !skipConfirm { + if !ios.IsStdinTTY() { + return fmt.Errorf("refusing to delete %s without a TTY; pass --yes to confirm non-interactively", slug) + } + prompt := fmt.Sprintf("Type the full repo slug to confirm deletion (%s): ", slug) + answer, err := promptLine(prompt) + if err != nil { + return err + } + if answer != slug { + fmt.Fprintln(ios.ErrOut, "Confirmation mismatch; aborting.") + return nil + } + } + + if _, err := client.DeleteRepo(owner, name); err != nil { + return fmt.Errorf("failed to delete %s: %w", slug, err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Deleted %s\n", cs.SuccessIcon(), slug) + return nil +} diff --git a/cmd/repo_search.go b/cmd/repo_search.go new file mode 100644 index 0000000..a67eed3 --- /dev/null +++ b/cmd/repo_search.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" + "github.com/spf13/cobra" +) + +var repoSearchCmd = &cobra.Command{ + Use: "search [query]", + Aliases: []string{"s"}, + Short: "Search repositories on the current host", + Long: `Search repositories using the host's search index. + +The query is matched against name by default. Pass --topic to match against +topics only, or --description to include descriptions. --type limits results +to "source" (non-fork, non-mirror), "fork", or "mirror" repositories.`, + Example: ` # Search by name substring + fgj repo search tea + + # Search by topic + fgj repo search ci --topic + + # Find only forks + fgj repo search go --type fork + + # List private repos owned by a user (no query) + fgj repo search --owner alice --private --limit 50 + + # Output as JSON + fgj repo search platform --json`, + Args: cobra.MaximumNArgs(1), + RunE: runRepoSearch, +} + +func init() { + repoCmd.AddCommand(repoSearchCmd) + + repoSearchCmd.Flags().Bool("topic", false, "Match query against topics only") + repoSearchCmd.Flags().Bool("description", false, "Include descriptions in the search") + repoSearchCmd.Flags().Bool("private", false, "Limit to private repositories") + repoSearchCmd.Flags().Bool("archived", false, "Include archived repositories") + repoSearchCmd.Flags().Bool("exclude-templates", false, "Exclude template repositories") + repoSearchCmd.Flags().String("type", "", "Filter by repo type: source, fork, mirror") + repoSearchCmd.Flags().String("owner", "", "Limit to repos owned by this user or org") + repoSearchCmd.Flags().String("sort", "", "Sort by: alpha, created, updated, size, id") + repoSearchCmd.Flags().String("order", "", "Order: asc or desc") + repoSearchCmd.Flags().IntP("limit", "L", 30, "Maximum number of results") + + addJSONFlags(repoSearchCmd, "Output as JSON") +} + +func runRepoSearch(cmd *cobra.Command, args []string) error { + client, err := loadClient() + if err != nil { + return err + } + + query := "" + if len(args) == 1 { + query = args[0] + } + + topic, _ := cmd.Flags().GetBool("topic") + desc, _ := cmd.Flags().GetBool("description") + private, _ := cmd.Flags().GetBool("private") + archived, _ := cmd.Flags().GetBool("archived") + excludeTemplates, _ := cmd.Flags().GetBool("exclude-templates") + sort, _ := cmd.Flags().GetString("sort") + order, _ := cmd.Flags().GetString("order") + typeFlag, _ := cmd.Flags().GetString("type") + limit, _ := cmd.Flags().GetInt("limit") + if limit <= 0 { + limit = 30 + } + + var repoType gitea.RepoType + switch typeFlag { + case "": + repoType = gitea.RepoTypeNone + case "source": + repoType = gitea.RepoTypeSource + case "fork": + repoType = gitea.RepoTypeFork + case "mirror": + repoType = gitea.RepoTypeMirror + default: + return fmt.Errorf("invalid --type %q (must be source, fork, or mirror)", typeFlag) + } + + opt := gitea.SearchRepoOptions{ + ListOptions: gitea.ListOptions{PageSize: limit}, + Keyword: query, + KeywordIsTopic: topic, + KeywordInDescription: desc, + IsPrivate: optionalBool(private), + IsArchived: optionalBool(archived), + ExcludeTemplate: excludeTemplates, + Type: repoType, + Sort: sort, + Order: order, + } + + if o, _ := cmd.Flags().GetString("owner"); o != "" { + u, _, err := client.GetUserInfo(o) + if err != nil { + return fmt.Errorf("failed to resolve owner %q: %w", o, err) + } + opt.OwnerID = u.ID + } + + repos, _, err := client.SearchRepos(opt) + if err != nil { + return fmt.Errorf("search failed: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, repos) + } + + if len(repos) == 0 { + fmt.Fprintln(ios.Out, "No repositories match.") + return nil + } + + tp := ios.NewTablePrinter() + tp.AddHeader("FULL NAME", "VISIBILITY", "DESCRIPTION", "STARS") + for _, r := range repos { + visibility := "public" + if r.Private { + visibility = "private" + } + tp.AddRow(r.FullName, visibility, r.Description, fmt.Sprintf("%d", r.Stars)) + } + return tp.Render() +} + +// optionalBool returns a pointer when the user explicitly wants the positive +// filter (IsPrivate/IsArchived); nil means "no filter" to the SDK. +func optionalBool(v bool) *bool { + if !v { + return nil + } + return &v +} diff --git a/cmd/webhook.go b/cmd/webhook.go new file mode 100644 index 0000000..5b9a6cb --- /dev/null +++ b/cmd/webhook.go @@ -0,0 +1,293 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "code.gitea.io/sdk/gitea" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "github.com/spf13/cobra" +) + +var webhookCmd = &cobra.Command{ + Use: "webhook", + Aliases: []string{"webhooks", "hook"}, + Short: "Manage repository webhooks", + Long: "List, create, update, and delete webhooks attached to a repository.", +} + +var webhookListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List webhooks for a repository", + Example: ` # List webhooks on the current repository + fgj webhook list + + # List with JSON output + fgj webhook list --json`, + RunE: runWebhookList, +} + +var webhookCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a repository webhook", + Long: `Create a webhook that delivers events to . + +Event names follow the Gitea/Forgejo webhook event model: push, pull_request, +issues, issue_comment, release, create, delete, fork, wiki, repository, and others. +Omit --events to deliver only the default (push).`, + Example: ` # Create a Gitea-format push webhook + fgj webhook create https://example.com/hook + + # Multiple events and a content type + fgj webhook create https://ci.example.com/hook \ + --events push,pull_request,release \ + --content-type application/json \ + --secret "$HOOK_SECRET" + + # Slack-style webhook + fgj webhook create https://hooks.slack.com/services/XXX --type slack`, + Args: cobra.ExactArgs(1), + RunE: runWebhookCreate, +} + +var webhookUpdateCmd = &cobra.Command{ + Use: "update ", + Aliases: []string{"edit"}, + Short: "Update a repository webhook", + Long: "Update an existing webhook. Flags you omit are left unchanged.", + Args: cobra.ExactArgs(1), + Example: ` # Disable a webhook + fgj webhook update 12 --active=false + + # Change events and URL + fgj webhook update 12 --url https://new.example.com/hook --events push,release`, + RunE: runWebhookUpdate, +} + +var webhookDeleteCmd = &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm"}, + Short: "Delete a repository webhook", + Args: cobra.ExactArgs(1), + RunE: runWebhookDelete, +} + +func init() { + rootCmd.AddCommand(webhookCmd) + webhookCmd.AddCommand(webhookListCmd) + webhookCmd.AddCommand(webhookCreateCmd) + webhookCmd.AddCommand(webhookUpdateCmd) + webhookCmd.AddCommand(webhookDeleteCmd) + + addRepoFlags(webhookListCmd) + addJSONFlags(webhookListCmd, "Output as JSON") + + addRepoFlags(webhookCreateCmd) + webhookCreateCmd.Flags().String("type", "gitea", "Hook type (gitea, slack, discord, msteams, telegram, feishu, gogs)") + webhookCreateCmd.Flags().StringSlice("events", []string{"push"}, "Events to deliver (comma-separated)") + webhookCreateCmd.Flags().String("content-type", "application/json", "Content type (application/json or application/x-www-form-urlencoded)") + webhookCreateCmd.Flags().String("secret", "", "HMAC secret used to sign payloads") + webhookCreateCmd.Flags().String("branch-filter", "", "Glob filter for branches that trigger the hook") + webhookCreateCmd.Flags().String("authorization-header", "", "Authorization header value sent with each delivery") + webhookCreateCmd.Flags().Bool("active", true, "Whether the hook is active on creation") + + addRepoFlags(webhookUpdateCmd) + webhookUpdateCmd.Flags().String("url", "", "New target URL") + webhookUpdateCmd.Flags().StringSlice("events", nil, "New event list (replaces existing)") + webhookUpdateCmd.Flags().String("content-type", "", "New content type") + webhookUpdateCmd.Flags().String("secret", "", "New HMAC secret") + webhookUpdateCmd.Flags().String("branch-filter", "", "New branch filter") + webhookUpdateCmd.Flags().String("authorization-header", "", "New authorization header") + webhookUpdateCmd.Flags().Bool("active", true, "Enable or disable the hook") + + addRepoFlags(webhookDeleteCmd) + webhookDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") +} + +func runWebhookList(cmd *cobra.Command, args []string) error { + client, owner, name, err := newWebhookClient(cmd) + if err != nil { + return err + } + + hooks, _, err := client.ListRepoHooks(owner, name, gitea.ListHooksOptions{}) + if err != nil { + return fmt.Errorf("failed to list webhooks: %w", err) + } + + if wantJSON(cmd) { + return outputJSON(cmd, hooks) + } + + if len(hooks) == 0 { + fmt.Fprintln(ios.Out, "No webhooks.") + return nil + } + + tp := ios.NewTablePrinter() + tp.AddHeader("ID", "TYPE", "URL", "EVENTS", "ACTIVE") + for _, h := range hooks { + url := h.Config["url"] + active := "no" + if h.Active { + active = "yes" + } + tp.AddRow( + strconv.FormatInt(h.ID, 10), + h.Type, + url, + strings.Join(h.Events, ","), + active, + ) + } + return tp.Render() +} + +func runWebhookCreate(cmd *cobra.Command, args []string) error { + client, owner, name, err := newWebhookClient(cmd) + if err != nil { + return err + } + + url := args[0] + hookType, _ := cmd.Flags().GetString("type") + events, _ := cmd.Flags().GetStringSlice("events") + contentType, _ := cmd.Flags().GetString("content-type") + secret, _ := cmd.Flags().GetString("secret") + branchFilter, _ := cmd.Flags().GetString("branch-filter") + authHeader, _ := cmd.Flags().GetString("authorization-header") + active, _ := cmd.Flags().GetBool("active") + + opt := gitea.CreateHookOption{ + Type: gitea.HookType(hookType), + Config: map[string]string{ + "url": url, + "content_type": contentType, + }, + Events: events, + BranchFilter: branchFilter, + Active: active, + AuthorizationHeader: authHeader, + } + if secret != "" { + opt.Config["secret"] = secret + } + + hook, _, err := client.CreateRepoHook(owner, name, opt) + if err != nil { + return fmt.Errorf("failed to create webhook: %w", err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Created webhook %d (%s → %s)\n", cs.SuccessIcon(), hook.ID, hook.Type, url) + return nil +} + +func runWebhookUpdate(cmd *cobra.Command, args []string) error { + client, owner, name, err := newWebhookClient(cmd) + if err != nil { + return err + } + + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid webhook id %q: %w", args[0], err) + } + + opt := gitea.EditHookOption{} + + // Only set fields the user explicitly provided. + cfg := map[string]string{} + if url, _ := cmd.Flags().GetString("url"); url != "" { + cfg["url"] = url + } + if ct, _ := cmd.Flags().GetString("content-type"); ct != "" { + cfg["content_type"] = ct + } + if secret, _ := cmd.Flags().GetString("secret"); secret != "" { + cfg["secret"] = secret + } + if len(cfg) > 0 { + opt.Config = cfg + } + + if cmd.Flags().Changed("events") { + events, _ := cmd.Flags().GetStringSlice("events") + opt.Events = events + } + if cmd.Flags().Changed("branch-filter") { + bf, _ := cmd.Flags().GetString("branch-filter") + opt.BranchFilter = bf + } + if cmd.Flags().Changed("authorization-header") { + auth, _ := cmd.Flags().GetString("authorization-header") + opt.AuthorizationHeader = auth + } + if cmd.Flags().Changed("active") { + active, _ := cmd.Flags().GetBool("active") + opt.Active = &active + } + + if _, err := client.EditRepoHook(owner, name, id, opt); err != nil { + return fmt.Errorf("failed to update webhook %d: %w", id, err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Updated webhook %d\n", cs.SuccessIcon(), id) + return nil +} + +func runWebhookDelete(cmd *cobra.Command, args []string) error { + client, owner, name, err := newWebhookClient(cmd) + if err != nil { + return err + } + + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid webhook id %q: %w", args[0], err) + } + + skipConfirm, _ := cmd.Flags().GetBool("yes") + if !skipConfirm && ios.IsStdinTTY() { + answer, err := promptLine(fmt.Sprintf("Delete webhook %d in %s/%s? [y/N]: ", id, owner, name)) + if err != nil { + return err + } + if answer != "y" && answer != "Y" && answer != "yes" { + fmt.Fprintln(ios.ErrOut, "Cancelled.") + return nil + } + } + + if _, err := client.DeleteRepoHook(owner, name, id); err != nil { + return fmt.Errorf("failed to delete webhook %d: %w", id, err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Deleted webhook %d\n", cs.SuccessIcon(), id) + return nil +} + +func newWebhookClient(cmd *cobra.Command) (*api.Client, string, string, error) { + repo, _ := cmd.Flags().GetString("repo") + owner, name, err := parseRepo(repo) + if err != nil { + return nil, "", "", err + } + + cfg, err := config.Load() + if err != nil { + return nil, "", "", err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return nil, "", "", err + } + + return client, owner, name, nil +}