2
0

feat(api): implement v2 workflow status and failure endpoints
Some checks failed
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 7m27s
Build and Release / Unit Tests (push) Successful in 7m45s
Build and Release / Lint (push) Successful in 7m58s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m7s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 5m35s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h6m42s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 5m49s
Build and Release / Build Binary (linux/arm64) (push) Failing after 2m57s

Adds v2 API endpoints for optimized workflow status queries. Implements /workflows/status to fetch latest run per workflow in single query using MAX(id) grouping. Adds /runs/{id}/failure-log endpoint returning structured failure data with job details, failed steps, log tails (last 200 lines), and workflow YAML content. Reduces client-side API calls and processing overhead.
This commit is contained in:
2026-02-14 11:54:55 -05:00
parent de7d6a719e
commit 56cf9d1833
5 changed files with 289 additions and 0 deletions

View File

@@ -125,6 +125,21 @@ func (opts FindRunOptions) ToOrders() string {
return "`action_run`.`id` DESC"
}
// GetLatestRunPerWorkflow returns the most recent run for each workflow in a repo.
// Uses a subquery to find the MAX(id) per workflow_id, then loads those runs.
func GetLatestRunPerWorkflow(ctx context.Context, repoID int64) (RunList, error) {
subQuery := builder.Select("MAX(id)").
From("`action_run`").
Where(builder.Eq{"`action_run`.repo_id": repoID}).
GroupBy("`action_run`.workflow_id")
var runs RunList
err := db.GetEngine(ctx).
Where(builder.In("`action_run`.id", subQuery)).
Find(&runs)
return runs, err
}
type StatusInfo struct {
Status int
DisplayedStatus string

View File

@@ -205,3 +205,45 @@ type ActionRunnersResponse struct {
Entries []*ActionRunner `json:"runners"`
TotalCount int64 `json:"total_count"`
}
// ActionWorkflowStatus represents the latest run status for a single workflow
type ActionWorkflowStatus struct {
WorkflowID string `json:"workflow_id"`
WorkflowName string `json:"workflow_name"`
Status string `json:"status"`
Conclusion string `json:"conclusion,omitempty"`
RunID int64 `json:"run_id"`
RunNumber int64 `json:"run_number"`
Event string `json:"event"`
HeadBranch string `json:"head_branch,omitempty"`
HTMLURL string `json:"html_url"`
// swagger:strfmt date-time
StartedAt time.Time `json:"started_at"`
// swagger:strfmt date-time
CompletedAt time.Time `json:"completed_at"`
}
// ActionWorkflowStatusResponse returns the latest run status per workflow
type ActionWorkflowStatusResponse struct {
Workflows []*ActionWorkflowStatus `json:"workflows"`
}
// ActionJobFailureDetail represents a failed job with its log excerpt
type ActionJobFailureDetail struct {
JobID int64 `json:"job_id"`
JobName string `json:"job_name"`
Status string `json:"status"`
Conclusion string `json:"conclusion,omitempty"`
FailedSteps []string `json:"failed_steps"`
Log string `json:"log"`
}
// ActionRunFailureLog returns a structured failure summary for a workflow run
type ActionRunFailureLog struct {
RunID int64 `json:"run_id"`
Status string `json:"status"`
Conclusion string `json:"conclusion,omitempty"`
WorkflowID string `json:"workflow_id"`
WorkflowYAML string `json:"workflow_yaml,omitempty"`
FailedJobs []*ActionJobFailureDetail `json:"failed_jobs"`
}

View File

@@ -0,0 +1,184 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v2
import (
"bufio"
"io"
"strings"
actions_model "code.gitcaddy.com/server/v3/models/actions"
"code.gitcaddy.com/server/v3/modules/actions"
"code.gitcaddy.com/server/v3/modules/gitrepo"
"code.gitcaddy.com/server/v3/modules/log"
api "code.gitcaddy.com/server/v3/modules/structs"
"code.gitcaddy.com/server/v3/services/context"
"code.gitcaddy.com/server/v3/services/convert"
)
const maxLogTailLines = 200
// GetRunFailureLog returns a structured failure summary for a workflow run.
// It includes failed jobs, their failed step names, extracted log tails,
// and the workflow YAML — all in a single response.
func GetRunFailureLog(ctx *context.APIContext) {
runID := ctx.PathParamInt64("run_id")
repo := ctx.Repo.Repository
// 1. Load the run
run, err := actions_model.GetRunByID(ctx, runID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if run == nil || run.RepoID != repo.ID {
ctx.APIErrorNotFound("run not found")
return
}
runStatus, runConclusion := convert.ToActionsStatus(run.Status)
// 2. Load all jobs for this run
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
// 3. Filter to failed jobs; if none explicitly failed, include all
var targetJobs []*actions_model.ActionRunJob
for _, job := range jobs {
if job.Status.IsFailure() {
targetJobs = append(targetJobs, job)
}
}
if len(targetJobs) == 0 {
targetJobs = jobs
}
// 4. For each target job, load steps and log tail
failedJobs := make([]*api.ActionJobFailureDetail, 0, len(targetJobs))
for _, job := range targetJobs {
jobStatus, jobConclusion := convert.ToActionsStatus(job.Status)
detail := &api.ActionJobFailureDetail{
JobID: job.ID,
JobName: job.Name,
Status: jobStatus,
Conclusion: jobConclusion,
}
// Load steps to find failed step names
if job.TaskID > 0 {
steps, err := actions_model.GetTaskStepsByTaskID(ctx, job.TaskID)
if err != nil {
log.Error("GetTaskStepsByTaskID(%d): %v", job.TaskID, err)
} else {
for _, step := range steps {
if step.Status.IsFailure() {
detail.FailedSteps = append(detail.FailedSteps, step.Name)
}
}
}
// Read log tail from the task
detail.Log = readLogTail(ctx, job.TaskID)
}
failedJobs = append(failedJobs, detail)
}
// 5. Read workflow YAML from the git repo
workflowYAML := readWorkflowYAML(ctx, run.WorkflowID)
ctx.JSON(200, &api.ActionRunFailureLog{
RunID: run.ID,
Status: runStatus,
Conclusion: runConclusion,
WorkflowID: run.WorkflowID,
WorkflowYAML: workflowYAML,
FailedJobs: failedJobs,
})
}
// readLogTail reads the last maxLogTailLines from a task's log, stripping timestamps.
func readLogTail(ctx *context.APIContext, taskID int64) string {
task, err := actions_model.GetTaskByID(ctx, taskID)
if err != nil {
log.Error("GetTaskByID(%d): %v", taskID, err)
return ""
}
f, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename)
if err != nil {
log.Error("OpenLogs(%s): %v", task.LogFilename, err)
return ""
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
log.Error("ReadAll log: %v", err)
return ""
}
// Split into lines, take last N
scanner := bufio.NewScanner(strings.NewReader(string(content)))
var allLines []string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
// Strip timestamp prefix
_, parsed, parseErr := actions.ParseLog(line)
if parseErr == nil {
allLines = append(allLines, parsed)
} else {
allLines = append(allLines, line)
}
}
if len(allLines) > maxLogTailLines {
allLines = allLines[len(allLines)-maxLogTailLines:]
}
return strings.Join(allLines, "\n")
}
// readWorkflowYAML reads the workflow file content from the repo's default branch.
func readWorkflowYAML(ctx *context.APIContext, workflowID string) string {
gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository)
if err != nil {
log.Error("OpenRepository: %v", err)
return ""
}
defer gitRepo.Close()
commit, err := gitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
if err != nil {
log.Error("GetBranchCommit(%s): %v", ctx.Repo.Repository.DefaultBranch, err)
return ""
}
// Try .gitea/workflows/ first, then .github/workflows/
for _, prefix := range []string{".gitea/workflows/", ".github/workflows/"} {
blob, err := commit.GetBlobByPath(prefix + workflowID)
if err != nil {
continue
}
reader, err := blob.DataAsync()
if err != nil {
continue
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
continue
}
return string(data)
}
return ""
}

View File

@@ -0,0 +1,46 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v2
import (
actions_model "code.gitcaddy.com/server/v3/models/actions"
"code.gitcaddy.com/server/v3/modules/git"
api "code.gitcaddy.com/server/v3/modules/structs"
"code.gitcaddy.com/server/v3/services/context"
"code.gitcaddy.com/server/v3/services/convert"
)
// ListWorkflowStatuses returns the latest run status for each workflow in the repository.
// This is a batch endpoint that replaces the need to fetch all runs and filter client-side.
func ListWorkflowStatuses(ctx *context.APIContext) {
repo := ctx.Repo.Repository
runs, err := actions_model.GetLatestRunPerWorkflow(ctx, repo.ID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
workflows := make([]*api.ActionWorkflowStatus, 0, len(runs))
for _, run := range runs {
status, conclusion := convert.ToActionsStatus(run.Status)
workflows = append(workflows, &api.ActionWorkflowStatus{
WorkflowID: run.WorkflowID,
WorkflowName: run.Title,
Status: status,
Conclusion: conclusion,
RunID: run.ID,
RunNumber: run.Index,
Event: string(run.Event),
HeadBranch: git.RefName(run.Ref).BranchName(),
StartedAt: run.Started.AsLocalTime(),
CompletedAt: run.Stopped.AsLocalTime(),
HTMLURL: run.HTMLURL(),
})
}
ctx.JSON(200, &api.ActionWorkflowStatusResponse{
Workflows: workflows,
})
}

View File

@@ -146,6 +146,8 @@ func Routes() *web.Router {
m.Get("/runners/status", repoAssignment(), ListRunnersStatus)
m.Get("/runners/{runner_id}/status", repoAssignment(), GetRunnerStatus)
m.Post("/workflows/validate", repoAssignment(), reqToken(), web.Bind(api.WorkflowValidationRequest{}), ValidateWorkflow)
m.Get("/workflows/status", repoAssignment(), ListWorkflowStatuses)
m.Get("/runs/{run_id}/failure-log", repoAssignment(), GetRunFailureLog)
})
// Releases v2 API - Enhanced releases with app update support