feat: pr approve/reject, repo migrate/template, secret stdin fix, docs

- fgj pr approve / pr reject: thin shortcuts over 'pr review --approve'
  and '--request-changes'. Reject requires a body.
- fgj repo migrate: wrap SDK MigrateRepo. Supports git, github, gitlab,
  gitea, gogs services; mirror mode with --mirror-interval; selective
  import (wiki/labels/milestones/issues/PRs/releases/LFS); auth via
  --auth-token or --auth-username/--auth-password. Defaults owner to
  the authenticated user.
- fgj repo create-from-template: wrap SDK CreateRepoFromTemplate with
  fine-grained --with-{content,topics,labels,webhooks,git-hooks,avatar}
  flags. Template is owner/name; new repo defaults to the current user.
- Rework 'fgj actions secret create' input. New cmd/secret_input.go
  resolves values from --body, --body-file (path or '-'), hidden TTY
  prompt via term.ReadPassword, or piped stdin. Trims trailing
  whitespace, rejects empty values. Replaces fmt.Scanln which broke on
  spaces/newlines and echoed input.
- CHANGELOG: v0.4.0 Unreleased section documenting all additions,
  changes, and development items.
- README: updated feature list with new commands.
This commit is contained in:
sid 2026-04-19 22:14:43 -06:00
parent 424fb63a8b
commit 4eeef2ceca
7 changed files with 551 additions and 23 deletions

View file

@ -251,12 +251,26 @@ var actionsSecretListCmd = &cobra.Command{
var actionsSecretCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create or update a repository secret",
Long: "Create or update a secret for Forgejo Actions. The secret value will be read from stdin.",
Example: ` # Create a secret (will prompt for value)
Long: `Create or update a secret for Forgejo Actions.
The secret value is read from the first available source:
1. --body <value>
2. --body-file <path> (use "-" for stdin)
3. interactive prompt (hidden input) if stdin is a TTY
4. stdin (when piped)
Trailing newlines are trimmed. Empty values are rejected.`,
Example: ` # Interactive (hidden prompt)
fgj actions secret create DEPLOY_TOKEN
# Create a secret for a specific repo
fgj actions secret create API_KEY -R owner/repo`,
# Pipe a value
op read op://vault/github/token | fgj actions secret create GH_TOKEN --body-file -
# Read from a file
fgj actions secret create TLS_KEY --body-file ./server.key
# Inline (visible in shell history use sparingly)
fgj actions secret create DEBUG_FLAG --body "on"`,
Args: cobra.ExactArgs(1),
RunE: runActionsSecretCreate,
}
@ -397,6 +411,8 @@ func init() {
// Add flags for secret commands
addRepoFlags(actionsSecretListCmd)
addRepoFlags(actionsSecretCreateCmd)
actionsSecretCreateCmd.Flags().String("body", "", "Secret value (visible in shell history)")
actionsSecretCreateCmd.Flags().String("body-file", "", "Read secret value from file, or '-' for stdin")
addRepoFlags(actionsSecretDeleteCmd)
// Add flags for variable commands
@ -1254,12 +1270,9 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
secretName := args[0]
// Read secret value from stdin
fmt.Fprint(ios.ErrOut, "Enter secret value: ")
var secretValue string
_, err = fmt.Scanln(&secretValue)
secretValue, err := readSecretValue(cmd, secretName)
if err != nil {
return fmt.Errorf("failed to read secret value: %w", err)
return err
}
opt := gitea.CreateSecretOption{
@ -1267,12 +1280,12 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
Data: secretValue,
}
_, err = client.CreateRepoActionSecret(owner, name, opt)
if err != nil {
if _, err := client.CreateRepoActionSecret(owner, name, opt); err != nil {
return fmt.Errorf("failed to create secret: %w", err)
}
fmt.Fprintf(ios.Out, "Secret '%s' created successfully\n", secretName)
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Secret %q created\n", cs.SuccessIcon(), secretName)
return nil
}

102
cmd/pr_approve_reject.go Normal file
View file

@ -0,0 +1,102 @@
package cmd
import (
"fmt"
"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 prApproveCmd = &cobra.Command{
Use: "approve <number>",
Aliases: []string{"lgtm"},
Short: "Approve a pull request",
Long: "Shortcut for 'fgj pr review <n> --approve'. Body is optional.",
Example: ` # Approve with no body
fgj pr approve 42
# Approve with a message
fgj pr approve 42 -b "Thanks, shipping."`,
Args: cobra.ExactArgs(1),
RunE: runPRApproveReject(gitea.ReviewStateApproved, "approved", false),
}
var prRejectCmd = &cobra.Command{
Use: "reject <number>",
Short: "Request changes on a pull request",
Long: "Shortcut for 'fgj pr review <n> --request-changes'. Body is required.",
Example: ` # Reject with explanation
fgj pr reject 42 -b "See the inline comments on auth.go"
# Reject with a longer message from a file
fgj pr reject 42 --body-file feedback.md`,
Args: cobra.ExactArgs(1),
RunE: runPRApproveReject(gitea.ReviewStateRequestChanges, "reviewed with requested changes", true),
}
func init() {
prCmd.AddCommand(prApproveCmd)
prCmd.AddCommand(prRejectCmd)
for _, c := range []*cobra.Command{prApproveCmd, prRejectCmd} {
c.Flags().StringP("repo", "R", "", "Repository in owner/name format")
c.Flags().StringP("body", "b", "", "Review body/message")
c.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)")
addJSONFlags(c, "Output created review as JSON")
}
}
func runPRApproveReject(state gitea.ReviewStateType, verb string, requireBody bool) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
prNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid pull request number: %w", err)
}
body, err := readBody(cmd)
if err != nil {
return err
}
if requireBody && body == "" {
return fmt.Errorf("a body is required (use --body or --body-file)")
}
repo, _ := cmd.Flags().GetString("repo")
owner, name, err := parseRepo(repo)
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
}
ios.StartSpinner("Submitting review...")
review, _, err := client.CreatePullReview(owner, name, prNumber, gitea.CreatePullReviewOptions{
State: state,
Body: body,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to submit review: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, review)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s PR #%d %s\n", cs.SuccessIcon(), prNumber, verb)
if review.HTMLURL != "" {
fmt.Fprintf(ios.Out, "View at: %s\n", review.HTMLURL)
}
return nil
}
}

170
cmd/repo_migrate.go Normal file
View file

@ -0,0 +1,170 @@
package cmd
import (
"fmt"
"strings"
"code.gitea.io/sdk/gitea"
"github.com/spf13/cobra"
)
var repoMigrateCmd = &cobra.Command{
Use: "migrate <clone-url>",
Aliases: []string{"m"},
Short: "Migrate a repository from an external service",
Long: `Import a repository from GitHub, GitLab, Gogs, Gitea, or a plain Git
remote. By default the migration is a one-shot import; pass --mirror
to keep syncing on an interval.
Authentication for the source repo is passed via --auth-token or
--auth-username + --auth-password. Neither is stored after the
migration completes on the server side.`,
Example: ` # Migrate a GitHub repo to this user's account
fgj repo migrate https://github.com/cli/cli \
--name gh-mirror --service github --auth-token "$GH_TOKEN"
# Mirror a plain Git remote into an org
fgj repo migrate https://example.com/project.git \
--name project --owner infrastructure --mirror --mirror-interval 8h
# Migrate with all content kinds
fgj repo migrate https://gitea.com/user/repo \
--name repo --service gitea --auth-token "$TOKEN" \
--wiki --labels --milestones --issues --pulls --releases --lfs`,
Args: cobra.ExactArgs(1),
RunE: runRepoMigrate,
}
func init() {
repoCmd.AddCommand(repoMigrateCmd)
repoMigrateCmd.Flags().String("name", "", "Name for the new repository (required)")
repoMigrateCmd.Flags().String("owner", "", "Owner (user or org) for the new repository (defaults to you)")
repoMigrateCmd.Flags().String("service", "git", "Source service: git, github, gitlab, gitea, gogs")
repoMigrateCmd.Flags().StringP("description", "d", "", "Description of the new repository")
repoMigrateCmd.Flags().String("auth-token", "", "Auth token for the source repo (preferred over username/password)")
repoMigrateCmd.Flags().String("auth-username", "", "Auth username for the source repo")
repoMigrateCmd.Flags().String("auth-password", "", "Auth password for the source repo")
repoMigrateCmd.Flags().Bool("private", false, "Make the new repository private")
repoMigrateCmd.Flags().Bool("mirror", false, "Mirror the source (keep syncing) instead of one-shot import")
repoMigrateCmd.Flags().String("mirror-interval", "", "Mirror sync interval (e.g. 8h, 24h); only with --mirror")
repoMigrateCmd.Flags().Bool("wiki", false, "Include wiki in the migration")
repoMigrateCmd.Flags().Bool("labels", false, "Include labels")
repoMigrateCmd.Flags().Bool("milestones", false, "Include milestones")
repoMigrateCmd.Flags().Bool("issues", false, "Include issues")
repoMigrateCmd.Flags().Bool("pulls", false, "Include pull requests")
repoMigrateCmd.Flags().Bool("releases", false, "Include releases")
repoMigrateCmd.Flags().Bool("lfs", false, "Include Git LFS content")
repoMigrateCmd.Flags().String("lfs-endpoint", "", "Explicit Git LFS server URL")
_ = repoMigrateCmd.MarkFlagRequired("name")
addJSONFlags(repoMigrateCmd, "Output created repository as JSON")
}
func runRepoMigrate(cmd *cobra.Command, args []string) error {
cloneURL := args[0]
repoName, _ := cmd.Flags().GetString("name")
if strings.TrimSpace(repoName) == "" {
return fmt.Errorf("--name is required")
}
owner, _ := cmd.Flags().GetString("owner")
serviceStr, _ := cmd.Flags().GetString("service")
service, err := parseGitService(serviceStr)
if err != nil {
return err
}
client, err := loadClient()
if err != nil {
return err
}
// Default owner = authenticated user.
if owner == "" {
user, _, err := client.GetMyUserInfo()
if err != nil {
return fmt.Errorf("failed to resolve current user (pass --owner to override): %w", err)
}
owner = user.UserName
}
description, _ := cmd.Flags().GetString("description")
authToken, _ := cmd.Flags().GetString("auth-token")
authUser, _ := cmd.Flags().GetString("auth-username")
authPass, _ := cmd.Flags().GetString("auth-password")
private, _ := cmd.Flags().GetBool("private")
mirror, _ := cmd.Flags().GetBool("mirror")
mirrorInterval, _ := cmd.Flags().GetString("mirror-interval")
if mirrorInterval != "" && !mirror {
return fmt.Errorf("--mirror-interval requires --mirror")
}
wiki, _ := cmd.Flags().GetBool("wiki")
labels, _ := cmd.Flags().GetBool("labels")
milestones, _ := cmd.Flags().GetBool("milestones")
issues, _ := cmd.Flags().GetBool("issues")
pulls, _ := cmd.Flags().GetBool("pulls")
releases, _ := cmd.Flags().GetBool("releases")
lfs, _ := cmd.Flags().GetBool("lfs")
lfsEndpoint, _ := cmd.Flags().GetString("lfs-endpoint")
opt := gitea.MigrateRepoOption{
RepoName: repoName,
RepoOwner: owner,
CloneAddr: cloneURL,
Service: service,
AuthUsername: authUser,
AuthPassword: authPass,
AuthToken: authToken,
Private: private,
Description: description,
Mirror: mirror,
MirrorInterval: mirrorInterval,
Wiki: wiki,
Labels: labels,
Milestones: milestones,
Issues: issues,
PullRequests: pulls,
Releases: releases,
LFS: lfs,
LFSEndpoint: lfsEndpoint,
}
ios.StartSpinner("Starting migration...")
repo, _, err := client.MigrateRepo(opt)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("migration failed: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, repo)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Migrated to %s\n", cs.SuccessIcon(), repo.FullName)
if repo.HTMLURL != "" {
fmt.Fprintf(ios.Out, " %s\n", repo.HTMLURL)
}
return nil
}
func parseGitService(s string) (gitea.GitServiceType, error) {
switch strings.ToLower(s) {
case "", "git", "plain":
return gitea.GitServicePlain, nil
case "github":
return gitea.GitServiceGithub, nil
case "gitlab":
return gitea.GitServiceGitlab, nil
case "gitea", "forgejo":
return gitea.GitServiceGitea, nil
case "gogs":
return gitea.GitServiceGogs, nil
default:
return "", fmt.Errorf("unknown --service %q (expected: git, github, gitlab, gitea, gogs)", s)
}
}

113
cmd/repo_template.go Normal file
View file

@ -0,0 +1,113 @@
package cmd
import (
"fmt"
"strings"
"code.gitea.io/sdk/gitea"
"github.com/spf13/cobra"
)
var repoCreateFromTemplateCmd = &cobra.Command{
Use: "create-from-template <template-owner/template-name> <new-name>",
Aliases: []string{"ct"},
Short: "Create a repository from a template",
Long: `Scaffold a new repository based on an existing template repository.
By default this copies only the default branch content. Pass --with-<kind>
flags to include topics, labels, webhooks, git hooks, and other template
metadata.`,
Example: ` # Create a new repo under your account from a template
fgj repo create-from-template org/template-name my-new-repo
# Target a specific owner and make it private
fgj repo create-from-template org/template-name new-repo --owner myorg --private
# Copy everything: content, topics, labels, webhooks, hooks, avatar
fgj repo create-from-template org/template-name new-repo \
--with-content --with-topics --with-labels \
--with-webhooks --with-git-hooks --with-avatar`,
Args: cobra.ExactArgs(2),
RunE: runRepoCreateFromTemplate,
}
func init() {
repoCmd.AddCommand(repoCreateFromTemplateCmd)
repoCreateFromTemplateCmd.Flags().String("owner", "", "Owner (user or org) for the new repository (defaults to you)")
repoCreateFromTemplateCmd.Flags().StringP("description", "d", "", "Description for the new repository")
repoCreateFromTemplateCmd.Flags().Bool("private", false, "Make the new repository private")
repoCreateFromTemplateCmd.Flags().Bool("with-content", true, "Include default branch content from the template")
repoCreateFromTemplateCmd.Flags().Bool("with-topics", false, "Include topics from the template")
repoCreateFromTemplateCmd.Flags().Bool("with-labels", false, "Include labels from the template")
repoCreateFromTemplateCmd.Flags().Bool("with-webhooks", false, "Include webhooks from the template")
repoCreateFromTemplateCmd.Flags().Bool("with-git-hooks", false, "Include git hooks from the template")
repoCreateFromTemplateCmd.Flags().Bool("with-avatar", false, "Include the template repo's avatar")
addJSONFlags(repoCreateFromTemplateCmd, "Output created repository as JSON")
}
func runRepoCreateFromTemplate(cmd *cobra.Command, args []string) error {
templateSlug := args[0]
newName := args[1]
parts := strings.SplitN(templateSlug, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return fmt.Errorf("template must be in owner/name format (got %q)", templateSlug)
}
templateOwner, templateName := parts[0], parts[1]
client, err := loadClient()
if err != nil {
return err
}
owner, _ := cmd.Flags().GetString("owner")
if owner == "" {
user, _, err := client.GetMyUserInfo()
if err != nil {
return fmt.Errorf("failed to resolve current user (pass --owner to override): %w", err)
}
owner = user.UserName
}
description, _ := cmd.Flags().GetString("description")
private, _ := cmd.Flags().GetBool("private")
withContent, _ := cmd.Flags().GetBool("with-content")
withTopics, _ := cmd.Flags().GetBool("with-topics")
withLabels, _ := cmd.Flags().GetBool("with-labels")
withWebhooks, _ := cmd.Flags().GetBool("with-webhooks")
withGitHooks, _ := cmd.Flags().GetBool("with-git-hooks")
withAvatar, _ := cmd.Flags().GetBool("with-avatar")
opt := gitea.CreateRepoFromTemplateOption{
Owner: owner,
Name: newName,
Description: description,
Private: private,
GitContent: withContent,
Topics: withTopics,
Labels: withLabels,
Webhooks: withWebhooks,
GitHooks: withGitHooks,
Avatar: withAvatar,
}
ios.StartSpinner("Creating from template...")
repo, _, err := client.CreateRepoFromTemplate(templateOwner, templateName, opt)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("template instantiation failed: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, repo)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Created %s from template %s\n", cs.SuccessIcon(), repo.FullName, templateSlug)
if repo.HTMLURL != "" {
fmt.Fprintf(ios.Out, " %s\n", repo.HTMLURL)
}
return nil
}

