Merge PR #420: fix(api): 单次调用支持 :owner/:repo 占位符替换(修复 issue #20)
# Conflicts: # cmd/api/api.go
This commit is contained in:
commit
e16f302e3d
|
|
@ -7,25 +7,17 @@ import (
|
|||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/context"
|
||||
repocontext "github.com/gitlink-org/gitlink-cli/internal/context"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
// apiOwnerPlaceholder and apiRepoPlaceholder match the REST-style :owner / :repo
|
||||
// path placeholders used throughout the GitLink API docs and shortcut commands.
|
||||
var (
|
||||
apiOwnerPlaceholder = regexp.MustCompile(`:owner\b`)
|
||||
apiRepoPlaceholder = regexp.MustCompile(`:repo\b`)
|
||||
)
|
||||
|
||||
func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
|
|
@ -69,32 +61,6 @@ func validateAPIArgs(c *cobra.Command, args []string) error {
|
|||
return cobra.ExactArgs(2)(c, args)
|
||||
}
|
||||
|
||||
// msysPathRe matches Windows drive-letter prefixes produced by MSYS2/Git Bash
|
||||
// path conversion, e.g. "C:/Program Files/Git/v1/owner/repo" for input "/v1/owner/repo".
|
||||
var msysPathRe = regexp.MustCompile(`^[A-Za-z]:/`)
|
||||
|
||||
// restoreAPIPath restores an API path polluted by MSYS2/Git Bash path
|
||||
// conversion on Windows, e.g. "C:/Program Files/Git/v1/owner/repo" -> "/v1/owner/repo".
|
||||
// If the path does not start with a drive letter, or no known API prefix is
|
||||
// found, the original path is returned unchanged.
|
||||
func restoreAPIPath(path string) string {
|
||||
if !msysPathRe.MatchString(path) {
|
||||
return path
|
||||
}
|
||||
// Pick the EARLIEST occurrence among known API prefixes, so a path like
|
||||
// ".../api/v1/users" restores to "/api/v1/users" rather than "/v1/users".
|
||||
bestIdx := -1
|
||||
for _, prefix := range []string{"/v1/", "/v2/", "/api/", "/users/", "/projects/"} {
|
||||
if idx := strings.Index(path, prefix); idx >= 0 && (bestIdx == -1 || idx < bestIdx) {
|
||||
bestIdx = idx
|
||||
}
|
||||
}
|
||||
if bestIdx >= 0 {
|
||||
return path[bestIdx:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func runAPI(c *cobra.Command, args []string) error {
|
||||
batchFile, _ := c.Flags().GetString("batch-file")
|
||||
if batchFile != "" {
|
||||
|
|
@ -102,12 +68,13 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
}
|
||||
|
||||
method := strings.ToUpper(args[0])
|
||||
path := args[1]
|
||||
|
||||
// Fix MSYS2/Git Bash path auto-conversion on Windows first:
|
||||
// "/v1/owner/repo" is rewritten to "C:/Program Files/Git/v1/owner/repo".
|
||||
rawPath := restoreAPIPath(args[1])
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
path, err := resolveAPIPath(c, rawPath)
|
||||
path, err := resolvePathPlaceholders(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -146,37 +113,24 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
return output.Print(env, resolveFormat())
|
||||
}
|
||||
|
||||
// resolveAPIPath prepares a single-call path: it renders {{var}} templates
|
||||
// supplied via --var (consistent with batch mode), substitutes the REST-style
|
||||
// :owner / :repo placeholders (resolved from --owner/--repo or the git remote,
|
||||
// exactly like the shortcut commands), and ensures a leading slash.
|
||||
func resolveAPIPath(c *cobra.Command, rawPath string) (string, error) {
|
||||
path := rawPath
|
||||
|
||||
overrides, err := parseBatchVars(c)
|
||||
// resolvePathPlaceholders substitutes :owner/:repo (and {{owner}}/{{repo}})
|
||||
// segments in a single-call path with the global --owner/--repo flags or the
|
||||
// values auto-resolved from the current git remote, matching the help-text
|
||||
// examples. Paths without placeholders are returned unchanged.
|
||||
func resolvePathPlaceholders(path string) (string, error) {
|
||||
hasColon := strings.Contains(path, "/:owner") || strings.Contains(path, "/:repo")
|
||||
hasBrace := strings.Contains(path, "{{owner}}") || strings.Contains(path, "{{repo}}")
|
||||
if !hasColon && !hasBrace {
|
||||
return path, nil
|
||||
}
|
||||
owner, repo, err := repocontext.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(overrides) > 0 {
|
||||
rendered, rerr := renderTemplate(path, overrides)
|
||||
if rerr != nil {
|
||||
return "", rerr
|
||||
}
|
||||
path = rendered
|
||||
}
|
||||
|
||||
if apiOwnerPlaceholder.MatchString(path) || apiRepoPlaceholder.MatchString(path) {
|
||||
owner, repo, rerr := context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo)
|
||||
if rerr != nil {
|
||||
return "", fmt.Errorf("path contains :owner/:repo placeholders but they could not be resolved: %w", rerr)
|
||||
}
|
||||
path = apiOwnerPlaceholder.ReplaceAllLiteralString(path, owner)
|
||||
path = apiRepoPlaceholder.ReplaceAllLiteralString(path, repo)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
return "", fmt.Errorf("path contains :owner/:repo placeholders: %w", err)
|
||||
}
|
||||
path = strings.ReplaceAll(path, "/:owner", "/"+owner)
|
||||
path = strings.ReplaceAll(path, "/:repo", "/"+repo)
|
||||
path = strings.ReplaceAll(path, "{{owner}}", owner)
|
||||
path = strings.ReplaceAll(path, "{{repo}}", repo)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,55 @@ func TestResolveFormat(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolvePathPlaceholders(t *testing.T) {
|
||||
origOwner, origRepo := cmdutil.Owner, cmdutil.Repo
|
||||
t.Cleanup(func() { cmdutil.Owner, cmdutil.Repo = origOwner, origRepo })
|
||||
cmdutil.Owner, cmdutil.Repo = "demo-owner", "demo-repo"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"colon placeholders", "/:owner/:repo/issues", "/demo-owner/demo-repo/issues"},
|
||||
{"colon with suffix", "/:owner/:repo/issues/42", "/demo-owner/demo-repo/issues/42"},
|
||||
{"brace placeholders", "/{{owner}}/{{repo}}/pulls", "/demo-owner/demo-repo/pulls"},
|
||||
{"no placeholders unchanged", "/users/me", "/users/me"},
|
||||
{"literal path unchanged", "/Gitlink/gitlink-cli/issues", "/Gitlink/gitlink-cli/issues"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := resolvePathPlaceholders(tt.path)
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePathPlaceholders(%q): %v", tt.path, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("resolvePathPlaceholders(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePathPlaceholdersUnresolvable(t *testing.T) {
|
||||
origOwner, origRepo := cmdutil.Owner, cmdutil.Repo
|
||||
t.Cleanup(func() { cmdutil.Owner, cmdutil.Repo = origOwner, origRepo })
|
||||
cmdutil.Owner, cmdutil.Repo = "", ""
|
||||
|
||||
tmp := t.TempDir()
|
||||
origWD, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(origWD) })
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := resolvePathPlaceholders("/:owner/:repo/issues"); err == nil {
|
||||
t.Fatal("expected error when owner/repo cannot be resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAPICmd(t *testing.T) {
|
||||
cmd := NewAPICmd()
|
||||
if cmd.Use != "api (<METHOD> <PATH> | --batch-file <FILE>)" {
|
||||
|
|
|
|||
Loading…
Reference in New Issue