diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index aee72188746..278d709c09e 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -382,6 +382,8 @@ func MockActionsRunsJobs(ctx *context.Context) { // └ deep_job (regular) // cross_caller (caller, cross-repo, expanded) // └ external_job (regular) + // build (linux|windows|macos) (regular matrix; graph folds into one "build" node) + // build-call (linux|windows|macos) (caller matrix, each calls build.yml; folds into one "build-call" node like "build") // final (regular, needs local_caller + cross_caller) const ( prepareID = int64(400) @@ -392,6 +394,21 @@ func MockActionsRunsJobs(ctx *context.Context) { crossCallerID = int64(405) externalJobID = int64(406) finalID = int64(407) + + // Regular matrix set – the graph already folds these into a single "build" node. + buildLinuxID = int64(410) + buildWindowsID = int64(411) + buildMacosID = int64(412) + + // Caller matrix set – each matrix leg calls the same reusable workflow. #38466: like the + // regular "build" matrix above, these fold into one "build-call" node. Matrix legs share a + // single JobID, so the legs below use JobID "build-call" and differ only by their name suffix. + buildCallLinuxID = int64(420) + buildCallWindowsID = int64(421) + buildCallMacosID = int64(422) + buildCallLinuxJobID = int64(423) + buildCallWinJobID = int64(424) + buildCallMacJobID = int64(425) ) resp.State.Run.Jobs = []*actions.ViewJob{ @@ -432,6 +449,53 @@ func MockActionsRunsJobs(ctx *context.Context) { Status: actions_model.StatusWaiting.String(), Duration: "0s", ParentJobID: crossCallerID, }, + + // Regular matrix "build" – these fold into one matrix node in the graph. The matrix legs + // share a single JobID ("build"); the " (variant)" name suffix distinguishes the legs. + { + ID: buildLinuxID, Link: jobLink(buildLinuxID), JobID: "build", Name: "build (linux)", + Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"}, + }, + { + ID: buildWindowsID, Link: jobLink(buildWindowsID), JobID: "build", Name: "build (windows)", + Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"}, + }, + { + ID: buildMacosID, Link: jobLink(buildMacosID), JobID: "build", Name: "build (macos)", + Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"}, + }, + + // Caller matrix "build-call" – each leg calls the same reusable workflow. #38466: like the + // regular "build" matrix above, these fold into one node. The matrix legs share a single + // JobID ("build-call"); the " (variant)" name suffix distinguishes the legs. + { + ID: buildCallLinuxID, Link: jobLink(buildCallLinuxID), JobID: "build-call", Name: "build-call (linux)", + Status: actions_model.StatusSuccess.String(), Duration: "1m", Needs: []string{"prepare"}, + IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml", + }, + { + ID: buildCallLinuxJobID, Link: jobLink(buildCallLinuxJobID), JobID: "bc_linux_build", Name: "build", + Status: actions_model.StatusSuccess.String(), Duration: "1m", ParentJobID: buildCallLinuxID, + }, + { + ID: buildCallWindowsID, Link: jobLink(buildCallWindowsID), JobID: "build-call", Name: "build-call (windows)", + Status: actions_model.StatusSuccess.String(), Duration: "2m", Needs: []string{"prepare"}, + IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml", + }, + { + ID: buildCallWinJobID, Link: jobLink(buildCallWinJobID), JobID: "bc_windows_build", Name: "build", + Status: actions_model.StatusSuccess.String(), Duration: "2m", ParentJobID: buildCallWindowsID, + }, + { + ID: buildCallMacosID, Link: jobLink(buildCallMacosID), JobID: "build-call", Name: "build-call (macos)", + Status: actions_model.StatusSuccess.String(), Duration: "90s", Needs: []string{"prepare"}, + IsReusableCaller: true, CallUses: "./.gitea/workflows/build.yml", + }, + { + ID: buildCallMacJobID, Link: jobLink(buildCallMacJobID), JobID: "bc_macos_build", Name: "build", + Status: actions_model.StatusSuccess.String(), Duration: "90s", ParentJobID: buildCallMacosID, + }, + { ID: finalID, Link: jobLink(finalID), JobID: "final", Name: "final", Status: actions_model.StatusBlocked.String(), Duration: "0s", diff --git a/web_src/js/components/WorkflowGraph.utils.test.ts b/web_src/js/components/WorkflowGraph.utils.test.ts index 9d79766d83c..85a5c0db2fc 100644 --- a/web_src/js/components/WorkflowGraph.utils.test.ts +++ b/web_src/js/components/WorkflowGraph.utils.test.ts @@ -157,16 +157,18 @@ test('reusable callers with identical dependency signature are kept as separate expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('cross-repo caller'); }); -test('reusable caller with matrix-pattern name does not get absorbed into a sibling matrix node', () => { +test('matrix legs that call a reusable workflow are folded into a single matrix node', () => { const jobs: ActionsJob[] = [ - {id: 1, link: '', jobId: 'deploy_dev', name: 'deploy (dev)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'}, - {id: 2, link: '', jobId: 'deploy_qa', name: 'deploy (qa)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'}, - {id: 3, link: '', jobId: 'deploy_staging', name: 'deploy (staging)', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2s', callUses: './.gitea/workflows/deploy.yml'}, + {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'}, ]; const graph = createWorkflowGraphModel(jobs); - expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('deploy (staging)'); - const matrixNode = graph.nodes.find((n) => n.type === 'matrix'); - expect(matrixNode?.jobs.map((j) => j.id).sort()).toEqual([1, 2]); + const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix'); + expect(matrixNodes).toHaveLength(1); + expect(matrixNodes[0].matrixKey).toBe('build-call'); + expect(matrixNodes[0].jobs.map((j) => j.id).sort()).toEqual([2, 3, 4]); }); test('directed highlight state covers ancestors and descendants of the hovered node', () => { diff --git a/web_src/js/components/WorkflowGraph.utils.ts b/web_src/js/components/WorkflowGraph.utils.ts index 3cacceb24d8..f2a8d095a50 100644 --- a/web_src/js/components/WorkflowGraph.utils.ts +++ b/web_src/js/components/WorkflowGraph.utils.ts @@ -264,9 +264,8 @@ function buildVisualGraph( const matrixJobsByKey = new Map(); for (const job of jobs) { - // Reusable callers are distinct workflow files — never fold them into a matrix bucket - // even if their display name happens to look like "name (variant)". - if (job.isReusableCaller) continue; + // 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, []); @@ -322,10 +321,8 @@ function buildVisualGraph( const visualIdByJobId = new Map(); for (const job of jobs) { const matrixKey = matrixKeyFromJobName(job.name); - // Symmetric with the matrix-bucket loop above: a reusable caller whose display name - // happens to look like "name (variant)" must never be folded into the matrix node, or it - // would silently vanish (its visualId would point at a matrix node it isn't part of). - if (matrixKey && !job.isReusableCaller && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) { + // Symmetric with the matrix-bucket loop above (callers included). + if (matrixKey && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) { visualIdByJobId.set(job.id, `matrix:${matrixKey}`); continue; }