Files
Runner/e2e/gitea_api.go
T
bircni 8260e2def2 test: add end-to-end Gitea compatibility suite (#1180)
Adds a real-Gitea compatibility suite against stable and nightly to catch runner and API drift before release.

The suite shares one Gitea and regular runner, using repository runners only for cache v1/v2 and ephemeral behavior. It covers registration, payload and log encoding, secrets, variables, services, artifacts, outputs, matrices, cache, dispatch, live logs, cancellation, and ephemeral teardown.

CI runs both images in parallel. Warm local timings:

| Image | Before | After |
| --- | ---: | ---: |
| `gitea/gitea:latest` | ~70s | 26.54s |
| `gitea/gitea:main-nightly` | ~54s | 22.53s |

The former serial matrix took about 2 minutes. Parallel suite execution is now bounded by the slower ~26.5-second variant.

Shared Renovate matcher: https://gitea.com/gitea/renovate-config/pulls/552

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1180
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-08-23 12:32:22 +00:00

218 lines
6.1 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"
"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"`
}
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) 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) 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 "", fmt.Errorf("GET %s: %d: %s", url, resp.StatusCode, 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"
}