Merge branch 'master' into mc_branch

This commit is contained in:
camelliamc 2026-06-29 10:44:59 +08:00
commit 582d27273f
8 changed files with 271 additions and 10 deletions

View File

@ -1,5 +1,7 @@
package cmdutil
import "errors"
// Global flags shared across all commands.
var (
Owner string
@ -7,3 +9,14 @@ var (
Format string
Debug bool
)
// ErrSilent 是 sentinel error表示错误已经被上层处理过如已按 envelope 格式
// 输出到 stdout调用方cmd.Execute只需返回非零退出码不要再把消息
// 打印到 stderr。
//
// 使用方式:
//
// if TryPrintError(err, format) {
// return cmdutil.ErrSilent
// }
var ErrSilent = errors.New("silent error: already reported via envelope")

View File

@ -1,6 +1,7 @@
package cmd
import (
"errors"
"fmt"
"os"
@ -47,6 +48,11 @@ var versionCmd = &cobra.Command{
func Execute() error {
if err := rootCmd.Execute(); err != nil {
// ErrSilent 表示错误已经按 envelope 格式输出到 stdout如 API 错误),
// 这里只需保留非零退出码,不需要再 stderr 重复打印。
if errors.Is(err, cmdutil.ErrSilent) {
return err
}
fmt.Fprintln(os.Stderr, err)
return err
}

View File

@ -71,6 +71,11 @@ func printTable(w io.Writer, envelope *Envelope) error {
case []interface{}:
return printSliceTable(w, data)
case map[string]interface{}:
// GitLink API 经常返回 {"count":N, "items":[...]} 这种"包装列表"
// 优先解包内层 slice of maps按列表渲染
if unwrapped := unwrapSingleListField(data); unwrapped != nil {
return printSliceTable(w, unwrapped)
}
// For maps with nested structures, prefer JSON
if hasComplexValues(data) {
return printJSON(w, envelope)
@ -82,6 +87,59 @@ func printTable(w io.Writer, envelope *Envelope) error {
}
}
// unwrapSingleListField 检测 map 是否为"包装列表"结构:
// 至少包含一个 []interface{}(元素为 map或为空数组字段。
// 若是,返回该 slice用于按列表渲染表格否则返回 nil。
//
// 优先选择已知列表字段名projects/webhooks/issues 等),
// 若 map 中只有一个 slice of maps 字段,也直接使用。
func unwrapSingleListField(m map[string]interface{}) []interface{} {
knownListFields := []string{
"projects", "webhooks", "issues", "users", "pull_requests",
"builds", "releases", "branches", "teams", "members",
"orgs", "items", "records", "results", "wikis", "search",
}
// 1. 优先选择已知字段名(即使数组为空,也接受 — 空数组也是合法列表)
for _, name := range knownListFields {
if s, ok := m[name].([]interface{}); ok {
if isSliceOfMaps(s) {
return s
}
}
}
// 2. 兜底:检测是否只有一个 slice of maps 字段
var listField string
var listValue []interface{}
for k, v := range m {
s, ok := v.([]interface{})
if !ok {
continue
}
if !isSliceOfMaps(s) {
continue
}
if listField != "" {
// 多个列表字段,无法自动解包
return nil
}
listField = k
listValue = s
}
return listValue
}
// isSliceOfMaps 判断 slice 是否为 map 元素的列表
// 空 slice 也算合法列表(用于在 printSliceTable 中触发 "No results" 输出)
func isSliceOfMaps(s []interface{}) bool {
if len(s) == 0 {
return true // 空数组视为列表printSliceTable 会输出 "No results"
}
_, ok := s[0].(map[string]interface{})
return ok
}
func hasComplexValues(m map[string]interface{}) bool {
for _, v := range m {
switch v.(type) {

View File

@ -0,0 +1,81 @@
package output
import (
"bytes"
"strings"
"testing"
)
func TestPrintTable_WrappedEmptyList(t *testing.T) {
env := &Envelope{OK: true, Data: map[string]interface{}{
"count": 0,
"projects": []interface{}{},
}}
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo failed: %v", err)
}
got := buf.String()
if !strings.Contains(got, "No results") {
t.Errorf("expected 'No results', got: %q", got)
}
}
func TestPrintTable_WrappedList(t *testing.T) {
env := &Envelope{OK: true, Data: map[string]interface{}{
"count": 2,
"projects": []interface{}{
map[string]interface{}{"id": 1.0, "name": "alpha"},
map[string]interface{}{"id": 2.0, "name": "beta"},
},
}}
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo failed: %v", err)
}
got := buf.String()
if !strings.Contains(got, "alpha") || !strings.Contains(got, "beta") {
t.Errorf("expected alpha/beta in output, got: %q", got)
}
if !strings.Contains(got, "id") || !strings.Contains(got, "name") {
t.Errorf("expected header id/name, got: %q", got)
}
}
func TestUnwrapSingleListField_KnownName(t *testing.T) {
m := map[string]interface{}{
"count": 2.0,
"projects": []interface{}{map[string]interface{}{"id": 1.0}},
}
got := unwrapSingleListField(m)
if got == nil || len(got) != 1 {
t.Fatalf("expected slice len=1, got %v", got)
}
}
func TestUnwrapSingleListField_MultipleUnknownListsReturnsNil(t *testing.T) {
// 两个未知名字的 list 字段 — 无法自动选择,返回 nil
m := map[string]interface{}{
"foo_list": []interface{}{map[string]interface{}{"id": 1.0}},
"bar_list": []interface{}{map[string]interface{}{"id": 2.0}},
}
if got := unwrapSingleListField(m); got != nil {
t.Errorf("expected nil for multiple unknown list fields, got len=%d", len(got))
}
}
func TestUnwrapSingleListField_KnownNamePreferred(t *testing.T) {
// 已知 name 优先 — 即使有其他 list 字段也用 known name
m := map[string]interface{}{
"projects": []interface{}{map[string]interface{}{"id": 1.0}},
"users": []interface{}{map[string]interface{}{"id": 2.0}},
}
got := unwrapSingleListField(m)
if got == nil || len(got) != 1 {
t.Fatalf("expected projects slice len=1, got %v", got)
}
first := got[0].(map[string]interface{})
if first["id"] != 1.0 {
t.Errorf("expected projects[0].id=1, got %v", first["id"])
}
}

View File

@ -0,0 +1,68 @@
package common
import (
"errors"
"github.com/gitlink-org/gitlink-cli/internal/client"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
// TryPrintError 尝试把 error 转换为 envelope 格式并输出到 stdout。
// 返回 true 表示已识别并处理false 表示该 error 类型不被识别,应由调用方走原有路径。
//
// 设计意图:让 shortcut 的错误处理路径与 api 命令对齐,
// 使 `--format json` 输出可被 jq 解析的标准 envelope
//
// {"ok":false, "error":{"code":N, "message":"...", "suggestion":"..."}}
//
// 支持的 error 类型:
// - *client.APIError : HTTP 错误404/403/401 等code = HTTP 状态码
// - *clierrors.CLIError : 业务级错误(输入/认证/网络等code 由 kind 映射
//
// 注:放在 common 包(而非 output 包)以避免与 client 包形成导入循环。
func TryPrintError(err error, format string) bool {
if err == nil {
return false
}
var apiErr *client.APIError
if errors.As(err, &apiErr) {
env := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion)
_ = output.Print(env, format)
return true
}
var cliErr *clierrors.CLIError
if errors.As(err, &cliErr) {
env := output.ErrorEnvelope(kindToCode(cliErr.Kind), cliErr.Message, cliErr.Suggestion)
_ = output.Print(env, format)
return true
}
return false
}
// kindToCode 把 CLIError.Kind 映射到近似的 HTTP 状态码,用于 envelope.error.code 字段
func kindToCode(kind clierrors.ErrorKind) int {
switch kind {
case clierrors.KindAuth:
return 401
case clierrors.KindInput:
return 400
case clierrors.KindNotFound:
return 404
case clierrors.KindForbidden:
return 403
case clierrors.KindNetwork:
return 503
case clierrors.KindServer:
return 500
case clierrors.KindConfig:
return 500
case clierrors.KindGit:
return 500
default:
return 500
}
}

View File

@ -6,6 +6,8 @@ import (
"strconv"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
)
// MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand.
@ -55,7 +57,16 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
}
}
return s.Run(ctx)
err = s.Run(ctx)
if err != nil {
// 当错误为已识别的 API/CLI 错误时,按 envelope 格式输出到 stdout
// 让 `--format json` 输出可被 jq 解析的标准结构。
// 已识别后返回 ErrSilent保留非零退出码但 cmd.Execute 不会再 stderr 重复输出。
if TryPrintError(err, ctx.Format) {
return cmdutil.ErrSilent
}
}
return err
},
}

View File

@ -5,6 +5,7 @@ import (
"net/url"
"strings"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -99,7 +100,10 @@ func Shortcuts() []*common.Shortcut {
events := parseEvents(ctx.Arg("events"))
if len(events) == 0 {
return fmt.Errorf("no valid events specified. Supported events: %s", strings.Join(supportedEvents, ", "))
return clierrors.InputError(
"no valid events specified",
fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")),
)
}
payload := map[string]interface{}{
@ -180,7 +184,10 @@ func Shortcuts() []*common.Shortcut {
if events := ctx.Arg("events"); events != "" {
validEvents := parseEvents(events)
if len(validEvents) == 0 {
return fmt.Errorf("no valid events specified. Supported events: %s", strings.Join(supportedEvents, ", "))
return clierrors.InputError(
"no valid events specified",
fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")),
)
}
payload["events"] = validEvents
}
@ -258,7 +265,10 @@ func Shortcuts() []*common.Shortcut {
eventType := ctx.Arg("event")
if !isEventSupported(eventType) {
return fmt.Errorf("unsupported event type: %s. Supported events: %s", eventType, strings.Join(supportedEvents, ", "))
return clierrors.InputError(
fmt.Sprintf("unsupported event type: %s", eventType),
fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")),
)
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)

View File

@ -163,6 +163,17 @@ func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (s
return string(decoded), nil
}
// fetchWikiPage 按 pageName 查询 wiki 页面,返回完整的 envelope。
// 调用方负责处理错误(包括 404和后续的 sub_url 重试逻辑。
func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
}
func resolveContent(ctx *common.RuntimeContext) (string, error) {
if content := ctx.Arg("content"); content != "" {
return content, nil
@ -480,12 +491,15 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", title)
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
env, err := fetchWikiPage(ctx, projectID, title)
if err != nil {
// GitLink 后端命名规则: 创建 wiki 时会自动给 sub_url 追加 ".-" 后缀,
// 而 wiki +list 返回的 title 不带后缀。若 title 不带后缀且首次查询失败,
// 自动用 title + ".-" 重试一次。
if !strings.HasSuffix(title, ".-") {
env, err = fetchWikiPage(ctx, projectID, title+".-")
}
}
if err != nil {
return fmt.Errorf("查看 Wiki 页面失败: %w", err)
}