Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.26', '1.27']
go-version: ['1.27']
steps:
- name: Checkout code
uses: actions/checkout@v4
Expand Down Expand Up @@ -50,7 +50,7 @@ jobs:
run: go test -short -v -race -count=1 -coverprofile=coverage.out ./...

- name: Upload coverage
if: matrix.go-version == '1.26'
if: matrix.go-version == '1.27'
uses: codecov/codecov-action@v4
with:
file: ./coverage.out
Expand All @@ -67,7 +67,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.26'
go-version: '1.27'

- name: Run golangci-lint
uses: golangci/golangci-lint-action@v7
Expand All @@ -85,7 +85,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.26'
go-version: '1.27'

- name: Check formatting
run: |
Expand All @@ -111,7 +111,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.26'
go-version: '1.27'

- name: Run integration tests
env:
Expand All @@ -134,7 +134,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.26'
go-version: '1.27'

- name: Build
run: go build ./...
Expand Down
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
version: "2"
run:
go: "1.26"
go: "1.27"
output:
formats:
text:
Expand Down
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [v0.16.0] - 2026-11-11
### Fixed
- **Retry-After 三道闸(issue #4)**:`getRetryWaitTime` 的 `Retry-After`
分支此前直接返回未校验的秒数——3600 秒照等(越过 `MaxBackoff`)、
负数秒产出负时长(`time.After` 立即触发,退避退化成忙等)、
HTTP-date 形式经 `Atoi` 必然失败被静默忽略。现在统一走
`retryAfterDuration`:非正值(负数/零/过去的日期)与不可解析值
回落指数退避;delay-seconds 与 HTTP-date(`http.ParseTime`)两种
RFC 9110 形式都支持;解析结果与指数退避共用同一道 `MaxBackoff`
钳制;超大秒数在 Duration 转换前封顶防溢出。零值
`InitialBackoff`/`MaxBackoff` 按文档默认(1s/30s)归一。
- **Paginate 页数封顶(issue #5)**:服务端异常恒返满页时,旧实现
300ms 可打出 12 万次请求、只能靠 ctx 止损。现在游走以
`maxPaginatePages = 50`(与 go-gongfeng 对齐)封顶,触顶 yield
哨兵错误 `ErrTooManyPages`(errors.Is 可判定)而非静默结束——
「封顶截断」与「取完」可区分。注:未做「与上一页内容相同即停」
的短路——合法数据里跨页全同(如 200 条同名项)无法与服务端
忽略分页参数区分,误杀面大于收益,封顶已把损害有界化。
- **383 处端点路径统一转义(issue #6)**:全仓端点路径从裸
`fmt.Sprintf` 收编到新 helper `pathf`——路径侧的字符串实参一律
`url.PathEscape`(`/`→`%2F`、`?`→`%3F`、`#`→`%23`,调用方控制
的 owner/repo/org 被隔离在自己的段内,不再能插层级或改写端点);
`?` 后的 query 实参不动;`%d` 等数值动词原样;仓库文件路径这类
天然含 `/` 的参数走 `pathSegs`(逐段转义、保留分隔符)。顺带把
六处裸拼 query 值(search 三件的 `q=`/过滤参数、events 的
year/next、namespace 的 path、archive 的 ref/format、contents/raw
的 ref)改为 `url.QueryEscape`/`url.Values`。
- **响应体读取有界(issue #7)**:client/upload/oauth 全部 9 处
`io.ReadAll` 收口到 `readBodyLimited`(`io.LimitReader` 实现),
分层默认上限:JSON 端点 16 MiB、二进制下载(raw 文件/release
资产/artifact/日志归档)1 GiB;超限报
`ResponseBodyTooLargeError`(`IsResponseBodyTooLarge` 判定)而非
无界分配(实测 256MB 响应曾致堆 +569.7MB)。
- **client 字段读侧持锁(issue #8)**:`baseURL`/`httpClient` 的
读点(doRequest/doRawRequest/doFormRequest/doRawBodyRequest/
retry 循环/upload)此前全部裸读,与持锁写的
`SetHTTPClient`/`SetBaseURL` 构成 data race(interface 撕裂可致
runtime crash)。现在统一经 `requestSnapshot`(RLock 下取值),
与仓内 `retryPolicy`/hooks 的既有读法对齐。
- **upload 走共享管线(issue #9)**:`UploadFileReader`/
`UploadImageReader` 此前自建请求直发——审计 hook 从不触发、
429/5xx 从不重试,与 CHANGELOG「All HTTP request methods now run
request/response hooks and support retry policy」的承诺相悖。现在
构造 multipart body 后走 `doBufferedRequestAt`(鉴权 → request
hook → 重试 → response hook → 有界读体)。同时修复一个连带的
潜伏缺陷:重试第 2+ 次尝试此前复用已耗尽的 body reader(发空
body),现在经 `req.GetBody` 重放——JSON POST 与 multipart 上传
的重试都因此真正可重入(有测试断言三次尝试 body 逐字一致)。
- **ListRepositories 不再丢 Type/Sort(issue #10)**:
`ListRepositoriesOptions` 新增自有 `toQuery`(遮蔽嵌入的
`ListOptions.toQuery`),把 `type`/`sort` 一并上 wire 且经
`QueryEscape`;`ListRepositories`(含 Owner 分支)与
`ListCurrentUserRepositories` 收敛到同一 helper,杜绝同端点同
结构体再次分叉。

### Changed
- **请求执行核心合并**:doRequestAt/doFormRequest/doRawBodyRequest
五份复制粘贴的管线(鉴权→hook→重试→hook→读体→解码)合并为
`doBufferedRequestAt` 单核;doRawRequest/doRawRequestAt 共享
`doRawRequestWith`。行为差异收敛:form 请求对空 body 从「json
unmarshal 报错」变为「跳过解码」(与其余路径一致)。
- **地板提升**:go 指令 1.26→1.27(CI matrix/lint/fmt/integration/
release 与 .golangci.yml 同步);删除手写 `min`,用内建;
`for range n`、`slices.Clone` 就位。本仓零第三方依赖,
`go get -u ./...` 无可升项。

### Added
- `SetMaxResponseBodySize`/`MaxResponseBodySize`:全局覆盖响应体
读上限(<=0 恢复分层默认)。
- `ResponseBodyTooLargeError`/`IsResponseBodyTooLarge`、
`ErrTooManyPages` 哨兵,供调用方程序化判定。

## [v0.15.0] - 2026-11-06
### Added
- **`FindActionRunByHeadSHA`(issue #2 的诚实解法)**:v8 wire 的 runs 列表
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -965,7 +965,9 @@ if err != nil {

### Rate Limit 自动重试

配置自动重试策略,遇到 429/5xx 自动等待重试:
配置自动重试策略,遇到 429/5xx 自动等待重试。服务端返回 `Retry-After`
(delay-seconds 或 HTTP-date 均可)时优先采用该等待,但与指数退避共用
`MaxBackoff` 上限;非正值与不可解析值回落指数退避:

```go
client.SetRetryPolicy(gitcode.RetryPolicy{
Expand All @@ -979,7 +981,10 @@ client.SetRetryPolicy(gitcode.RetryPolicy{

### 自动分页

自动遍历所有分页,无需手动处理 `Page` 参数:
自动遍历所有分页,无需手动处理 `Page` 参数。分页游走以 50 页封顶
(`per_page=100` × 50 页远超任何真实列表):触顶时迭代器 yield 哨兵错误
`gitcode.ErrTooManyPages`(`errors.Is` 可判定),表示结果被截断而非取完——
服务端异常恒返满页时不会无限打请求:

```go
// 收集所有结果到一个 slice
Expand Down
43 changes: 21 additions & 22 deletions actions_v8.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
Expand Down Expand Up @@ -76,7 +75,7 @@ type ListWorkflowsResponse struct {
// GET /v8/repos/{owner}/{repo}/actions/workflows (page/per_page).
func (c *Client) ListActionWorkflows(ctx context.Context, owner, repo string, opts ListOptions) (*ListWorkflowsResponse, error) {
var resp ListWorkflowsResponse
err := c.doRequestAt(ctx, c.v8BaseURL(), http.MethodGet, fmt.Sprintf("/repos/%s/%s/actions/workflows?%s", owner, repo, opts.toQuery()), nil, &resp)
err := c.doRequestAt(ctx, c.v8BaseURL(), http.MethodGet, pathf("/repos/%s/%s/actions/workflows?%s", owner, repo, opts.toQuery()), nil, &resp)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -107,7 +106,7 @@ func (c *Client) DispatchActionWorkflow(ctx context.Context, owner, repo, workfl
opts.Inputs = map[string]string{}
}
var resp DispatchActionWorkflowResult
err := c.doV8Request(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflowID), opts, &resp)
err := c.doV8Request(ctx, http.MethodPost, pathf("/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflowID), opts, &resp)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -143,7 +142,7 @@ type WorkflowValidation struct {
func (c *Client) ValidateActionWorkflowYAML(ctx context.Context, owner, repo string, yamlContent []byte) (*WorkflowValidation, error) {
body := map[string]string{"base64_content": base64.StdEncoding.EncodeToString(yamlContent)}
var resp WorkflowValidation
err := c.doV8Request(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/%s/actions/workflows/validate", owner, repo), body, &resp)
err := c.doV8Request(ctx, http.MethodPost, pathf("/repos/%s/%s/actions/workflows/validate", owner, repo), body, &resp)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -226,7 +225,7 @@ type ListWorkflowRunsResponse struct {
// GET /v8/repos/{owner}/{repo}/actions/runs.
func (c *Client) ListActionRuns(ctx context.Context, owner, repo string, opts ListWorkflowRunsOptions) (*ListWorkflowRunsResponse, error) {
var resp ListWorkflowRunsResponse
err := c.doV8Request(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/%s/actions/runs?%s", owner, repo, opts.toQuery()), nil, &resp)
err := c.doV8Request(ctx, http.MethodGet, pathf("/repos/%s/%s/actions/runs?%s", owner, repo, opts.toQuery()), nil, &resp)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -367,7 +366,7 @@ type WorkflowRunDetail struct {
// GET /v8/repos/{owner}/{repo}/actions/runs/{run_id}.
func (c *Client) GetActionRun(ctx context.Context, owner, repo, runID string) (*WorkflowRunDetail, error) {
var resp WorkflowRunDetail
err := c.doV8Request(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/%s/actions/runs/%s", owner, repo, runID), nil, &resp)
err := c.doV8Request(ctx, http.MethodGet, pathf("/repos/%s/%s/actions/runs/%s", owner, repo, runID), nil, &resp)
if err != nil {
return nil, err
}
Expand All @@ -384,7 +383,7 @@ type stopRetryRerunResult struct {
// POST /v8/repos/{owner}/{repo}/actions/runs/{run_id}/stop → {success}.
func (c *Client) StopActionRun(ctx context.Context, owner, repo, runID string) (bool, error) {
var resp stopRetryRerunResult
err := c.doV8Request(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/%s/actions/runs/%s/stop", owner, repo, runID), nil, &resp)
err := c.doV8Request(ctx, http.MethodPost, pathf("/repos/%s/%s/actions/runs/%s/stop", owner, repo, runID), nil, &resp)
return resp.Success, err
}

Expand All @@ -393,7 +392,7 @@ func (c *Client) StopActionRun(ctx context.Context, owner, repo, runID string) (
// POST /v8/repos/{owner}/{repo}/actions/runs/{run_id}/retry → {success}.
func (c *Client) RetryActionRun(ctx context.Context, owner, repo, runID string) (bool, error) {
var resp stopRetryRerunResult
err := c.doV8Request(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/%s/actions/runs/%s/retry", owner, repo, runID), nil, &resp)
err := c.doV8Request(ctx, http.MethodPost, pathf("/repos/%s/%s/actions/runs/%s/retry", owner, repo, runID), nil, &resp)
return resp.Success, err
}

Expand All @@ -402,7 +401,7 @@ func (c *Client) RetryActionRun(ctx context.Context, owner, repo, runID string)
// POST /v8/repos/{owner}/{repo}/actions/runs/{run_id}/rerun → {success}.
func (c *Client) RerunActionRun(ctx context.Context, owner, repo, runID string) (bool, error) {
var resp stopRetryRerunResult
err := c.doV8Request(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/%s/actions/runs/%s/rerun", owner, repo, runID), nil, &resp)
err := c.doV8Request(ctx, http.MethodPost, pathf("/repos/%s/%s/actions/runs/%s/rerun", owner, repo, runID), nil, &resp)
return resp.Success, err
}

Expand All @@ -418,7 +417,7 @@ type ListActionJobsResponse struct {
// GET /v8/repos/{owner}/{repo}/actions/runs/{run_id}/jobs.
func (c *Client) ListActionRunJobs(ctx context.Context, owner, repo, runID string) (*ListActionJobsResponse, error) {
var resp ListActionJobsResponse
err := c.doV8Request(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/%s/actions/runs/%s/jobs", owner, repo, runID), nil, &resp)
err := c.doV8Request(ctx, http.MethodGet, pathf("/repos/%s/%s/actions/runs/%s/jobs", owner, repo, runID), nil, &resp)
if err != nil {
return nil, err
}
Expand All @@ -431,7 +430,7 @@ func (c *Client) ListActionRunJobs(ctx context.Context, owner, repo, runID strin
// GET /v8/repos/{owner}/{repo}/actions/runs/{run_id}/jobs/{job_id}.
func (c *Client) GetActionJob(ctx context.Context, owner, repo, runID, jobID string) (*ActionJob, error) {
var job ActionJob
err := c.doV8Request(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/%s/actions/runs/%s/jobs/%s", owner, repo, runID, jobID), nil, &job)
err := c.doV8Request(ctx, http.MethodGet, pathf("/repos/%s/%s/actions/runs/%s/jobs/%s", owner, repo, runID, jobID), nil, &job)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -462,7 +461,7 @@ type ActionJobLogs struct {
// POST /v8/repos/{owner}/{repo}/actions/runs/{run_id}/jobs/{job_id}/logs.
func (c *Client) GetActionJobLogs(ctx context.Context, owner, repo, runID, jobID string, opts GetActionJobLogsOptions) (*ActionJobLogs, error) {
var resp ActionJobLogs
err := c.doV8Request(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/%s/actions/runs/%s/jobs/%s/logs", owner, repo, runID, jobID), opts, &resp)
err := c.doV8Request(ctx, http.MethodPost, pathf("/repos/%s/%s/actions/runs/%s/jobs/%s/logs", owner, repo, runID, jobID), opts, &resp)
if err != nil {
return nil, err
}
Expand All @@ -474,7 +473,7 @@ func (c *Client) GetActionJobLogs(ctx context.Context, owner, repo, runID, jobID
// GET /v8/repos/{owner}/{repo}/actions/runs/{run_id}/jobs/{job_id}/download-log.
func (c *Client) DownloadActionJobLogs(ctx context.Context, owner, repo, runID, jobID string) ([]byte, error) {
return c.doRawRequestAt(ctx, c.v8BaseURL(), http.MethodGet,
fmt.Sprintf("/repos/%s/%s/actions/runs/%s/jobs/%s/download-log", owner, repo, runID, jobID))
pathf("/repos/%s/%s/actions/runs/%s/jobs/%s/download-log", owner, repo, runID, jobID))
}

// --- Artifacts ---
Expand Down Expand Up @@ -525,7 +524,7 @@ type ListActionArtifactsResponse struct {
// GET /v8/repos/{owner}/{repo}/actions/artifacts.
func (c *Client) ListActionArtifacts(ctx context.Context, owner, repo string, opts ListActionArtifactsOptions) (*ListActionArtifactsResponse, error) {
var resp ListActionArtifactsResponse
err := c.doV8Request(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/%s/actions/artifacts?%s", owner, repo, opts.toQuery()), nil, &resp)
err := c.doV8Request(ctx, http.MethodGet, pathf("/repos/%s/%s/actions/artifacts?%s", owner, repo, opts.toQuery()), nil, &resp)
if err != nil {
return nil, err
}
Expand All @@ -537,7 +536,7 @@ func (c *Client) ListActionArtifacts(ctx context.Context, owner, repo string, op
// GET /v8/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts.
func (c *Client) ListActionRunArtifacts(ctx context.Context, owner, repo, runID string, opts ListOptions) (*ListActionArtifactsResponse, error) {
var resp ListActionArtifactsResponse
err := c.doV8Request(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/%s/actions/runs/%s/artifacts?%s", owner, repo, runID, opts.toQuery()), nil, &resp)
err := c.doV8Request(ctx, http.MethodGet, pathf("/repos/%s/%s/actions/runs/%s/artifacts?%s", owner, repo, runID, opts.toQuery()), nil, &resp)
if err != nil {
return nil, err
}
Expand All @@ -549,7 +548,7 @@ func (c *Client) ListActionRunArtifacts(ctx context.Context, owner, repo, runID
// GET /v8/repos/{owner}/{repo}/actions/artifacts/{artifact_id}.
func (c *Client) GetActionArtifact(ctx context.Context, owner, repo, artifactID string) (*ActionArtifact, error) {
var artifact ActionArtifact
err := c.doV8Request(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/%s/actions/artifacts/%s", owner, repo, artifactID), nil, &artifact)
err := c.doV8Request(ctx, http.MethodGet, pathf("/repos/%s/%s/actions/artifacts/%s", owner, repo, artifactID), nil, &artifact)
if err != nil {
return nil, err
}
Expand All @@ -560,7 +559,7 @@ func (c *Client) GetActionArtifact(ctx context.Context, owner, repo, artifactID
//
// DELETE /v8/repos/{owner}/{repo}/actions/artifacts/{artifact_id}.
func (c *Client) DeleteActionArtifact(ctx context.Context, owner, repo, artifactID string) error {
return c.doV8Request(ctx, http.MethodDelete, fmt.Sprintf("/repos/%s/%s/actions/artifacts/%s", owner, repo, artifactID), nil, nil)
return c.doV8Request(ctx, http.MethodDelete, pathf("/repos/%s/%s/actions/artifacts/%s", owner, repo, artifactID), nil, nil)
}

// DownloadActionArtifact downloads an artifact archive in format (the
Expand All @@ -569,7 +568,7 @@ func (c *Client) DeleteActionArtifact(ctx context.Context, owner, repo, artifact
// GET /v8/repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}.
func (c *Client) DownloadActionArtifact(ctx context.Context, owner, repo, artifactID, format string) ([]byte, error) {
return c.doRawRequestAt(ctx, c.v8BaseURL(), http.MethodGet,
fmt.Sprintf("/repos/%s/%s/actions/artifacts/%s/%s", owner, repo, artifactID, format))
pathf("/repos/%s/%s/actions/artifacts/%s/%s", owner, repo, artifactID, format))
}

// --- Runners ---
Expand Down Expand Up @@ -620,7 +619,7 @@ type ListActionRunnersResponse struct {
// GET /v8/repos/{owner}/{repo}/actions/runners.
func (c *Client) ListRepoActionRunners(ctx context.Context, owner, repo string, opts ListActionRunnersOptions) (*ListActionRunnersResponse, error) {
var resp ListActionRunnersResponse
err := c.doV8Request(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/%s/actions/runners?%s", owner, repo, opts.toQuery()), nil, &resp)
err := c.doV8Request(ctx, http.MethodGet, pathf("/repos/%s/%s/actions/runners?%s", owner, repo, opts.toQuery()), nil, &resp)
if err != nil {
return nil, err
}
Expand All @@ -633,7 +632,7 @@ func (c *Client) ListRepoActionRunners(ctx context.Context, owner, repo string,
// envelopes mirror the repo host-runners shape.
func (c *Client) listRepoRunnersVariant(ctx context.Context, owner, repo, suffix string, opts ListActionRunnersOptions) (*ListActionRunnersResponse, error) {
var resp ListActionRunnersResponse
err := c.doV8Request(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/%s/actions/%s?%s", owner, repo, suffix, opts.toQuery()), nil, &resp)
err := c.doV8Request(ctx, http.MethodGet, pathf("/repos/%s/%s/actions/%s?%s", owner, repo, suffix, opts.toQuery()), nil, &resp)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -684,7 +683,7 @@ type ListOrgRunnerGroupsResponse struct {
// GET /v8/orgs/{org}/actions/runner-groups.
func (c *Client) ListOrgActionRunnerGroups(ctx context.Context, org string, opts ListActionRunnersOptions) (*ListOrgRunnerGroupsResponse, error) {
var resp ListOrgRunnerGroupsResponse
err := c.doV8Request(ctx, http.MethodGet, fmt.Sprintf("/orgs/%s/actions/runner-groups?%s", org, opts.toQuery()), nil, &resp)
err := c.doV8Request(ctx, http.MethodGet, pathf("/orgs/%s/actions/runner-groups?%s", org, opts.toQuery()), nil, &resp)
if err != nil {
return nil, err
}
Expand All @@ -696,7 +695,7 @@ func (c *Client) ListOrgActionRunnerGroups(ctx context.Context, org string, opts
// GET /v8/orgs/{org}/actions/runner-groups/{group_id}.
func (c *Client) GetActionRunnerGroup(ctx context.Context, org, groupID string) (*ActionRunnerGroup, error) {
var group ActionRunnerGroup
err := c.doV8Request(ctx, http.MethodGet, fmt.Sprintf("/orgs/%s/actions/runner-groups/%s", org, groupID), nil, &group)
err := c.doV8Request(ctx, http.MethodGet, pathf("/orgs/%s/actions/runner-groups/%s", org, groupID), nil, &group)
if err != nil {
return nil, err
}
Expand Down
Loading
Loading