Compare commits
5 Commits
master
...
fix/client
| Author | SHA1 | Date |
|---|---|---|
|
|
3767ed5852 | |
|
|
8d9e7e7a91 | |
|
|
c9ae4c70b8 | |
|
|
c1f156f7df | |
|
|
198108d282 |
|
|
@ -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.
|
||||
|
|
@ -3,10 +3,12 @@ package client
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||
|
|
@ -24,12 +26,38 @@ type APIError struct {
|
|||
StatusCode int
|
||||
Code interface{}
|
||||
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 {
|
||||
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) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
|
|
@ -100,50 +128,56 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
|
||||
// Check HTTP-level errors
|
||||
if resp.StatusCode >= 400 {
|
||||
message := responseMessage(respData)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(string(respData))
|
||||
}
|
||||
return nil, &APIError{
|
||||
StatusCode: 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
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(respData, &raw); err != nil {
|
||||
if len(bytes.TrimSpace(respData)) == 0 {
|
||||
return output.SuccessEnvelope(nil, nil), 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
|
||||
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
|
||||
// Support both {"status":N, "message":"..."} and gateway {"code":N, "msg":"..."}
|
||||
var bodyCode float64
|
||||
var bodyCode int
|
||||
var bodyMsg string
|
||||
if status, ok := raw["status"]; ok {
|
||||
switch v := status.(type) {
|
||||
case float64:
|
||||
bodyCode = v
|
||||
case int:
|
||||
bodyCode = float64(v)
|
||||
}
|
||||
bodyCode = numericCode(status)
|
||||
bodyMsg, _ = raw["message"].(string)
|
||||
} else if code, ok := raw["code"]; ok {
|
||||
switch v := code.(type) {
|
||||
case float64:
|
||||
bodyCode = v
|
||||
case int:
|
||||
bodyCode = float64(v)
|
||||
}
|
||||
bodyCode = numericCode(code)
|
||||
bodyMsg, _ = raw["msg"].(string)
|
||||
if bodyMsg == "" {
|
||||
bodyMsg, _ = raw["message"].(string)
|
||||
}
|
||||
}
|
||||
if bodyCode != 0 && bodyCode != 200 && bodyCode != 201 && bodyCode != 204 && bodyCode != 1 {
|
||||
suggestion := suggestFix(int(bodyCode))
|
||||
return output.ErrorEnvelope(int(bodyCode), bodyMsg, suggestion), &APIError{
|
||||
StatusCode: int(bodyCode),
|
||||
Code: int(bodyCode),
|
||||
if !isSuccessCode(bodyCode) {
|
||||
suggestion := suggestFix(bodyCode)
|
||||
return output.ErrorEnvelope(bodyCode, bodyMsg, suggestion), &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: bodyCode,
|
||||
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 {
|
||||
var parsedData interface{}
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if strings.HasSuffix(path, ".json") {
|
||||
return false
|
||||
|
|
@ -224,7 +312,7 @@ func suggestFix(code int) string {
|
|||
return "请先运行 gitlink-cli auth login 登录"
|
||||
case 403:
|
||||
return "权限不足,请确认账户权限或联系项目管理员"
|
||||
case 404:
|
||||
case -2, 404:
|
||||
return "资源不存在,请检查 owner/repo/id 是否正确"
|
||||
case 422:
|
||||
return "参数校验失败,请检查请求参数"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package client
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
|
|
@ -25,6 +27,7 @@ func TestSuggestFix(t *testing.T) {
|
|||
{401, "请先运行 gitlink-cli auth login 登录"},
|
||||
{403, "权限不足,请确认账户权限或联系项目管理员"},
|
||||
{404, "资源不存在,请检查 owner/repo/id 是否正确"},
|
||||
{-2, "资源不存在,请检查 owner/repo/id 是否正确"},
|
||||
{422, "参数校验失败,请检查请求参数"},
|
||||
{500, ""},
|
||||
{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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/test.json" {
|
||||
|
|
@ -131,6 +197,45 @@ func TestClientDoHTTPError(t *testing.T) {
|
|||
if apiErr.StatusCode != 404 {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,73 +1,262 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// PaginateAll fetches all pages and returns combined results.
|
||||
func (c *Client) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) {
|
||||
if params == nil {
|
||||
params = url.Values{}
|
||||
}
|
||||
if params.Get("limit") == "" {
|
||||
params.Set("limit", "50")
|
||||
}
|
||||
var ErrDuplicatePage = errors.New("server returned a duplicate page")
|
||||
|
||||
var all []json.RawMessage
|
||||
page := 1
|
||||
|
||||
for {
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
env, err := c.Get(path, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !env.OK {
|
||||
return nil, fmt.Errorf("API error on page %d", page)
|
||||
}
|
||||
|
||||
// Try to extract array from data
|
||||
var items []json.RawMessage
|
||||
switch data := env.Data.(type) {
|
||||
case []interface{}:
|
||||
for _, item := range data {
|
||||
raw, _ := json.Marshal(item)
|
||||
items = append(items, raw)
|
||||
}
|
||||
case map[string]interface{}:
|
||||
// Some endpoints wrap in {"data": [...], "total_count": N}
|
||||
if arr, ok := data["data"]; ok {
|
||||
if slice, ok := arr.([]interface{}); ok {
|
||||
for _, item := range slice {
|
||||
raw, _ := json.Marshal(item)
|
||||
items = append(items, raw)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single object, not paginated
|
||||
raw, _ := json.Marshal(data)
|
||||
return []json.RawMessage{raw}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
all = append(all, items...)
|
||||
|
||||
// Check if we got fewer items than limit
|
||||
limit, _ := strconv.Atoi(params.Get("limit"))
|
||||
if len(items) < limit {
|
||||
break
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
return all, nil
|
||||
var defaultListFields = []string{
|
||||
"data", "items", "list", "issues", "project_issues", "pulls",
|
||||
"pull_requests", "tags", "commits", "issue_tags", "contributors",
|
||||
"releases", "versions", "users",
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
result := &PaginationResult{}
|
||||
seenItems := make(map[string]struct{})
|
||||
seenPages := make(map[string]struct{})
|
||||
|
||||
for pageOffset := 0; pageOffset < options.MaxPages; pageOffset++ {
|
||||
page := options.StartPage + pageOffset
|
||||
query.Set(options.PageParam, strconv.Itoa(page))
|
||||
env, err := c.Get(path, query)
|
||||
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 result.Items, err
|
||||
}
|
||||
|
||||
func normalizePaginationOptions(options PaginationOptions) PaginationOptions {
|
||||
if len(options.ListFields) == 0 {
|
||||
options.ListFields = append([]string(nil), defaultListFields...)
|
||||
}
|
||||
if len(options.IdentityFields) == 0 {
|
||||
options.IdentityFields = append([]string(nil), defaultIdentityFields...)
|
||||
}
|
||||
if options.PageParam == "" {
|
||||
options.PageParam = "page"
|
||||
}
|
||||
if options.LimitParam == "" {
|
||||
options.LimitParam = "limit"
|
||||
}
|
||||
if options.StartPage <= 0 {
|
||||
options.StartPage = 1
|
||||
}
|
||||
if options.Limit <= 0 {
|
||||
options.Limit = 50
|
||||
}
|
||||
if options.MaxPages <= 0 {
|
||||
options.MaxPages = 100
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func cloneValues(values url.Values) url.Values {
|
||||
cloned := make(url.Values, len(values))
|
||||
for key, entries := range values {
|
||||
cloned[key] = append([]string(nil), entries...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func extractPageItems(data interface{}, fields []string) ([]json.RawMessage, bool, error) {
|
||||
if list, ok := data.([]interface{}); ok {
|
||||
return marshalItems(list)
|
||||
}
|
||||
object, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false, 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[:])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
"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.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.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",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"cmd.dataset.view.short": "查看仓库数据集",
|
||||
"cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。",
|
||||
"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.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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -95,6 +95,12 @@ func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.R
|
|||
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.
|
||||
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
|
||||
return output.Print(env, ctx.Format)
|
||||
|
|
|
|||
Loading…
Reference in New Issue