diff --git a/cmd/api/api.go b/cmd/api/api.go index cae531a..82018e8 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -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") diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index dad41a8..eef0771 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -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{ { diff --git a/doc/changes/api-path-placeholders.md b/doc/changes/api-path-placeholders.md new file mode 100644 index 0000000..3f5b870 --- /dev/null +++ b/doc/changes/api-path-placeholders.md @@ -0,0 +1,38 @@ +# 修复 `api` 命令单次调用不替换 `:owner/:repo` 占位符 + +## 背景 + +`gitlink-cli api ` 单次调用此前直接把 `` 原样发送给服务端,**不会替换 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`,复用与批处理模式相同的模板引擎渲染 `` 中的 `{{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}}` 渲染、缺失模板变量报错;既有测试全部通过。