Skip to content

Commit

Permalink
feat: complete refactor
Browse files Browse the repository at this point in the history
* Remove redundant global flags and properly handle viper-mapping for
  flags used across multiple commands.
* Add `matching` command to find heads/tags matching a commitish.
* Fallback to retrieving the GitHub CLI auth token from `gh auth token`;
  disable with `--no-cli-token=false` or `GHUP_NO_CLI_TOKEN=0`.
* Fix automatic resolution of owner from origin to respect the current
  branch's remote rather than simply taking first github.com remote,
  which was subject to errors due to alpha-sorting.
  • Loading branch information
isometry committed Feb 5, 2025
1 parent 9fa2e88 commit 3c0a550
Show file tree
Hide file tree
Showing 18 changed files with 819 additions and 484 deletions.
123 changes: 60 additions & 63 deletions cmd/content.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package cmd

import (
"context"
"encoding/base64"
"fmt"
"os"

"github.com/apex/log"
"github.com/go-git/go-git/v5/plumbing"
Expand All @@ -17,64 +17,57 @@ import (
)

var contentCmd = &cobra.Command{
Use: "content [flags] [<file-spec> ...]",
Short: "Manage content via the GitHub V4 API",
Args: cobra.ArbitraryArgs,
PreRunE: validateFlags,
RunE: runContentCmd,
Use: "content [flags] [<file-spec> ...]",
Short: "Manage content via the GitHub V4 API",
Args: cobra.ArbitraryArgs,
RunE: runContentCmd,
}

func init() {
contentCmd.Flags().Bool("create-branch", true, "create missing target branch")
viper.BindPFlag("create-branch", contentCmd.Flags().Lookup("create-branch"))
viper.BindEnv("create-branch", "GHUP_CREATE_BRANCH")
defaultsOnce.Do(loadDefaults)

contentCmd.Flags().String("pr-title", "", "create pull request iff target branch is created and title is specified")
viper.BindPFlag("pr-title", contentCmd.Flags().Lookup("pr-title"))
viper.BindEnv("pr-title", "GHUP_PR_TITLE")
flags := contentCmd.Flags()

contentCmd.Flags().String("pr-body", "", "pull request body")
viper.BindPFlag("pr-body", contentCmd.Flags().Lookup("pr-body"))
viper.BindEnv("pr-body", "GHUP_PR_BODY")
flags.StringSliceP("update", "u", []string{}, "`file-spec` to update")
flags.StringSliceP("delete", "d", []string{}, "`file-path` to delete")
flags.StringP("separator", "s", ":", "file-spec separator")
addCommitMessageFlags(flags)
addBranchFlag(flags)
flags.Bool("create-branch", true, "create missing target branch")
flags.StringP("base-branch", "B", "", `base branch `+"`name`"+` (default: "[remote-default-branch])"`)
addPullRequestFlags(flags)
// addDryRunFlag(flags)
addForceFlag(flags)

contentCmd.Flags().Bool("pr-draft", false, "create pull request in draft mode")
viper.BindPFlag("pr-draft", contentCmd.Flags().Lookup("pr-draft"))
viper.BindEnv("pr-draft", "GHUP_PR_DRAFT")

contentCmd.Flags().String("base-branch", "", `base branch `+"`name`"+` (default: "[remote-default-branch])"`)
viper.BindPFlag("base-branch", contentCmd.Flags().Lookup("base-branch"))
viper.BindEnv("base-branch", "GHUP_BASE_BRANCH")

contentCmd.Flags().StringP("separator", "s", ":", "file-spec separator")
viper.BindPFlag("separator", contentCmd.Flags().Lookup("separator"))

contentCmd.Flags().StringSliceP("update", "u", []string{}, "`file-spec` to update")
viper.BindPFlag("update", contentCmd.Flags().Lookup("update"))

contentCmd.Flags().StringSliceP("delete", "d", []string{}, "`file-path` to delete")
viper.BindPFlag("delete", contentCmd.Flags().Lookup("delete"))

contentCmd.Flags().SortFlags = false
flags.SetNormalizeFunc(normalizeFlags)
flags.SortFlags = false

rootCmd.AddCommand(contentCmd)
}

func runContentCmd(cmd *cobra.Command, args []string) (err error) {
ctx := context.Background()

client, err := remote.NewTokenClient(ctx, viper.GetString("token"))
if err != nil {
return fmt.Errorf("NewTokenClient: %w", err)
}
ctx := cmd.Context()

separator := viper.GetString("separator")
if len(separator) < 1 {
return fmt.Errorf("invalid separator")
}

repoInfo, err := client.GetRepositoryInfo(owner, repo, branch)
repo := remote.Repo{
Owner: viper.GetString("owner"),
Name: viper.GetString("repo"),
}
branch := viper.GetString("branch")
force := viper.GetBool("force")

client, err := remote.NewClient(ctx, repo, token)
if err != nil {
return fmt.Errorf("GetRepositoryInfo(%s, %s, %s): %w", owner, repo, branch, err)
return fmt.Errorf("NewClient(%s): %w", repo, err)
}

repoInfo, err := client.GetRepositoryInfo(branch)
if err != nil {
return fmt.Errorf("GetRepositoryInfo(%s, %s): %w", repo, branch, err)
}

if repoInfo.IsEmpty {
Expand All @@ -95,9 +88,9 @@ func runContentCmd(cmd *cobra.Command, args []string) (err error) {
targetOid = repoInfo.DefaultBranch.Commit
log.Infof("defaulting base branch to %q", baseBranch)
} else {
targetOid, err = client.GetRefOidV4(owner, repo, baseBranch)
targetOid, err = client.GetRefOidV4(baseBranch)
if err != nil {
return fmt.Errorf("GetRefOidV4(%s, %s, %s): %w", owner, repo, baseBranch, err)
return fmt.Errorf("GetRefOidV4(%s, %s): %w", repo, baseBranch, err)
}
}

Expand All @@ -113,43 +106,46 @@ func runContentCmd(cmd *cobra.Command, args []string) (err error) {
newBranch = true
}

updateFiles := append(args, viper.GetStringSlice("update")...)
deleteFiles := viper.GetStringSlice("delete")
updateFiles := make(map[string]githubv4.FileAddition, 0)
deleteFiles := make(map[string]githubv4.FileDeletion, 0)

additions := []githubv4.FileAddition{}
deletions := []githubv4.FileDeletion{}

for _, arg := range updateFiles {
target, content, err := local.GetLocalFileContent(arg, separator)
for spec := range util.SliceChain(viper.GetStringSlice("update"), args) {
source, target, err := local.SplitUpdateSpec(spec, separator)
if err != nil {
return fmt.Errorf("GetLocalFileContent(%s, %s): %w", arg, separator, err)
return fmt.Errorf("GetLocalFileContent(%s, %s): %w", spec, separator, err)
}
content, err := os.ReadFile(source)
local_hash := plumbing.ComputeHash(plumbing.BlobObject, content).String()
remote_hash := client.GetFileHashV4(owner, repo, branch, target)
remote_hash := client.GetFileHashV4(branch, target)
log.Infof("local: %s, remote: %s", local_hash, remote_hash)
if local_hash != remote_hash || force {
log.Infof("%q queued for addition", target)
additions = append(additions, githubv4.FileAddition{
updateFiles[target] = githubv4.FileAddition{
Path: githubv4.String(target),
Contents: githubv4.Base64String(base64.StdEncoding.EncodeToString(content)),
})
}
} else {
log.Infof("%q (%s) on target branch: skipping addition", target, remote_hash)
}
}

for _, target := range deleteFiles {
remote_hash := client.GetFileHashV4(owner, repo, branch, target)
for _, path := range viper.GetStringSlice("delete") {
deleteFiles[path] = githubv4.FileDeletion{}
remote_hash := client.GetFileHashV4(branch, path)
if remote_hash != "" || force {
log.Infof("%q queued for deletion", target)
deletions = append(deletions, githubv4.FileDeletion{
Path: githubv4.String(target),
})
log.Infof("%q queued for deletion", path)
deleteFiles[path] = githubv4.FileDeletion{
Path: githubv4.String(path),
}
} else {
log.Infof("%q absent on target branch: skipping deletion", target)
log.Infof("%q absent on target branch: skipping deletion", path)
}

}

additions := util.MapValues(updateFiles)
deletions := util.MapValues(deleteFiles)

if len(additions) == 0 && len(deletions) == 0 {
log.Warn("nothing to do")
return nil
Expand All @@ -159,13 +155,14 @@ func runContentCmd(cmd *cobra.Command, args []string) (err error) {
Additions: &additions,
Deletions: &deletions,
}

log.Debugf("Additions: %+v", additions)
log.Debugf("Deletions: %+v", deletions)

message = util.BuildCommitMessage()
message := util.BuildCommitMessage()

input := githubv4.CreateCommitOnBranchInput{
Branch: remote.CommittableBranch(owner, repo, branch),
Branch: remote.CommittableBranch(repo, branch),
Message: remote.CommitMessage(message),
ExpectedHeadOid: targetOid,
FileChanges: &changes,
Expand Down
15 changes: 15 additions & 0 deletions cmd/defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package cmd

import (
"sync"

"github.com/creasty/defaults"
)

var defaultsOnce sync.Once

func loadDefaults() {
if err := defaults.Set(&localRepo); err != nil {
panic(err)
}
}
125 changes: 125 additions & 0 deletions cmd/flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package cmd

import (
"cmp"
"errors"
"fmt"
"strings"

"github.com/nexthink-oss/ghup/internal/util"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)

type FlagConfig struct {
Env []string
}

type FlagConfigMap map[string]FlagConfig

// flagConfigMap maps flag names to environment variable bindings.
// The first environment variable that is set will be used.
// Flags not in the map are still bound to `GHUP_<FLAG_NAME>`.
var flagConfigMap = FlagConfigMap{
"token": {Env: []string{"GHUP_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"}},
"owner": {Env: []string{"GHUP_OWNER", "GITHUB_OWNER", "GITHUB_REPOSITORY_OWNER"}},
"repo": {Env: []string{"GHUP_REPO", "GITHUB_REPO", "GITHUB_REPOSITORY_NAME"}},
"branch": {Env: []string{"GHUP_BRANCH", "CHANGE_BRANCH", "BRANCH_NAME", "GIT_BRANCH"}},
"ref": {Env: []string{"GHUP_REF", "GITHUB_REF"}},
"author-trailer": {Env: []string{"GHUP_AUTHOR_TRAILER", "GHUP_TRAILER_KEY"}},
"user-name": {Env: []string{"GHUP_TRAILER_NAME", "GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME"}},
"user-email": {Env: []string{"GHUP_TRAILER_EMAIL", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL"}},
"pr-title": {Env: []string{"GHUP_PR_TITLE"}},
"pr-body": {Env: []string{"GHUP_PR_BODY"}},
"pr-draft": {Env: []string{"GHUP_PR_DRAFT"}},
}

func bindEnvFlag(flag *pflag.Flag) {
name := flag.Name
if flagConfig, ok := flagConfigMap[name]; ok && len(flagConfig.Env) > 0 {
args := make([]string, 1+len(flagConfig.Env))
args[0] = name
copy(args[1:], flagConfig.Env)
viper.BindEnv(args...)
}
}

func normalizeFlags(_ *pflag.FlagSet, name string) pflag.NormalizedName {
// Normalize 'foo.bar' to 'foo-bar'
name = strings.Replace(name, ".", "-", -1)

// Support alternative flag names
switch name {
case "name":
name = "repo"
break
case "author-trailer":
name = "user-trailer"
break
case "author-name":
name = "user-name"
break
case "author-email":
name = "user-email"
break
}

return pflag.NormalizedName(name)
}

// processFlags binds flags to viper and checks mandatory flags are set
func processFlags(cmd *cobra.Command, args []string) error {
flags := cmd.Flags()
viper.BindPFlags(flags)
flags.VisitAll(bindEnvFlag)

// bindEnvRootFlags()

errs := make([]error, 0)

token = cmp.Or[string](viper.GetString("token"), util.GetCliAuthToken())
if token == "" {
errs = append(errs, fmt.Errorf("token is required"))
}

if viper.GetString("owner") == "" {
errs = append(errs, fmt.Errorf("owner is required"))
}

if viper.GetString("repo") == "" {
errs = append(errs, fmt.Errorf("repo is required"))
}

if flags.Lookup("branch") != nil && viper.GetString("branch") == "" {
errs = append(errs, fmt.Errorf("branch is required"))
}

return errors.Join(errs...)
}

func addDryRunFlag(flagSet *pflag.FlagSet) {
flagSet.BoolP("dry-run", "n", false, "dry-run mode")
}

func addForceFlag(flagSet *pflag.FlagSet) {
flagSet.BoolP("force", "f", false, "force operation")
}

func addBranchFlag(flagSet *pflag.FlagSet) {
flagSet.StringP("branch", "b", localRepo.Branch, "target branch `name`")
}

func addCommitMessageFlags(flagSet *pflag.FlagSet) {
flagSet.StringP("message", "m", "Commit via API", "commit message")
flagSet.String("author-trailer", "Co-Authored-By", "`key` for commit author trailer (blank to disable)")
flagSet.String("user-name", localRepo.User.Name, "`name` for commit author trailer")
flagSet.String("user-email", localRepo.User.Email, "`email` for commit author trailer")
flagSet.StringToString("trailer", nil, "extra `key=value` commit trailers")
}

func addPullRequestFlags(flagSet *pflag.FlagSet) {
flagSet.String("pr-title", "", "pull request title")
flagSet.String("pr-body", "", "pull request body")
flagSet.Bool("pr-draft", false, "create pull request in draft mode")
}
Loading

0 comments on commit 3c0a550

Please sign in to comment.