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:
parent
424fb63a8b
commit
4eeef2ceca
7 changed files with 551 additions and 23 deletions
59
CHANGELOG.md
59
CHANGELOG.md
|
|
@ -5,7 +5,64 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased] — 0.4.0
|
||||||
|
|
||||||
|
### Added — Repository Management
|
||||||
|
|
||||||
|
- `fgj branch {list,rename,delete}` — list branches with protection
|
||||||
|
status, rename branches, delete (protected branches are refused).
|
||||||
|
- `fgj repo delete` — type-to-confirm deletion; `--yes` for scripts.
|
||||||
|
- `fgj repo search` — search repositories on the current host by
|
||||||
|
query, topic, or description; filter by `--type`, `--owner`,
|
||||||
|
`--private`, `--archived`.
|
||||||
|
- `fgj repo migrate` — import from GitHub, GitLab, Gitea, Gogs, or
|
||||||
|
a plain Git remote; supports mirror mode and selective import of
|
||||||
|
wiki/labels/milestones/issues/PRs/releases/LFS.
|
||||||
|
- `fgj repo create-from-template` — scaffold a repo from a template,
|
||||||
|
with fine-grained control over what to copy (content, topics,
|
||||||
|
labels, webhooks, git hooks, avatar).
|
||||||
|
|
||||||
|
### Added — Pull Requests
|
||||||
|
|
||||||
|
- `fgj pr clean <n>` — delete the local branch created by
|
||||||
|
`fgj pr checkout`. Refuses if the PR is open (use `--force`) or
|
||||||
|
if the branch is currently checked out.
|
||||||
|
- `fgj pr approve <n>` / `fgj pr reject <n>` — shortcuts over
|
||||||
|
`pr review`; `reject` requires a body.
|
||||||
|
- `fgj pr review-comments <n>` — list inline review comments across
|
||||||
|
every review on a PR.
|
||||||
|
- `fgj pr resolve <comment-id>` / `fgj pr unresolve <comment-id>` —
|
||||||
|
mark review threads (un)resolved. Requires Forgejo 8.x+ /
|
||||||
|
Gitea 1.22+ server-side.
|
||||||
|
|
||||||
|
### Added — Notifications & Organizations
|
||||||
|
|
||||||
|
- `fgj notification list [--all]` / `fgj notification read <id>` —
|
||||||
|
list unread (default) or all notifications; mark individual
|
||||||
|
threads read.
|
||||||
|
- `fgj org {list,create,delete}` — list your orgs, create with
|
||||||
|
visibility/description, delete with confirmation.
|
||||||
|
- `fgj webhook {list,create,update,delete}` — full CRUD on repo
|
||||||
|
webhooks: gitea/slack/discord/etc. hook types, event selection,
|
||||||
|
content type, secret, branch filter, auth header.
|
||||||
|
|
||||||
|
### Added — Misc
|
||||||
|
|
||||||
|
- `fgj open [number] [--url]` — launch the repo / issue / PR page in
|
||||||
|
the default browser; auto-detects issue-vs-PR; prints URL on
|
||||||
|
non-TTY stdout or with `--url`.
|
||||||
|
- `fgj whoami` — show the authenticated user and host.
|
||||||
|
- `fgj admin user list` — admin-gated user enumeration.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `fgj actions secret create` stdin handling reworked. Adds `--body`
|
||||||
|
(inline) and `--body-file` (path or `-` for stdin) flags; interactive
|
||||||
|
prompts now use hidden input via `term.ReadPassword`; piped stdin is
|
||||||
|
read whole (prior `fmt.Scanln` broke on spaces/newlines and echoed
|
||||||
|
the typed value). Empty values are rejected.
|
||||||
|
- Gitea SDK bumped `v0.22.1` → `v0.23.2` (last release compatible with
|
||||||
|
Go 1.24; `v0.24+` requires Go 1.26).
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
|
|
|
||||||
25
README.md
25
README.md
|
|
@ -10,20 +10,25 @@
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Multi-instance support (works with any Forgejo or Gitea instance)
|
- Multi-instance support (works with any Forgejo or Gitea instance)
|
||||||
- Pull request management (create, list, view, merge, diff, comment, review)
|
- Pull requests — list, view, create, merge, close, reopen, edit, checkout, clean, diff, comment, review, approve, reject, checks, review-comments, resolve/unresolve
|
||||||
- Issue tracking (create, list, view, comment, close, labels)
|
- Issues — create, list, view, comment, close, reopen, edit, labels, dependencies
|
||||||
- Repository operations (view, list, create, edit, clone, fork)
|
- Repositories — view, list, create, edit, clone, fork, rename, delete, search, migrate, create-from-template
|
||||||
- Label management (list, create, edit, delete)
|
- Branches — list, rename, delete
|
||||||
- Milestone management (list, view, create, edit, delete)
|
- Labels / milestones / wiki — full CRUD
|
||||||
- Wiki page management (list, view, create, edit, delete)
|
- Organizations — list, create, delete
|
||||||
- Issue dependencies (`--add-dependency`, `--remove-dependency`)
|
- Webhooks — list, create, update, delete
|
||||||
- Forgejo Actions (workflow runs, watch/rerun/cancel, enable/disable, secrets, variables)
|
- Notifications — list (unread or all), mark read
|
||||||
- Releases (create, upload, delete)
|
- Forgejo Actions — workflow runs, watch/rerun/cancel, workflows enable/disable, secrets, variables
|
||||||
|
- Releases — create, upload, delete
|
||||||
|
- Admin — `admin user list` (admin-token only)
|
||||||
|
- `fgj open` — launch a repo / issue / PR in the browser
|
||||||
|
- `fgj whoami` — show the authenticated user on the current host
|
||||||
- Raw API access (`fgj api`) for arbitrary REST calls
|
- Raw API access (`fgj api`) for arbitrary REST calls
|
||||||
- Shell completions (bash, zsh, fish, PowerShell) and man pages
|
- Shell completions (bash, zsh, fish, PowerShell) and man pages
|
||||||
- JSON output (`--json`) for all list/view commands
|
- JSON output (`--json`, `--json-fields`, `--jq`) for all list/view commands
|
||||||
- Structured JSON error output (`--json-errors`) for machine consumption
|
- Structured JSON error output (`--json-errors`) for machine consumption
|
||||||
- Automatic repository and hostname detection from git context
|
- Automatic repository and hostname detection from git context
|
||||||
|
- Directory-scoped host defaults (`match_dirs`)
|
||||||
- Secure authentication with personal access tokens
|
- Secure authentication with personal access tokens
|
||||||
- XDG Base Directory compliant config location
|
- XDG Base Directory compliant config location
|
||||||
- AI coding agent friendly
|
- AI coding agent friendly
|
||||||
|
|
|
||||||
|
|
@ -251,12 +251,26 @@ var actionsSecretListCmd = &cobra.Command{
|
||||||
var actionsSecretCreateCmd = &cobra.Command{
|
var actionsSecretCreateCmd = &cobra.Command{
|
||||||
Use: "create <name>",
|
Use: "create <name>",
|
||||||
Short: "Create or update a repository secret",
|
Short: "Create or update a repository secret",
|
||||||
Long: "Create or update a secret for Forgejo Actions. The secret value will be read from stdin.",
|
Long: `Create or update a secret for Forgejo Actions.
|
||||||
Example: ` # Create a secret (will prompt for value)
|
|
||||||
|
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
|
fgj actions secret create DEPLOY_TOKEN
|
||||||
|
|
||||||
# Create a secret for a specific repo
|
# Pipe a value
|
||||||
fgj actions secret create API_KEY -R owner/repo`,
|
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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runActionsSecretCreate,
|
RunE: runActionsSecretCreate,
|
||||||
}
|
}
|
||||||
|
|
@ -397,6 +411,8 @@ func init() {
|
||||||
// Add flags for secret commands
|
// Add flags for secret commands
|
||||||
addRepoFlags(actionsSecretListCmd)
|
addRepoFlags(actionsSecretListCmd)
|
||||||
addRepoFlags(actionsSecretCreateCmd)
|
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)
|
addRepoFlags(actionsSecretDeleteCmd)
|
||||||
|
|
||||||
// Add flags for variable commands
|
// Add flags for variable commands
|
||||||
|
|
@ -1254,12 +1270,9 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
|
||||||
|
|
||||||
secretName := args[0]
|
secretName := args[0]
|
||||||
|
|
||||||
// Read secret value from stdin
|
secretValue, err := readSecretValue(cmd, secretName)
|
||||||
fmt.Fprint(ios.ErrOut, "Enter secret value: ")
|
|
||||||
var secretValue string
|
|
||||||
_, err = fmt.Scanln(&secretValue)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read secret value: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
opt := gitea.CreateSecretOption{
|
opt := gitea.CreateSecretOption{
|
||||||
|
|
@ -1267,12 +1280,12 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
|
||||||
Data: secretValue,
|
Data: secretValue,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = client.CreateRepoActionSecret(owner, name, opt)
|
if _, err := client.CreateRepoActionSecret(owner, name, opt); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create secret: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
102
cmd/pr_approve_reject.go
Normal file
102
cmd/pr_approve_reject.go
Normal 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
170
cmd/repo_migrate.go
Normal 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
113
cmd/repo_template.go
Normal 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
68
cmd/secret_input.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue