giteabot backport / giteabot (push) Canceled after 0s
giteabot / giteabot (push) Canceled after 0s
release-nightly / nightly-binary (push) Canceled after 0s
release-nightly / nightly-container (push) Canceled after 0s
cache-seeder / gobuild (push) Canceled after 0s
cache-seeder / lint (bindata, lint-backend) (push) Canceled after 0s
release-nightly-snapcraft / build-and-publish (push) Canceled after 0s
Includes direct password setup links in registration emails. Assisted-by: Codex:GPT-5
105 lines
3.2 KiB
Go
105 lines
3.2 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
|
|
actions_model "gitea.dev/models/actions"
|
|
"gitea.dev/modules/log"
|
|
"gitea.dev/modules/util"
|
|
)
|
|
|
|
const jobSummaryRouteBase = "/_apis/pipelines/workflows/{run_id}/jobs/{job_id}/steps/{step_index}/summary"
|
|
|
|
func uploadJobSummary(ctx *ArtifactContext) {
|
|
task, _, ok := validateRunID(ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
jobID := ctx.PathParamInt64("job_id")
|
|
if jobID <= 0 || task.Job.ID != jobID {
|
|
ctx.HTTPError(http.StatusBadRequest, "job_id mismatch")
|
|
return
|
|
}
|
|
|
|
stepIndex, err := strconv.ParseInt(ctx.PathParam("step_index"), 10, 64)
|
|
if err != nil || stepIndex < 0 {
|
|
ctx.HTTPError(http.StatusBadRequest, "invalid step_index")
|
|
return
|
|
}
|
|
steps, err := actions_model.GetTaskStepsByTaskID(ctx, task.ID)
|
|
if err != nil {
|
|
log.Error("Error getting task steps: %v", err)
|
|
ctx.HTTPError(http.StatusInternalServerError, "Error getting task steps")
|
|
return
|
|
}
|
|
if !slices.ContainsFunc(steps, func(s *actions_model.ActionTaskStep) bool { return s.Index == stepIndex }) {
|
|
ctx.HTTPError(http.StatusBadRequest, "step_index mismatch")
|
|
return
|
|
}
|
|
|
|
contentType, ok := normalizeJobSummaryContentType(ctx.Req.Header.Get("Content-Type"))
|
|
if !ok {
|
|
ctx.HTTPError(http.StatusBadRequest, "invalid summary content type")
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, actions_model.MaxJobSummarySize+1))
|
|
if err != nil {
|
|
log.Error("Error reading job summary request body: %v", err)
|
|
ctx.HTTPError(http.StatusInternalServerError, "read request body")
|
|
return
|
|
}
|
|
message := "success"
|
|
if len(body) == 0 {
|
|
// PUT with an empty body clears any previously-stored summary for this step.
|
|
if err := actions_model.DeleteActionRunJobSummary(ctx, task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, stepIndex); err != nil {
|
|
log.Error("Error deleting job summary: %v", err)
|
|
ctx.HTTPError(http.StatusInternalServerError, "Error deleting job summary")
|
|
return
|
|
}
|
|
message = "cleared"
|
|
} else if err := actions_model.UpsertActionRunJobSummary(ctx, task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, stepIndex, contentType, body); err != nil {
|
|
if errors.Is(err, actions_model.ErrJobSummaryAggregateExceeded) {
|
|
ctx.HTTPError(http.StatusBadRequest, "job summary aggregate size exceeded")
|
|
return
|
|
}
|
|
if errors.Is(err, util.ErrInvalidArgument) {
|
|
ctx.HTTPError(http.StatusBadRequest, "invalid summary")
|
|
return
|
|
}
|
|
log.Error("Error upsert job summary: %v", err)
|
|
ctx.HTTPError(http.StatusInternalServerError, "Error upsert job summary")
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, map[string]any{
|
|
"message": message,
|
|
"sizeBytes": len(body),
|
|
"runAttempt": task.Job.RunAttemptID,
|
|
})
|
|
}
|
|
|
|
func normalizeJobSummaryContentType(contentType string) (string, bool) {
|
|
if contentType == "" || contentType == "application/octet-stream" {
|
|
return actions_model.JobSummaryContentTypeMarkdown, true
|
|
}
|
|
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
if mediaType != actions_model.JobSummaryContentTypeMarkdown {
|
|
return "", false
|
|
}
|
|
return actions_model.JobSummaryContentTypeMarkdown, true
|
|
}
|