Compare commits

...

5 Commits

Author SHA1 Message Date
NeeNe 3767ed5852 docs(client): document response and pagination changes 2026-07-10 16:43:47 +08:00
NeeNe 8d9e7e7a91 fix(i18n): add missing ignore descriptions 2026-07-10 16:43:47 +08:00
NeeNe c9ae4c70b8 feat(common): add guarded operation executor 2026-07-10 16:43:47 +08:00
NeeNe c1f156f7df feat(client): add resilient pagination 2026-07-10 16:43:47 +08:00
NeeNe 198108d282 fix(client): support heterogeneous API responses 2026-07-10 16:43:47 +08:00
10 changed files with 942 additions and 86 deletions

View File

@ -0,0 +1,32 @@
# Client response and pagination foundation
The shared HTTP client now decodes every valid JSON root shape instead of
assuming that all endpoints return objects. Object, array, scalar, plain-text,
and empty responses are preserved in the output envelope. GitLink endpoints
that encode JSON inside a string-valued `data` field are decoded into the
corresponding object, array, or scalar.
`APIError` exposes stable error kinds for HTTP failures, GitLink business
failures, authentication, permission, and missing resources. Callers can use
`client.IsNotFound`, `client.IsAuthentication`, and `client.IsPermission`
without matching translated error text. Both HTTP 404 and GitLink
`status: -2` are classified as not found.
The new `Client.Paginate` API accepts endpoint-specific list fields while also
supporting common GitLink wrappers such as `issues`, `pulls`, `tags`,
`commits`, and `issue_tags`. It:
- preserves caller-owned query values;
- honors `page`, `limit`, `total_count`, and a configurable maximum page count;
- detects repeated pages;
- deduplicates records by stable ID, number, login, or SHA;
- returns page count, server total, completeness, and truncation metadata;
- retains successfully fetched items when a later page fails.
The existing `PaginateAll` helper remains available as a compatibility wrapper.
`shortcuts/common.GuardedOperation` centralizes write safety for shortcut
commands. It provides dry-run previews, risk levels, explicit confirmation,
recursive sensitive-field redaction, batch continue/stop policies, structured
success/failure/skipped counts, partial-success details, and stable exit codes
for confirmation and batch failures.

View File

