diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d0d78..5d0d63d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **`FindActionRunByHeadSHA`(issue #2 的诚实解法)**:v8 wire 的 runs 列表 + **没有** head_sha 过滤器——文档化 query 参数集只有 + event/status/branch/executor/pull_request_id/workflow_id/workflow_name/ + page/per_page/startTime/endTime(docs.gitcode.com "获取仓库所有的流水线的 + 运行记录",2026-11-06 核对;head_sha 只是响应字段)。按 wire-provenance + 纪律不发明过滤器;helper 用调用方给的文档化参数(prune)预过滤 + + per_page=100 分页游走 + 客户端 head_sha 匹配,命中即停(列表新→旧,新 + commit 的 run 通常在第 1 页);50 页封顶,未命中返回哨兵 + `ErrActionRunNotFound`(errors.Is 可判定)。四组单测(命中带 prune 断言/ + 未命中/跨页/空 SHA 不出网)。 + +### Changed +- **`Assign/UnassignPullRequestReviewers`、`ResetPullRequestReviewStatus` + 补语义注释(issue #2 第 3 条)**:wire 词表是 `/pulls/{n}/assignees`, + 但 GitCode 文档写明语义是「指派/取消/重置 PR **审查**」——SDK 命名忠于 + 平台语义(审查人),与 GitHub/Gitea 的 assignee(经办人)相反;下游 + 跨平台统一面不得按 GitHub 血统把此方法接成 assignee 语义。 + +### 已有能力指路(issue #2 第 2 条,提交时已过时) +- job logs 线路自 v0.10.0(2026-11-02 v8 重写)就有: + `GetActionJobLogs`(POST /v8/repos/{o}/{r}/actions/runs/{run_id}/jobs/ + {job_id}/logs,增量分页 body `{step_id, offset, limit, sort}`)+ + `DownloadActionJobLogs`(GET …/download-log 归档);docs sitemap 同样有 + GET/POST logs 两页。issue 里的「/actions/jobs/{id}/logs」是 GitHub 形状, + v8 把 job 挂在 runs/{run_id}/jobs/{job_id} 下。 + ## [v0.14.0] - 2026-11-04 ### Fixed / Changed (breaking) diff --git a/actions_test.go b/actions_test.go index c8cfa0f..bd5c5fa 100644 --- a/actions_test.go +++ b/actions_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -372,3 +373,101 @@ func TestOrgActionRunnerGroups_V8(t *testing.T) { t.Errorf("get group: %s %+v", cap.Path, group) } } + +// FindActionRunByHeadSHA: the v8 wire has no head_sha filter, so the finder +// pages the documented filters and matches client-side. Hit on page 1 must +// ship prune filters and per_page=100 in the query. +func TestFindActionRunByHeadSHAHit(t *testing.T) { + var cap v8Capture + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + q, _ := url.ParseQuery(r.URL.RawQuery) + cap = v8Capture{Method: r.Method, Path: r.URL.Path, Query: q, Body: string(raw)} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"total_count":1,"workflow_runs":[{"workflow_run_id":"r1","head_sha":"abc123","status":"FAILED"}]}`)) + })) + t.Cleanup(server.Close) + client := NewClientWithBaseURL(server.URL+"/api/v5", "test-token") + + run, err := client.FindActionRunByHeadSHA(context.Background(), "o", "r", "abc123", ListWorkflowRunsOptions{Branch: "main", Status: "FAILED"}) + if err != nil { + t.Fatal(err) + } + if run.WorkflowRunID != "r1" { + t.Fatalf("expected run r1, got %q", run.WorkflowRunID) + } + if !strings.HasPrefix(cap.Path, "/api/v8/repos/o/r/actions/runs") { + t.Fatalf("finder must hit the v8 runs list, got %s", cap.Path) + } + if cap.Query.Get("per_page") != "100" || cap.Query.Get("page") != "1" { + t.Fatalf("finder must scan at per_page=100 from page 1, got page=%s per_page=%s", cap.Query.Get("page"), cap.Query.Get("per_page")) + } + if cap.Query.Get("branch") != "main" || cap.Query.Get("status") != "FAILED" { + t.Fatalf("prune filters must reach the wire, got %v", cap.Query) + } +} + +// No matching SHA across the full list (full page, then short page) must +// surface ErrActionRunNotFound, not a fabricated run or a hang. +func TestFindActionRunByHeadSHANotFound(t *testing.T) { + full := `{"total_count":200,"workflow_runs":[` + + `{"workflow_run_id":"r1","head_sha":"aaa"}` + strings.Repeat(`,{"workflow_run_id":"rx","head_sha":"bbb"}`, 98) + `]}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q, _ := url.ParseQuery(r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + if q.Get("page") == "2" { + _, _ = w.Write([]byte(`{"total_count":200,"workflow_runs":[{"workflow_run_id":"r2","head_sha":"ccc"}]}`)) + return + } + _, _ = w.Write([]byte(full)) + })) + t.Cleanup(server.Close) + client := NewClientWithBaseURL(server.URL+"/api/v5", "test-token") + + run, err := client.FindActionRunByHeadSHA(context.Background(), "o", "r", "deadbeef", ListWorkflowRunsOptions{}) + if run != nil { + t.Fatalf("expected nil run, got %+v", run) + } + if !errors.Is(err, ErrActionRunNotFound) { + t.Fatalf("expected ErrActionRunNotFound, got %v", err) + } +} + +// A match on a later page must be found (walker does not stop at page 1). +func TestFindActionRunByHeadSHASecondPage(t *testing.T) { + page1 := `{"total_count":101,"workflow_runs":[` + strings.Repeat(`{"workflow_run_id":"rx","head_sha":"aaa"},`, 99) + `{"workflow_run_id":"r99","head_sha":"bbb"}]}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q, _ := url.ParseQuery(r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + if q.Get("page") == "2" { + _, _ = w.Write([]byte(`{"total_count":101,"workflow_runs":[{"workflow_run_id":"r100","head_sha":"deadbeef"}]}`)) + return + } + _, _ = w.Write([]byte(page1)) + })) + t.Cleanup(server.Close) + client := NewClientWithBaseURL(server.URL+"/api/v5", "test-token") + + run, err := client.FindActionRunByHeadSHA(context.Background(), "o", "r", "deadbeef", ListWorkflowRunsOptions{}) + if err != nil { + t.Fatal(err) + } + if run.WorkflowRunID != "r100" { + t.Fatalf("expected page-2 run r100, got %q", run.WorkflowRunID) + } +} + +// Empty sha must be rejected before any wire traffic. +func TestFindActionRunByHeadSHARequiresSHA(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ })) + t.Cleanup(server.Close) + client := NewClientWithBaseURL(server.URL+"/api/v5", "test-token") + + if _, err := client.FindActionRunByHeadSHA(context.Background(), "o", "r", "", ListWorkflowRunsOptions{}); err == nil { + t.Fatal("expected an error for empty sha") + } + if calls != 0 { + t.Fatalf("empty sha must not hit the wire, got %d calls", calls) + } +} diff --git a/actions_v8.go b/actions_v8.go index d6c8489..79b60c1 100644 --- a/actions_v8.go +++ b/actions_v8.go @@ -24,6 +24,7 @@ package gitcode import ( "context" "encoding/base64" + "errors" "fmt" "net/http" "net/url" @@ -232,6 +233,55 @@ func (c *Client) ListActionRuns(ctx context.Context, owner, repo string, opts Li return &resp, nil } +// maxFindRunPages caps the by-SHA scan (per_page=100 × 50 pages exceeds any +// real run list; a server that keeps returning full pages must not spin the +// walker forever). +const maxFindRunPages = 50 + +// ErrActionRunNotFound is returned by FindActionRunByHeadSHA when no run in +// the scanned window carries the requested head_sha. +var ErrActionRunNotFound = errors.New("gitcode: no action run matched head_sha") + +// FindActionRunByHeadSHA locates a run by commit SHA. +// +// The v8 wire has NO head_sha filter on the runs list: the documented query +// set is event/status/branch/executor/pull_request_id/workflow_id/ +// workflow_name/page/per_page/startTime/endTime — head_sha appears only as +// a response field (docs.gitcode.com "获取仓库所有的流水线的运行记录", +// checked 2026-11-06). This helper therefore pages ListActionRuns with the +// documented filters you pass in prune and matches head_sha client-side, +// returning the first hit (the list is newest-first, so a fresh commit's +// run usually sits on page 1). +// +// Pass every documented filter that fits your intent — Branch plus Status +// for a CI-failure diagnosis, for instance — to keep the scan short; an +// unpruned scan walks the repo's whole run history (capped at +// maxFindRunPages pages, then it returns the last error of exhaustion). +// Not-found surfaces as ErrActionRunNotFound (errors.Is). +func (c *Client) FindActionRunByHeadSHA(ctx context.Context, owner, repo, sha string, prune ListWorkflowRunsOptions) (*WorkflowRun, error) { + if sha == "" { + return nil, errors.New("gitcode: head sha is required") + } + prune.PerPage = 100 + for page := 1; page <= maxFindRunPages; page++ { + prune.Page = page + resp, err := c.ListActionRuns(ctx, owner, repo, prune) + if err != nil { + return nil, err + } + for _, run := range resp.WorkflowRuns { + if run != nil && run.HeadSHA == sha { + return run, nil + } + } + // Short page ⇒ last page; no hit in it means no hit anywhere. + if len(resp.WorkflowRuns) < 100 { + break + } + } + return nil, ErrActionRunNotFound +} + // WorkflowRunDetail is a run with its full stage → job → step tree. Pre // steps hang off stages with their own (task-shaped) item model. type ActionPreStep struct { diff --git a/pulls.go b/pulls.go index e457d7a..234abca 100644 --- a/pulls.go +++ b/pulls.go @@ -328,10 +328,20 @@ func (c *Client) ReplacePullRequestLabels(ctx context.Context, owner, repo strin return c.doRequest(ctx, http.MethodPut, fmt.Sprintf("/repos/%s/%s/pulls/%d/labels", owner, repo, number), labels, nil) } +// AssignPullRequestReviewers 指派用户审查 PR。 +// +// 语义警告:wire 路径是 /pulls/{n}/assignees(词表像经办人),但 GitCode +// 文档标题写明这是「指派用户审查 Pull Request」——本方法命名忠于平台 +// 语义(审查人),与 GitHub/Gitea 的 assignee(经办人)语义相反。下游做 +// 跨平台 CR 指派人统一面时不要按 GitHub 血统把此方法接到 assignee 语义上。 +// assignees 是用户个人空间地址,逗号分隔。 func (c *Client) AssignPullRequestReviewers(ctx context.Context, owner, repo string, number int, assignees string) error { return c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/%s/pulls/%d/assignees", owner, repo, number), map[string]string{"assignees": assignees}, nil) } +// UnassignPullRequestReviewers 取消用户的 PR 审查指派。语义同 +// AssignPullRequestReviewers——wire 词表 assignees,平台语义是审查人, +// 与 GitHub/Gitea 的 assignee(经办人)相反。 func (c *Client) UnassignPullRequestReviewers(ctx context.Context, owner, repo string, number int, assignees string) error { return c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/repos/%s/%s/pulls/%d/assignees?assignees=%s", owner, repo, number, assignees), nil, nil) } @@ -364,6 +374,10 @@ func (c *Client) ResetPullRequestTestStatus(ctx context.Context, owner, repo str return c.doRequest(ctx, http.MethodPatch, fmt.Sprintf("/repos/%s/%s/pulls/%d/testers", owner, repo, number), body, nil) } +// ResetPullRequestReviewStatus 重置 PR 审查状态。wire 路径词表是 +// assignees,但平台语义是「重置 Pull Request 审查的状态」(见 +// AssignPullRequestReviewers 的语义警告:词表 assignees、语义审查人, +// 与 GitHub/Gitea 的经办人 assignee 相反)。 func (c *Client) ResetPullRequestReviewStatus(ctx context.Context, owner, repo string, number int, resetAll bool) error { body := map[string]interface{}{} if resetAll {