mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-22 09:17:28 +02:00
Merge branch 'main' into renovate/golang
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// maxExpandedNodes bounds how many nodes alias expansion may create. go-yaml's own alias guard does
|
||||
// not cover us: it only counts while decoding into values, and a workflow is kept as raw yaml.Nodes.
|
||||
const maxExpandedNodes = 50000
|
||||
|
||||
var errTooManyYamlNodes = errors.New("maximum YAML nodes exceeded")
|
||||
|
||||
// ReadWorkflow decodes a workflow file with its aliases expanded. Callers inspect the workflow's
|
||||
// raw nodes by kind, and an alias is a kind none of them expect.
|
||||
func ReadWorkflow(content []byte) (*model.Workflow, error) {
|
||||
doc, err := resolveYamlAliases(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readWorkflowDoc(doc)
|
||||
}
|
||||
|
||||
func readWorkflowDoc(doc *yaml.Node) (*model.Workflow, error) {
|
||||
if doc.Kind == 0 {
|
||||
return nil, io.EOF // what a yaml decoder reports for an empty file
|
||||
}
|
||||
w := new(model.Workflow)
|
||||
return w, doc.Decode(w)
|
||||
}
|
||||
|
||||
// decodeResolved is yaml.Unmarshal with aliases expanded first.
|
||||
func decodeResolved(content []byte, out any) error {
|
||||
doc, err := resolveYamlAliases(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return decodeYamlDoc(doc, out)
|
||||
}
|
||||
|
||||
func decodeYamlDoc(doc *yaml.Node, out any) error {
|
||||
if doc.Kind == 0 {
|
||||
return nil // an empty document, as yaml.Unmarshal treats it
|
||||
}
|
||||
return doc.Decode(out)
|
||||
}
|
||||
|
||||
// resolveYamlAliases parses content and replaces every alias with a copy of the node its anchor names.
|
||||
func resolveYamlAliases(content []byte) (*yaml.Node, error) {
|
||||
doc := &yaml.Node{}
|
||||
if err := yaml.Unmarshal(content, doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
budget := maxExpandedNodes
|
||||
return doc, expandAliases(doc, &budget)
|
||||
}
|
||||
|
||||
// expandAliases replaces node's alias descendants in place.
|
||||
func expandAliases(node *yaml.Node, budget *int) error {
|
||||
node.Anchor = "" // a name for a node, not part of the workflow: keep it out of the payloads
|
||||
if err := rejectMergeKeys(node); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, child := range node.Content {
|
||||
if child.Kind != yaml.AliasNode {
|
||||
if err := expandAliases(child, budget); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
copied, err := copyExpanded(child.Alias, budget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node.Content[i] = copied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyExpanded deep copies a node expandAliases already expanded and validated, since an anchor is
|
||||
// declared before the alias naming it. An anchor aliased from inside itself is the exception, and
|
||||
// recurses here until it exhausts budget.
|
||||
func copyExpanded(node *yaml.Node, budget *int) (*yaml.Node, error) {
|
||||
if *budget--; *budget < 0 {
|
||||
return nil, errTooManyYamlNodes
|
||||
}
|
||||
if node.Kind == yaml.AliasNode {
|
||||
return copyExpanded(node.Alias, budget)
|
||||
}
|
||||
|
||||
copied := *node
|
||||
copied.Content = make([]*yaml.Node, len(node.Content))
|
||||
for i, child := range node.Content {
|
||||
child, err := copyExpanded(child, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copied.Content[i] = child
|
||||
}
|
||||
return &copied, nil
|
||||
}
|
||||
|
||||
// rejectMergeKeys refuses `<<: *anchor`, same as GitHub does
|
||||
func rejectMergeKeys(node *yaml.Node) error {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < len(node.Content)-1; i += 2 {
|
||||
if node.Content[i].Tag == "!!merge" {
|
||||
return errors.New("merge keys (`<<`) are not supported, alias the whole value instead")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseResolvesAliases(t *testing.T) {
|
||||
got, err := Parse([]byte(`on: push
|
||||
env: &common_env
|
||||
SHARED: "1"
|
||||
jobs:
|
||||
a:
|
||||
runs-on: linux
|
||||
env: *common_env
|
||||
steps: &common_steps [{run: echo hi}]
|
||||
b:
|
||||
runs-on: linux
|
||||
env: *common_env
|
||||
steps: *common_steps
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
|
||||
for _, workflow := range got {
|
||||
_, job := workflow.Job()
|
||||
var env map[string]string
|
||||
require.NoError(t, job.Env.Decode(&env))
|
||||
assert.Equal(t, map[string]string{"SHARED": "1"}, env)
|
||||
require.Len(t, job.Steps, 1)
|
||||
|
||||
payload, err := workflow.Marshal()
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(payload), "common_")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsAliases(t *testing.T) {
|
||||
job := func(body string) []byte {
|
||||
return []byte("on: push\njobs:\n a:\n runs-on: linux\n" + body)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name, wantErr string
|
||||
content []byte
|
||||
}{
|
||||
{
|
||||
name: "nested aliases exceed the node limit",
|
||||
content: []byte(`on: push
|
||||
x0: &x0 [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
x1: &x1 [*x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0]
|
||||
x2: &x2 [*x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1]
|
||||
x3: &x3 [*x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2]
|
||||
x4: &x4 [*x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3]
|
||||
jobs: {a: {runs-on: linux, steps: [{run: echo}]}}
|
||||
`),
|
||||
wantErr: "maximum YAML nodes exceeded",
|
||||
},
|
||||
{
|
||||
name: "anchor aliased from inside itself",
|
||||
content: job(" steps: &s [{run: echo}, *s]\n"),
|
||||
wantErr: "maximum YAML nodes exceeded",
|
||||
},
|
||||
{
|
||||
name: "merge key",
|
||||
content: job(" env: &e {X: \"1\"}\n container:\n image: alpine\n env:\n <<: *e\n"),
|
||||
wantErr: "merge keys (`<<`) are not supported",
|
||||
},
|
||||
{
|
||||
name: "alias before its anchor",
|
||||
content: job(" env: *e\n container: {image: alpine, env: &e {X: \"1\"}}\n"),
|
||||
wantErr: "unknown anchor 'e' referenced",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := Parse(tt.content)
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -43,7 +42,7 @@ func rawMatrixReadsNeeds(node *yaml.Node) bool {
|
||||
// a scalar), neither of which describes the one job the payload stands for.
|
||||
func ParseRawSingleWorkflow(payload []byte) (*SingleWorkflow, *Job, error) {
|
||||
swf := &SingleWorkflow{}
|
||||
if err := yaml.Unmarshal(payload, swf); err != nil {
|
||||
if err := decodeResolved(payload, swf); err != nil {
|
||||
return nil, nil, fmt.Errorf("unmarshal single workflow: %w", err)
|
||||
}
|
||||
id, job := swf.Job()
|
||||
@@ -96,14 +95,21 @@ func expressionReadsContext(value, contextName string) bool {
|
||||
}
|
||||
|
||||
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
origin, err := model.ReadWorkflow(bytes.NewReader(content))
|
||||
// The workflow is split into one document per job below, which would strand an alias whose
|
||||
// anchor lands in another one.
|
||||
doc, err := resolveYamlAliases(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model.ReadWorkflow: %w", err)
|
||||
return nil, fmt.Errorf("resolve aliases: %w", err)
|
||||
}
|
||||
|
||||
origin, err := readWorkflowDoc(doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read workflow: %w", err)
|
||||
}
|
||||
|
||||
workflow := &SingleWorkflow{}
|
||||
if err := yaml.Unmarshal(content, workflow); err != nil {
|
||||
return nil, fmt.Errorf("yaml.Unmarshal: %w", err)
|
||||
if err := decodeYamlDoc(doc, workflow); err != nil {
|
||||
return nil, fmt.Errorf("decode workflow: %w", err)
|
||||
}
|
||||
|
||||
pc := &parseContext{}
|
||||
@@ -248,9 +254,6 @@ func validateMatrixFilters(job *model.Job) error {
|
||||
entries = value.Content
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Kind == yaml.AliasNode {
|
||||
entry = entry.Alias
|
||||
}
|
||||
if entry.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("matrix %s must be a list of mappings", name)
|
||||
}
|
||||
|
||||
@@ -259,9 +259,11 @@ func (evt *Event) Inputs() []WorkflowDispatchInput {
|
||||
}
|
||||
|
||||
func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
|
||||
w := new(model.Workflow)
|
||||
err := yaml.NewDecoder(bytes.NewReader(content)).Decode(w)
|
||||
return w.RawConcurrency, err
|
||||
w, err := ReadWorkflow(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w.RawConcurrency, nil
|
||||
}
|
||||
|
||||
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {
|
||||
|
||||
@@ -63,7 +63,7 @@ func ParseWorkflowCallSpec(content []byte) (*WorkflowCallSpec, error) {
|
||||
var doc struct {
|
||||
On yaml.Node `yaml:"on"`
|
||||
}
|
||||
if err := yaml.Unmarshal(content, &doc); err != nil {
|
||||
if err := decodeResolved(content, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse workflow yaml: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
@@ -121,7 +120,7 @@ func GetContentFromEntry(ctx context.Context, gitRepo *git.Repository, entry *gi
|
||||
}
|
||||
|
||||
func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) {
|
||||
workflow, err := model.ReadWorkflow(bytes.NewReader(content))
|
||||
workflow, err := jobparser.ReadWorkflow(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -268,13 +268,18 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
{jobID: "prep-jdk", name: "prep-jdk", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
|
||||
{jobID: "code-analysis", name: "code-analysis", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
|
||||
|
||||
// Matrix expansion (the " (...)" suffix is the heuristic the frontend uses to group rows)
|
||||
{jobID: "matrix-e2e-1-chromium", name: "matrix-e2e (1, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e-1-firefox", name: "matrix-e2e (1, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e-2-chromium", name: "matrix-e2e (2, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e-3-chromium", name: "matrix-e2e (3, chromium)", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e-3-firefox", name: "matrix-e2e (3, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e-99-webkit", name: "matrix-e2e (99, webkit)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
// Matrix expansion: the legs share a single JobID, which is what the frontend groups rows on
|
||||
{jobID: "matrix-e2e", name: "matrix-e2e (1, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e", name: "matrix-e2e (1, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e", name: "matrix-e2e (2, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e", name: "matrix-e2e (3, chromium)", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e", name: "matrix-e2e (3, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e", name: "matrix-e2e (99, webkit)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
|
||||
// Matrix legs whose `name:` interpolates matrix values, so no " (...)" suffix is derived
|
||||
{jobID: "e2e-browsers", name: "E2E on chromium", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "e2e-browsers", name: "E2E on firefox", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "e2e-browsers", name: "E2E on webkit", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
|
||||
{jobID: "unit-test", name: "unit-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "arch-test", name: "arch-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
|
||||
@@ -285,13 +290,13 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
"arch-test",
|
||||
"integration-test",
|
||||
"code-analysis",
|
||||
"matrix-e2e-1-chromium",
|
||||
"matrix-e2e-1-firefox",
|
||||
"matrix-e2e-2-chromium",
|
||||
"matrix-e2e-3-chromium",
|
||||
"matrix-e2e-3-firefox",
|
||||
"matrix-e2e-99-webkit",
|
||||
"matrix-e2e",
|
||||
"e2e-browsers",
|
||||
}},
|
||||
|
||||
// Separate jobs that only look like matrix legs, so they must stay separate nodes
|
||||
{jobID: "deploy-staging", name: "Deploy (staging)", status: actions_model.StatusSuccess, duration: "5s", needs: []string{"build-image"}},
|
||||
{jobID: "deploy-prod", name: "Deploy (prod)", status: actions_model.StatusSuccess, duration: "6s", needs: []string{"deploy-staging"}},
|
||||
}
|
||||
|
||||
resp.State.Run.Jobs = nil
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stdCtx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -21,6 +20,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
@@ -220,7 +220,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
|
||||
ctx.ServerError("GetContentFromEntry", err)
|
||||
return nil, ""
|
||||
}
|
||||
wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
|
||||
wf, err := jobparser.ReadWorkflow(content)
|
||||
if err != nil {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
|
||||
workflows = append(workflows, workflow)
|
||||
@@ -390,7 +390,7 @@ func loadScopedWorkflowModel(ctx *context.Context, repo *repo_model.Repository,
|
||||
if content == nil {
|
||||
return nil // the workflow does not exist on the source's default branch
|
||||
}
|
||||
wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
|
||||
wf, err := jobparser.ReadWorkflow(content)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
@@ -20,6 +18,7 @@ import (
|
||||
unit_model "gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/json"
|
||||
@@ -553,7 +552,7 @@ func handleSchedules(
|
||||
crons := make([]*actions_model.ActionSchedule, 0, len(detectedWorkflows))
|
||||
for _, dwf := range detectedWorkflows {
|
||||
// Check cron job condition. Only working in default branch
|
||||
workflow, err := model.ReadWorkflow(bytes.NewReader(dwf.Content))
|
||||
workflow, err := jobparser.ReadWorkflow(dwf.Content)
|
||||
if err != nil {
|
||||
log.Error("ReadWorkflow: %v", err)
|
||||
continue
|
||||
|
||||
@@ -40,17 +40,13 @@ func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPe
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unwrap DocumentNode and resolve AliasNode
|
||||
// Unwrap DocumentNode
|
||||
node := rawPerms
|
||||
for node.Kind == yaml.DocumentNode || node.Kind == yaml.AliasNode {
|
||||
if node.Kind == yaml.DocumentNode {
|
||||
if len(node.Content) == 0 {
|
||||
return nil
|
||||
}
|
||||
node = node.Content[0]
|
||||
} else {
|
||||
node = node.Alias
|
||||
for node.Kind == yaml.DocumentNode {
|
||||
if len(node.Content) == 0 {
|
||||
return nil
|
||||
}
|
||||
node = node.Content[0]
|
||||
}
|
||||
|
||||
if node.Kind == yaml.ScalarNode && node.Value == "" {
|
||||
|
||||
@@ -22,8 +22,6 @@ import (
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/convert"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
|
||||
@@ -125,12 +123,12 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
return 0, err
|
||||
}
|
||||
|
||||
singleWorkflow := &jobparser.SingleWorkflow{}
|
||||
if err := yaml.Unmarshal(content, singleWorkflow); err != nil {
|
||||
workflow, err := jobparser.ReadWorkflow(content)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to unmarshal workflow content: %w", err)
|
||||
}
|
||||
// get inputs from post
|
||||
workflowDispatch := singleWorkflow.WorkflowDispatchConfig()
|
||||
workflowDispatch := workflow.WorkflowDispatchConfig()
|
||||
if workflowDispatch == nil {
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewInvalidArgumentErrorf("workflow %q has no workflow_dispatch event trigger", workflowID),
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -16,7 +15,6 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
@@ -31,6 +29,7 @@ import (
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/httplib"
|
||||
@@ -566,7 +565,7 @@ func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, gi
|
||||
content, err := actions.GetContentFromEntry(ctx, gitRepo, entry)
|
||||
name := entry.Name()
|
||||
if err == nil {
|
||||
workflow, err := model.ReadWorkflow(bytes.NewReader(content))
|
||||
workflow, err := jobparser.ReadWorkflow(content)
|
||||
if err == nil {
|
||||
// Only use the name when specified in the workflow file
|
||||
if workflow.Name != "" {
|
||||
|
||||
@@ -224,6 +224,16 @@ progress::-moz-progress-bar {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
progress {
|
||||
outline: 1px solid CanvasText;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
progress::-webkit-progress-value {
|
||||
background: CanvasText;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
caret-color: var(--color-caret);
|
||||
}
|
||||
|
||||
@@ -172,6 +172,29 @@ input[type="checkbox"]:indeterminate::before {
|
||||
background: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
input[type="checkbox"]:checked,
|
||||
input[type="checkbox"]:indeterminate {
|
||||
background: CanvasText;
|
||||
border-color: CanvasText;
|
||||
}
|
||||
input[type="checkbox"]:disabled:checked,
|
||||
input[type="checkbox"]:disabled:indeterminate {
|
||||
background: GrayText;
|
||||
border-color: GrayText;
|
||||
}
|
||||
input[type="radio"]:disabled:checked {
|
||||
border-color: GrayText;
|
||||
}
|
||||
.ui.toggle.checkbox label::before {
|
||||
outline: 1px solid CanvasText;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.ui.toggle.checkbox label::after {
|
||||
background: CanvasText;
|
||||
}
|
||||
}
|
||||
|
||||
label.gt-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -305,3 +305,10 @@ it can also be rendered as flex by adding flex-related classes (the general ".it
|
||||
filter: grayscale(0.5);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.ui.label {
|
||||
outline: 1px solid CanvasText;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,6 +720,12 @@ td .commit-summary {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.repository.branches .commit-divergence .bar {
|
||||
background: CanvasText;
|
||||
}
|
||||
}
|
||||
|
||||
.repository.commits .header .search input {
|
||||
font-weight: var(--font-weight-normal);
|
||||
padding: 5px 10px;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {getCurrentLocale} from '../utils.ts';
|
||||
type HeatmapValue = {date: Date; count: number};
|
||||
type HeatmapCell = {date: Date; colorIndex: number; ariaLabel: string; tooltip: string};
|
||||
type MonthLabel = {monthIdx: number; weekIdx: number};
|
||||
type DayLabel = {dayIdx: number; rowIdx: number};
|
||||
|
||||
const props = defineProps<{
|
||||
values: HeatmapValue[];
|
||||
@@ -47,10 +48,26 @@ function shiftDate(d: Date, days: number): Date {
|
||||
return out;
|
||||
}
|
||||
|
||||
function getWeekFirstDay() {
|
||||
const userLocale = navigator.language || 'en-US';
|
||||
try {
|
||||
// Intl.Locale.prototype.getWeekInfo() is still "Limited availability"
|
||||
const localeInfo = new Intl.Locale(userLocale);
|
||||
const weekInfo = localeInfo.getWeekInfo();
|
||||
return weekInfo.firstDay;
|
||||
} catch {
|
||||
// Fallback: countries that widely use Sunday as the first day of the week
|
||||
const region = userLocale.split('-')[1];
|
||||
const sundayRegions = ['US', 'CA', 'MX', 'JP', 'KR', 'IL', 'SA', 'IN', 'BR'];
|
||||
return !region || sundayRegions.includes(region) ? 7 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
const grid = computed(() => {
|
||||
const start = shiftDate(now, -trailingDays);
|
||||
const padStart = start.getDay();
|
||||
const padEnd = daysInWeek - 1 - now.getDay();
|
||||
const firstDayIdx = getWeekFirstDay() % daysInWeek; // 0 to 6
|
||||
const padStart = (start.getDay() - firstDayIdx + daysInWeek) % daysInWeek;
|
||||
const padEnd = (firstDayIdx - now.getDay() - 1 + daysInWeek) % daysInWeek;
|
||||
const weekCount = (trailingDays + 1 + padStart + padEnd) / daysInWeek;
|
||||
|
||||
const maxCount = props.values.length ? Math.max(...props.values.map((v) => v.count)) : 0;
|
||||
@@ -95,9 +112,17 @@ const grid = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const dayLabels: DayLabel[] = [];
|
||||
for (let i = 0; i < daysInWeek; i++) {
|
||||
const labelDay = firstDayIdx + i;
|
||||
if (labelDay % 2 === 0) continue; // only show "Mon/Wed/Fri/Sun" because of limited vertical space
|
||||
const dayIdx = labelDay % daysInWeek;
|
||||
dayLabels.push({dayIdx, rowIdx: i});
|
||||
}
|
||||
|
||||
const width = gridLeft + (cellSize * weekCount) + squareBorder;
|
||||
const height = gridTop + (cellSize * daysInWeek);
|
||||
return {calendar, monthLabels, width, height};
|
||||
return {calendar, monthLabels, dayLabels, width, height};
|
||||
});
|
||||
|
||||
const legendViewBox = `${cellSize} 0 ${squareSize * (colorRange.length + 2)} ${squareSize}`;
|
||||
@@ -162,9 +187,15 @@ function handleDayClick(date: Date) {
|
||||
</text>
|
||||
</g>
|
||||
<g class="heatmap-day-labels" :transform="`translate(0, ${gridTop})`">
|
||||
<text class="heatmap-day-label" :x="0" :y="20">{{ locale.heatMapLocale.days[1] }}</text>
|
||||
<text class="heatmap-day-label" :x="0" :y="44">{{ locale.heatMapLocale.days[3] }}</text>
|
||||
<text class="heatmap-day-label" :x="0" :y="69">{{ locale.heatMapLocale.days[5] }}</text>
|
||||
<text
|
||||
v-for="day in grid.dayLabels"
|
||||
:key="day.dayIdx"
|
||||
class="heatmap-day-label"
|
||||
:x="0"
|
||||
:y="day.rowIdx * cellSize + squareSize - squareBorder"
|
||||
>
|
||||
{{ locale.heatMapLocale.days[day.dayIdx] }}
|
||||
</text>
|
||||
</g>
|
||||
<g class="heatmap-grid" :transform="`translate(${gridLeft}, ${gridTop})`" @mouseover="lazyInitTooltip">
|
||||
<g
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {computeGraphHighlightState, computeJobLevels, createWorkflowGraphModel, matrixKeyFromJobName} from './WorkflowGraph.utils.ts';
|
||||
import {computeGraphHighlightState, computeJobLevels, createWorkflowGraphModel} from './WorkflowGraph.utils.ts';
|
||||
import type {ActionsJob} from '../modules/gitea-actions.ts';
|
||||
|
||||
const mockJobs: ActionsJob[] = [
|
||||
@@ -8,12 +8,12 @@ const mockJobs: ActionsJob[] = [
|
||||
{id: 4, link: '', jobId: 'job-103', name: 'job-103', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100']},
|
||||
{id: 5, link: '', jobId: 'prep-jdk', name: 'prep-jdk', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
||||
{id: 6, link: '', jobId: 'code-analysis', name: 'code-analysis', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
||||
{id: 7, link: '', jobId: 'matrix-e2e-1-chromium', name: 'matrix-e2e (1, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 8, link: '', jobId: 'matrix-e2e-1-firefox', name: 'matrix-e2e (1, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 9, link: '', jobId: 'matrix-e2e-2-chromium', name: 'matrix-e2e (2, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 10, link: '', jobId: 'matrix-e2e-3-chromium', name: 'matrix-e2e (3, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 11, link: '', jobId: 'matrix-e2e-3-firefox', name: 'matrix-e2e (3, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 12, link: '', jobId: 'matrix-e2e-99-webkit', name: 'matrix-e2e (99, webkit)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 7, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (1, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 8, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (1, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 9, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (2, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 10, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (3, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 11, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (3, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 12, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (99, webkit)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 13, link: '', jobId: 'unit-test', name: 'unit-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
|
||||
{id: 14, link: '', jobId: 'arch-test', name: 'arch-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
|
||||
{id: 15, link: '', jobId: 'integration-test', name: 'integration-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['prep-jdk', 'code-analysis']},
|
||||
@@ -21,12 +21,7 @@ const mockJobs: ActionsJob[] = [
|
||||
'unit-test',
|
||||
'arch-test',
|
||||
'integration-test',
|
||||
'matrix-e2e-1-chromium',
|
||||
'matrix-e2e-1-firefox',
|
||||
'matrix-e2e-2-chromium',
|
||||
'matrix-e2e-3-chromium',
|
||||
'matrix-e2e-3-firefox',
|
||||
'matrix-e2e-99-webkit',
|
||||
'matrix-e2e',
|
||||
]},
|
||||
];
|
||||
|
||||
@@ -45,14 +40,14 @@ const wfTest1Jobs: ActionsJob[] = [
|
||||
{id: 3, link: '', jobId: 'lint-backend', name: 'Lint Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['init']},
|
||||
{id: 4, link: '', jobId: 'build-frontend', name: 'Build Frontend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['lint-frontend']},
|
||||
{id: 5, link: '', jobId: 'build-backend', name: 'Build Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '5s', needs: ['lint-backend']},
|
||||
{id: 6, link: '', jobId: 'tu-api-t', name: 'Unit Tests (api, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 7, link: '', jobId: 'tu-api-f', name: 'Unit Tests (api, false)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 8, link: '', jobId: 'tu-svc-t', name: 'Unit Tests (service, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 6, link: '', jobId: 'unit-tests', name: 'Unit Tests (api, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 7, link: '', jobId: 'unit-tests', name: 'Unit Tests (api, false)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 8, link: '', jobId: 'unit-tests', name: 'Unit Tests (service, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 9, link: '', jobId: 'test-integration', name: 'Integration Tests', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '6s', needs: ['build-backend']},
|
||||
{id: 10, link: '', jobId: 'te-c-d', name: 'E2E Tests (chrome, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'tu-api-t', 'tu-api-f', 'tu-svc-t']},
|
||||
{id: 11, link: '', jobId: 'te-c-m', name: 'E2E Tests (chrome, mobile)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'tu-api-t', 'tu-api-f', 'tu-svc-t']},
|
||||
{id: 12, link: '', jobId: 'te-f-d', name: 'E2E Tests (firefox, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'tu-api-t', 'tu-api-f', 'tu-svc-t']},
|
||||
{id: 13, link: '', jobId: 'bundle-app', name: 'Bundle Application', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['tu-api-t', 'tu-api-f', 'tu-svc-t', 'test-integration', 'te-c-d', 'te-c-m', 'te-f-d']},
|
||||
{id: 10, link: '', jobId: 'e2e-tests', name: 'E2E Tests (chrome, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
|
||||
{id: 11, link: '', jobId: 'e2e-tests', name: 'E2E Tests (chrome, mobile)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
|
||||
{id: 12, link: '', jobId: 'e2e-tests', name: 'E2E Tests (firefox, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
|
||||
{id: 13, link: '', jobId: 'bundle-app', name: 'Bundle Application', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['unit-tests', 'test-integration', 'e2e-tests']},
|
||||
{id: 14, link: '', jobId: 'deploy-dev', name: 'Deploy to Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
|
||||
{id: 15, link: '', jobId: 'deploy-qa', name: 'Deploy to QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
|
||||
{id: 16, link: '', jobId: 'verify-dev', name: 'Verify Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-dev']},
|
||||
@@ -61,9 +56,25 @@ const wfTest1Jobs: ActionsJob[] = [
|
||||
{id: 19, link: '', jobId: 'post-deploy-checks', name: 'Post-Deploy Checks', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-prod']},
|
||||
];
|
||||
|
||||
test('matrix key heuristic strips trailing parameter list', () => {
|
||||
expect(matrixKeyFromJobName('matrix-e2e (1, chromium)')).toBe('matrix-e2e');
|
||||
expect(matrixKeyFromJobName('plain-job')).toBeNull();
|
||||
const mockJob = (id: number, jobId: string, name: string, needs?: string[]): ActionsJob =>
|
||||
({id, link: '', jobId, name, status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s', needs});
|
||||
|
||||
test('matrix nodes key on job id, not on the display name', () => {
|
||||
const legs = createWorkflowGraphModel([mockJob(1, 'explicit', 'leg one'), mockJob(2, 'explicit', 'leg two')]);
|
||||
expect(legs.nodes).toHaveLength(1);
|
||||
expect(legs.nodes[0].type).toBe('matrix');
|
||||
expect(legs.nodes[0].name).toBe('explicit');
|
||||
expect(legs.nodes[0].jobs.map((j) => j.id)).toEqual([1, 2]);
|
||||
|
||||
const lookalikes = createWorkflowGraphModel([
|
||||
mockJob(1, 'setup', 'setup'),
|
||||
mockJob(2, 'build-fast', 'build (fast)', ['setup']),
|
||||
mockJob(3, 'build-slow', 'build (slow)', ['setup']),
|
||||
]);
|
||||
expect(lookalikes.nodes.map((n) => n.type)).toEqual(['job', 'group']);
|
||||
|
||||
const noJobId = createWorkflowGraphModel([mockJob(1, '', 'first'), mockJob(2, '', 'second')]);
|
||||
expect(noJobId.nodes.map((n) => n.id)).toEqual(['job:1', 'job:2']);
|
||||
});
|
||||
|
||||
test('computeJobLevels keeps stable topological levels', () => {
|
||||
@@ -87,7 +98,7 @@ test('graph model collapses matrix and groups jobs that share parents and childr
|
||||
|
||||
test('expanded matrix height includes summary and toggle rows', () => {
|
||||
const collapsed = createWorkflowGraphModel(mockJobs);
|
||||
const expanded = createWorkflowGraphModel(mockJobs, new Set(['matrix-e2e']));
|
||||
const expanded = createWorkflowGraphModel(mockJobs, new Set(['matrix:matrix-e2e']));
|
||||
const collapsedMatrix = collapsed.nodes.find((n) => n.id === 'matrix:matrix-e2e');
|
||||
const expandedMatrix = expanded.nodes.find((n) => n.id === 'matrix:matrix-e2e');
|
||||
|
||||
@@ -125,7 +136,7 @@ test('different-row edge uses cubic bezier curve', () => {
|
||||
test('multi-level pipeline with two matrices and a converging leaf renders without errors', () => {
|
||||
const graph = createWorkflowGraphModel(wfTest1Jobs);
|
||||
const matrices = graph.nodes.filter((n) => n.type === 'matrix');
|
||||
expect(matrices.map((n) => n.matrixKey).sort()).toEqual(['E2E Tests', 'Unit Tests']);
|
||||
expect(matrices.map((n) => n.name).sort()).toEqual(['E2E Tests', 'Unit Tests']);
|
||||
|
||||
const deployProd = graph.nodes.find((n) => n.id === 'job:18');
|
||||
const verifyDev = graph.nodes.find((n) => n.id === 'job:16');
|
||||
@@ -160,14 +171,14 @@ test('reusable callers with identical dependency signature are kept as separate
|
||||
test('matrix legs that call a reusable workflow are folded into a single matrix node', () => {
|
||||
const jobs: ActionsJob[] = [
|
||||
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
|
||||
{id: 2, link: '', jobId: 'build_call_linux', name: 'build-call (linux)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '1m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
{id: 3, link: '', jobId: 'build_call_windows', name: 'build-call (windows)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
{id: 4, link: '', jobId: 'build_call_macos', name: 'build-call (macos)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '90s', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
{id: 2, link: '', jobId: 'build-call', name: 'build-call (linux)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '1m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
{id: 3, link: '', jobId: 'build-call', name: 'build-call (windows)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
{id: 4, link: '', jobId: 'build-call', name: 'build-call (macos)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '90s', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
||||
];
|
||||
const graph = createWorkflowGraphModel(jobs);
|
||||
const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix');
|
||||
expect(matrixNodes).toHaveLength(1);
|
||||
expect(matrixNodes[0].matrixKey).toBe('build-call');
|
||||
expect(matrixNodes[0].name).toBe('build-call');
|
||||
expect(matrixNodes[0].jobs.map((j) => j.id).sort()).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ export type GraphNode = {
|
||||
level: number;
|
||||
displayHeight: number;
|
||||
jobs: ActionsJob[];
|
||||
matrixKey?: string;
|
||||
};
|
||||
|
||||
export type Edge = {
|
||||
@@ -88,10 +87,14 @@ function graphIdForJob(job: ActionsJob): string {
|
||||
return `job:${job.id}`;
|
||||
}
|
||||
|
||||
export function matrixKeyFromJobName(name: string): string | null {
|
||||
const idx = name.indexOf(' (');
|
||||
if (idx === -1) return null;
|
||||
return name.slice(0, idx).trim() || null;
|
||||
// matrix legs are named `<job name> (<combination>)`; a workflow-provided `name:` may not be
|
||||
function matrixLabel(matrixJobs: ActionsJob[], jobId: string): string {
|
||||
const prefixes = new Set(matrixJobs.map((job) => {
|
||||
const idx = job.name.indexOf(' (');
|
||||
return idx === -1 ? '' : job.name.slice(0, idx).trim();
|
||||
}));
|
||||
const [prefix] = prefixes;
|
||||
return prefixes.size === 1 && prefix ? prefix : jobId;
|
||||
}
|
||||
|
||||
export function boxBottom(node: GraphNode): number {
|
||||
@@ -251,7 +254,7 @@ type VisualGraphBuild = {
|
||||
|
||||
function buildVisualGraph(
|
||||
jobs: ActionsJob[],
|
||||
expandedMatrixKeys: ReadonlySet<string>,
|
||||
expandedMatrixNodeIds: ReadonlySet<string>,
|
||||
options: WorkflowGraphLayoutOptions,
|
||||
): VisualGraphBuild {
|
||||
const jobsByJobId = new Map<string, ActionsJob[]>();
|
||||
@@ -262,18 +265,8 @@ function buildVisualGraph(
|
||||
jobsByJobId.get(job.jobId)!.push(job);
|
||||
}
|
||||
|
||||
const matrixJobsByKey = new Map<string, ActionsJob[]>();
|
||||
for (const job of jobs) {
|
||||
// Matrix legs that call a reusable workflow are still one logical job (a single `uses:`
|
||||
// expanded over the matrix), so fold them into a matrix node like any other matrix job.
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
if (!matrixKey) continue;
|
||||
if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []);
|
||||
matrixJobsByKey.get(matrixKey)!.push(job);
|
||||
}
|
||||
for (const list of matrixJobsByKey.values()) {
|
||||
list.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
|
||||
}
|
||||
// legs of one matrix job share its `jobId`; their display names are free-form so cannot key them
|
||||
const isMatrixLeg = (job: ActionsJob): boolean => Boolean(job.jobId) && jobsByJobId.get(job.jobId)!.length > 1;
|
||||
|
||||
const directNeedsByJobId = buildDirectNeedsMap(jobs);
|
||||
const rawLevels = computeJobLevels(jobs);
|
||||
@@ -298,7 +291,7 @@ function buildVisualGraph(
|
||||
const groupsById = new Map<string, ActionsJob[]>();
|
||||
const groupCandidateBuckets = new Map<string, ActionsJob[]>();
|
||||
for (const job of jobs) {
|
||||
if (matrixKeyFromJobName(job.name)) continue;
|
||||
if (isMatrixLeg(job)) continue;
|
||||
// Reusable callers represent distinct workflow files — keep each as its own node so the
|
||||
// graph mirrors GitHub Actions, where every caller shows up as its own box even when
|
||||
// siblings share an identical (parents, children) dependency signature.
|
||||
@@ -319,36 +312,25 @@ function buildVisualGraph(
|
||||
}
|
||||
|
||||
const visualIdByJobId = new Map<number, string>();
|
||||
for (const job of jobs) {
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
// Symmetric with the matrix-bucket loop above (callers included).
|
||||
if (matrixKey && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
|
||||
visualIdByJobId.set(job.id, `matrix:${matrixKey}`);
|
||||
continue;
|
||||
}
|
||||
visualIdByJobId.set(job.id, groupedJobIds.get(job.id) || graphIdForJob(job));
|
||||
}
|
||||
|
||||
const emittedNodeIds = new Set<string>();
|
||||
const nodes: GraphNode[] = [];
|
||||
for (const job of jobs) {
|
||||
const visualId = visualIdByJobId.get(job.id);
|
||||
if (!visualId || emittedNodeIds.has(visualId)) continue;
|
||||
const matrixJobs = isMatrixLeg(job) ? jobsByJobId.get(job.jobId)! : null;
|
||||
const visualId = matrixJobs ? `matrix:${job.jobId}` : (groupedJobIds.get(job.id) || graphIdForJob(job));
|
||||
visualIdByJobId.set(job.id, visualId);
|
||||
if (emittedNodeIds.has(visualId)) continue;
|
||||
emittedNodeIds.add(visualId);
|
||||
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
if (matrixKey && visualId.startsWith('matrix:')) {
|
||||
const matrixJobs = matrixJobsByKey.get(matrixKey) || [];
|
||||
if (matrixJobs) {
|
||||
nodes.push({
|
||||
id: visualId,
|
||||
type: 'matrix',
|
||||
name: matrixKey,
|
||||
name: matrixLabel(matrixJobs, job.jobId),
|
||||
status: aggregateStatus(matrixJobs),
|
||||
duration: '',
|
||||
x: 0, y: 0, level: 0,
|
||||
displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixKeys.has(matrixKey), options),
|
||||
displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixNodeIds.has(visualId), options),
|
||||
jobs: matrixJobs,
|
||||
matrixKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -539,11 +521,11 @@ function buildRoutedEdges(
|
||||
|
||||
export function createWorkflowGraphModel(
|
||||
jobs: ActionsJob[],
|
||||
expandedMatrixKeys: ReadonlySet<string> = new Set(),
|
||||
expandedMatrixNodeIds: ReadonlySet<string> = new Set(),
|
||||
partialOptions: Partial<WorkflowGraphLayoutOptions> = {},
|
||||
): WorkflowGraphModel {
|
||||
const options = {...defaultLayoutOptions, ...partialOptions};
|
||||
const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixKeys, options);
|
||||
const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixNodeIds, options);
|
||||
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
||||
const adjacency = buildNodeAdjacency(edges);
|
||||
assignNodeLevels(nodes, adjacency);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
|
||||
import {computed, onMounted, onUnmounted, ref, shallowRef, watch} from 'vue';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
@@ -48,17 +48,17 @@ const graphContainer = ref<HTMLElement | null>(null);
|
||||
const hoveredGraphId = ref<string | null>(null);
|
||||
|
||||
const stateKey = () => `${props.store.viewData.currentRun.repoId}-${props.workflowId}`;
|
||||
const expandedMatrixKeys = ref<Set<string>>(new Set());
|
||||
const expandedMatrixNodeIds = shallowRef<Set<string>>(new Set()); // always replaced wholesale, never mutated
|
||||
|
||||
function isMatrixExpanded(key: string): boolean {
|
||||
return expandedMatrixKeys.value.has(key);
|
||||
function isMatrixExpanded(nodeId: string): boolean {
|
||||
return expandedMatrixNodeIds.value.has(nodeId);
|
||||
}
|
||||
|
||||
function toggleMatrixExpanded(key: string) {
|
||||
const next = new Set(expandedMatrixKeys.value);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
expandedMatrixKeys.value = next;
|
||||
function toggleMatrixExpanded(nodeId: string) {
|
||||
const next = new Set(expandedMatrixNodeIds.value);
|
||||
if (next.has(nodeId)) next.delete(nodeId);
|
||||
else next.add(nodeId);
|
||||
expandedMatrixNodeIds.value = next;
|
||||
}
|
||||
|
||||
const loadSavedState = () => {
|
||||
@@ -86,7 +86,7 @@ const saveState = () => {
|
||||
localUserSettings.setJsonObject(settingKeyStates, Object.fromEntries(sortedStates));
|
||||
};
|
||||
|
||||
const graphModel = computed(() => createWorkflowGraphModel(props.jobs, expandedMatrixKeys.value));
|
||||
const graphModel = computed(() => createWorkflowGraphModel(props.jobs, expandedMatrixNodeIds.value));
|
||||
const jobsWithLayout = computed(() => graphModel.value.nodes);
|
||||
const edges = computed(() => graphModel.value.edges);
|
||||
const routedEdges = computed<RoutedEdge[]>(() => graphModel.value.routedEdges);
|
||||
@@ -309,15 +309,15 @@ function onNodeClick(job: GraphNode | ActionsJob, event: MouseEvent) {
|
||||
@mouseenter="handleNodeMouseEnter(job.id)"
|
||||
@mouseleave="handleNodeMouseLeave"
|
||||
>
|
||||
<title>Matrix: {{ job.matrixKey }}</title>
|
||||
<title>Matrix: {{ job.name }}</title>
|
||||
<rect :x="job.x" :y="job.y" :width="nodeWidth" :height="job.displayHeight" rx="6" class="job-rect"/>
|
||||
<foreignObject :x="job.x" :y="job.y" :width="nodeWidth" :height="job.displayHeight" class="matrix-foreign-object">
|
||||
<div class="matrix-panel" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<div class="matrix-panel-label" @click.stop="toggleMatrixExpanded(job.matrixKey!)">Matrix: {{ job.matrixKey }}</div>
|
||||
<div class="matrix-panel-label" @click.stop="toggleMatrixExpanded(job.id)">Matrix: {{ job.name }}</div>
|
||||
<div
|
||||
v-if="!isMatrixExpanded(job.matrixKey!)"
|
||||
v-if="!isMatrixExpanded(job.id)"
|
||||
class="matrix-panel-collapsed"
|
||||
@click.stop="toggleMatrixExpanded(job.matrixKey!)"
|
||||
@click.stop="toggleMatrixExpanded(job.id)"
|
||||
>
|
||||
<div class="matrix-panel-summary-row">
|
||||
<ActionStatusIcon :status="job.status" icon-variant="circle-fill"/>
|
||||
|
||||
Reference in New Issue
Block a user