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:
parent
17ca49d0c5
commit
adccd6f6f7
6 changed files with 871 additions and 0 deletions
85
cmd/admin.go
Normal file
85
cmd/admin.go
Normal 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
98
cmd/pr_clean.go
Normal 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
171
cmd/pr_review_comments.go
Normal 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
78
cmd/repo_delete.go
Normal 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
146
cmd/repo_search.go
Normal 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
293
cmd/webhook.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue