mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-11 17:55:32 +02:00
Move streaming log endpoint to separate branch
GetWorkflowRunLogsStream (POST /runs/{run}/logs) and its supporting
code are moved to feature/runner-logs-stream-api for a standalone PR
where the POST-vs-GET design question can be resolved independently.
Removed from this branch:
- GetWorkflowRunLogsStream handler and route
- getRunJobsAndCurrent helper (only used by the stream handler)
- services/actions/log.go (ReadStepLogs)
- ActionLogCursor/Request/StepLine/Step/Response structs
- TestAPIActionsGetWorkflowRunLogsStream integration test
- Regenerated swagger specs accordingly
Co-Authored-By: Claude Sonnet <claude-sonnet-4-6@anthropic.com>
This commit is contained in:
parent
47ffd078dd
commit
678e17367b
@ -228,35 +228,3 @@ type RunDetails struct {
|
||||
RunURL string `json:"run_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// ActionLogCursor represents a cursor position within a step's log
|
||||
type ActionLogCursor struct {
|
||||
Step int `json:"step"`
|
||||
Cursor int64 `json:"cursor"`
|
||||
Expanded bool `json:"expanded"`
|
||||
}
|
||||
|
||||
// ActionLogRequest is the request body for the streaming log endpoint
|
||||
type ActionLogRequest struct {
|
||||
LogCursors []ActionLogCursor `json:"logCursors"`
|
||||
}
|
||||
|
||||
// ActionLogStepLine represents a single log line within a step
|
||||
type ActionLogStepLine struct {
|
||||
Index int64 `json:"index"`
|
||||
Message string `json:"message"`
|
||||
Timestamp float64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ActionLogStep represents log lines for a single step with cursor state
|
||||
type ActionLogStep struct {
|
||||
Step int `json:"step"`
|
||||
Cursor int64 `json:"cursor"`
|
||||
Lines []*ActionLogStepLine `json:"lines"`
|
||||
Started int64 `json:"started"`
|
||||
}
|
||||
|
||||
// ActionLogResponse is the response body for the streaming log endpoint
|
||||
type ActionLogResponse struct {
|
||||
StepsLog []*ActionLogStep `json:"stepsLog"`
|
||||
}
|
||||
|
||||
@ -1268,10 +1268,7 @@ func Routes() *web.Router {
|
||||
m.Get("/{job_id}/logs", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowJobLogs)
|
||||
m.Post("/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob)
|
||||
})
|
||||
m.Group("/logs", func() {
|
||||
m.Get("", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogs)
|
||||
m.Post("", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogsStream)
|
||||
})
|
||||
m.Get("/logs", reqToken(), reqRepoReader(unit.TypeActions), repo.GetWorkflowRunLogs)
|
||||
m.Get("/artifacts", repo.GetArtifactsOfRun)
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,14 +5,11 @@ package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/common"
|
||||
actions_service "code.gitea.io/gitea/services/actions"
|
||||
@ -237,24 +234,6 @@ func getRunJobs(ctx *context.APIContext, run *actions_model.ActionRun) ([]*actio
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func getRunJobsAndCurrent(ctx *context.APIContext, run *actions_model.ActionRun, jobIndex int64) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, error) {
|
||||
jobs, err := getRunJobs(ctx, run)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
return nil, nil, util.ErrNotExist
|
||||
}
|
||||
|
||||
if jobIndex >= 0 {
|
||||
if jobIndex >= int64(len(jobs)) {
|
||||
return nil, nil, util.ErrNotExist
|
||||
}
|
||||
return jobs[jobIndex], jobs, nil
|
||||
}
|
||||
return jobs[0], jobs, nil
|
||||
}
|
||||
|
||||
func GetWorkflowRunLogs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/logs repository getWorkflowRunLogs
|
||||
// ---
|
||||
@ -369,144 +348,3 @@ func GetWorkflowJobLogs(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func GetWorkflowRunLogsStream(ctx *context.APIContext) {
|
||||
// swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/logs repository getWorkflowRunLogsStream
|
||||
// ---
|
||||
// summary: Get streaming workflow run logs with cursor support
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repository
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: run
|
||||
// in: path
|
||||
// description: run ID
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: job
|
||||
// in: query
|
||||
// description: job index (0-based), defaults to first job
|
||||
// type: integer
|
||||
// required: false
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// type: object
|
||||
// properties:
|
||||
// logCursors:
|
||||
// type: array
|
||||
// items:
|
||||
// type: object
|
||||
// properties:
|
||||
// step:
|
||||
// type: integer
|
||||
// cursor:
|
||||
// type: integer
|
||||
// expanded:
|
||||
// type: boolean
|
||||
// responses:
|
||||
// "200":
|
||||
// description: Streaming logs
|
||||
// schema:
|
||||
// type: object
|
||||
// properties:
|
||||
// stepsLog:
|
||||
// type: array
|
||||
// items:
|
||||
// type: object
|
||||
// properties:
|
||||
// step:
|
||||
// type: integer
|
||||
// cursor:
|
||||
// type: integer
|
||||
// lines:
|
||||
// type: array
|
||||
// items:
|
||||
// type: object
|
||||
// properties:
|
||||
// index:
|
||||
// type: integer
|
||||
// message:
|
||||
// type: string
|
||||
// timestamp:
|
||||
// type: number
|
||||
// started:
|
||||
// type: integer
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
_, run, err := getRunID(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
jobIndex := int64(-1)
|
||||
if ctx.FormString("job") != "" {
|
||||
jobIndex = int64(ctx.FormInt("job"))
|
||||
}
|
||||
|
||||
var req api.ActionLogRequest
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
req = api.ActionLogRequest{LogCursors: []api.ActionLogCursor{}}
|
||||
} else {
|
||||
ctx.APIError(http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
current, _, err := getRunJobsAndCurrent(ctx, run, jobIndex)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var task *actions_model.ActionTask
|
||||
if current.TaskID > 0 {
|
||||
task, err = actions_model.GetTaskByID(ctx, current.TaskID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
task.Job = current
|
||||
if err := task.LoadAttributes(ctx); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response := &api.ActionLogResponse{
|
||||
StepsLog: make([]*api.ActionLogStep, 0),
|
||||
}
|
||||
|
||||
if task != nil {
|
||||
logs, err := actions_service.ReadStepLogs(ctx, req.LogCursors, task, "Log has expired and is no longer available")
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
response.StepsLog = append(response.StepsLog, logs...)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/modules/actions"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
)
|
||||
|
||||
// ReadStepLogs reads log lines for the given cursor positions from a task.
|
||||
// expiredMessage is used as the log content when the task's logs have expired.
|
||||
func ReadStepLogs(ctx context.Context, cursors []api.ActionLogCursor, task *actions_model.ActionTask, expiredMessage string) ([]*api.ActionLogStep, error) {
|
||||
var logs []*api.ActionLogStep
|
||||
steps := actions.FullSteps(task)
|
||||
|
||||
for _, cursor := range cursors {
|
||||
if !cursor.Expanded {
|
||||
continue
|
||||
}
|
||||
if cursor.Step >= len(steps) {
|
||||
continue
|
||||
}
|
||||
step := steps[cursor.Step]
|
||||
|
||||
if task.LogExpired {
|
||||
if cursor.Cursor == 0 {
|
||||
logs = append(logs, &api.ActionLogStep{
|
||||
Step: cursor.Step,
|
||||
Cursor: 1,
|
||||
Lines: []*api.ActionLogStepLine{{
|
||||
Index: 1,
|
||||
Message: expiredMessage,
|
||||
Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second),
|
||||
}},
|
||||
Started: int64(step.Started),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
logLines := make([]*api.ActionLogStepLine, 0)
|
||||
index := step.LogIndex + cursor.Cursor
|
||||
validCursor := cursor.Cursor >= 0 &&
|
||||
// !(cursor.Cursor < step.LogLength) when the frontend tries to fetch the next
|
||||
// line before it's ready — return same cursor and empty lines to let caller retry.
|
||||
cursor.Cursor < step.LogLength &&
|
||||
// !(index < len(task.LogIndexes)) when task data is older than step data.
|
||||
index < int64(len(task.LogIndexes))
|
||||
|
||||
if validCursor {
|
||||
length := step.LogLength - cursor.Cursor
|
||||
offset := task.LogIndexes[index]
|
||||
logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("actions.ReadLogs: %w", err)
|
||||
}
|
||||
for i, row := range logRows {
|
||||
logLines = append(logLines, &api.ActionLogStepLine{
|
||||
Index: cursor.Cursor + int64(i) + 1, // 1-based
|
||||
Message: row.Content,
|
||||
Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logs = append(logs, &api.ActionLogStep{
|
||||
Step: cursor.Step,
|
||||
Cursor: cursor.Cursor + int64(len(logLines)),
|
||||
Lines: logLines,
|
||||
Started: int64(step.Started),
|
||||
})
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
115
templates/swagger/v1_json.tmpl
generated
115
templates/swagger/v1_json.tmpl
generated
@ -5912,121 +5912,6 @@
|
||||
"$ref": "#/responses/notFound"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"repository"
|
||||
],
|
||||
"summary": "Get streaming workflow run logs with cursor support",
|
||||
"operationId": "getWorkflowRunLogsStream",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "owner of the repo",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repository",
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "run ID",
|
||||
"name": "run",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "job index (0-based), defaults to first job",
|
||||
"name": "job",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"logCursors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"type": "integer"
|
||||
},
|
||||
"expanded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"step": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Streaming logs",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stepsLog": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"index": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"started": {
|
||||
"type": "integer"
|
||||
},
|
||||
"step": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/actions/runs/{run}/rerun": {
|
||||
|
||||
@ -16815,130 +16815,6 @@
|
||||
"tags": [
|
||||
"repository"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "getWorkflowRunLogsStream",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "owner of the repo",
|
||||
"in": "path",
|
||||
"name": "owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "name of the repository",
|
||||
"in": "path",
|
||||
"name": "repo",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "run ID",
|
||||
"in": "path",
|
||||
"name": "run",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "job index (0-based), defaults to first job",
|
||||
"in": "query",
|
||||
"name": "job",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"logCursors": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"type": "integer"
|
||||
},
|
||||
"expanded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"step": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-originalParamName": "body"
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"stepsLog": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lines": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"index": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"started": {
|
||||
"type": "integer"
|
||||
},
|
||||
"step": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Streaming logs"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFound"
|
||||
}
|
||||
},
|
||||
"summary": "Get streaming workflow run logs with cursor support",
|
||||
"tags": [
|
||||
"repository"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/repos/{owner}/{repo}/actions/runs/{run}/rerun": {
|
||||
|
||||
@ -9,7 +9,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -549,35 +548,3 @@ func TestAPIActionsGetWorkflowJobLogs(t *testing.T) {
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIActionsGetWorkflowRunLogsStream(t *testing.T) {
|
||||
defer prepareTestEnvActionsArtifacts(t)()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||
session := loginUser(t, user.Name)
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
|
||||
t.Run("EmptyCursors", func(t *testing.T) {
|
||||
req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName()), strings.NewReader(`{"logCursors": []}`)).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
var logResp map[string]any
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &logResp)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, logResp, "stepsLog")
|
||||
})
|
||||
|
||||
t.Run("WithCursor", func(t *testing.T) {
|
||||
req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName()), strings.NewReader(`{"logCursors": [{"step": 0, "cursor": 0, "expanded": true}]}`)).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/logs", repo.FullName()), strings.NewReader(`{"logCursors": []}`)).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user