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 (
|
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 "参数校验失败,请检查请求参数"
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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[:])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue