Files
act_runner/e2e/gitea_api.go
Lunny Xiao b712c91c2d test(e2e): cover host mode, reusable workflows, scheduling, reconnect and CLI flags (#1347)
Extends the e2e suite with scenarios for host labels, local `workflow_call`, label routing, `runner.capacity`, a dropped and restored connection to Gitea, and the daemon's `--once`, `--labels` and `shutdown_timeout` behaviour.

Stacked on https://gitea.com/gitea/runner/pulls/1346, so its commit shows here until that merges. The new scenarios pass locally against gitea/gitea:latest and main-nightly.

Assisted-by: Codet:codet
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1347
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-10-28 04:32:50 +00:00

243 lines
6.9 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build e2e
package e2e
import (
"bytes"
"context"
"encoding/base64"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const pollInterval = 250 * time.Millisecond
type GiteaAPI struct {
baseURL string
token string
}
type ActionRun struct {
ID int64 `json:"id"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
Event string `json:"event"`
}
type ActionJob struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
RunnerName string `json:"runner_name"`
}
type ActionRunner struct {
Name string `json:"name"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
}
func (a *GiteaAPI) CreateRepo(ctx context.Context, name string) error {
body := map[string]any{"name": name, "auto_init": true}
return a.doJSON(ctx, http.MethodPost, "/api/v1/user/repos", body, nil)
}
func (a *GiteaAPI) CreateFile(ctx context.Context, repo, path, content, message string) error {
body := map[string]any{
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
}
url := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", giteaAdminUser, repo, path)
return a.doJSON(ctx, http.MethodPost, url, body, nil)
}
func (a *GiteaAPI) CreateTag(ctx context.Context, repo, name string) error {
body := map[string]any{"tag_name": name}
url := fmt.Sprintf("/api/v1/repos/%s/%s/tags", giteaAdminUser, repo)
return a.doJSON(ctx, http.MethodPost, url, body, nil)
}
func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error {
body := map[string]any{"data": value}
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name)
return a.doJSON(ctx, http.MethodPut, url, body, nil)
}
func (a *GiteaAPI) DefaultBranch(ctx context.Context, repo string) (string, error) {
var resp struct {
DefaultBranch string `json:"default_branch"`
}
url := fmt.Sprintf("/api/v1/repos/%s/%s", giteaAdminUser, repo)
if err := a.doJSON(ctx, http.MethodGet, url, nil, &resp); err != nil {
return "", err
}
return resp.DefaultBranch, nil
}
func (a *GiteaAPI) CreateVariable(ctx context.Context, repo, name, value string) error {
body := map[string]any{"value": value}
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", giteaAdminUser, repo, name)
return a.doJSON(ctx, http.MethodPost, url, body, nil)
}
func (a *GiteaAPI) DispatchWorkflow(ctx context.Context, repo, workflowID, ref string, inputs map[string]string) error {
body := map[string]any{"ref": ref}
if len(inputs) > 0 {
body["inputs"] = inputs
}
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", giteaAdminUser, repo, workflowID)
return a.doJSON(ctx, http.MethodPost, url, body, nil)
}
var ErrCancelUnsupported = errors.New("run cancellation is unsupported by this gitea version")
func (a *GiteaAPI) CancelRun(ctx context.Context, repo string, runID int64) error {
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/cancel", giteaAdminUser, repo, runID)
err := a.doJSON(ctx, http.MethodPost, url, nil, nil)
var status statusError
if errors.As(err, &status) && status.routeAbsent() {
return ErrCancelUnsupported
}
return err
}
func (a *GiteaAPI) Runs(ctx context.Context, repo string) ([]ActionRun, error) {
var resp struct {
WorkflowRuns []ActionRun `json:"workflow_runs"`
}
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?limit=1", giteaAdminUser, repo)
if err := a.doJSON(ctx, http.MethodGet, url, nil, &resp); err != nil {
return nil, err
}
return resp.WorkflowRuns, nil
}
func (a *GiteaAPI) WaitForRunConclusion(ctx context.Context, repo string, runID int64, timeout time.Duration) (*ActionRun, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", giteaAdminUser, repo, runID)
for {
var run ActionRun
if err := a.doJSON(ctx, http.MethodGet, url, nil, &run); err != nil {
return nil, err
}
if run.Status == "completed" {
return &run, nil
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("run %d did not complete within %s (last status %q): %w", runID, timeout, run.Status, ctx.Err())
case <-time.After(pollInterval):
}
}
}
func (a *GiteaAPI) Jobs(ctx context.Context, repo string, runID int64) ([]ActionJob, error) {
var resp struct {
Jobs []ActionJob `json:"jobs"`
}
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/jobs", giteaAdminUser, repo, runID)
if err := a.doJSON(ctx, http.MethodGet, url, nil, &resp); err != nil {
return nil, err
}
return resp.Jobs, nil
}
func (a *GiteaAPI) Runners(ctx context.Context, repo string) ([]ActionRunner, error) {
var resp struct {
Runners []ActionRunner `json:"runners"`
}
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", giteaAdminUser, repo)
if err := a.doJSON(ctx, http.MethodGet, url, nil, &resp); err != nil {
return nil, err
}
return resp.Runners, nil
}
func (a *GiteaAPI) JobLogs(ctx context.Context, repo string, jobID int64) (string, error) {
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/jobs/%d/logs", giteaAdminUser, repo, jobID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+url, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "token "+a.token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode >= 300 {
return "", statusError{method: http.MethodGet, path: url, code: resp.StatusCode, body: string(body)}
}
return string(body), nil
}
func (a *GiteaAPI) doJSON(ctx context.Context, method, path string, reqBody, respBody any) error {
var bodyReader io.Reader
if reqBody != nil {
encoded, err := json.Marshal(reqBody)
if err != nil {
return err
}
bodyReader = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, a.baseURL+path, bodyReader)
if err != nil {
return err
}
req.Header.Set("Authorization", "token "+a.token)
if reqBody != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 300 {
return statusError{method: method, path: path, code: resp.StatusCode, body: string(body)}
}
if respBody != nil && len(body) > 0 {
return json.Unmarshal(body, respBody)
}
return nil
}
type statusError struct {
method, path string
code int
body string
}
func (e statusError) Error() string {
return fmt.Sprintf("%s %s: %d: %s", e.method, e.path, e.code, e.body)
}
func (e statusError) routeAbsent() bool {
return e.code == http.StatusNotFound && strings.TrimSpace(e.body) == "404 page not found"
}