Merge pull request 'feat(output): 全局 --query/-q 点分路径输出字段提取(零依赖,对标 gh --jq)' (#342) from Taoyouce/gitlink-cli:feat/output-query into master
This commit is contained in:
commit
afc3b5773a
|
|
@ -759,6 +759,8 @@ gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
|||
| `--repo` | Repository name | `--repo forgeplus` |
|
||||
| `--format` | Output format (json/table/yaml; workflow also supports markdown) | `--format json` |
|
||||
| `--debug` | Enable debug output | `--debug` |
|
||||
| `--lang` | Interface language (en/zh) | `--lang zh` |
|
||||
| `--jq` | Extract a value from the output by dot-separated path | `--jq data.issues.0.subject` |
|
||||
|
||||
**Automatic context resolution:** When running inside a git repository, `--owner` and `--repo` are automatically resolved from `git remote origin`.
|
||||
|
||||
|
|
|
|||
|
|
@ -663,6 +663,8 @@ gitlink-cli completion powershell | Out-String | Invoke-Expression
|
|||
| `--repo` | 仓库名称 | `--repo forgeplus` |
|
||||
| `--format` | 输出格式(json/table/yaml) | `--format json` |
|
||||
| `--debug` | 启用调试输出 | `--debug` |
|
||||
| `--lang` | 界面语言(en/zh) | `--lang zh` |
|
||||
| `--jq` | 按点分路径从输出中提取字段 | `--jq data.issues.0.subject` |
|
||||
|
||||
**自动上下文解析**:在 git 仓库目录下,`--owner` 和 `--repo` 会自动从 `git remote origin` 解析。
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
doctorCmd "github.com/gitlink-org/gitlink-cli/cmd/doctor"
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
)
|
||||
|
||||
|
|
@ -55,6 +56,7 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
|
|||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format"))
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug"))
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang"))
|
||||
rootCmd.PersistentFlags().StringVar(&output.Query, "jq", "", tr.T("flag.jq"))
|
||||
|
||||
rootCmd.AddCommand(authCmd.NewAuthCmd(tr))
|
||||
rootCmd.AddCommand(apiCmd.NewAPICmd(tr))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
# 全局 `--jq` 输出字段提取
|
||||
|
||||
## 动机
|
||||
|
||||
对标 `gh --jq`:Agent 与 shell 脚本消费 CLI 输出时经常只需要单个字段
|
||||
(如最新 commit 的 sha、issue 总数),此前必须依赖外部 `jq` 或自行解析
|
||||
完整 JSON 信封。新增零依赖的点分路径提取,纯 Go 标准库实现,
|
||||
不引入任何第三方 JSON 查询库(避免供应链风险)。
|
||||
|
||||
## 行为
|
||||
|
||||
- 新增全局持久 flag `--jq`,对所有命令生效(不占用 `--query`,与 api 子命令的查询参数 flag 无冲突)。
|
||||
- 路径语法:点分段;段为对象键,或非负整数作为数组下标。
|
||||
例:`--jq data.commits.0.sha`、`--jq data.total_issues_count`。
|
||||
- 标量(字符串/数字/布尔/null)输出裸值,方便 shell 管道直接消费;
|
||||
对象与数组输出缩进 JSON。
|
||||
- 错误信息可操作:键不存在时列出该层全部可用键(排序后);
|
||||
数组下标越界/非数字段给出数组长度提示。
|
||||
|
||||
## 生产实测
|
||||
|
||||
- `issue +list --limit 2 --jq data.issues.0.subject` → 裸标题字符串
|
||||
- `issue +list --jq data.total_issues_count` → `4197`
|
||||
- 误键 `--jq data.issues.0.name` → 报错并列出 29 个可用键(含 `subject`)
|
||||
|
||||
## 测试
|
||||
|
||||
`internal/output/query_test.go` 6 个单测:字符串/数字标量裸值输出、
|
||||
对象 JSON 输出、缺键错误含可用键列表、下标越界、数组段非数字。
|
||||
|
|
@ -192,6 +192,7 @@
|
|||
"flag.issue.status_id": "Issue status ID",
|
||||
"flag.issue.tag_ids": "Comma-separated issue tag IDs",
|
||||
"flag.issue.title": "Issue title",
|
||||
"flag.jq": "Extract a value from the output by dot-separated path (e.g. data.commits.0.sha)",
|
||||
"flag.lang": "Display language",
|
||||
"flag.limit": "Items per page",
|
||||
"flag.org.id": "Organization ID",
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@
|
|||
"flag.issue.status_id": "议题状态 ID",
|
||||
"flag.issue.tag_ids": "逗号分隔的议题标签 ID",
|
||||
"flag.issue.title": "议题标题",
|
||||
"flag.jq": "按点分路径从输出中提取字段(如 data.commits.0.sha)",
|
||||
"flag.lang": "显示语言",
|
||||
"flag.limit": "每页条目数",
|
||||
"flag.org.id": "组织 ID",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ func Print(envelope *Envelope, format string) error {
|
|||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
if Query != "" {
|
||||
return PrintQuery(os.Stdout, envelope, Query)
|
||||
}
|
||||
return PrintTo(os.Stdout, envelope, format)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Query holds the global --query dot-path; when non-empty, Print extracts
|
||||
// and prints only the matching value instead of the full envelope.
|
||||
var Query string
|
||||
|
||||
// PrintQuery extracts a value from the envelope by a dot-separated path and
|
||||
// prints it. Path segments are object keys; non-negative integers index into
|
||||
// arrays (e.g. "data.commits.0.sha"). Strings and other scalars are printed
|
||||
// as raw values so results can be consumed directly by shell pipelines;
|
||||
// objects and arrays are printed as indented JSON.
|
||||
func PrintQuery(w io.Writer, envelope *Envelope, path string) error {
|
||||
raw, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var root interface{}
|
||||
if err := json.Unmarshal(raw, &root); err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := resolvePath(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
_, err = fmt.Fprintln(w, v)
|
||||
case nil:
|
||||
_, err = fmt.Fprintln(w, "null")
|
||||
case float64, bool:
|
||||
_, err = fmt.Fprintln(w, v)
|
||||
default:
|
||||
data, merr := json.MarshalIndent(v, "", " ")
|
||||
if merr != nil {
|
||||
return merr
|
||||
}
|
||||
_, err = fmt.Fprintln(w, string(data))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func resolvePath(root interface{}, path string) (interface{}, error) {
|
||||
current := root
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, fmt.Errorf("query path is empty")
|
||||
}
|
||||
for _, segment := range strings.Split(path, ".") {
|
||||
if segment == "" {
|
||||
return nil, fmt.Errorf("query path %q contains an empty segment", path)
|
||||
}
|
||||
switch node := current.(type) {
|
||||
case map[string]interface{}:
|
||||
value, ok := node[segment]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("query key %q not found (available: %s)", segment, strings.Join(mapKeys(node), ", "))
|
||||
}
|
||||
current = value
|
||||
case []interface{}:
|
||||
index, err := strconv.Atoi(segment)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query segment %q must be an array index (array has %d items)", segment, len(node))
|
||||
}
|
||||
if index < 0 || index >= len(node) {
|
||||
return nil, fmt.Errorf("query index %d out of range (array has %d items)", index, len(node))
|
||||
}
|
||||
current = node[index]
|
||||
default:
|
||||
return nil, fmt.Errorf("query segment %q cannot descend into a scalar value", segment)
|
||||
}
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func mapKeys(node map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(node))
|
||||
for key := range node {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func queryEnvelope() *Envelope {
|
||||
return SuccessEnvelope(map[string]interface{}{
|
||||
"total_count": 2,
|
||||
"commits": []interface{}{
|
||||
map[string]interface{}{"sha": "abc", "message": "first"},
|
||||
map[string]interface{}{"sha": "def", "message": "second"},
|
||||
},
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func TestPrintQueryScalarString(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := PrintQuery(&buf, queryEnvelope(), "data.commits.1.sha"); err != nil {
|
||||
t.Fatalf("PrintQuery returned error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "def" {
|
||||
t.Fatalf("got %q, want def", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintQueryNumber(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := PrintQuery(&buf, queryEnvelope(), "data.total_count"); err != nil {
|
||||
t.Fatalf("PrintQuery returned error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "2" {
|
||||
t.Fatalf("got %q, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintQueryObjectAsJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := PrintQuery(&buf, queryEnvelope(), "data.commits.0"); err != nil {
|
||||
t.Fatalf("PrintQuery returned error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `"sha": "abc"`) || !strings.Contains(out, `"message": "first"`) {
|
||||
t.Fatalf("object output missing fields: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintQueryMissingKeyListsAvailable(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintQuery(&buf, queryEnvelope(), "data.nope")
|
||||
if err == nil || !strings.Contains(err.Error(), "commits, total_count") {
|
||||
t.Fatalf("expected missing-key error listing available keys, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintQueryIndexOutOfRange(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintQuery(&buf, queryEnvelope(), "data.commits.5")
|
||||
if err == nil || !strings.Contains(err.Error(), "out of range") {
|
||||
t.Fatalf("expected out-of-range error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintQueryNonIndexSegmentOnArray(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintQuery(&buf, queryEnvelope(), "data.commits.sha")
|
||||
if err == nil || !strings.Contains(err.Error(), "array index") {
|
||||
t.Fatalf("expected array-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue