forked from Gitlink/gitlink-cli
Merge branch 'master' into mc_branch
This commit is contained in:
commit
582d27273f
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue