feat(actions): add job summaries (GITHUB_STEP_SUMMARY) (#37500)
- Add GitHub-style Actions **job summaries** support
(`GITHUB_STEP_SUMMARY` / `workflow/SUMMARY.md`) and render them on the
run Summary view.
- Store uploaded summaries internally in the DB (not as downloadable
artifacts).
- Add runtime-token endpoint for runners to upload summaries:
- `PUT
/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/jobs/{job_id}/summary`
- Advertise support to runners via `RunnerService.Declare` response
header:
- `X-Gitea-Actions-Capabilities: job-summary`
- Devtest: extend `/devtest/repo-action-view/...` to include mock
`jobSummaries` for previewing UI rendering.
## Compatibility
- New Gitea + old runner: no summary upload → UI shows nothing (no
behavior change)
- New runner + old Gitea: capability not advertised → runner skips
upload (no behavior change)
## Screenshot:
<img width="2017" height="729"
src="https://github.com/user-attachments/assets/31f8b945-50c4-40e1-9f40-382901a53013"
/>
Fixes #23721
PR on gitea-runner https://gitea.com/gitea/runner/pulls/917
---------
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
co-authored by
GitHub
silverwind
Claude
parent
b1c088e9cf
commit
3b1e75764e
@@ -0,0 +1,207 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.dev/models/db"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// JobSummaryCapability is the runner-declare capability string for job summaries.
|
||||||
|
JobSummaryCapability = "job-summary"
|
||||||
|
|
||||||
|
// JobSummaryContentTypeMarkdown is the only accepted content type for job summaries.
|
||||||
|
JobSummaryContentTypeMarkdown = "text/markdown"
|
||||||
|
|
||||||
|
// MaxJobSummarySize is the maximum accepted per-step summary payload size in bytes.
|
||||||
|
MaxJobSummarySize = 1024 * 1024 // 1 MiB
|
||||||
|
|
||||||
|
// MaxJobSummaryAggregateSize is the maximum aggregate size of all step summaries within
|
||||||
|
// a single job attempt. Matches GitHub's documented per-job summary cap of 1 MiB.
|
||||||
|
MaxJobSummaryAggregateSize = 1024 * 1024 // 1 MiB
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunnerCapabilities returns the value advertised in the X-Gitea-Actions-Capabilities header.
|
||||||
|
// When more capabilities are added, return them comma-separated so runners can split on ", ".
|
||||||
|
func RunnerCapabilities() string {
|
||||||
|
return JobSummaryCapability
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionRunJobSummary struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
|
||||||
|
RepoID int64 `xorm:"UNIQUE(summary_key)"`
|
||||||
|
RunID int64 `xorm:"UNIQUE(summary_key)"`
|
||||||
|
RunAttemptID int64 `xorm:"UNIQUE(summary_key) NOT NULL DEFAULT 0"`
|
||||||
|
JobID int64 `xorm:"UNIQUE(summary_key)"`
|
||||||
|
StepIndex int64 `xorm:"UNIQUE(summary_key)"`
|
||||||
|
|
||||||
|
Content string `xorm:"LONGTEXT"`
|
||||||
|
ContentType string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'text/markdown'"`
|
||||||
|
// ContentSize is the byte length of Content. Stored explicitly because LENGTH()
|
||||||
|
// counts characters (not bytes) on PostgreSQL, SQLite and MSSQL, which would let
|
||||||
|
// multibyte UTF-8 content bypass the aggregate cap.
|
||||||
|
ContentSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||||
|
|
||||||
|
Created timeutil.TimeStamp `xorm:"created"`
|
||||||
|
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(ActionRunJobSummary))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64) (*ActionRunJobSummary, error) {
|
||||||
|
var s ActionRunJobSummary
|
||||||
|
has, err := db.GetEngine(ctx).
|
||||||
|
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index=?", repoID, runID, runAttemptID, jobID, stepIndex).
|
||||||
|
Get(&s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, util.ErrNotExist
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrJobSummaryAggregateExceeded is returned when a step summary upload would push the
|
||||||
|
// aggregate size of summaries for a single job attempt over MaxJobSummaryAggregateSize.
|
||||||
|
var ErrJobSummaryAggregateExceeded = util.NewInvalidArgumentErrorf("job summary aggregate size exceeded")
|
||||||
|
|
||||||
|
func UpsertActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64, contentType string, content []byte) error {
|
||||||
|
if runID <= 0 || jobID <= 0 || repoID <= 0 || stepIndex < 0 {
|
||||||
|
return util.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
if len(content) == 0 {
|
||||||
|
// Treat empty summaries as no-op; runner may create SUMMARY.md but never write to it.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(content) > MaxJobSummarySize {
|
||||||
|
return util.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
if contentType != JobSummaryContentTypeMarkdown {
|
||||||
|
return util.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
|
||||||
|
// The aggregate check is best-effort: a tx wouldn't actually serialize concurrent
|
||||||
|
// step uploads (no row-level lock on the parent job), so wrapping these two
|
||||||
|
// statements only adds round-trip cost without changing the race semantics.
|
||||||
|
// The current step is excluded because the upsert below replaces its size with len(content).
|
||||||
|
otherSize, err := sumOtherJobSummarySizes(ctx, repoID, runID, runAttemptID, jobID, stepIndex)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if otherSize+int64(len(content)) > MaxJobSummaryAggregateSize {
|
||||||
|
return ErrJobSummaryAggregateExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
now := timeutil.TimeStampNow()
|
||||||
|
return upsertActionRunJobSummary(ctx, &ActionRunJobSummary{
|
||||||
|
RepoID: repoID,
|
||||||
|
RunID: runID,
|
||||||
|
RunAttemptID: runAttemptID,
|
||||||
|
JobID: jobID,
|
||||||
|
StepIndex: stepIndex,
|
||||||
|
Content: string(content),
|
||||||
|
ContentSize: int64(len(content)),
|
||||||
|
ContentType: contentType,
|
||||||
|
Created: now,
|
||||||
|
Updated: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumOtherJobSummarySizes returns the total stored size of all step summaries for a job
|
||||||
|
// except excludeStepIndex, computed in the database to avoid loading every row.
|
||||||
|
func sumOtherJobSummarySizes(ctx context.Context, repoID, runID, runAttemptID, jobID, excludeStepIndex int64) (int64, error) {
|
||||||
|
return db.GetEngine(ctx).
|
||||||
|
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index<>?", repoID, runID, runAttemptID, jobID, excludeStepIndex).
|
||||||
|
SumInt(new(ActionRunJobSummary), "content_size")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteActionRunJobSummary removes the stored summary for a specific step. Used when
|
||||||
|
// a runner PUTs an empty body to clear a previously-uploaded step summary.
|
||||||
|
func DeleteActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).
|
||||||
|
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index=?", repoID, runID, runAttemptID, jobID, stepIndex).
|
||||||
|
Delete(new(ActionRunJobSummary))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertActionRunJobSummary(ctx context.Context, summary *ActionRunJobSummary) error {
|
||||||
|
engine := db.GetEngine(ctx)
|
||||||
|
columns := "`repo_id`, `run_id`, `run_attempt_id`, `job_id`, `step_index`, `content`, `content_type`, `content_size`, `created`, `updated`"
|
||||||
|
values := []any{
|
||||||
|
summary.RepoID,
|
||||||
|
summary.RunID,
|
||||||
|
summary.RunAttemptID,
|
||||||
|
summary.JobID,
|
||||||
|
summary.StepIndex,
|
||||||
|
summary.Content,
|
||||||
|
summary.ContentType,
|
||||||
|
summary.ContentSize,
|
||||||
|
summary.Created,
|
||||||
|
summary.Updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
if setting.Database.Type.IsPostgreSQL() || setting.Database.Type.IsSQLite3() {
|
||||||
|
args := append([]any{"INSERT INTO `action_run_job_summary` (" + columns + ") VALUES (?,?,?,?,?,?,?,?,?,?) " +
|
||||||
|
"ON CONFLICT (`repo_id`, `run_id`, `run_attempt_id`, `job_id`, `step_index`) DO UPDATE SET " +
|
||||||
|
"`content` = excluded.`content`, `content_type` = excluded.`content_type`, `content_size` = excluded.`content_size`, `updated` = excluded.`updated`"}, values...)
|
||||||
|
_, err := engine.Exec(args...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if setting.Database.Type.IsMySQL() {
|
||||||
|
args := append([]any{
|
||||||
|
"INSERT INTO `action_run_job_summary` (" + columns + ") VALUES (?,?,?,?,?,?,?,?,?,?) " +
|
||||||
|
"ON DUPLICATE KEY UPDATE `content` = VALUES(`content`), `content_type` = VALUES(`content_type`), `content_size` = VALUES(`content_size`), `updated` = VALUES(`updated`)",
|
||||||
|
}, values...)
|
||||||
|
_, err := engine.Exec(args...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if setting.Database.Type.IsMSSQL() {
|
||||||
|
_, err := engine.Exec(`
|
||||||
|
MERGE INTO action_run_job_summary WITH (HOLDLOCK) AS target
|
||||||
|
USING (SELECT ? AS repo_id, ? AS run_id, ? AS run_attempt_id, ? AS job_id, ? AS step_index) AS source
|
||||||
|
ON target.repo_id = source.repo_id
|
||||||
|
AND target.run_id = source.run_id
|
||||||
|
AND target.run_attempt_id = source.run_attempt_id
|
||||||
|
AND target.job_id = source.job_id
|
||||||
|
AND target.step_index = source.step_index
|
||||||
|
WHEN MATCHED THEN
|
||||||
|
UPDATE SET content = ?, content_type = ?, content_size = ?, updated = ?
|
||||||
|
WHEN NOT MATCHED THEN
|
||||||
|
INSERT (repo_id, run_id, run_attempt_id, job_id, step_index, content, content_type, content_size, created, updated)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
|
`,
|
||||||
|
summary.RepoID, summary.RunID, summary.RunAttemptID, summary.JobID, summary.StepIndex,
|
||||||
|
summary.Content, summary.ContentType, summary.ContentSize, summary.Updated,
|
||||||
|
summary.RepoID, summary.RunID, summary.RunAttemptID, summary.JobID, summary.StepIndex, summary.Content, summary.ContentType, summary.ContentSize, summary.Created, summary.Updated)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return util.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActionRunJobSummaries lists the stored summaries for a run attempt, ordered by job
|
||||||
|
// then step. A positive jobID scopes the lookup to that single job, used by the job view to
|
||||||
|
// avoid rendering every job's summary on each poll; jobID<=0 returns all jobs in the attempt.
|
||||||
|
func ListActionRunJobSummaries(ctx context.Context, repoID, runID, runAttemptID, jobID int64) ([]*ActionRunJobSummary, error) {
|
||||||
|
sess := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=?", repoID, runID, runAttemptID)
|
||||||
|
if jobID > 0 {
|
||||||
|
sess = sess.And("job_id=?", jobID)
|
||||||
|
}
|
||||||
|
var summaries []*ActionRunJobSummary
|
||||||
|
if err := sess.OrderBy("job_id ASC, step_index ASC").Find(&summaries); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return summaries, nil
|
||||||
|
}
|
||||||
@@ -413,6 +413,7 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(333, "Add bypass allowlist to branch protection", v1_27.AddBranchProtectionBypassAllowlist),
|
newMigration(333, "Add bypass allowlist to branch protection", v1_27.AddBranchProtectionBypassAllowlist),
|
||||||
newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner),
|
newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner),
|
||||||
newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob),
|
newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob),
|
||||||
|
newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.dev/models/db"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddActionRunJobSummaryTable(x db.EngineMigration) error {
|
||||||
|
type ActionRunJobSummary struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
|
||||||
|
RepoID int64 `xorm:"UNIQUE(summary_key)"`
|
||||||
|
RunID int64 `xorm:"UNIQUE(summary_key)"`
|
||||||
|
RunAttemptID int64 `xorm:"UNIQUE(summary_key) NOT NULL DEFAULT 0"`
|
||||||
|
JobID int64 `xorm:"UNIQUE(summary_key)"`
|
||||||
|
StepIndex int64 `xorm:"UNIQUE(summary_key)"`
|
||||||
|
|
||||||
|
Content string `xorm:"LONGTEXT"`
|
||||||
|
ContentType string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'text/markdown'"`
|
||||||
|
ContentSize int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||||
|
|
||||||
|
Created timeutil.TimeStamp `xorm:"created"`
|
||||||
|
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return x.Sync(new(ActionRunJobSummary))
|
||||||
|
}
|
||||||
@@ -3798,6 +3798,7 @@
|
|||||||
"actions.runs.view_workflow_file": "View workflow file",
|
"actions.runs.view_workflow_file": "View workflow file",
|
||||||
"actions.runs.summary": "Summary",
|
"actions.runs.summary": "Summary",
|
||||||
"actions.runs.all_jobs": "All jobs",
|
"actions.runs.all_jobs": "All jobs",
|
||||||
|
"actions.runs.job_summaries": "Job summaries",
|
||||||
"actions.runs.expand_caller_jobs": "Show jobs of this reusable workflow caller",
|
"actions.runs.expand_caller_jobs": "Show jobs of this reusable workflow caller",
|
||||||
"actions.runs.collapse_caller_jobs": "Hide jobs of this reusable workflow caller",
|
"actions.runs.collapse_caller_jobs": "Hide jobs of this reusable workflow caller",
|
||||||
"actions.runs.attempt": "Attempt",
|
"actions.runs.attempt": "Attempt",
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ func ArtifactsRoutes(prefix string) *web.Router {
|
|||||||
m.Get("/{artifact_id}/download", r.downloadArtifact)
|
m.Get("/{artifact_id}/download", r.downloadArtifact)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Job summary upload endpoint (GITHUB_STEP_SUMMARY).
|
||||||
|
m.Put(jobSummaryRouteBase, uploadJobSummary)
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -161,7 +161,7 @@ func (s *Service) Declare(
|
|||||||
return nil, status.Errorf(codes.Internal, "update runner: %v", err)
|
return nil, status.Errorf(codes.Internal, "update runner: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return connect.NewResponse(&runnerv1.DeclareResponse{
|
resp := connect.NewResponse(&runnerv1.DeclareResponse{
|
||||||
Runner: &runnerv1.Runner{
|
Runner: &runnerv1.Runner{
|
||||||
Id: runner.ID,
|
Id: runner.ID,
|
||||||
Uuid: runner.UUID,
|
Uuid: runner.UUID,
|
||||||
@@ -170,7 +170,11 @@ func (s *Service) Declare(
|
|||||||
Version: runner.Version,
|
Version: runner.Version,
|
||||||
Labels: runner.AgentLabels,
|
Labels: runner.AgentLabels,
|
||||||
},
|
},
|
||||||
}), nil
|
})
|
||||||
|
// Capabilities are communicated via headers to avoid a hard dependency on a proto bump.
|
||||||
|
// Older runners ignore unknown headers; newer runners can use this for feature negotiation.
|
||||||
|
resp.Header().Set("X-Gitea-Actions-Capabilities", actions_model.RunnerCapabilities())
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchTask assigns a task to the runner
|
// FetchTask assigns a task to the runner
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
@@ -89,6 +90,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
resp.State.Run.CanDeleteArtifact = true
|
resp.State.Run.CanDeleteArtifact = true
|
||||||
resp.State.Run.WorkflowID = "workflow-id.yml"
|
resp.State.Run.WorkflowID = "workflow-id.yml"
|
||||||
resp.State.Run.TriggerEvent = "push"
|
resp.State.Run.TriggerEvent = "push"
|
||||||
|
renderUtils := templates.NewRenderUtils(ctx)
|
||||||
user2, _ := user_model.GetUserByID(ctx, 2)
|
user2, _ := user_model.GetUserByID(ctx, 2)
|
||||||
if user2 == nil {
|
if user2 == nil {
|
||||||
user2 = &user_model.User{Name: "user2"}
|
user2 = &user_model.User{Name: "user2"}
|
||||||
@@ -196,6 +198,20 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
resp.State.Run.CanRerun = runID == 30 && isLatestAttempt
|
resp.State.Run.CanRerun = runID == 30 && isLatestAttempt
|
||||||
resp.State.Run.CanRerunFailed = runID == 30 && isLatestAttempt
|
resp.State.Run.CanRerunFailed = runID == 30 && isLatestAttempt
|
||||||
|
|
||||||
|
// Mock job summaries so the devtest page can preview the Summary panel rendering.
|
||||||
|
resp.State.Run.JobSummaries = []*actions.ViewJobSummary{
|
||||||
|
{
|
||||||
|
JobID: runID * 10,
|
||||||
|
JobName: "job 100 (testsubname)",
|
||||||
|
SummaryHTML: renderUtils.MarkdownToHtml("### Devtest job summary\n\n- Markdown rendering\n- Links: [example](https://example.com)\n\n```sh\necho hello\n```\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
JobID: runID*10 + 2,
|
||||||
|
JobName: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
|
||||||
|
SummaryHTML: renderUtils.MarkdownToHtml("### Another summary\n\nThis demonstrates multiple job summaries in one run.\n\n- Item A\n- Item B\n"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{
|
||||||
Name: "artifact-a",
|
Name: "artifact-a",
|
||||||
Size: 100 * 1024,
|
Size: 100 * 1024,
|
||||||
|
|||||||
@@ -315,6 +315,8 @@ type ViewResponse struct {
|
|||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
|
TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
|
||||||
TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule
|
TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule
|
||||||
|
|
||||||
|
JobSummaries []*ViewJobSummary `json:"jobSummaries,omitempty"`
|
||||||
} `json:"run"`
|
} `json:"run"`
|
||||||
CurrentJob struct {
|
CurrentJob struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -344,6 +346,12 @@ type ViewJob struct {
|
|||||||
CallUses string `json:"callUses,omitempty"`
|
CallUses string `json:"callUses,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ViewJobSummary struct {
|
||||||
|
JobID int64 `json:"jobId"`
|
||||||
|
JobName string `json:"jobName"`
|
||||||
|
SummaryHTML template.HTML `json:"summaryHTML"`
|
||||||
|
}
|
||||||
|
|
||||||
type ViewRunAttempt struct {
|
type ViewRunAttempt struct {
|
||||||
Attempt int64 `json:"attempt"`
|
Attempt int64 `json:"attempt"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -649,12 +657,42 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
|
|||||||
resp.State.Run.PullRequest = refInfo.PullRequest
|
resp.State.Run.PullRequest = refInfo.PullRequest
|
||||||
resp.State.Run.TriggerEvent = run.TriggerEvent
|
resp.State.Run.TriggerEvent = run.TriggerEvent
|
||||||
|
|
||||||
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts all share run_attempt_id=0,
|
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts and summaries all
|
||||||
// so passing 0 here scopes to this run's legacy artifacts only.
|
// share run_attempt_id=0, so passing 0 here scopes to this run's legacy rows only.
|
||||||
var runAttemptID int64
|
var runAttemptID int64
|
||||||
if attempt != nil {
|
if attempt != nil {
|
||||||
runAttemptID = attempt.ID
|
runAttemptID = attempt.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each step's markdown is rendered independently so an unclosed construct
|
||||||
|
// in one step can't bleed into the next.
|
||||||
|
// On a single-job view only that job's summaries are needed; the run view shows all.
|
||||||
|
// Scoping server-side avoids rendering every job's markdown on each 1s poll.
|
||||||
|
summaries, err := actions_model.ListActionRunJobSummaries(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID, ctx.PathParamInt64("job"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("ListActionRunJobSummaries", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(summaries) > 0 {
|
||||||
|
jobNameByID := make(map[int64]string, len(jobs))
|
||||||
|
for _, j := range jobs {
|
||||||
|
jobNameByID[j.ID] = j.Name
|
||||||
|
}
|
||||||
|
renderUtils := templates.NewRenderUtils(ctx)
|
||||||
|
var current *ViewJobSummary
|
||||||
|
for _, s := range summaries {
|
||||||
|
if s.ContentType != actions_model.JobSummaryContentTypeMarkdown {
|
||||||
|
log.Warn("Skip unsupported job summary content type %q for run %d job %d step %d", s.ContentType, s.RunID, s.JobID, s.StepIndex)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if current == nil || current.JobID != s.JobID {
|
||||||
|
current = &ViewJobSummary{JobID: s.JobID, JobName: jobNameByID[s.JobID]}
|
||||||
|
resp.State.Run.JobSummaries = append(resp.State.Run.JobSummaries, current)
|
||||||
|
}
|
||||||
|
current.SummaryHTML += renderUtils.MarkdownToHtml(s.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
arts, err := actions_model.ListUploadedArtifactsMetaByRunAttempt(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID)
|
arts, err := actions_model.ListUploadedArtifactsMetaByRunAttempt(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("ListUploadedArtifactsMetaByRunAttempt", err)
|
ctx.ServerError("ListUploadedArtifactsMetaByRunAttempt", err)
|
||||||
|
|||||||
@@ -232,6 +232,10 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
|
|||||||
RepoID: repoID,
|
RepoID: repoID,
|
||||||
RunID: run.ID,
|
RunID: run.ID,
|
||||||
})
|
})
|
||||||
|
recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJobSummary{
|
||||||
|
RepoID: repoID,
|
||||||
|
RunID: run.ID,
|
||||||
|
})
|
||||||
|
|
||||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
// TODO: Deleting task records could break current ephemeral runner implementation. This is a temporary workaround suggested by ChristopherHX.
|
// TODO: Deleting task records could break current ephemeral runner implementation. This is a temporary workaround suggested by ChristopherHX.
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams
|
|||||||
&actions_model.ActionScheduleSpec{RepoID: repoID},
|
&actions_model.ActionScheduleSpec{RepoID: repoID},
|
||||||
&actions_model.ActionSchedule{RepoID: repoID},
|
&actions_model.ActionSchedule{RepoID: repoID},
|
||||||
&actions_model.ActionArtifact{RepoID: repoID},
|
&actions_model.ActionArtifact{RepoID: repoID},
|
||||||
|
&actions_model.ActionRunJobSummary{RepoID: repoID},
|
||||||
&actions_model.ActionRunnerToken{RepoID: repoID},
|
&actions_model.ActionRunnerToken{RepoID: repoID},
|
||||||
&issues_model.IssuePin{RepoID: repoID},
|
&issues_model.IssuePin{RepoID: repoID},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}"
|
data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}"
|
||||||
data-locale-summary="{{ctx.Locale.Tr "actions.runs.summary"}}"
|
data-locale-summary="{{ctx.Locale.Tr "actions.runs.summary"}}"
|
||||||
data-locale-all-jobs="{{ctx.Locale.Tr "actions.runs.all_jobs"}}"
|
data-locale-all-jobs="{{ctx.Locale.Tr "actions.runs.all_jobs"}}"
|
||||||
|
data-locale-job-summaries="{{ctx.Locale.Tr "actions.runs.job_summaries"}}"
|
||||||
data-locale-expand-caller-jobs="{{ctx.Locale.Tr "actions.runs.expand_caller_jobs"}}"
|
data-locale-expand-caller-jobs="{{ctx.Locale.Tr "actions.runs.expand_caller_jobs"}}"
|
||||||
data-locale-collapse-caller-jobs="{{ctx.Locale.Tr "actions.runs.collapse_caller_jobs"}}"
|
data-locale-collapse-caller-jobs="{{ctx.Locale.Tr "actions.runs.collapse_caller_jobs"}}"
|
||||||
data-locale-triggered-via="{{ctx.Locale.Tr "actions.runs.triggered_via"}}"
|
data-locale-triggered-via="{{ctx.Locale.Tr "actions.runs.triggered_via"}}"
|
||||||
|
|||||||
@@ -63,18 +63,51 @@ jobs:
|
|||||||
task2 := runner2.fetchTask(t)
|
task2 := runner2.fetchTask(t)
|
||||||
_, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
_, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||||
|
|
||||||
|
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, job1.ID, 0, "text/markdown", []byte("### Hello summary\n\nFrom first step.\n")))
|
||||||
|
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, job1.ID, 1, "text/markdown", []byte("From second step.\n")))
|
||||||
|
// A second job's summary in the same run/attempt: the run view must include it,
|
||||||
|
// but the single-job view must scope it out.
|
||||||
|
otherJobID := job1.ID + 1
|
||||||
|
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, otherJobID, 0, "text/markdown", []byte("### Other job summary\n")))
|
||||||
|
|
||||||
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID))
|
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID))
|
||||||
user2Session.MakeRequest(t, req, http.StatusOK)
|
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, 999999))
|
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, 999999))
|
||||||
user2Session.MakeRequest(t, req, http.StatusNotFound)
|
user2Session.MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
|
||||||
// run1 and job1 belong to repo1, success
|
findSummary := func(viewResp *actions_web.ViewResponse, jobID int64) *actions_web.ViewJobSummary {
|
||||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))
|
for _, s := range viewResp.State.Run.JobSummaries {
|
||||||
|
if s.JobID == jobID {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
assertJob1Summary := func(t *testing.T, s *actions_web.ViewJobSummary) {
|
||||||
|
t.Helper()
|
||||||
|
require.NotNil(t, s)
|
||||||
|
assert.Contains(t, string(s.SummaryHTML), "Hello summary")
|
||||||
|
assert.Contains(t, string(s.SummaryHTML), "From second step")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run view: summaries for every job in the run.
|
||||||
|
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID))
|
||||||
resp := user2Session.MakeRequest(t, req, http.StatusOK)
|
resp := user2Session.MakeRequest(t, req, http.StatusOK)
|
||||||
viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})
|
viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})
|
||||||
|
require.Len(t, viewResp.State.Run.JobSummaries, 2)
|
||||||
|
assertJob1Summary(t, findSummary(viewResp, job1.ID))
|
||||||
|
assert.Contains(t, string(findSummary(viewResp, otherJobID).SummaryHTML), "Other job summary")
|
||||||
|
|
||||||
|
// Job view: scoped server-side to the requested job, the other job's summary excluded.
|
||||||
|
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))
|
||||||
|
resp = user2Session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
viewResp = DecodeJSON(t, resp, &actions_web.ViewResponse{})
|
||||||
assert.Len(t, viewResp.State.Run.Jobs, 1)
|
assert.Len(t, viewResp.State.Run.Jobs, 1)
|
||||||
assert.Equal(t, job1.ID, viewResp.State.Run.Jobs[0].ID)
|
assert.Equal(t, job1.ID, viewResp.State.Run.Jobs[0].ID)
|
||||||
|
require.Len(t, viewResp.State.Run.JobSummaries, 1)
|
||||||
|
assertJob1Summary(t, findSummary(viewResp, job1.ID))
|
||||||
|
assert.Nil(t, findSummary(viewResp, otherJobID))
|
||||||
|
|
||||||
// run2 and job2 do not belong to repo1, failure
|
// run2 and job2 do not belong to repo1, failure
|
||||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run2.ID, job2.ID))
|
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run2.ID, job2.ID))
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
|
actions_model "gitea.dev/models/actions"
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
|
"gitea.dev/models/db"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/tests"
|
"gitea.dev/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -44,6 +47,148 @@ func prepareTestEnvActionsArtifacts(t *testing.T) func() {
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getArtifactFixtureTask(t *testing.T) *actions_model.ActionTask {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
task, err := actions_model.GetRunningTaskByToken(t.Context(), "8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, task.LoadJob(t.Context()))
|
||||||
|
ensureArtifactFixtureTaskSteps(t, task)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureArtifactFixtureTaskSteps(t *testing.T, task *actions_model.ActionTask) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
steps, err := actions_model.GetTaskStepsByTaskID(t.Context(), task.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
existingIndexes := make(map[int64]bool, len(steps))
|
||||||
|
for _, step := range steps {
|
||||||
|
existingIndexes[step.Index] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var stepsToInsert []*actions_model.ActionTaskStep
|
||||||
|
for _, idx := range []int64{0, 1} {
|
||||||
|
if existingIndexes[idx] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stepsToInsert = append(stepsToInsert, &actions_model.ActionTaskStep{
|
||||||
|
TaskID: task.ID,
|
||||||
|
Index: idx,
|
||||||
|
RepoID: task.RepoID,
|
||||||
|
Status: actions_model.StatusWaiting,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(stepsToInsert) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.GetEngine(t.Context()).Insert(stepsToInsert)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsJobSummaryUpload(t *testing.T) {
|
||||||
|
defer prepareTestEnvActionsArtifacts(t)()
|
||||||
|
|
||||||
|
const runnerToken = "8061e833a55f6fc0157c98b883e91fcfeeb1a71a"
|
||||||
|
task := getArtifactFixtureTask(t)
|
||||||
|
summaryURL := func(stepIndex int64) string {
|
||||||
|
return fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/%d/summary", task.Job.RunID, task.Job.ID, stepIndex)
|
||||||
|
}
|
||||||
|
putSummary := func(stepIndex int64, body, contentType string) *RequestWrapper {
|
||||||
|
return NewRequestWithBody(t, "PUT", summaryURL(stepIndex), strings.NewReader(body)).
|
||||||
|
AddTokenAuth(runnerToken).
|
||||||
|
SetHeader("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
body := "### Uploaded summary\n\n- line one\n"
|
||||||
|
MakeRequest(t, putSummary(0, body, "text/markdown; charset=utf-8"), http.StatusOK)
|
||||||
|
|
||||||
|
summary, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, actions_model.JobSummaryContentTypeMarkdown, summary.ContentType)
|
||||||
|
assert.Equal(t, body, summary.Content)
|
||||||
|
|
||||||
|
staleUpdated := summary.Updated - 60
|
||||||
|
_, err = db.GetEngine(t.Context()).ID(summary.ID).Cols("updated").Update(&actions_model.ActionRunJobSummary{Updated: staleUpdated})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updatedBody := "### Updated summary\n\n- refreshed\n"
|
||||||
|
MakeRequest(t, putSummary(0, updatedBody, actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
|
||||||
|
|
||||||
|
summary, err = actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, updatedBody, summary.Content)
|
||||||
|
assert.Greater(t, summary.Updated, staleUpdated)
|
||||||
|
|
||||||
|
stepTwoBody := "### Second step summary\n\n- another step\n"
|
||||||
|
MakeRequest(t, putSummary(1, stepTwoBody, actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
|
||||||
|
|
||||||
|
summary, err = actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, stepTwoBody, summary.Content)
|
||||||
|
|
||||||
|
summaries, err := actions_model.ListActionRunJobSummaries(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, summaries, 2)
|
||||||
|
assert.Equal(t, int64(0), summaries[0].StepIndex)
|
||||||
|
assert.Equal(t, int64(1), summaries[1].StepIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid-content-type", func(t *testing.T) {
|
||||||
|
resp := MakeRequest(t, putSummary(0, "summary", "text/html"), http.StatusBadRequest)
|
||||||
|
assert.Contains(t, resp.Body.String(), "invalid summary content type")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("size-limit", func(t *testing.T) {
|
||||||
|
resp := MakeRequest(t, putSummary(0, strings.Repeat("a", actions_model.MaxJobSummarySize+1), actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
|
||||||
|
assert.Contains(t, resp.Body.String(), "invalid summary")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("aggregate-size-limit", func(t *testing.T) {
|
||||||
|
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0,
|
||||||
|
actions_model.JobSummaryContentTypeMarkdown, []byte(strings.Repeat("a", actions_model.MaxJobSummaryAggregateSize-1024))))
|
||||||
|
resp := MakeRequest(t, putSummary(1, strings.Repeat("b", 4096), actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
|
||||||
|
assert.Contains(t, resp.Body.String(), "aggregate size exceeded")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("job-mismatch", func(t *testing.T) {
|
||||||
|
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/0/summary", task.Job.RunID, task.Job.ID+1), strings.NewReader("summary")).
|
||||||
|
AddTokenAuth(runnerToken).
|
||||||
|
SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown)
|
||||||
|
resp := MakeRequest(t, req, http.StatusBadRequest)
|
||||||
|
assert.Contains(t, resp.Body.String(), "job_id mismatch")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("run-mismatch", func(t *testing.T) {
|
||||||
|
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/0/summary", task.Job.RunID+1, task.Job.ID), strings.NewReader("summary")).
|
||||||
|
AddTokenAuth(runnerToken).
|
||||||
|
SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown)
|
||||||
|
resp := MakeRequest(t, req, http.StatusBadRequest)
|
||||||
|
assert.Contains(t, resp.Body.String(), "run-id does not match")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid-step-index", func(t *testing.T) {
|
||||||
|
resp := MakeRequest(t, putSummary(-1, "summary", actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
|
||||||
|
assert.Contains(t, resp.Body.String(), "invalid step_index")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("step-index-mismatch", func(t *testing.T) {
|
||||||
|
resp := MakeRequest(t, putSummary(999, "summary", actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
|
||||||
|
assert.Contains(t, resp.Body.String(), "step_index mismatch")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty-body-clears", func(t *testing.T) {
|
||||||
|
MakeRequest(t, putSummary(0, "### keep me", actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
|
||||||
|
MakeRequest(t, putSummary(0, "", actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
|
||||||
|
|
||||||
|
_, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
|
||||||
|
require.ErrorIs(t, err, util.ErrNotExist)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestActionsArtifactUploadSingleFile(t *testing.T) {
|
func TestActionsArtifactUploadSingleFile(t *testing.T) {
|
||||||
defer prepareTestEnvActionsArtifacts(t)()
|
defer prepareTestEnvActionsArtifacts(t)()
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export function createEmptyActionsRun(): ActionsRun {
|
|||||||
triggerEvent: '',
|
triggerEvent: '',
|
||||||
pullRequest: null,
|
pullRequest: null,
|
||||||
jobs: [] as Array<ActionsJob>,
|
jobs: [] as Array<ActionsJob>,
|
||||||
|
jobSummaries: [],
|
||||||
commit: {
|
commit: {
|
||||||
localeCommit: '',
|
localeCommit: '',
|
||||||
localePushedBy: '',
|
localePushedBy: '',
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ const props = defineProps<{
|
|||||||
const locale = props.locale;
|
const locale = props.locale;
|
||||||
const store = createActionRunViewStore(props.actionsViewUrl);
|
const store = createActionRunViewStore(props.actionsViewUrl);
|
||||||
const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
|
const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
|
||||||
|
const visibleJobSummaries = computed(() => {
|
||||||
|
const summaries = run.value.jobSummaries || [];
|
||||||
|
if (!props.jobId) return summaries;
|
||||||
|
return summaries.filter((summary) => summary.jobId === props.jobId);
|
||||||
|
});
|
||||||
|
|
||||||
type JobListItem = {
|
type JobListItem = {
|
||||||
job: ActionsJob;
|
job: ActionsJob;
|
||||||
@@ -277,19 +282,35 @@ async function deleteArtifact(name: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-view-right">
|
<div class="action-view-right">
|
||||||
<ActionRunSummaryView
|
<div class="action-view-right-panel">
|
||||||
v-if="!props.jobId"
|
<ActionRunSummaryView
|
||||||
:store="store"
|
v-if="!props.jobId"
|
||||||
:locale="locale"
|
:store="store"
|
||||||
:artifact-count="artifacts.length"
|
:locale="locale"
|
||||||
/>
|
:artifact-count="artifacts.length"
|
||||||
<ActionRunJobView
|
/>
|
||||||
v-else
|
<ActionRunJobView
|
||||||
:store="store"
|
v-else
|
||||||
:locale="locale"
|
:store="store"
|
||||||
:actions-view-url="props.actionsViewUrl"
|
:locale="locale"
|
||||||
:job-id="props.jobId"
|
:actions-view-url="props.actionsViewUrl"
|
||||||
/>
|
:job-id="props.jobId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="visibleJobSummaries.length" class="action-view-right-panel job-summary-section">
|
||||||
|
<div class="job-summary-section-header">
|
||||||
|
{{ locale.jobSummaries }}
|
||||||
|
</div>
|
||||||
|
<div class="job-summary-list">
|
||||||
|
<div v-for="s in visibleJobSummaries" :key="s.jobId" class="job-summary-item">
|
||||||
|
<div class="job-summary-header">
|
||||||
|
<strong class="gt-ellipsis">{{ s.jobName || `Job ${s.jobId}` }}</strong>
|
||||||
|
</div>
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div class="markup job-summary-body" v-html="s.summaryHTML"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -463,25 +484,32 @@ async function deleteArtifact(name: string) {
|
|||||||
width: 70%;
|
width: 70%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-view-right-panel {
|
||||||
border: 1px solid var(--color-console-border);
|
border: 1px solid var(--color-console-border);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
background: var(--color-console-bg);
|
background: var(--color-console-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* begin fomantic button overrides */
|
/* begin fomantic button overrides */
|
||||||
|
|
||||||
.action-view-right .ui.button,
|
.action-view-right-panel .ui.button,
|
||||||
.action-view-right .ui.button:focus {
|
.action-view-right-panel .ui.button:focus {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-console-fg-subtle);
|
color: var(--color-console-fg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-view-right .ui.button:hover {
|
.action-view-right-panel .ui.button:hover {
|
||||||
background: var(--color-console-hover-bg);
|
background: var(--color-console-hover-bg);
|
||||||
color: var(--color-console-fg);
|
color: var(--color-console-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-view-right .ui.button:active {
|
.action-view-right-panel .ui.button:active {
|
||||||
background: var(--color-console-active-bg);
|
background: var(--color-console-active-bg);
|
||||||
color: var(--color-console-fg);
|
color: var(--color-console-fg);
|
||||||
}
|
}
|
||||||
@@ -499,4 +527,39 @@ async function deleteArtifact(name: string) {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-summary-section {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-summary-section-header {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-console-border);
|
||||||
|
background: var(--color-console-bg);
|
||||||
|
color: var(--color-console-fg);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-summary-list {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-summary-item {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--color-console-hover-bg);
|
||||||
|
border: 1px solid var(--color-console-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-summary-header {
|
||||||
|
color: var(--color-console-fg);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-summary-body {
|
||||||
|
color: var(--color-console-fg);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function initRepositoryActionView() {
|
|||||||
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
||||||
summary: el.getAttribute('data-locale-summary'),
|
summary: el.getAttribute('data-locale-summary'),
|
||||||
allJobs: el.getAttribute('data-locale-all-jobs'),
|
allJobs: el.getAttribute('data-locale-all-jobs'),
|
||||||
|
jobSummaries: el.getAttribute('data-locale-job-summaries'),
|
||||||
expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'),
|
expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'),
|
||||||
collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'),
|
collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'),
|
||||||
triggeredVia: el.getAttribute('data-locale-triggered-via'),
|
triggeredVia: el.getAttribute('data-locale-triggered-via'),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type ActionsRun = {
|
|||||||
link: string,
|
link: string,
|
||||||
} | null,
|
} | null,
|
||||||
jobs: Array<ActionsJob>,
|
jobs: Array<ActionsJob>,
|
||||||
|
jobSummaries?: Array<ActionsJobSummary>,
|
||||||
commit: {
|
commit: {
|
||||||
localeCommit: string,
|
localeCommit: string,
|
||||||
localePushedBy: string,
|
localePushedBy: string,
|
||||||
@@ -46,6 +47,12 @@ export type ActionsRun = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ActionsJobSummary = {
|
||||||
|
jobId: number,
|
||||||
|
jobName: string,
|
||||||
|
summaryHTML: string,
|
||||||
|
};
|
||||||
|
|
||||||
export type ActionsRunAttempt = {
|
export type ActionsRunAttempt = {
|
||||||
attempt: number;
|
attempt: number;
|
||||||
status: ActionsStatus;
|
status: ActionsStatus;
|
||||||
|
|||||||
Reference in New Issue
Block a user