Merge pull request 'feat(output): 全局 --query/-q 点分路径输出字段提取(零依赖,对标 gh --jq)' (#342) from Taoyouce/gitlink-cli:feat/output-query into master

This commit is contained in:
wbtiger 2026-07-14 22:24:53 +08:00
commit afc3b5773a
9 changed files with 202 additions and 0 deletions

View File

@ -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`.

View File

@ -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` 解析。

View File

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

View File

@ -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 输出、缺键错误含可用键列表、下标越界、数组段非数字。

View File

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

View File

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

View File

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

90
internal/output/query.go Normal file
View File

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

View File

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