fix(api): resolve :owner/:repo and --var placeholders in single calls #254
|
|
@ -7,16 +7,26 @@ 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"
|
||||
"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.
|
||||
// The \b boundary keeps :owner/:repo from matching longer tokens like :owner_id.
|
||||
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 {
|
||||
|
|
@ -30,6 +40,8 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
|
|||
gitlink-cli api GET /projects --query 'page=1&limit=10'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body-file issue.json
|
||||
gitlink-cli api GET /:owner/:repo/commits --owner Gitlink --repo gitlink-cli
|
||||
gitlink-cli api GET /v1/{{owner}}/gitlink-cli/issues --var owner=Gitlink
|
||||
gitlink-cli api --batch-file plan.json --dry-run
|
||||
gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=gitlink-cli`,
|
||||
Args: validateAPIArgs,
|
||||
|
|
@ -67,10 +79,9 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
}
|
||||
|
||||
method := strings.ToUpper(args[0])
|
||||
path := args[1]
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
path, err := resolveAPIPath(c, args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli, err := client.New()
|
||||
|
|
@ -107,6 +118,42 @@ 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)
|
||||
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)
|
||||
}
|
||||
// ReplaceAllLiteralString avoids interpreting $ in owner/repo as a
|
||||
// regexp replacement reference.
|
||||
path = apiOwnerPlaceholder.ReplaceAllLiteralString(path, owner)
|
||||
path = apiRepoPlaceholder.ReplaceAllLiteralString(path, repo)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func readJSONBody(c *cobra.Command) (interface{}, error) {
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
bodyFile, _ := c.Flags().GetString("body-file")
|
||||
|
|
|
|||
|
|
@ -202,6 +202,56 @@ func TestRunAPINoPrefix(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunAPIResolvesOwnerRepoPlaceholders(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/testowner/testrepo/commits.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
cmdutil.Owner, cmdutil.Repo = "testowner", "testrepo"
|
||||
defer func() { cmdutil.Owner, cmdutil.Repo = "", "" }()
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/:owner/:repo/commits"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI :owner/:repo error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIRendersVarTemplateSingleCall(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/Mengz/gitlink-cli/issues.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
cmdutil.Owner, cmdutil.Repo = "", ""
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/v1/{{owner}}/gitlink-cli/issues", "--var", "owner=Mengz"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI --var single call error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIMissingVarTemplateSingleCall(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not reach server")
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/{{missing}}", "--var", "present=1"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error for missing template variable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBatchRequestsTemplateVars(t *testing.T) {
|
||||
requests, err := renderBatchRequests([]batchRequest{
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
# 修复 `api` 命令单次调用不替换 `:owner/:repo` 占位符
|
||||
|
||||
## 背景
|
||||
|
||||
`gitlink-cli api <METHOD> <PATH>` 单次调用此前直接把 `<PATH>` 原样发送给服务端,**不会替换 REST 风格的 `:owner` / `:repo` 占位符**。这导致:
|
||||
|
||||
- 该命令自身的帮助 `Example`(如 `api POST /:owner/:repo/issues`)跑不通;
|
||||
- 依赖 `:owner/:repo` 写法的 Skill / 文档(如 `api GET /:owner/:repo/commits`)报错;
|
||||
- 占位符替换能力此前只存在于 `--batch-file` 批处理模式的 `{{var}}` 模板中,单次调用无法复用。
|
||||
|
||||
对应 Issue:`bug: api 命令单次调用不替换 :owner/:repo 占位符(0.2.0)`。
|
||||
|
||||
## 变更
|
||||
|
||||
`api` 单次调用现在按以下顺序处理路径:
|
||||
|
||||
1. **`{{var}}` 模板渲染**:若提供了 `--var key=value`,复用与批处理模式相同的模板引擎渲染 `<PATH>` 中的 `{{key}}`,缺失变量时报错。
|
||||
2. **`:owner` / `:repo` 占位符替换**:当路径包含 `:owner` / `:repo` 时,使用与所有 shortcut 一致的解析逻辑 `context.ResolveOwnerRepo(--owner, --repo → git remote origin)` 解析仓库归属并替换;无法解析时给出明确错误提示。
|
||||
3. 保持原有的「缺失前导 `/` 自动补全」行为。
|
||||
|
||||
不含占位符、且未传 `--var` 的调用(如 `api GET /users/me`)行为完全不变。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
# 自动从当前 git 仓库或 --owner/--repo 解析
|
||||
gitlink-cli api GET /:owner/:repo/commits --owner Gitlink --repo gitlink-cli
|
||||
|
||||
# 单次调用也支持 {{var}} 模板
|
||||
gitlink-cli api GET /v1/{{owner}}/gitlink-cli/issues --var owner=Gitlink
|
||||
```
|
||||
|
||||
## 实现与测试
|
||||
|
||||
- 改动集中在 `cmd/api/api.go`:新增 `resolveAPIPath` 辅助函数与 `:owner` / `:repo` 占位符正则(`\b` 边界避免误伤 `:owner_id` 等更长 token;`ReplaceAllLiteralString` 避免 `$` 被当作正则替换引用)。
|
||||
- 复用既有 `parseBatchVars` / `renderTemplate`(`cmd/api/batch.go`)与 `internal/context.ResolveOwnerRepo`,无新增依赖。
|
||||
- 更新命令 `Example` 帮助文案。
|
||||
- 新增单元测试:`:owner/:repo` 解析替换、单次调用 `{{var}}` 渲染、缺失模板变量报错;既有测试全部通过。
|
||||
Loading…
Reference in New Issue