Merge PR #420: fix(api): 单次调用支持 :owner/:repo 占位符替换(修复 issue #20)

# Conflicts:
#	cmd/api/api.go
This commit is contained in:
wbtiger 2026-07-14 22:49:52 +08:00
commit e16f302e3d
2 changed files with 71 additions and 68 deletions

View File

@ -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
}

View File

@ -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>)" {