@ -3,10 +3,12 @@ package client
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"github.com/gitlink-org/gitlink-cli/internal/auth" "github.com/gitlink-org/gitlink-cli/internal/auth"
@ -24,12 +26,38 @@ type APIError struct {
StatusCode int StatusCode int
Code interface{} Code interface{}
Message string Message string
Kind ErrorKind
} }
type ErrorKind string
const (
ErrorKindHTTP ErrorKind = "http"
ErrorKindBusiness ErrorKind = "business"
ErrorKindNotFound ErrorKind = "not_found"
ErrorKindAuthentication ErrorKind = "authentication"
ErrorKindPermission ErrorKind = "permission"
)
func (e *APIError) Error() string { func (e *APIError) Error() string {
return fmt.Sprintf("[%v] %s", e.Code, e.Message) return fmt.Sprintf("[%v] %s", e.Code, e.Message)
} }
func IsNotFound(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && (apiErr.Kind == ErrorKindNotFound || apiErr.StatusCode == http.StatusNotFound || numericCode(apiErr.Code) == -2)
}
func IsAuthentication(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.Kind == ErrorKindAuthentication
}
func IsPermission(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.Kind == ErrorKindPermission
}
func New() (*Client, error) { func New() (*Client, error) {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@ -100,50 +128,56 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
// Check HTTP-level errors // Check HTTP-level errors
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
message := responseMessage(respData)
if message == "" {
message = strings.TrimSpace(string(respData))
}
return nil, &APIError{ return nil, &APIError{
StatusCode: resp.StatusCode, StatusCode: resp.StatusCode,
Code: resp.StatusCode, Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))), Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, message),
Kind: classifyError(resp.StatusCode, resp.StatusCode),
} }
} }
// Parse JSON if len(bytes.TrimSpace(respData)) == 0 {
var raw map[string]interface{} return output.SuccessEnvelope(nil, nil), nil
if err := json.Unmarshal(respData, &raw); err != nil { }
// Decode the JSON root as any. GitLink endpoints legitimately return
// objects, arrays, scalars, plain text, and empty responses.
var decoded interface{}
if err := json.Unmarshal(respData, &decoded); err != nil {
// Not JSON, return as-is // Not JSON, return as-is
return output.SuccessEnvelope(string(respData), nil), nil return output.SuccessEnvelope(string(respData), nil), nil
} }
raw, isObject := decoded.(map[string]interface{})
if !isObject {
return output.SuccessEnvelope(decoded, nil), nil
}
// Check GitLink error-in-body pattern // Check GitLink error-in-body pattern
// Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."} // Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."}
var bodyCode float64 var bodyCode int
var bodyMsg string var bodyMsg string
if status, ok := raw["status"]; ok { if status, ok := raw["status"]; ok {
switch v := status.(type) { bodyCode = numericCode(status)
case float64:
bodyCode = v
case int:
bodyCode = float64(v)
}
bodyMsg, _ = raw["message"].(string) bodyMsg, _ = raw["message"].(string)
} else if code, ok := raw["code"]; ok { } else if code, ok := raw["code"]; ok {
switch v := code.(type) { bodyCode = numericCode(code)
case float64:
bodyCode = v
case int:
bodyCode = float64(v)
}
bodyMsg, _ = raw["msg"].(string) bodyMsg, _ = raw["msg"].(string)
if bodyMsg == "" { if bodyMsg == "" {
bodyMsg, _ = raw["message"].(string) bodyMsg, _ = raw["message"].(string)
} }
} }
if bodyCode != 0 && bodyCode != 200 && bodyCode != 201 && bodyCode != 204 && bodyCode != 1 { if !isSuccessCode(bodyCode) {
suggestion := suggestFix(int(bodyCode)) suggestion := suggestFix(bodyCode)
return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{ return output.ErrorEnvelope(bodyCode, bodyMsg, suggestion), &APIError{
StatusCode: int(bodyCode), StatusCode: resp.StatusCode,
Code: int(bodyCode), Code: bodyCode,
Message: bodyMsg, Message: bodyMsg,
Kind: classifyError(resp.StatusCode, bodyCode),
} }
} }
@ -151,7 +185,7 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
if dataStr, ok := raw["data"].(string); ok { if dataStr, ok := raw["data"].(string); ok {
var parsedData interface{} var parsedData interface{}
if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil { if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil {
raw["data"] = json.RawMessage(dataStr) raw["data"] = parsedData
} }
} }
@ -173,6 +207,60 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
return output.SuccessEnvelope(raw, meta), nil return output.SuccessEnvelope(raw, meta), nil
} }
func numericCode(value interface{}) int {
switch code := value.(type) {
case float64:
return int(code)
case float32:
return int(code)
case int:
return code
case int64:
return int(code)
case json.Number:
parsed, _ := code.Int64()
return int(parsed)
case string:
parsed, _ := strconv.Atoi(code)
return parsed
default:
return 0
}
}
func isSuccessCode(code int) bool {
return code == 0 || code == 1 || code == 200 || code == 201 || code == 204
}
func classifyError(httpStatus, code int) ErrorKind {
if httpStatus == http.StatusUnauthorized || code == http.StatusUnauthorized {
return ErrorKindAuthentication
}
if httpStatus == http.StatusForbidden || code == http.StatusForbidden {
return ErrorKindPermission
}
if httpStatus == http.StatusNotFound || code == http.StatusNotFound || code == -2 {
return ErrorKindNotFound
}
if httpStatus >= 400 {
return ErrorKindHTTP
}
return ErrorKindBusiness
}
func responseMessage(data []byte) string {
var payload map[string]interface{}
if json.Unmarshal(data, &payload) != nil {
return ""
}
for _, key := range []string{"message", "msg", "error"} {
if value, ok := payload[key].(string); ok && value != "" {
return value
}
}
return ""
}
func shouldAppendJSONSuffix(path string) bool { func shouldAppendJSONSuffix(path string) bool {
if strings.HasSuffix(path, ".json") { if strings.HasSuffix(path, ".json") {
return false return false
@ -224,7 +312,7 @@ func suggestFix(code int) string {
return "请先运行 gitlink-cli auth login 登录" return "请先运行 gitlink-cli auth login 登录"
case 403: case 403:
return "权限不足,请确认账户权限或联系项目管理员" return "权限不足,请确认账户权限或联系项目管理员"
case 404: case -2, 404:
return "资源不存在,请检查 owner/repo/id 是否正确" return "资源不存在,请检查 owner/repo/id 是否正确"
case 422: case 422:
return "参数校验失败,请检查请求参数" return "参数校验失败,请检查请求参数"

View File

@ -2,6 +2,8 @@ package client
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@ -25,6 +27,7 @@ func TestSuggestFix(t *testing.T) {
{401, "请先运行 gitlink-cli auth login 登录"}, {401, "请先运行 gitlink-cli auth login 登录"},
{403, "权限不足,请确认账户权限或联系项目管理员"}, {403, "权限不足,请确认账户权限或联系项目管理员"},
{404, "资源不存在,请检查 owner/repo/id 是否正确"}, {404, "资源不存在,请检查 owner/repo/id 是否正确"},
{-2, "资源不存在,请检查 owner/repo/id 是否正确"},
{422, "参数校验失败,请检查请求参数"}, {422, "参数校验失败,请检查请求参数"},
{500, ""}, {500, ""},
{0, ""}, {0, ""},
@ -55,6 +58,69 @@ func TestClientDoSuccess(t *testing.T) {
} }
} }
func TestClientDoTopLevelArray(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[{"id":1},{"id":2}]`))
}))
defer server.Close()
env, err := (&Client{HTTP: server.Client(), BaseURL: server.URL}).Do("GET", "/items", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
items, ok := env.Data.([]interface{})
if !ok || len(items) != 2 {
t.Fatalf("Data = %#v, want two-item root array", env.Data)
}
}
func TestClientDoJSONScalarsAndEmptyResponse(t *testing.T) {
tests := []struct {
name string
body string
want interface{}
}{
{name: "string", body: `"value"`, want: "value"},
{name: "number", body: `42`, want: float64(42)},
{name: "boolean", body: `true`, want: true},
{name: "null", body: `null`, want: nil},
{name: "empty", body: ``, want: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(tt.body))
}))
defer server.Close()
env, err := (&Client{HTTP: server.Client(), BaseURL: server.URL}).Do("GET", "/value", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fmt.Sprint(env.Data) != fmt.Sprint(tt.want) {
t.Fatalf("Data = %#v, want %#v", env.Data, tt.want)
}
})
}
}
func TestClientDoParsesJSONStringData(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"code":200,"data":"[{\"id\":1}]"}`))
}))
defer server.Close()
env, err := (&Client{HTTP: server.Client(), BaseURL: server.URL}).Do("GET", "/items", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
object := env.Data.(map[string]interface{})
items, ok := object["data"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data = %#v, want parsed array", object["data"])
}
}
func TestClientDoJSONSuffix(t *testing.T) { func TestClientDoJSONSuffix(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/test.json" { if r.URL.Path != "/api/test.json" {
@ -131,6 +197,45 @@ func TestClientDoHTTPError(t *testing.T) {
if apiErr.StatusCode != 404 { if apiErr.StatusCode != 404 {
t.Fatalf("StatusCode = %d, want 404", apiErr.StatusCode) t.Fatalf("StatusCode = %d, want 404", apiErr.StatusCode)
} }
if !IsNotFound(err) || apiErr.Kind != ErrorKindNotFound {
t.Fatalf("error classification = %q, want not_found", apiErr.Kind)
}
}
func TestClientDoBusinessNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":-2,"message":"missing"}`))
}))
defer server.Close()
env, err := (&Client{HTTP: server.Client(), BaseURL: server.URL}).Do("GET", "/missing", nil, nil)
if err == nil || !IsNotFound(err) {
t.Fatalf("err = %v, want stable not-found classification", err)
}
if env == nil || env.OK {
t.Fatal("want unsuccessful envelope for business error")
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusOK || apiErr.Code != -2 {
t.Fatalf("APIError = %#v", apiErr)
}
}
func TestClientDoAuthenticationAndPermissionClassification(t *testing.T) {
for _, tt := range []struct {
status int
check func(error) bool
}{{http.StatusUnauthorized, IsAuthentication}, {http.StatusForbidden, IsPermission}} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.status)
w.Write([]byte(`{"message":"denied"}`))
}))
_, err := (&Client{HTTP: server.Client(), BaseURL: server.URL}).Do("GET", "/denied", nil, nil)
server.Close()
if err == nil || !tt.check(err) {
t.Fatalf("status %d classification failed: %v", tt.status, err)
}
}
} }
func TestClientDoNonJSON(t *testing.T) { func TestClientDoNonJSON(t *testing.T) {

View File

@ -1,73 +1,262 @@
package client package client
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/url" "net/url"
"strconv" "strconv"
) )
// PaginateAll fetches all pages and returns combined results. var ErrDuplicatePage = errors.New("server returned a duplicate page")
func (c *Client) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) {
if params == nil { var defaultListFields = []string{
params = url.Values{} "data", "items", "list", "issues", "project_issues", "pulls",
} "pull_requests", "tags", "commits", "issue_tags", "contributors",
if params.Get("limit") == "" { "releases", "versions", "users",
params.Set("limit", "50") }
var defaultIdentityFields = []string{
"id", "sha", "commit_id", "pull_request_id", "pull_request_number",
"project_issues_index", "number", "identifier", "login",
}
type PaginationOptions struct {
ListFields []string
IdentityFields []string
PageParam string
LimitParam string
StartPage int
Limit int
MaxPages int
}
type PaginationResult struct {
Items []json.RawMessage `json:"items"`
PagesFetched int `json:"pages_fetched"`
ServerTotal int `json:"server_total,omitempty"`
HasTotal bool `json:"has_total"`
Complete bool `json:"complete"`
Truncated bool `json:"truncated"`
}
type PaginationError struct {
Page int
Cause error
}
func (e *PaginationError) Error() string {
return fmt.Sprintf("pagination failed on page %d: %v", e.Page, e.Cause)
}
func (e *PaginationError) Unwrap() error { return e.Cause }
// Paginate fetches a bounded set of pages and reports whether the result is
// complete. Callers may declare the business wrapper field used by an endpoint.
func (c *Client) Paginate(path string, params url.Values, options PaginationOptions) (*PaginationResult, error) {
options = normalizePaginationOptions(options)
query := cloneValues(params)
if query.Get(options.LimitParam) == "" {
query.Set(options.LimitParam, strconv.Itoa(options.Limit))
} else if limit, err := strconv.Atoi(query.Get(options.LimitParam)); err == nil && limit > 0 {
options.Limit = limit
} }
var all []json.RawMessage result := &PaginationResult{}
page := 1 seenItems := make(map[string]struct{})
seenPages := make(map[string]struct{})
for { for pageOffset := 0; pageOffset < options.MaxPages; pageOffset++ {
params.Set("page", strconv.Itoa(page)) page := options.StartPage + pageOffset
env, err := c.Get(path, params) query.Set(options.PageParam, strconv.Itoa(page))
env, err := c.Get(path, query)
if err != nil { if err != nil {
result.Truncated = result.PagesFetched > 0
return result, &PaginationError{Page: page, Cause: err}
}
if !env.OK {
result.Truncated = result.PagesFetched > 0
return result, &PaginationError{Page: page, Cause: errors.New("API returned an unsuccessful envelope")}
}
items, listFound, err := extractPageItems(env.Data, options.ListFields)
if err != nil {
result.Truncated = result.PagesFetched > 0
return result, &PaginationError{Page: page, Cause: err}
}
if total, ok := extractTotal(env.Data); ok && total >= len(items) {
result.ServerTotal = total
result.HasTotal = true
} else if env.Meta != nil && env.Meta.TotalCount > 0 {
result.ServerTotal = env.Meta.TotalCount
result.HasTotal = true
}
fingerprint := pageFingerprint(items)
if len(items) > 0 {
if _, duplicate := seenPages[fingerprint]; duplicate {
result.Truncated = true
return result, &PaginationError{Page: page, Cause: ErrDuplicatePage}
}
seenPages[fingerprint] = struct{}{}
}
result.PagesFetched++
for _, item := range items {
identity := itemIdentity(item, options.IdentityFields)
if _, duplicate := seenItems[identity]; duplicate {
continue
}
seenItems[identity] = struct{}{}
result.Items = append(result.Items, item)
}
if !listFound {
result.Complete = true
return result, nil
}
if len(items) == 0 || (result.HasTotal && len(result.Items) >= result.ServerTotal) {
result.Complete = true
return result, nil
}
if !result.HasTotal && len(items) < options.Limit {
result.Complete = true
return result, nil
}
}
result.Truncated = true
return result, &PaginationError{Page: options.StartPage + options.MaxPages, Cause: fmt.Errorf("maximum page limit reached (%d)", options.MaxPages)}
}
// PaginateAll preserves the original API while using the complete pagination
// implementation and common GitLink wrapper fields.
func (c *Client) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) {
result, err := c.Paginate(path, params, PaginationOptions{})
if result == nil {
return nil, err return nil, err
} }
return result.Items, err
if !env.OK { }
return nil, fmt.Errorf("API error on page %d", page)
} func normalizePaginationOptions(options PaginationOptions) PaginationOptions {
if len(options.ListFields) == 0 {
// Try to extract array from data options.ListFields = append([]string(nil), defaultListFields...)
var items []json.RawMessage }
switch data := env.Data.(type) { if len(options.IdentityFields) == 0 {
case []interface{}: options.IdentityFields = append([]string(nil), defaultIdentityFields...)
for _, item := range data { }
raw, _ := json.Marshal(item) if options.PageParam == "" {
items = append(items, raw) options.PageParam = "page"
} }
case map[string]interface{}: if options.LimitParam == "" {
// Some endpoints wrap in {"data": [...], "total_count": N} options.LimitParam = "limit"
if arr, ok := data["data"]; ok { }
if slice, ok := arr.([]interface{}); ok { if options.StartPage <= 0 {
for _, item := range slice { options.StartPage = 1
raw, _ := json.Marshal(item) }
items = append(items, raw) if options.Limit <= 0 {
} options.Limit = 50
} }
} else { if options.MaxPages <= 0 {
// Single object, not paginated options.MaxPages = 100
raw, _ := json.Marshal(data) }
return []json.RawMessage{raw}, nil return options
} }
}
func cloneValues(values url.Values) url.Values {
if len(items) == 0 { cloned := make(url.Values, len(values))
break for key, entries := range values {
} cloned[key] = append([]string(nil), entries...)
}
all = append(all, items...) return cloned
}
// Check if we got fewer items than limit
limit, _ := strconv.Atoi(params.Get("limit")) func extractPageItems(data interface{}, fields []string) ([]json.RawMessage, bool, error) {
if len(items) < limit { if list, ok := data.([]interface{}); ok {
break return marshalItems(list)
} }
object, ok := data.(map[string]interface{})
page++ if !ok {
} return nil, false, nil
}
return all, nil
for _, field := range fields {
value, exists := object[field]
if !exists {
continue
}
if list, ok := value.([]interface{}); ok {
items, _, err := marshalItems(list)
return items, true, err
}
if nested, ok := value.(map[string]interface{}); ok {
if items, found, err := extractPageItems(nested, fields); found || err != nil {
return items, found, err
}
}
}
if nested, ok := object["data"].(map[string]interface{}); ok {
if items, found, err := extractPageItems(nested, fields); found || err != nil {
return items, found, err
}
}
raw, err := json.Marshal(object)
if err != nil {
return nil, false, err
}
return []json.RawMessage{raw}, false, nil
}
func marshalItems(items []interface{}) ([]json.RawMessage, bool, error) {
result := make([]json.RawMessage, 0, len(items))
for _, item := range items {
raw, err := json.Marshal(item)
if err != nil {
return nil, true, err
}
result = append(result, raw)
}
return result, true, nil
}
func extractTotal(data interface{}) (int, bool) {
object, ok := data.(map[string]interface{})
if !ok {
return 0, false
}
for _, key := range []string{"total_count", "total", "count"} {
if value, exists := object[key]; exists {
return numericCode(value), true
}
}
if nested, ok := object["data"].(map[string]interface{}); ok {
return extractTotal(nested)
}
return 0, false
}
func pageFingerprint(items []json.RawMessage) string {
hash := sha256.New()
for _, item := range items {
hash.Write(item)
hash.Write([]byte{0})
}
return hex.EncodeToString(hash.Sum(nil))
}
func itemIdentity(item json.RawMessage, fields []string) string {
var object map[string]interface{}
if json.Unmarshal(item, &object) == nil {
for _, field := range fields {
if value, exists := object[field]; exists && value != nil && fmt.Sprint(value) != "" {
return field + ":" + fmt.Sprint(value)
}
}
}
hash := sha256.Sum256(item)
return "content:" + hex.EncodeToString(hash[:])
} }