68
cmd/secret_input.go Normal file
View file

@ -0,0 +1,68 @@
package cmd
import (
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// readSecretValue resolves the value for a secret/token flag from, in order:
// 1. --body (inline; visible in shell history)
// 2. --body-file (file path, or "-" for stdin)
// 3. interactive TTY prompt (hidden)
// 4. piped stdin
//
// Trailing whitespace (including the final newline common in heredocs and
// `echo ... | fgj ...`) is trimmed. An empty resolved value is rejected so we
// never silently write an empty secret.
func readSecretValue(cmd *cobra.Command, label string) (string, error) {
if v, _ := cmd.Flags().GetString("body"); v != "" {
return strings.TrimRight(v, "\r\n"), nil
}
if path, _ := cmd.Flags().GetString("body-file"); path != "" {
var raw []byte
var err error
if path == "-" {
raw, err = io.ReadAll(ios.In)
} else {
raw, err = os.ReadFile(path)
}
if err != nil {
return "", fmt.Errorf("failed to read secret from %q: %w", path, err)
}
value := strings.TrimRight(string(raw), "\r\n")
if value == "" {
return "", fmt.Errorf("secret value from %q is empty", path)
}
return value, nil
}
if ios.IsStdinTTY() {
fmt.Fprintf(ios.ErrOut, "Value for %s: ", label)
pw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(ios.ErrOut)
if err != nil {
return "", fmt.Errorf("failed to read secret: %w", err)
}
value := strings.TrimRight(string(pw), "\r\n")
if value == "" {
return "", fmt.Errorf("secret value is empty")
}
return value, nil
}
raw, err := io.ReadAll(ios.In)
if err != nil {
return "", fmt.Errorf("failed to read secret from stdin: %w", err)
}
value := strings.TrimRight(string(raw), "\r\n")
if value == "" {
return "", fmt.Errorf("secret value from stdin is empty")
}
return value, nil
}