From ed4d7ea08df377463e288af7378318e198e3e34e Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 20 Aug 2026 07:03:10 +0200 Subject: [PATCH 1/4] fix: resolve YAML anchors and aliases in Actions workflows (#38984) Workflows using YAML anchors are rejected as invalid, because a workflow is split into one document per job and an alias whose anchor lands in another job's document no longer resolves. Aliases are now expanded once, right after the workflow is parsed and before anything reads or splits it, bounded like GitHub's parser so nested aliases cannot expand without limit. Merge keys stay unsupported, as they are upstream. Fixes https://github.com/go-gitea/gitea/issues/38983 Signed-off-by: silverwind --- modules/actions/jobparser/aliases.go | 121 +++++++++++++++++++++ modules/actions/jobparser/aliases_test.go | 85 +++++++++++++++ modules/actions/jobparser/jobparser.go | 21 ++-- modules/actions/jobparser/model.go | 8 +- modules/actions/jobparser/workflow_call.go | 2 +- modules/actions/workflows.go | 3 +- routers/web/repo/actions/actions.go | 6 +- services/actions/notifier_helper.go | 5 +- services/actions/permission_parser.go | 14 +-- services/actions/workflow.go | 8 +- services/convert/convert.go | 5 +- 11 files changed, 240 insertions(+), 38 deletions(-) create mode 100644 modules/actions/jobparser/aliases.go create mode 100644 modules/actions/jobparser/aliases_test.go diff --git a/modules/actions/jobparser/aliases.go b/modules/actions/jobparser/aliases.go new file mode 100644 index 00000000000..664e6943c73 --- /dev/null +++ b/modules/actions/jobparser/aliases.go @@ -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 +} diff --git a/modules/actions/jobparser/aliases_test.go b/modules/actions/jobparser/aliases_test.go new file mode 100644 index 00000000000..71f385e9e52 --- /dev/null +++ b/modules/actions/jobparser/aliases_test.go @@ -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) + }) + } +} diff --git a/modules/actions/jobparser/jobparser.go b/modules/actions/jobparser/jobparser.go index ec43b6e94f0..1368cd34bd3 100644 --- a/modules/actions/jobparser/jobparser.go +++ b/modules/actions/jobparser/jobparser.go @@ -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) } diff --git a/modules/actions/jobparser/model.go b/modules/actions/jobparser/model.go index 51a86863517..7be29b6d247 100644 --- a/modules/actions/jobparser/model.go +++ b/modules/actions/jobparser/model.go @@ -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) { diff --git a/modules/actions/jobparser/workflow_call.go b/modules/actions/jobparser/workflow_call.go index c81ee2dce4a..bba4aeb54ef 100644 --- a/modules/actions/jobparser/workflow_call.go +++ b/modules/actions/jobparser/workflow_call.go @@ -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) } diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index 97af9e38bd3..2a1535faa35 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -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 } diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index ca6db691445..8014463fe8a 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -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 } diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index 527e9da5caf..2955e5f2dc8 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -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 diff --git a/services/actions/permission_parser.go b/services/actions/permission_parser.go index a94bc466c23..2e35815e06d 100644 --- a/services/actions/permission_parser.go +++ b/services/actions/permission_parser.go @@ -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 == "" { diff --git a/services/actions/workflow.go b/services/actions/workflow.go index 39d98408072..7d0726f164f 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -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), diff --git a/services/convert/convert.go b/services/convert/convert.go index 3ec8f5ab7ac..84f9cc1b317 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -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 != "" { From 8f7eb9f1615e901994e911caeb939515443d9a7a Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 20 Aug 2026 07:11:57 +0200 Subject: [PATCH 2/4] enhance(ui): forced colors mode enhancements (#38991) Improve various UI elements while in [forced color mode](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/forced-colors). --- web_src/css/base.css | 10 ++++++++++ web_src/css/modules/checkbox.css | 23 +++++++++++++++++++++++ web_src/css/modules/label.css | 7 +++++++ web_src/css/repo.css | 6 ++++++ 4 files changed, 46 insertions(+) diff --git a/web_src/css/base.css b/web_src/css/base.css index fdcf55848d8..5611e9bb87c 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -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); } diff --git a/web_src/css/modules/checkbox.css b/web_src/css/modules/checkbox.css index a90082c1a7a..b986301f3b4 100644 --- a/web_src/css/modules/checkbox.css +++ b/web_src/css/modules/checkbox.css @@ -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; diff --git a/web_src/css/modules/label.css b/web_src/css/modules/label.css index 4be3f32a7e6..a0c1f43e6ea 100644 --- a/web_src/css/modules/label.css +++ b/web_src/css/modules/label.css @@ -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; + } +} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 1ab1e579767..b2f5f717025 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -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; From fa5d876171f4527f96238a6e5c56c4b095d164b1 Mon Sep 17 00:00:00 2001 From: bn-zr <174343671+bn-zr@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:41:14 +0200 Subject: [PATCH 3/4] fix(actions): Fix how jobs in matrixes are grouped (#38980) The workflow graph decided which job rows belonged to the same matrix by parsing display names: it stripped a trailing `" (...)"` off `name` and grouped rows sharing the prefix. That guesses at a string the user controls, and it fails both ways. `jobparser` only appends the ` ()` suffix when `name:` contains no `${{ }}`, so a leg named `E2E on ${{ matrix.browser }}` never grouped, while two unrelated jobs `build (fast)` and `build (slow)` folded into one bogus matrix panel. Matrix legs already have a real identity: expansion clones one row per combination, all sharing the workflow's `JobID` and differing only in `Name`. Group on that instead, so a matrix is whatever the backend says it is. Matrix expansion state is keyed on the graph node id for the same reason. Closes https://github.com/go-gitea/gitea/issues/38975, though that report's own example already groups on main, since `explicit (${{ matrix.leg }})` interpolates to a name that still ends in a suffix. The interpolated shapes above are the broken ones. Assisted-by: Claude Code:claude-opus-5 Co-authored-by: bircni Co-authored-by: silverwind --- routers/web/devtest/mock_actions.go | 31 +++++---- .../js/components/WorkflowGraph.utils.test.ts | 69 +++++++++++-------- web_src/js/components/WorkflowGraph.utils.ts | 60 ++++++---------- web_src/js/components/WorkflowGraph.vue | 28 ++++---- 4 files changed, 93 insertions(+), 95 deletions(-) diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 4efba4354ba..bf99f461bd6 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -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 diff --git a/web_src/js/components/WorkflowGraph.utils.test.ts b/web_src/js/components/WorkflowGraph.utils.test.ts index 85a5c0db2fc..fed34380279 100644 --- a/web_src/js/components/WorkflowGraph.utils.test.ts +++ b/web_src/js/components/WorkflowGraph.utils.test.ts @@ -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]); }); diff --git a/web_src/js/components/WorkflowGraph.utils.ts b/web_src/js/components/WorkflowGraph.utils.ts index f2a8d095a50..883e32ba259 100644 --- a/web_src/js/components/WorkflowGraph.utils.ts +++ b/web_src/js/components/WorkflowGraph.utils.ts @@ -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 ` ()`; 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, + expandedMatrixNodeIds: ReadonlySet, options: WorkflowGraphLayoutOptions, ): VisualGraphBuild { const jobsByJobId = new Map(); @@ -262,18 +265,8 @@ function buildVisualGraph( jobsByJobId.get(job.jobId)!.push(job); } - const matrixJobsByKey = new Map(); - 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(); const groupCandidateBuckets = new Map(); 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(); - 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(); 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 = new Set(), + expandedMatrixNodeIds: ReadonlySet = new Set(), partialOptions: Partial = {}, ): 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); diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue index b95db93473f..f4e733fb484 100644 --- a/web_src/js/components/WorkflowGraph.vue +++ b/web_src/js/components/WorkflowGraph.vue @@ -1,5 +1,5 @@