View File

@ -0,0 +1,133 @@
package client
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
)
func TestPaginateBusinessWrapperMultiPageAndDeduplicate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page == 1 {
fmt.Fprint(w, `{"total_count":3,"issues":[{"id":1},{"id":2}]}`)
return
}
fmt.Fprint(w, `{"total_count":3,"issues":[{"id":2},{"id":3}]}`)
}))
defer server.Close()
params := url.Values{"category": {"open"}, "limit": {"2"}}
result, err := (&Client{HTTP: server.Client(), BaseURL: server.URL}).Paginate(
"/issues", params, PaginationOptions{ListFields: []string{"issues"}},
)
if err != nil {
t.Fatalf("Paginate error: %v", err)
}
if !result.Complete || result.PagesFetched != 2 || len(result.Items) != 3 || result.ServerTotal != 3 {
t.Fatalf("unexpected result: %+v", result)
}
if params.Get("page") != "" || params.Get("category") != "open" {
t.Fatalf("input params mutated: %v", params)
}
}
func TestPaginateNestedWrapperAndServerPageCap(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
start := (page - 1) * 2
fmt.Fprintf(w, `{"data":{"total_count":6,"issue_tags":[{"id":%d},{"id":%d}]}}`, start+1, start+2)
}))
defer server.Close()
result, err := (&Client{HTTP: server.Client(), BaseURL: server.URL}).Paginate(
"/tags", nil, PaginationOptions{ListFields: []string{"issue_tags"}, Limit: 50},
)
if err != nil {
t.Fatalf("Paginate error: %v", err)
}
if result.PagesFetched != 3 || len(result.Items) != 6 || !result.Complete {
t.Fatalf("unexpected result: %+v", result)
}
}
func TestPaginateEqualLimitFetchesFinalEmptyPage(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
fmt.Fprint(w, `[{"sha":"a"},{"sha":"b"}]`)
return
}
fmt.Fprint(w, `[]`)
}))
defer server.Close()
result, err := (&Client{HTTP: server.Client(), BaseURL: server.URL}).Paginate(
"/commits", nil, PaginationOptions{Limit: 2},
)
if err != nil {
t.Fatalf("Paginate error: %v", err)
}
if calls != 2 || result.PagesFetched != 2 || len(result.Items) != 2 || !result.Complete {
t.Fatalf("unexpected result: %+v calls=%d", result, calls)
}
}
func TestPaginateDuplicatePageStops(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"pulls":[{"id":1},{"id":2}]}`)
}))
defer server.Close()
result, err := (&Client{HTTP: server.Client(), BaseURL: server.URL}).Paginate(
"/pulls", nil, PaginationOptions{ListFields: []string{"pulls"}, Limit: 2},
)
if err == nil || !errors.Is(err, ErrDuplicatePage) {
t.Fatalf("err = %v, want duplicate-page error", err)
}
if len(result.Items) != 2 || result.PagesFetched != 1 || !result.Truncated || result.Complete {
t.Fatalf("unexpected partial result: %+v", result)
}
}
func TestPaginatePartialFailureRetainsItems(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("page") == "1" {
fmt.Fprint(w, `{"commits":[{"sha":"a"},{"sha":"b"}],"total_count":4}`)
return
}
w.WriteHeader(http.StatusBadGateway)
fmt.Fprint(w, `{"message":"upstream failed"}`)
}))
defer server.Close()
result, err := (&Client{HTTP: server.Client(), BaseURL: server.URL}).Paginate(
"/commits", nil, PaginationOptions{ListFields: []string{"commits"}, Limit: 2},
)
if err == nil {
t.Fatal("expected page-two error")
}
if len(result.Items) != 2 || result.PagesFetched != 1 || !result.Truncated || result.Complete {
t.Fatalf("unexpected partial result: %+v", result)
}
}
func TestPaginateMaxPagesReportsTruncation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
fmt.Fprintf(w, `{"tags":[{"id":"%s-a"},{"id":"%s-b"}]}`, page, page)
}))
defer server.Close()
result, err := (&Client{HTTP: server.Client(), BaseURL: server.URL}).Paginate(
"/tags", nil, PaginationOptions{ListFields: []string{"tags"}, Limit: 2, MaxPages: 2},
)
if err == nil || !result.Truncated || result.PagesFetched != 2 || len(result.Items) != 4 {
t.Fatalf("unexpected result=%+v err=%v", result, err)
}
}

View File

@ -34,6 +34,7 @@
"cmd.dataset.view.short": "View a repository's dataset", "cmd.dataset.view.short": "View a repository's dataset",
"cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.", "cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.",
"cmd.doctor.short": "Diagnose gitlink-cli environment problems", "cmd.doctor.short": "Diagnose gitlink-cli environment problems",
"cmd.ignore.short": "Ignore file template operations",
"cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", "cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
"cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", "cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
"cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", "cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",

View File

@ -34,6 +34,7 @@
"cmd.dataset.view.short": "查看仓库数据集", "cmd.dataset.view.short": "查看仓库数据集",
"cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。", "cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。",
"cmd.doctor.short": "诊断 gitlink-cli 环境问题", "cmd.doctor.short": "诊断 gitlink-cli 环境问题",
"cmd.ignore.short": "忽略文件模板操作",
"cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", "cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
"cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run传入 --yes 后执行。", "cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run传入 --yes 后执行。",
"cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", "cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",

214
shortcuts/common/guarded.go Normal file
View File

@ -0,0 +1,214 @@
package common
import (
"errors"
"fmt"
"strings"
)
type RiskLevel string
const (
RiskLow RiskLevel = "low"
RiskMedium RiskLevel = "medium"
RiskHigh RiskLevel = "high"
)
const redactedValue = "<redacted>"
var ErrConfirmationRequired = errors.New("explicit confirmation is required")
type ExitCoder interface {
ExitCode() int
}
type ConfirmationError struct {
Operation string
Risk RiskLevel
}
func (e *ConfirmationError) Error() string {
return fmt.Sprintf("%s: %v for %s-risk operation", e.Operation, ErrConfirmationRequired, e.Risk)
}
func (e *ConfirmationError) Unwrap() error { return ErrConfirmationRequired }
func (e *ConfirmationError) ExitCode() int { return 2 }
type GuardedOperation struct {
Name string
Risk RiskLevel
DryRun bool
Confirmed bool
RequireConfirmation bool
Preview interface{}
SensitiveFields []string
}
type GuardedOperationResult struct {
Operation string `json:"operation"`
Risk RiskLevel `json:"risk"`
DryRun bool `json:"dry_run"`
Executed bool `json:"executed"`
Preview interface{} `json:"preview,omitempty"`
Data interface{} `json:"data,omitempty"`
}
func (op GuardedOperation) Execute(run func() (interface{}, error)) (*GuardedOperationResult, error) {
op = normalizeGuardedOperation(op)
result := &GuardedOperationResult{
Operation: op.Name,
Risk: op.Risk,
DryRun: op.DryRun,
Preview: RedactSensitive(op.Preview, op.SensitiveFields),
}
if op.DryRun {
return result, nil
}
if (op.RequireConfirmation || op.Risk == RiskHigh) && !op.Confirmed {
return result, &ConfirmationError{Operation: op.Name, Risk: op.Risk}
}
data, err := run()
if err != nil {
return result, err
}
result.Executed = true
result.Data = RedactSensitive(data, op.SensitiveFields)
return result, nil
}
type BatchOperation struct {
ID string
Preview interface{}
Run func() (interface{}, error)
}
type BatchOptions struct {
Guard GuardedOperation
ContinueOnError bool
}
type BatchItemResult struct {
ID string `json:"id"`
Status string `json:"status"`
Preview interface{} `json:"preview,omitempty"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
type BatchResult struct {
Operation string `json:"operation"`
Risk RiskLevel `json:"risk"`
DryRun bool `json:"dry_run"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Items []BatchItemResult `json:"items"`
}
type BatchError struct {
Failed int
Total int
}
func (e *BatchError) Error() string {
return fmt.Sprintf("batch operation failed for %d of %d items", e.Failed, e.Total)
}
func (e *BatchError) ExitCode() int { return 3 }
func ExecuteBatch(operations []BatchOperation, options BatchOptions) (*BatchResult, error) {
guard := normalizeGuardedOperation(options.Guard)
result := &BatchResult{
Operation: guard.Name,
Risk: guard.Risk,
DryRun: guard.DryRun,
Total: len(operations),
Items: make([]BatchItemResult, 0, len(operations)),
}
if !guard.DryRun && (guard.RequireConfirmation || guard.Risk == RiskHigh) && !guard.Confirmed {
result.Skipped = len(operations)
return result, &ConfirmationError{Operation: guard.Name, Risk: guard.Risk}
}
for index, operation := range operations {
item := BatchItemResult{
ID: operation.ID,
Preview: RedactSensitive(operation.Preview, guard.SensitiveFields),
}
if guard.DryRun {
item.Status = "skipped"
result.Skipped++
result.Items = append(result.Items, item)
continue
}
data, err := operation.Run()
if err != nil {
item.Status = "failed"
item.Error = err.Error()
result.Failed++
result.Items = append(result.Items, item)
if !options.ContinueOnError {
remaining := len(operations) - index - 1
result.Skipped += remaining
for _, skipped := range operations[index+1:] {
result.Items = append(result.Items, BatchItemResult{ID: skipped.ID, Status: "skipped", Preview: RedactSensitive(skipped.Preview, guard.SensitiveFields)})
}
break
}
continue
}
item.Status = "succeeded"
item.Data = RedactSensitive(data, guard.SensitiveFields)
result.Succeeded++
result.Items = append(result.Items, item)
}
if result.Failed > 0 {
return result, &BatchError{Failed: result.Failed, Total: result.Total}
}
return result, nil
}
func normalizeGuardedOperation(op GuardedOperation) GuardedOperation {
if op.Name == "" {
op.Name = "operation"
}
if op.Risk == "" {
op.Risk = RiskMedium
}
return op
}
func RedactSensitive(value interface{}, extraFields []string) interface{} {
sensitive := map[string]struct{}{
"token": {}, "access_token": {}, "authorization": {}, "password": {},
"secret": {}, "api_key": {}, "private_key": {}, "cookie": {},
}
for _, field := range extraFields {
sensitive[strings.ToLower(strings.TrimSpace(field))] = struct{}{}
}
return redactValue(value, sensitive)
}
func redactValue(value interface{}, sensitive map[string]struct{}) interface{} {
switch typed := value.(type) {
case map[string]interface{}:
redacted := make(map[string]interface{}, len(typed))
for key, child := range typed {
if _, ok := sensitive[strings.ToLower(key)]; ok {
redacted[key] = redactedValue
} else {
redacted[key] = redactValue(child, sensitive)
}
}
return redacted
case []interface{}:
redacted := make([]interface{}, len(typed))
for index, child := range typed {
redacted[index] = redactValue(child, sensitive)
}
return redacted
default:
return value
}
}

View File

@ -0,0 +1,87 @@
package common
import (
"errors"
"testing"
)
func TestGuardedOperationDryRunDoesNotExecuteAndRedacts(t *testing.T) {
executed := false
result, err := (GuardedOperation{
Name: "delete", Risk: RiskHigh, DryRun: true,
Preview: map[string]interface{}{"id": 1, "token": "secret"},
}).Execute(func() (interface{}, error) {
executed = true
return nil, nil
})
if err != nil || executed || result.Executed {
t.Fatalf("result=%+v executed=%v err=%v", result, executed, err)
}
if result.Preview.(map[string]interface{})["token"] != redactedValue {
t.Fatal("sensitive preview was not redacted")
}
}
func TestGuardedOperationRequiresExplicitConfirmation(t *testing.T) {
result, err := (GuardedOperation{Name: "delete", Risk: RiskHigh}).Execute(func() (interface{}, error) {
return nil, nil
})
if err == nil || !errors.Is(err, ErrConfirmationRequired) || result.Executed {
t.Fatalf("result=%+v err=%v", result, err)
}
var exitCoder ExitCoder
if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != 2 {
t.Fatalf("confirmation exit code = %v", err)
}
}
func TestGuardedOperationExecutesConfirmedWrite(t *testing.T) {
result, err := (GuardedOperation{Name: "delete", Risk: RiskHigh, Confirmed: true}).Execute(func() (interface{}, error) {
return map[string]interface{}{"deleted": true}, nil
})
if err != nil || !result.Executed || result.Data.(map[string]interface{})["deleted"] != true {
t.Fatalf("result=%+v err=%v", result, err)
}
}
func TestExecuteBatchContinueOnErrorSummarizesPartialSuccess(t *testing.T) {
operations := []BatchOperation{
{ID: "1", Run: func() (interface{}, error) { return map[string]interface{}{"password": "secret"}, nil }},
{ID: "2", Run: func() (interface{}, error) { return nil, errors.New("failed") }},
{ID: "3", Run: func() (interface{}, error) { return "ok", nil }},
}
result, err := ExecuteBatch(operations, BatchOptions{
Guard: GuardedOperation{Name: "batch", Risk: RiskMedium}, ContinueOnError: true,
})
if err == nil || result.Succeeded != 2 || result.Failed != 1 || result.Skipped != 0 {
t.Fatalf("result=%+v err=%v", result, err)
}
if result.Items[0].Data.(map[string]interface{})["password"] != redactedValue {
t.Fatal("batch result was not redacted")
}
var exitCoder ExitCoder
if !errors.As(err, &exitCoder) || exitCoder.ExitCode() != 3 {
t.Fatalf("batch exit code = %v", err)
}
}
func TestExecuteBatchStopsAndMarksRemainingItemsSkipped(t *testing.T) {
executedThird := false
operations := []BatchOperation{
{ID: "1", Run: func() (interface{}, error) { return nil, errors.New("failed") }},
{ID: "2", Run: func() (interface{}, error) { executedThird = true; return nil, nil }},
}
result, err := ExecuteBatch(operations, BatchOptions{Guard: GuardedOperation{Name: "batch"}})
if err == nil || result.Failed != 1 || result.Skipped != 1 || executedThird {
t.Fatalf("result=%+v executed=%v err=%v", result, executedThird, err)
}
}
func TestExecuteBatchDryRunSkipsAllItems(t *testing.T) {
result, err := ExecuteBatch([]BatchOperation{{ID: "1"}, {ID: "2"}}, BatchOptions{
Guard: GuardedOperation{Name: "batch", DryRun: true},
})
if err != nil || result.Skipped != 2 || result.Succeeded != 0 || result.Failed != 0 {
t.Fatalf("result=%+v err=%v", result, err)
}
}

View File

@ -95,6 +95,12 @@ func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.R
return ctx.Client.PaginateAll(path, params) return ctx.Client.PaginateAll(path, params)
} }
// Paginate fetches pages with an endpoint-specific list field and returns
// completeness metadata alongside any partial result.
func (ctx *RuntimeContext) Paginate(path string, params url.Values, options client.PaginationOptions) (*client.PaginationResult, error) {
return ctx.Client.Paginate(path, params, options)
}
// Output prints the envelope in the configured format. // Output prints the envelope in the configured format.
func (ctx *RuntimeContext) Output(env *output.Envelope) error { func (ctx *RuntimeContext) Output(env *output.Envelope) error {
return output.Print(env, ctx.Format) return output.Print(env, ctx.Format)