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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
99 changes: 99 additions & 0 deletions actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -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)
}
}
50 changes: 50 additions & 0 deletions actions_v8.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ package gitcode
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions pulls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading