Merge pull request 'fix(api): resolve :owner/:repo and --var placeholders in single calls' (#254) from luwanzhou/gitlink-cli:fix/api-owner-repo-placeholders into master

This commit is contained in:
wbtiger 2026-07-14 20:43:55 +08:00
commit e5cf341d26
3 changed files with 139 additions and 4 deletions

View File

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

View 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{
{

View File

@ -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}}` 渲染、缺失模板变量报错;既有测试全部通过。