feat: add webhook, repo delete/search, admin, pr clean/resolve/review-comments

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.
This commit is contained in:
sid 2026-04-19 22:01:29 -06:00
parent 17ca49d0c5
commit adccd6f6f7
6 changed files with 871 additions and 0 deletions

85
cmd/admin.go Normal file
View file

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

98
cmd/pr_clean.go Normal file
View file

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

171
cmd/pr_review_comments.go Normal file
View file

@ -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 <number>",
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 <comment-id>",
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 <comment-id>",
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
}

78
cmd/repo_delete.go Normal file
View file

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

146
cmd/repo_search.go Normal file
View file

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

293
cmd/webhook.go Normal file
View file

@ -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 <url>",
Short: "Create a repository webhook",
Long: `Create a webhook that delivers events to <url>.
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 <id>",
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 <id>",
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
}