mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-21 01:40:32 +00:00
Backport #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 ` (<combination>)` 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. ## Screenshots: Before: <img width="1268" height="618" alt="image" src="https://github.com/user-attachments/assets/2d98dd1f-5454-423c-b610-8db920f1e99c" /> after: <img width="1145" height="607" alt="image" src="https://github.com/user-attachments/assets/25da1804-8386-4f5b-a4e3-f63380010907" /> _Assisted-by: Claude Code:claude-opus-5_ Co-authored-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -268,13 +268,18 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
|||||||
{jobID: "prep-jdk", name: "prep-jdk", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
|
{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},
|
{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)
|
// Matrix expansion: the legs share a single JobID, which is what the frontend groups rows on
|
||||||
{jobID: "matrix-e2e-1-chromium", name: "matrix-e2e (1, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
{jobID: "matrix-e2e", 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", 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", 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", 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", 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"}},
|
{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: "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"}},
|
{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",
|
"arch-test",
|
||||||
"integration-test",
|
"integration-test",
|
||||||
"code-analysis",
|
"code-analysis",
|
||||||
"matrix-e2e-1-chromium",
|
"matrix-e2e",
|
||||||
"matrix-e2e-1-firefox",
|
"e2e-browsers",
|
||||||
"matrix-e2e-2-chromium",
|
|
||||||
"matrix-e2e-3-chromium",
|
|
||||||
"matrix-e2e-3-firefox",
|
|
||||||
"matrix-e2e-99-webkit",
|
|
||||||
}},
|
}},
|
||||||
|
|
||||||
|
// 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
|
resp.State.Run.Jobs = nil
|
||||||
|
|||||||
@@ -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';
|
import type {ActionsJob} from '../modules/gitea-actions.ts';
|
||||||
|
|
||||||
const mockJobs: ActionsJob[] = [
|
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: 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: 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: 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: 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-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: 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-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: 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-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: 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-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: 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-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: 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: 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: 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']},
|
{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',
|
'unit-test',
|
||||||
'arch-test',
|
'arch-test',
|
||||||
'integration-test',
|
'integration-test',
|
||||||
'matrix-e2e-1-chromium',
|
'matrix-e2e',
|
||||||
'matrix-e2e-1-firefox',
|
|
||||||
'matrix-e2e-2-chromium',
|
|
||||||
'matrix-e2e-3-chromium',
|
|
||||||
'matrix-e2e-3-firefox',
|
|
||||||
'matrix-e2e-99-webkit',
|
|
||||||
]},
|
]},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -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: 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: 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: 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: 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: 'tu-api-f', name: 'Unit Tests (api, false)', 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: 'tu-svc-t', name: 'Unit Tests (service, true)', 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: 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: 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: '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: 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: '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: 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: ['tu-api-t', 'tu-api-f', 'tu-svc-t', 'test-integration', 'te-c-d', 'te-c-m', 'te-f-d']},
|
{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: 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: 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']},
|
{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']},
|
{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', () => {
|
const mockJob = (id: number, jobId: string, name: string, needs?: string[]): ActionsJob =>
|
||||||
expect(matrixKeyFromJobName('matrix-e2e (1, chromium)')).toBe('matrix-e2e');
|
({id, link: '', jobId, name, status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s', needs});
|
||||||
expect(matrixKeyFromJobName('plain-job')).toBeNull();
|
|
||||||
|
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', () => {
|
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', () => {
|
test('expanded matrix height includes summary and toggle rows', () => {
|
||||||
const collapsed = createWorkflowGraphModel(mockJobs);
|
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 collapsedMatrix = collapsed.nodes.find((n) => n.id === 'matrix:matrix-e2e');
|
||||||
const expandedMatrix = expanded.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', () => {
|
test('multi-level pipeline with two matrices and a converging leaf renders without errors', () => {
|
||||||
const graph = createWorkflowGraphModel(wfTest1Jobs);
|
const graph = createWorkflowGraphModel(wfTest1Jobs);
|
||||||
const matrices = graph.nodes.filter((n) => n.type === 'matrix');
|
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 deployProd = graph.nodes.find((n) => n.id === 'job:18');
|
||||||
const verifyDev = graph.nodes.find((n) => n.id === 'job:16');
|
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', () => {
|
test('matrix legs that call a reusable workflow are folded into a single matrix node', () => {
|
||||||
const jobs: ActionsJob[] = [
|
const jobs: ActionsJob[] = [
|
||||||
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
|
{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: 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_windows', name: 'build-call (windows)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2m', 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_macos', name: 'build-call (macos)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '90s', 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 graph = createWorkflowGraphModel(jobs);
|
||||||
const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix');
|
const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix');
|
||||||
expect(matrixNodes).toHaveLength(1);
|
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]);
|
expect(matrixNodes[0].jobs.map((j) => j.id).sort()).toEqual([2, 3, 4]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export type GraphNode = {
|
|||||||
level: number;
|
level: number;
|
||||||
displayHeight: number;
|
displayHeight: number;
|
||||||
jobs: ActionsJob[];
|
jobs: ActionsJob[];
|
||||||
matrixKey?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Edge = {
|
export type Edge = {
|
||||||
@@ -88,10 +87,14 @@ function graphIdForJob(job: ActionsJob): string {
|
|||||||
return `job:${job.id}`;
|
return `job:${job.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matrixKeyFromJobName(name: string): string | null {
|
// matrix legs are named `<job name> (<combination>)`; a workflow-provided `name:` may not be
|
||||||
const idx = name.indexOf(' (');
|
function matrixLabel(matrixJobs: ActionsJob[], jobId: string): string {
|
||||||
if (idx === -1) return null;
|
const prefixes = new Set(matrixJobs.map((job) => {
|
||||||
return name.slice(0, idx).trim() || null;
|
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 {
|
export function boxBottom(node: GraphNode): number {
|
||||||
@@ -251,7 +254,7 @@ type VisualGraphBuild = {
|
|||||||
|
|
||||||
function buildVisualGraph(
|
function buildVisualGraph(
|
||||||
jobs: ActionsJob[],
|
jobs: ActionsJob[],
|
||||||
expandedMatrixKeys: ReadonlySet<string>,
|
expandedMatrixNodeIds: ReadonlySet<string>,
|
||||||
options: WorkflowGraphLayoutOptions,
|
options: WorkflowGraphLayoutOptions,
|
||||||
): VisualGraphBuild {
|
): VisualGraphBuild {
|
||||||
const jobsByJobId = new Map<string, ActionsJob[]>();
|
const jobsByJobId = new Map<string, ActionsJob[]>();
|
||||||
@@ -262,18 +265,8 @@ function buildVisualGraph(
|
|||||||
jobsByJobId.get(job.jobId)!.push(job);
|
jobsByJobId.get(job.jobId)!.push(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
const matrixJobsByKey = new Map<string, ActionsJob[]>();
|
// legs of one matrix job share its `jobId`; their display names are free-form so cannot key them
|
||||||
for (const job of jobs) {
|
const isMatrixLeg = (job: ActionsJob): boolean => Boolean(job.jobId) && jobsByJobId.get(job.jobId)!.length > 1;
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
const directNeedsByJobId = buildDirectNeedsMap(jobs);
|
const directNeedsByJobId = buildDirectNeedsMap(jobs);
|
||||||
const rawLevels = computeJobLevels(jobs);
|
const rawLevels = computeJobLevels(jobs);
|
||||||
@@ -298,7 +291,7 @@ function buildVisualGraph(
|
|||||||
const groupsById = new Map<string, ActionsJob[]>();
|
const groupsById = new Map<string, ActionsJob[]>();
|
||||||
const groupCandidateBuckets = new Map<string, ActionsJob[]>();
|
const groupCandidateBuckets = new Map<string, ActionsJob[]>();
|
||||||
for (const job of jobs) {
|
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
|
// 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
|
// graph mirrors GitHub Actions, where every caller shows up as its own box even when
|
||||||
// siblings share an identical (parents, children) dependency signature.
|
// siblings share an identical (parents, children) dependency signature.
|
||||||
@@ -319,36 +312,25 @@ function buildVisualGraph(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const visualIdByJobId = new Map<number, string>();
|
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 emittedNodeIds = new Set<string>();
|
||||||
const nodes: GraphNode[] = [];
|
const nodes: GraphNode[] = [];
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
const visualId = visualIdByJobId.get(job.id);
|
const matrixJobs = isMatrixLeg(job) ? jobsByJobId.get(job.jobId)! : null;
|
||||||
if (!visualId || emittedNodeIds.has(visualId)) continue;
|
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);
|
emittedNodeIds.add(visualId);
|
||||||
|
|
||||||
const matrixKey = matrixKeyFromJobName(job.name);
|
if (matrixJobs) {
|
||||||
if (matrixKey && visualId.startsWith('matrix:')) {
|
|
||||||
const matrixJobs = matrixJobsByKey.get(matrixKey) || [];
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: visualId,
|
id: visualId,
|
||||||
type: 'matrix',
|
type: 'matrix',
|
||||||
name: matrixKey,
|
name: matrixLabel(matrixJobs, job.jobId),
|
||||||
status: aggregateStatus(matrixJobs),
|
status: aggregateStatus(matrixJobs),
|
||||||
duration: '',
|
duration: '',
|
||||||
x: 0, y: 0, level: 0,
|
x: 0, y: 0, level: 0,
|
||||||
displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixKeys.has(matrixKey), options),
|
displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixNodeIds.has(visualId), options),
|
||||||
jobs: matrixJobs,
|
jobs: matrixJobs,
|
||||||
matrixKey,
|
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -539,11 +521,11 @@ function buildRoutedEdges(
|
|||||||
|
|
||||||
export function createWorkflowGraphModel(
|
export function createWorkflowGraphModel(
|
||||||
jobs: ActionsJob[],
|
jobs: ActionsJob[],
|
||||||
expandedMatrixKeys: ReadonlySet<string> = new Set(),
|
expandedMatrixNodeIds: ReadonlySet<string> = new Set(),
|
||||||
partialOptions: Partial<WorkflowGraphLayoutOptions> = {},
|
partialOptions: Partial<WorkflowGraphLayoutOptions> = {},
|
||||||
): WorkflowGraphModel {
|
): WorkflowGraphModel {
|
||||||
const options = {...defaultLayoutOptions, ...partialOptions};
|
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 nodesById = new Map(nodes.map((n) => [n.id, n]));
|
||||||
const adjacency = buildNodeAdjacency(edges);
|
const adjacency = buildNodeAdjacency(edges);
|
||||||
assignNodeLevels(nodes, adjacency);
|
assignNodeLevels(nodes, adjacency);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
|
import {computed, onMounted, onUnmounted, ref, shallowRef, watch} from 'vue';
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||||
import {localUserSettings} from '../modules/user-settings.ts';
|
import {localUserSettings} from '../modules/user-settings.ts';
|
||||||
@@ -48,17 +48,17 @@ const graphContainer = ref<HTMLElement | null>(null);
|
|||||||
const hoveredGraphId = ref<string | null>(null);
|
const hoveredGraphId = ref<string | null>(null);
|
||||||
|
|
||||||
const stateKey = () => `${props.store.viewData.currentRun.repoId}-${props.workflowId}`;
|
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 {
|
function isMatrixExpanded(nodeId: string): boolean {
|
||||||
return expandedMatrixKeys.value.has(key);
|
return expandedMatrixNodeIds.value.has(nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMatrixExpanded(key: string) {
|
function toggleMatrixExpanded(nodeId: string) {
|
||||||
const next = new Set(expandedMatrixKeys.value);
|
const next = new Set(expandedMatrixNodeIds.value);
|
||||||
if (next.has(key)) next.delete(key);
|
if (next.has(nodeId)) next.delete(nodeId);
|
||||||
else next.add(key);
|
else next.add(nodeId);
|
||||||
expandedMatrixKeys.value = next;
|
expandedMatrixNodeIds.value = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadSavedState = () => {
|
const loadSavedState = () => {
|
||||||
@@ -86,7 +86,7 @@ const saveState = () => {
|
|||||||
localUserSettings.setJsonObject(settingKeyStates, Object.fromEntries(sortedStates));
|
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 jobsWithLayout = computed(() => graphModel.value.nodes);
|
||||||
const edges = computed(() => graphModel.value.edges);
|
const edges = computed(() => graphModel.value.edges);
|
||||||
const routedEdges = computed<RoutedEdge[]>(() => graphModel.value.routedEdges);
|
const routedEdges = computed<RoutedEdge[]>(() => graphModel.value.routedEdges);
|
||||||
@@ -309,15 +309,15 @@ function onNodeClick(job: GraphNode | ActionsJob, event: MouseEvent) {
|
|||||||
@mouseenter="handleNodeMouseEnter(job.id)"
|
@mouseenter="handleNodeMouseEnter(job.id)"
|
||||||
@mouseleave="handleNodeMouseLeave"
|
@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"/>
|
<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">
|
<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" 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
|
<div
|
||||||
v-if="!isMatrixExpanded(job.matrixKey!)"
|
v-if="!isMatrixExpanded(job.id)"
|
||||||
class="matrix-panel-collapsed"
|
class="matrix-panel-collapsed"
|
||||||
@click.stop="toggleMatrixExpanded(job.matrixKey!)"
|
@click.stop="toggleMatrixExpanded(job.id)"
|
||||||
>
|
>
|
||||||
<div class="matrix-panel-summary-row">
|
<div class="matrix-panel-summary-row">
|
||||||
<ActionStatusIcon :status="job.status" icon-variant="circle-fill"/>
|
<ActionStatusIcon :status="job.status" icon-variant="circle-fill"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user