mirror of
https://github.com/go-gitea/gitea
synced 2026-06-11 05:03:08 +00:00
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(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(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable),
|
||||
}
|
||||
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.summary": "Summary",
|
||||
"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.collapse_caller_jobs": "Hide jobs of this reusable workflow caller",
|
||||
"actions.runs.attempt": "Attempt",
|
||||
|
||||
@@ -121,6 +121,9 @@ func ArtifactsRoutes(prefix string) *web.Router {
|
||||
m.Get("/{artifact_id}/download", r.downloadArtifact)
|
||||
})
|
||||
|
||||
// Job summary upload endpoint (GITHUB_STEP_SUMMARY).
|
||||
m.Put(jobSummaryRouteBase, uploadJobSummary)
|
||||
|
||||
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 connect.NewResponse(&runnerv1.DeclareResponse{
|
||||
resp := connect.NewResponse(&runnerv1.DeclareResponse{
|
||||
Runner: &runnerv1.Runner{
|
||||
Id: runner.ID,
|
||||
Uuid: runner.UUID,
|
||||
@@ -170,7 +170,11 @@ func (s *Service) Declare(
|
||||
Version: runner.Version,
|
||||
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
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
@@ -89,6 +90,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
resp.State.Run.CanDeleteArtifact = true
|
||||
resp.State.Run.WorkflowID = "workflow-id.yml"
|
||||
resp.State.Run.TriggerEvent = "push"
|
||||
renderUtils := templates.NewRenderUtils(ctx)
|
||||
user2, _ := user_model.GetUserByID(ctx, 2)
|
||||
if user2 == nil {
|
||||
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.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{
|
||||
Name: "artifact-a",
|
||||
Size: 100 * 1024,
|
||||
|
||||
@@ -315,6 +315,8 @@ type ViewResponse struct {
|
||||
Duration string `json:"duration"`
|
||||
TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
|
||||
TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule
|
||||
|
||||
JobSummaries []*ViewJobSummary `json:"jobSummaries,omitempty"`
|
||||
} `json:"run"`
|
||||
CurrentJob struct {
|
||||
Title string `json:"title"`
|
||||
@@ -344,6 +346,12 @@ type ViewJob struct {
|
||||
CallUses string `json:"callUses,omitempty"`
|
||||
}
|
||||
|
||||
type ViewJobSummary struct {
|
||||
JobID int64 `json:"jobId"`
|
||||
JobName string `json:"jobName"`
|
||||
SummaryHTML template.HTML `json:"summaryHTML"`
|
||||
}
|
||||
|
||||
type ViewRunAttempt struct {
|
||||
Attempt int64 `json:"attempt"`
|
||||
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.TriggerEvent = run.TriggerEvent
|
||||
|
||||
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts all share run_attempt_id=0,
|
||||
// so passing 0 here scopes to this run's legacy artifacts only.
|
||||
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts and summaries all
|
||||
// share run_attempt_id=0, so passing 0 here scopes to this run's legacy rows only.
|
||||
var runAttemptID int64
|
||||
if attempt != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
ctx.ServerError("ListUploadedArtifactsMetaByRunAttempt", err)
|
||||
|
||||
@@ -232,6 +232,10 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
|
||||
RepoID: repoID,
|
||||
RunID: run.ID,
|
||||
})
|
||||
recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJobSummary{
|
||||
RepoID: repoID,
|
||||
RunID: run.ID,
|
||||
})
|
||||
|
||||
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.
|
||||
|
||||
@@ -177,6 +177,7 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams
|
||||
&actions_model.ActionScheduleSpec{RepoID: repoID},
|
||||
&actions_model.ActionSchedule{RepoID: repoID},
|
||||
&actions_model.ActionArtifact{RepoID: repoID},
|
||||
&actions_model.ActionRunJobSummary{RepoID: repoID},
|
||||
&actions_model.ActionRunnerToken{RepoID: repoID},
|
||||
&issues_model.IssuePin{RepoID: repoID},
|
||||
); err != nil {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}"
|
||||
data-locale-summary="{{ctx.Locale.Tr "actions.runs.summary"}}"
|
||||
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-collapse-caller-jobs="{{ctx.Locale.Tr "actions.runs.collapse_caller_jobs"}}"
|
||||
data-locale-triggered-via="{{ctx.Locale.Tr "actions.runs.triggered_via"}}"
|
||||
|
||||
@@ -63,18 +63,51 @@ jobs:
|
||||
task2 := runner2.fetchTask(t)
|
||||
_, 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))
|
||||
user2Session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, 999999))
|
||||
user2Session.MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
// run1 and job1 belong to repo1, success
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))
|
||||
findSummary := func(viewResp *actions_web.ViewResponse, jobID int64) *actions_web.ViewJobSummary {
|
||||
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)
|
||||
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.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
|
||||
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"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -44,6 +47,148 @@ func prepareTestEnvActionsArtifacts(t *testing.T) func() {
|
||||
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) {
|
||||
defer prepareTestEnvActionsArtifacts(t)()
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ export function createEmptyActionsRun(): ActionsRun {
|
||||
triggerEvent: '',
|
||||
pullRequest: null,
|
||||
jobs: [] as Array<ActionsJob>,
|
||||
jobSummaries: [],
|
||||
commit: {
|
||||
localeCommit: '',
|
||||
localePushedBy: '',
|
||||
|
||||
@@ -22,6 +22,11 @@ const props = defineProps<{
|
||||
const locale = props.locale;
|
||||
const store = createActionRunViewStore(props.actionsViewUrl);
|
||||
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 = {
|
||||
job: ActionsJob;
|
||||
@@ -277,6 +282,7 @@ async function deleteArtifact(name: string) {
|
||||
</div>
|
||||
|
||||
<div class="action-view-right">
|
||||
<div class="action-view-right-panel">
|
||||
<ActionRunSummaryView
|
||||
v-if="!props.jobId"
|
||||
:store="store"
|
||||
@@ -291,6 +297,21 @@ async function deleteArtifact(name: string) {
|
||||
: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>
|
||||
</template>
|
||||
@@ -463,25 +484,32 @@ async function deleteArtifact(name: string) {
|
||||
width: 70%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-view-right-panel {
|
||||
border: 1px solid var(--color-console-border);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--color-console-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* begin fomantic button overrides */
|
||||
|
||||
.action-view-right .ui.button,
|
||||
.action-view-right .ui.button:focus {
|
||||
.action-view-right-panel .ui.button,
|
||||
.action-view-right-panel .ui.button:focus {
|
||||
background: transparent;
|
||||
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);
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
|
||||
.action-view-right .ui.button:active {
|
||||
.action-view-right-panel .ui.button:active {
|
||||
background: var(--color-console-active-bg);
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
@@ -499,4 +527,39 @@ async function deleteArtifact(name: string) {
|
||||
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>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function initRepositoryActionView() {
|
||||
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
||||
summary: el.getAttribute('data-locale-summary'),
|
||||
allJobs: el.getAttribute('data-locale-all-jobs'),
|
||||
jobSummaries: el.getAttribute('data-locale-job-summaries'),
|
||||
expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'),
|
||||
collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'),
|
||||
triggeredVia: el.getAttribute('data-locale-triggered-via'),
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ActionsRun = {
|
||||
link: string,
|
||||
} | null,
|
||||
jobs: Array<ActionsJob>,
|
||||
jobSummaries?: Array<ActionsJobSummary>,
|
||||
commit: {
|
||||
localeCommit: string,
|
||||
localePushedBy: string,
|
||||
@@ -46,6 +47,12 @@ export type ActionsRun = {
|
||||
},
|
||||
};
|
||||
|
||||
export type ActionsJobSummary = {
|
||||
jobId: number,
|
||||
jobName: string,
|
||||
summaryHTML: string,
|
||||
};
|
||||
|
||||
export type ActionsRunAttempt = {
|
||||
attempt: number;
|
||||
status: ActionsStatus;
|
||||
|
||||
Reference in New Issue
Block a user