├── .tool-versions ├── .gitattributes ├── src ├── main.ts ├── types.ts ├── post.ts ├── format_util.ts └── workflow_gantt.ts ├── renovate.json5 ├── .octocov.yml ├── .gitignore ├── dist └── main.js ├── action.yml ├── .github ├── actions │ └── setup-deno-with-cache │ │ └── action.yml ├── release-drafter.yml └── workflows │ ├── rebundle.yml │ ├── release.yml │ └── ci.yml ├── deno.jsonc ├── LICENSE ├── scripts └── build.ts ├── tests ├── github.test.ts ├── format_util.test.ts ├── fixture │ ├── workflow_job.json │ └── workflow.json ├── workflow_gantt_1.test.ts ├── workflow_gantt_2.test.ts └── workflow_gantt_sp.test.ts ├── cli.ts ├── CLAUDE.md ├── README.md └── deno.lock /.tool-versions: -------------------------------------------------------------------------------- 1 | deno 2.6.0 2 | nodejs 20.19.6 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true 2 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // It's a dummy file to do nothing at action main phase. 2 | const main = async () => {}; 3 | main(); 4 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: 'https://docs.renovatebot.com/renovate-schema.json', 3 | extends: [ 4 | 'github>Kesin11/renovate-config:oss', 5 | 'github>Omochice/renovate-config:deno', 6 | ':prConcurrentLimit10', 7 | 'schedule:weekends', 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.octocov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | if: false 3 | testExecutionTime: 4 | if: false 5 | diff: 6 | datastores: 7 | - artifact://${GITHUB_REPOSITORY} 8 | comment: 9 | if: is_pull_request 10 | summary: 11 | if: true 12 | report: 13 | if: is_default_branch 14 | datastores: 15 | - artifact://${GITHUB_REPOSITORY} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/deno 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=deno 3 | 4 | ### Deno ### 5 | /.idea/ 6 | /.vscode/ 7 | 8 | /node_modules 9 | 10 | .env 11 | *.orig 12 | *.pyc 13 | *.swp 14 | 15 | # End of https://www.toptal.com/developers/gitignore/api/deno 16 | 17 | # dnt 18 | /npm 19 | 20 | # Claude Code 21 | .claude/settings.local.json 22 | -------------------------------------------------------------------------------- /dist/main.js: -------------------------------------------------------------------------------- 1 | // npm/src/_dnt.polyfills.ts 2 | if (!Object.hasOwn) { 3 | Object.defineProperty(Object, "hasOwn", { 4 | value: function(object, property) { 5 | if (object == null) { 6 | throw new TypeError("Cannot convert undefined or null to object"); 7 | } 8 | return Object.prototype.hasOwnProperty.call(Object(object), property); 9 | }, 10 | configurable: true, 11 | enumerable: false, 12 | writable: true 13 | }); 14 | } 15 | 16 | // npm/src/main.ts 17 | var main = async () => { 18 | }; 19 | main(); 20 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "actions-timeline" 2 | description: "An Action shows timeline of a GitHub Action workflow in the run summary page." 3 | author: "kesin1202000@gmail.com" 4 | branding: 5 | icon: "activity" 6 | color: "blue" 7 | inputs: 8 | github-token: 9 | description: The GitHub token used to create an authenticated client 10 | default: ${{ github.token }} 11 | required: false 12 | show-waiting-runner: 13 | description: Show waiting runner time in the timeline. 14 | default: true 15 | required: false 16 | runs: 17 | using: "node20" 18 | main: "dist/main.js" 19 | post: "dist/post.js" 20 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type ganttJob = { 2 | section: string; 3 | steps: ganttStep[]; 4 | }; 5 | 6 | export type ganttStep = { 7 | name: string; 8 | id: `job${number}-${number}`; 9 | status: "" | "done" | "active" | "crit"; 10 | position: string; 11 | sec: number; 12 | }; 13 | 14 | // ref: https://docs.github.com/en/rest/actions/workflow-jobs?apiVersion=2022-11-28#get-a-job-for-a-workflow-run 15 | export type StepConclusion = 16 | | "success" 17 | | "failure" 18 | | "neutral" 19 | | "cancelled" 20 | | "skipped" 21 | | "timed_out" 22 | | "action_required" 23 | | null; 24 | 25 | export type GanttOptions = { 26 | showWaitingRunner?: boolean; 27 | }; 28 | -------------------------------------------------------------------------------- /.github/actions/setup-deno-with-cache/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Deno with cache 2 | description: setup-deno with dependencies cache 3 | runs: 4 | using: "composite" 5 | steps: 6 | # NOTE: Temporary disable. It cause non concistant behavior at `deno task bundle`. 7 | # - uses: actions/cache@v4 8 | # with: 9 | # path: ~/.cache/deno 10 | # key: deno-${{ github.job }}-${{ runner.os }}-${{ hashFiles('deno.lock') }} 11 | # restore-keys: | 12 | # deno-${{ github.job }}-${{ runner.os }}- 13 | - uses: denoland/setup-deno@v1 14 | with: 15 | deno-version-file: .tool-versions 16 | - name: Setup Node.js for dnt 17 | uses: actions/setup-node@v6 18 | with: 19 | node-version-file: .tool-versions 20 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "bundle": "deno run -A scripts/build.ts", 4 | "bundle:commit": "deno task bundle && git add -u dist && git commit -m 'deno task bundle'" 5 | }, 6 | "fmt": { 7 | "exclude": ["./dist/", "./npm/"], 8 | "proseWrap": "preserve" 9 | }, 10 | "lint": { 11 | "exclude": ["./dist/", "./npm/"] 12 | }, 13 | "imports": { 14 | "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", 15 | "@kesin11/gha-utils": "jsr:@kesin11/gha-utils@0.2.2", 16 | "@std/assert": "jsr:@std/assert@^1.0.13", 17 | "@std/collections": "jsr:@std/collections@^1.1.2", 18 | "@deno/dnt": "jsr:@deno/dnt@0.42.3", 19 | "esbuild": "npm:esbuild@0.27.1", 20 | "@actions/core": "npm:@actions/core@2.0.1", 21 | "@actions/github": "npm:@actions/github@6.0.1", 22 | "date-fns": "npm:date-fns@4.1.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kenta Kase 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | categories: 4 | - title: "BREAKING CHANGES" 5 | labels: 6 | - "BREAKING CHANGES" 7 | - title: "Features" 8 | labels: 9 | - "feature" 10 | - "enhancement" 11 | - title: "Fixes" 12 | labels: 13 | - "fix" 14 | - "bug" 15 | - "security" 16 | - title: "Dependencies" 17 | collapse-after: 3 18 | labels: 19 | - "dependencies" 20 | - "renovate" 21 | - title: "Documentation" 22 | labels: 23 | - "document" 24 | - "documentation" 25 | - title: "Internal improvement" 26 | labels: 27 | - "ci" 28 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 29 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 30 | version-resolver: 31 | major: 32 | labels: 33 | - "BREAKING CHANGES" 34 | minor: 35 | labels: 36 | - "feature" 37 | - "enhancement" 38 | patch: 39 | labels: 40 | - "bug" 41 | - "security" 42 | default: patch 43 | template: | 44 | ## [v$RESOLVED_VERSION](https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION) 45 | ## Changes 46 | $CHANGES 47 | -------------------------------------------------------------------------------- /.github/workflows/rebundle.yml: -------------------------------------------------------------------------------- 1 | name: Rebundle dist 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | # Rebundle dist and push if changed from main branch 10 | rebundle-dist: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # To bypass branch rule sets, we need to use GitHub App that allowed to bypass status check. 14 | - uses: actions/create-github-app-token@v2 15 | id: app-token 16 | with: 17 | app-id: ${{ secrets.BYPASS_APP_ID }} 18 | private-key: ${{ secrets.BYPASS_APP_PRIVATE_KEY }} 19 | - uses: actions/checkout@v6 20 | with: 21 | token: ${{ steps.app-token.outputs.token }} 22 | 23 | - uses: ./.github/actions/setup-deno-with-cache 24 | - name: Rebuild the dist/ directory 25 | run: deno task bundle 26 | - name: Commit and push dist/ if changed 27 | run: | 28 | if [ "$(git diff --ignore-space-at-eol dist/ | wc -l)" -gt "0" ]; then 29 | git config --global user.name "github-actions" 30 | git config --global user.email "github-actions@github.com" 31 | git add -u dist 32 | git commit -m "deno task bundle" 33 | git push origin main 34 | fi 35 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { build, emptyDir } from "@deno/dnt"; 2 | import * as esbuild from "esbuild"; 3 | 4 | console.debug("Start dnt ..."); 5 | 6 | const outDir = "./npm"; 7 | await emptyDir(outDir); 8 | await build({ 9 | entryPoints: ["./src/main.ts", "./src/post.ts"], 10 | outDir, 11 | typeCheck: false, 12 | test: false, 13 | declaration: false, 14 | esModule: false, 15 | shims: { 16 | deno: true, 17 | }, 18 | importMap: "deno.jsonc", 19 | package: { 20 | // Dummy package.json 21 | name: "@kesin11/actions-timeline", 22 | version: "0.1.0", 23 | description: 24 | "An Action shows timeline of a GitHub Action workflow in the run summary page.", 25 | license: "MIT", 26 | repository: { 27 | type: "git", 28 | url: "https://github.com/Kesin11/actions-timeline.git", 29 | }, 30 | bugs: { 31 | url: "https://github.com/Kesin11/actions-timeline/issues", 32 | }, 33 | }, 34 | }); 35 | 36 | console.log("Start esbuild ..."); 37 | const distDir = "./dist"; 38 | await emptyDir(distDir); 39 | 40 | await esbuild.build({ 41 | entryPoints: ["./npm/src/main.ts", "./npm/src/post.ts"], 42 | outdir: distDir, 43 | bundle: true, 44 | platform: "node", 45 | target: "node20", 46 | format: "cjs", 47 | minify: false, 48 | sourcemap: false, 49 | }).finally(() => { 50 | }); 51 | 52 | console.log("Complete!"); 53 | -------------------------------------------------------------------------------- /tests/github.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { parseWorkflowRunUrl } from "@kesin11/gha-utils"; 3 | 4 | Deno.test(parseWorkflowRunUrl.name, async (t) => { 5 | await t.step("Basic", () => { 6 | const url = 7 | "https://github.com/Kesin11/actions-timeline/actions/runs/1000000000/"; 8 | const actual = parseWorkflowRunUrl(url); 9 | const expect = { 10 | origin: "https://github.com", 11 | owner: "Kesin11", 12 | repo: "actions-timeline", 13 | runId: 1000000000, 14 | runAttempt: undefined, 15 | }; 16 | assertEquals(actual, expect); 17 | }); 18 | 19 | await t.step("with attempt", () => { 20 | const url = 21 | "https://github.com/Kesin11/actions-timeline/actions/runs/1000000000/attempts/2"; 22 | const actual = parseWorkflowRunUrl(url); 23 | const expect = { 24 | origin: "https://github.com", 25 | owner: "Kesin11", 26 | repo: "actions-timeline", 27 | runId: 1000000000, 28 | runAttempt: 2, 29 | }; 30 | assertEquals(actual, expect); 31 | }); 32 | 33 | await t.step("GHES", () => { 34 | const url = 35 | "https://your_host.github.com/Kesin11/actions-timeline/actions/runs/1000000000/attempts/2"; 36 | const actual = parseWorkflowRunUrl(url); 37 | const expect = { 38 | origin: "https://your_host.github.com", 39 | owner: "Kesin11", 40 | repo: "actions-timeline", 41 | runId: 1000000000, 42 | runAttempt: 2, 43 | }; 44 | assertEquals(actual, expect); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/post.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from "node:timers/promises"; 2 | import process from "node:process"; 3 | import { debug, getBooleanInput, getInput, info, summary } from "@actions/core"; 4 | import * as github from "@actions/github"; 5 | import { createMermaid } from "./workflow_gantt.ts"; 6 | import { Github } from "@kesin11/gha-utils"; 7 | 8 | const main = async () => { 9 | const token = getInput("github-token", { required: true }); 10 | const showWaitingRunner = getBooleanInput("show-waiting-runner"); 11 | const client = new Github({ token }); 12 | 13 | info("Wait for workflow API result stability..."); 14 | await setTimeout(1000); 15 | 16 | info("Fetch workflow..."); 17 | // Currently, @actions/core does not provide runAttempt. 18 | // ref: https://github.com/actions/toolkit/pull/1387 19 | const runAttempt = process.env.GITHUB_RUN_ATTEMPT 20 | ? Number(process.env.GITHUB_RUN_ATTEMPT) 21 | : 1; 22 | const workflowRun = await client.fetchWorkflowRun( 23 | github.context.repo.owner, 24 | github.context.repo.repo, 25 | github.context.runId, 26 | runAttempt, 27 | ); 28 | debug(JSON.stringify(workflowRun, null, 2)); 29 | info("Fetch workflow_job..."); 30 | const workflowJobs = await client.fetchWorkflowRunJobs(workflowRun); 31 | 32 | debug(JSON.stringify(workflowJobs, null, 2)); 33 | 34 | info("Create gantt mermaid diagram..."); 35 | const gantt = createMermaid(workflowRun, workflowJobs, { showWaitingRunner }); 36 | await summary.addRaw(gantt).write(); 37 | debug(gantt); 38 | 39 | info("Complete!"); 40 | }; 41 | main(); 42 | -------------------------------------------------------------------------------- /cli.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@cliffy/command"; 2 | import { createMermaid } from "./src/workflow_gantt.ts"; 3 | import { Github, parseWorkflowRunUrl } from "@kesin11/gha-utils"; 4 | 5 | const { options, args } = await new Command() 6 | .name("actions-timeline-cli") 7 | .description("Command line tool of actions-timeline") 8 | .option("-t, --token ", "GitHub token. ex: $(gh auth token)") 9 | .option( 10 | "-o, --output ", 11 | "Output md file path. If not set output to STDOUT. ex: output.md", 12 | ) 13 | .option( 14 | "--show-waiting-runner ", 15 | "Show waiting runner time in the timeline. Default: true", 16 | { default: true }, 17 | ) 18 | .arguments("") 19 | .parse(Deno.args); 20 | 21 | const url = args[0]; 22 | const runUrl = parseWorkflowRunUrl(url); 23 | 24 | const host = (runUrl.origin !== "https://github.com") 25 | ? runUrl.origin 26 | : undefined; 27 | const client = new Github({ token: options.token, host }); 28 | 29 | const workflowRun = await client.fetchWorkflowRun( 30 | runUrl.owner, 31 | runUrl.repo, 32 | runUrl.runId, 33 | runUrl.runAttempt, 34 | ); 35 | // const workflowJobs = await client.fetchWorkflowJobs([workflowRun]); 36 | const workflowJobs = await client.fetchWorkflowRunJobs(workflowRun); 37 | 38 | const gantt = createMermaid(workflowRun, workflowJobs, { 39 | showWaitingRunner: options.showWaitingRunner, 40 | }); 41 | 42 | if (options.output) { 43 | await Deno.writeTextFile(options.output, gantt); 44 | } else { 45 | console.log(gantt); 46 | } 47 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Common Commands 6 | 7 | ### Development 8 | 9 | - `deno task bundle` - Build the bundle (outputs to dist/ folder) 10 | - `deno task bundle:commit` - Bundle and commit changes 11 | - `deno fmt` - Format code 12 | - `deno lint` - Lint code 13 | - `deno test` - Run tests 14 | 15 | ### CLI tool 16 | 17 | - `deno run --allow-net --allow-write --allow-env=GITHUB_API_URL cli.ts` - Run CLI version 18 | 19 | ## Architecture 20 | 21 | This project is a GitHub Action that visualizes GitHub Actions workflow execution timelines using Mermaid gantt charts. 22 | 23 | ### Key Components 24 | 25 | - **action.yml** - GitHub Action configuration (inputs, runs settings) 26 | - **src/main.ts** - Main phase (does nothing - dummy file) 27 | - **src/post.ts** - Post-processing phase with actual logic 28 | - **src/workflow_gantt.ts** - Mermaid gantt chart generation logic 29 | - **src/github.ts** - GitHub API client 30 | - **cli.ts** - CLI version entry point 31 | 32 | ### Processing Flow 33 | 34 | 1. post.ts fetches workflow run and workflow jobs from GitHub API 35 | 2. workflow_gantt.ts generates Mermaid gantt chart 36 | 3. Outputs to GitHub Actions summary 37 | 38 | ### Directory Structure 39 | 40 | - `src/` - TypeScript source code 41 | - `dist/` - Bundled JavaScript for node20 42 | - `npm/` - npm package build output 43 | - `tests/` - Test files and fixtures 44 | 45 | ## Important Notes 46 | 47 | - This Action runs in the **post-processing** phase, so main.ts does nothing 48 | - The dist/ folder is updated via `deno task bundle` 49 | - Includes a 1-second delay for GitHub API result stability 50 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | permissions: 8 | contents: write 9 | # Comment out to enable these permissions if needed. 10 | # packages: write 11 | # deployments: write 12 | # id-token: write 13 | 14 | jobs: 15 | draft_release: 16 | runs-on: ubuntu-latest 17 | outputs: 18 | tag_name: ${{ steps.release-drafter.outputs.tag_name }} 19 | steps: 20 | # Get next version 21 | - uses: release-drafter/release-drafter@v6 22 | id: release-drafter 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | # Sample job for GitHub Actions repository. 27 | # It creates GitHub Releases and push semver(major, minor, patch) git tags. 28 | release: 29 | name: Release 30 | runs-on: ubuntu-latest 31 | if: github.event_name == 'workflow_dispatch' 32 | needs: draft_release 33 | steps: 34 | - uses: release-drafter/release-drafter@v6 35 | id: release-drafter 36 | with: 37 | publish: true 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | - name: Create semver outputs 41 | uses: actions/github-script@v8 42 | id: semver 43 | with: 44 | script: | 45 | const VERSION = "${{ steps.release-drafter.outputs.tag_name }}" 46 | const matched = VERSION.match(/(((v\d+)\.\d+).\d+)/) 47 | core.setOutput('major', matched[3]) 48 | core.setOutput('minor', matched[2]) 49 | core.setOutput('patch', matched[1]) 50 | - uses: actions/checkout@v6 51 | with: 52 | ref: "${{ steps.release-drafter.outputs.tag_name }}" 53 | - name: Update major and minor git tags 54 | run: | 55 | git push -f origin "refs/tags/${{ steps.release-drafter.outputs.tag_name }}:refs/tags/${{ steps.semver.outputs.major }}" 56 | git push -f origin "refs/tags/${{ steps.release-drafter.outputs.tag_name }}:refs/tags/${{ steps.semver.outputs.minor }}" 57 | -------------------------------------------------------------------------------- /src/format_util.ts: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns"; 2 | import type { ganttJob, ganttStep, StepConclusion } from "./types.ts"; 3 | 4 | export const diffSec = ( 5 | start?: string | Date | null, 6 | end?: string | Date | null, 7 | ): number => { 8 | if (!start || !end) return 0; 9 | const startDate = new Date(start); 10 | const endDate = new Date(end); 11 | 12 | return (endDate.getTime() - startDate.getTime()) / 1000; 13 | }; 14 | 15 | // Sec to elapsed format time like HH:mm:ss (ex. 70sec -> 00:01:10) 16 | export const formatElapsedTime = (sec: number): string => { 17 | const date = new Date(sec * 1000); 18 | const offsetMinute = date.getTimezoneOffset(); 19 | const timezonreIgnoredDate = new Date(sec * 1000 + offsetMinute * 60 * 1000); 20 | return format(timezonreIgnoredDate, "HH:mm:ss"); 21 | }; 22 | 23 | // Sec to elapsed short format time like 1h2m3s (ex. 70sec -> 1m10s) 24 | export const formatShortElapsedTime = (sec: number): string => { 25 | const date = new Date(sec * 1000); 26 | const offsetMinute = date.getTimezoneOffset(); 27 | const timezonreIgnoredDate = new Date(sec * 1000 + offsetMinute * 60 * 1000); 28 | if (sec < 60) { 29 | return format(timezonreIgnoredDate, "s's'"); 30 | } else if (sec < 60 * 60) { 31 | return format(timezonreIgnoredDate, "m'm's's'"); 32 | } else { 33 | return format(timezonreIgnoredDate, "H'h'm'm's's'"); 34 | } 35 | }; 36 | 37 | export const formatStep = (step: ganttStep): string => { 38 | switch (step.status) { 39 | case "": 40 | return `${step.name} :${step.id}, ${step.position}, ${step.sec}s`; 41 | default: 42 | return `${step.name} :${step.status}, ${step.id}, ${step.position}, ${step.sec}s`; 43 | } 44 | }; 45 | 46 | export const formatName = (name: string, sec: number): string => { 47 | return `${escapeName(name)} (${formatShortElapsedTime(sec)})`; 48 | }; 49 | 50 | export const escapeName = (name: string): string => { 51 | let escapedName = name; 52 | escapedName = escapedName.replaceAll(":", ""); 53 | escapedName = escapedName.replaceAll(";", ""); 54 | return escapedName; 55 | }; 56 | 57 | export function formatSection(job: ganttJob): string { 58 | return [ 59 | `section ${job.section}`, 60 | ...job.steps.map((step) => formatStep(step)), 61 | ].join("\n"); 62 | } 63 | 64 | export const convertStepToStatus = ( 65 | conclusion: StepConclusion, 66 | ): ganttStep["status"] => { 67 | switch (conclusion) { 68 | case "success": 69 | return ""; 70 | case "failure": 71 | return "crit"; 72 | case "cancelled": 73 | case "skipped": 74 | case "timed_out": 75 | return "done"; 76 | case "neutral": 77 | case "action_required": 78 | case null: 79 | return "active"; 80 | default: 81 | return "active"; 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pull-requests: write 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | check: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v6 22 | - uses: ./.github/actions/setup-deno-with-cache 23 | - id: fmt 24 | run: deno fmt --check 25 | - id: lint 26 | run: deno lint 27 | check-dist: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v6 31 | - uses: ./.github/actions/setup-deno-with-cache 32 | - name: Rebuild the dist/ directory 33 | run: deno task bundle 34 | 35 | # post processes 36 | - name: Upload dist for post job 37 | if: ${{ always() }} 38 | uses: actions/upload-artifact@v5 39 | with: 40 | name: js_dist 41 | path: | 42 | dist/ 43 | action.yml 44 | - name: Create dist/*.js size json 45 | run: | 46 | find ./dist -type f -printf '%s %f\n' \ 47 | | jq -n -R '{name: "dist_size", key: "dist_size", metrics: [inputs | capture("(?\\S+)\\s+(?.+)") + {unit: "KB"} | .value |= tonumber / 1024 | .name = .key ]}' \ 48 | > dist_js_sizes.json 49 | cat dist_js_sizes.json 50 | # octocov must needs some coverage files but this job don't exec test, so put dummy file. 51 | - name: Create dummy coverage 52 | run: touch coverage.out 53 | - uses: k1LoW/octocov-action@v1 54 | env: 55 | OCTOCOV_CUSTOM_METRICS_DIST_JS: dist_js_sizes.json 56 | test: 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v6 60 | - uses: ./.github/actions/setup-deno-with-cache 61 | - run: deno test --allow-env 62 | - name: CLI somke test 63 | run: | 64 | RUN_URL=$(gh run list -L 1 -w release --json url --jq .[].url) 65 | deno run --allow-net --allow-write --allow-env=GITHUB_API_URL cli.ts -t "${GITHUB_TOKEN}" -o output.md $RUN_URL 66 | cat output.md 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | 70 | run_self: 71 | runs-on: ubuntu-slim 72 | if: ${{ always() }} 73 | needs: [check, check-dist, test] 74 | steps: 75 | - name: Download bundled dist 76 | uses: actions/download-artifact@v6 77 | with: 78 | name: js_dist 79 | - name: Run self action with no options 80 | uses: ./ 81 | - name: Run self action with 'show-waiting-runner=false' 82 | uses: ./ 83 | with: 84 | show-waiting-runner: false 85 | -------------------------------------------------------------------------------- /tests/format_util.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { ganttStep } from "../src/types.ts"; 3 | import { 4 | escapeName, 5 | formatShortElapsedTime, 6 | formatStep, 7 | } from "../src/format_util.ts"; 8 | 9 | Deno.test(formatStep.name, async (t) => { 10 | const baseStep: ganttStep = { 11 | name: "Test step", 12 | id: "job0-1", 13 | status: "", 14 | position: "after job0-0", 15 | sec: 1, 16 | }; 17 | 18 | await t.step("status:empty", () => { 19 | const step: ganttStep = { ...baseStep, status: "" }; 20 | const actual = formatStep(step); 21 | const expect = `${step.name} :${step.id}, ${step.position}, ${step.sec}s`; 22 | assertEquals(actual, expect); 23 | }); 24 | await t.step("status:done", () => { 25 | const step: ganttStep = { ...baseStep, status: "done" }; 26 | const actual = formatStep(step); 27 | const expect = 28 | `${step.name} :done, ${step.id}, ${step.position}, ${step.sec}s`; 29 | assertEquals(actual, expect); 30 | }); 31 | await t.step("status:crit", () => { 32 | const step: ganttStep = { ...baseStep, status: "crit" }; 33 | const actual = formatStep(step); 34 | const expect = 35 | `${step.name} :crit, ${step.id}, ${step.position}, ${step.sec}s`; 36 | assertEquals(actual, expect); 37 | }); 38 | await t.step("status:active", () => { 39 | const step: ganttStep = { ...baseStep, status: "active" }; 40 | const actual = formatStep(step); 41 | const expect = 42 | `${step.name} :active, ${step.id}, ${step.position}, ${step.sec}s`; 43 | assertEquals(actual, expect); 44 | }); 45 | }); 46 | Deno.test(formatShortElapsedTime.name, async (t) => { 47 | await t.step("9sec => 9s", () => { 48 | const elapsedSec = 9; 49 | assertEquals(formatShortElapsedTime(elapsedSec), `9s`); 50 | }); 51 | await t.step("59sec => 59s", () => { 52 | const elapsedSec = 59; 53 | assertEquals(formatShortElapsedTime(elapsedSec), `59s`); 54 | }); 55 | await t.step("60sec => 1m0s", () => { 56 | const elapsedSec = 60; 57 | assertEquals(formatShortElapsedTime(elapsedSec), `1m0s`); 58 | }); 59 | await t.step("61sec => 1m1s", () => { 60 | const elapsedSec = 61; 61 | assertEquals(formatShortElapsedTime(elapsedSec), `1m1s`); 62 | }); 63 | await t.step("3600sec => 1h0m0s", () => { 64 | const elapsedSec = 60 * 60; // 1hour 65 | assertEquals(formatShortElapsedTime(elapsedSec), `1h0m0s`); 66 | }); 67 | await t.step("3660sec => 1h1m0s", () => { 68 | const elapsedSec = 60 * 60 + 60; // 1hour 1min 69 | assertEquals(formatShortElapsedTime(elapsedSec), `1h1m0s`); 70 | }); 71 | await t.step("3661sec => 1h1m1s", () => { 72 | const elapsedSec = 60 * 60 + 60 + 1; // 1hour 1min 1sec 73 | assertEquals(formatShortElapsedTime(elapsedSec), `1h1m1s`); 74 | }); 75 | }); 76 | 77 | Deno.test(escapeName.name, async (t) => { 78 | await t.step("Step name has colon", () => { 79 | const stepName = "check: deno"; 80 | const actual = escapeName(stepName); 81 | const expect = "check deno"; 82 | assertEquals(actual, expect); 83 | }); 84 | await t.step("Step has name semicolon", () => { 85 | const stepName = `Run if [ "pull_request" = "push" ]; then`; 86 | const actual = escapeName(stepName); 87 | const expect = `Run if [ "pull_request" = "push" ] then`; 88 | assertEquals(actual, expect); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /tests/fixture/workflow_job.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 16218974194, 4 | "run_id": 5977929222, 5 | "workflow_name": "POC", 6 | "head_branch": "main", 7 | "run_url": "https://api.github.com/repos/Kesin11/actions-timeline/actions/runs/5977929222", 8 | "run_attempt": 1, 9 | "node_id": "CR_kwDOKKIKiM8AAAADxrnn8g", 10 | "head_sha": "d9132dafd385889b4bd21730d4782c095b86394b", 11 | "url": "https://api.github.com/repos/Kesin11/actions-timeline/actions/jobs/16218974194", 12 | "html_url": "https://github.com/Kesin11/actions-timeline/actions/runs/5977929222/job/16218974194", 13 | "status": "completed", 14 | "conclusion": "success", 15 | "created_at": "2023-08-25T15:57:52Z", 16 | "started_at": "2023-08-25T15:57:59Z", 17 | "completed_at": "2023-08-25T15:58:17Z", 18 | "name": "run_self", 19 | "steps": [ 20 | { 21 | "name": "Set up job", 22 | "status": "completed", 23 | "conclusion": "success", 24 | "number": 1, 25 | "started_at": "2023-08-26T00:57:58.000+09:00", 26 | "completed_at": "2023-08-26T00:58:00.000+09:00" 27 | }, 28 | { 29 | "name": "Run actions/checkout@v3", 30 | "status": "completed", 31 | "conclusion": "success", 32 | "number": 2, 33 | "started_at": "2023-08-26T00:58:00.000+09:00", 34 | "completed_at": "2023-08-26T00:58:01.000+09:00" 35 | }, 36 | { 37 | "name": "Run denoland/setup-deno@v1", 38 | "status": "completed", 39 | "conclusion": "success", 40 | "number": 3, 41 | "started_at": "2023-08-26T00:58:02.000+09:00", 42 | "completed_at": "2023-08-26T00:58:03.000+09:00" 43 | }, 44 | { 45 | "name": "Run deno task bundle", 46 | "status": "completed", 47 | "conclusion": "success", 48 | "number": 4, 49 | "started_at": "2023-08-26T00:58:04.000+09:00", 50 | "completed_at": "2023-08-26T00:58:15.000+09:00" 51 | }, 52 | { 53 | "name": "Run self action", 54 | "status": "completed", 55 | "conclusion": "success", 56 | "number": 5, 57 | "started_at": "2023-08-26T00:58:15.000+09:00", 58 | "completed_at": "2023-08-26T00:58:15.000+09:00" 59 | }, 60 | { 61 | "name": "Post Run self action", 62 | "status": "completed", 63 | "conclusion": "success", 64 | "number": 9, 65 | "started_at": "2023-08-26T00:58:15.000+09:00", 66 | "completed_at": "2023-08-26T00:58:15.000+09:00" 67 | }, 68 | { 69 | "name": "Post Run actions/checkout@v3", 70 | "status": "completed", 71 | "conclusion": "success", 72 | "number": 10, 73 | "started_at": "2023-08-26T00:58:15.000+09:00", 74 | "completed_at": "2023-08-26T00:58:15.000+09:00" 75 | }, 76 | { 77 | "name": "Complete job", 78 | "status": "completed", 79 | "conclusion": "success", 80 | "number": 11, 81 | "started_at": "2023-08-26T00:58:15.000+09:00", 82 | "completed_at": "2023-08-26T00:58:15.000+09:00" 83 | } 84 | ], 85 | "check_run_url": "https://api.github.com/repos/Kesin11/actions-timeline/check-runs/16218974194", 86 | "labels": [ 87 | "ubuntu-latest" 88 | ], 89 | "runner_id": 2, 90 | "runner_name": "GitHub Actions 2", 91 | "runner_group_id": 2, 92 | "runner_group_name": "GitHub Actions" 93 | } 94 | ] 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # actions-timeline 2 | 3 | An Action shows timeline of a GitHub Action workflow in the run summary page. 4 | 5 | `actions-timeline` is a tool that allows developers to visualize the sequence of 6 | jobs and steps that occur during a GitHub Actions workflow. By examining the 7 | timeline, you can quickly identify any issues or bottlenecks in your workflow, 8 | and make adjustments as needed to improve performance and efficiency. 9 | 10 | ![Sample screenshot](https://user-images.githubusercontent.com/1324862/268660777-5ee9fffd-6ef7-4960-9632-3589cb7138e1.png) 11 | 12 | ## USAGE 13 | 14 | ```yaml 15 | jobs: 16 | build: 17 | runs-on: ubuntu-slim 18 | steps: 19 | # Register this action before your build step. It will then be executed at the end of the job post-processing. 20 | - uses: Kesin11/actions-timeline@v2 21 | with: 22 | # e.g.: ${{ secrets.MY_PAT }} 23 | # Default: ${{ github.token }} 24 | github-token: "" 25 | # Show waiting runner time in the timeline. 26 | # Default: true 27 | show-waiting-runner: true 28 | 29 | # Your build steps... 30 | ``` 31 | 32 | If your workflow has many jobs, you should run `actions-timeline` in the job 33 | that takes the most time, or create an independent job for `actions-timeline` in 34 | a last of the workflow. 35 | 36 | ```yaml 37 | jobs: 38 | build-1: 39 | build-2: 40 | build-3: 41 | 42 | actions-timeline: 43 | needs: [build-1, build-2, build-3] 44 | runs-on: ubuntu-slim 45 | steps: 46 | - uses: Kesin11/actions-timeline@v2 47 | ``` 48 | 49 | ## How it works 50 | 51 | `actions-timeline` fetches the jobs and steps of the workflow run from the 52 | GitHub API, and then generates a timeline with 53 | [mermaid gantt diagrams](https://mermaid.js.org/syntax/gantt.html). Thanks to 54 | the GitHub flavored markdown that can visualize mermaid diagrams, the timeline 55 | is displayed in the run summary page. 56 | 57 | This action is run on post-processing of the job, so you should register this 58 | action before your build step. If you register this action after your build 59 | step, the timeline will not include other post-processing steps. 60 | 61 | ## Support GHES 62 | 63 | `actions-timeline` can also work on GitHub Enterprise Server(GHES). It needs 64 | `GITHUB_API_URL` environment variable to access your GHES. Thanks to GitHub 65 | Actions, it sets 66 | [default environment variables](https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables) 67 | so you do not need to make any code changes. 68 | 69 | ## Known issues 70 | 71 | > [!IMPORTANT] 72 | > **In some cases, the workflow requires `actions:read' permission.** 73 | 74 | Sometimes the 75 | `actions:read' permission is needed in the workflow to fetch workflow jobs and steps. If you see the following error, you need to add the`actions:read' 76 | permission to your workflow. 77 | 78 | ```yaml 79 | jobs: 80 | build: 81 | permissions: 82 | actions: read 83 | runs-on: ubuntu-slim 84 | steps: 85 | - uses: Kesin11/actions-timeline@v2 86 | ``` 87 | 88 | > [!IMPORTANT] 89 | > **'Waiting for a runner' step is not supported < GHES v3.9** 90 | 91 | GET `workflow_job` API response does not contain `created_at` field in 92 | [GHES v3.8](https://docs.github.com/en/enterprise-server@3.8/rest/actions/workflow-jobs#get-a-job-for-a-workflow-run), 93 | it is added from 94 | [GHES v3.9](https://docs.github.com/en/enterprise-server@3.9/rest/actions/workflow-jobs?apiVersion=2022-11-28). 95 | So it is not possible to calculate the elapsed time the runner is waiting for a 96 | job, `actions-timeline` omits `Waiting for a runner` step in the timeline. 97 | 98 | # Similar works 99 | 100 | - https://github.com/Kesin11/github_actions_otel_trace 101 | - https://github.com/inception-health/otel-export-trace-action 102 | - https://github.com/runforesight/workflow-telemetry-action 103 | 104 | # CLI tool 105 | 106 | `actions-timeline` is also available as a CLI tool. You can use it with 107 | `deno run` command. 108 | 109 | ```bash 110 | deno run --allow-net --allow-write --allow-env=GITHUB_API_URL \ 111 | https://raw.githubusercontent.com/Kesin11/actions-timeline/main/cli.ts \ 112 | https://github.com/Kesin11/actions-timeline/actions/runs/8021493760/attempts/1 \ 113 | -t $(gh auth token) \ 114 | -o output.md 115 | 116 | # Fetch latest attempt if ommit attempts 117 | deno run --allow-net --allow-write --allow-env=GITHUB_API_URL \ 118 | https://raw.githubusercontent.com/Kesin11/actions-timeline/main/cli.ts \ 119 | https://github.com/Kesin11/actions-timeline/actions/runs/8021493760/ \ 120 | -t $(gh auth token) \ 121 | -o output.md 122 | 123 | # GHES 124 | deno run --allow-net --allow-write --allow-env=GITHUB_API_URL \ 125 | https://raw.githubusercontent.com/Kesin11/actions-timeline/main/cli.ts \ 126 | https://YOUR_ENTERPRISE_HOST/OWNER/REPO/actions/runs/RUN_ID/attempts/1 \ 127 | -t $(gh auth token -h YOUR_ENTERPRISE_HOST) \ 128 | -o output.md 129 | ``` 130 | 131 | `cli.ts` just outputs the markdown to file or STDOUT, so you have to use other 132 | tools to visualize mermaid diagrams. 133 | 134 | - Online editor: 135 | [Mermaid Live Editor](https://mermaid-js.github.io/mermaid-live-editor/) 136 | - VSCode extension: 137 | [Markdown Preview Mermaid Support](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-mermaid) 138 | - Local terminal: [mermaid-cli](https://github.com/mermaid-js/mermaid-cli) 139 | 140 | # DEVELOPMENT 141 | 142 | ## Setup 143 | 144 | ``` 145 | asdf install 146 | deno task setup:githooks 147 | ``` 148 | 149 | # DEBUG 150 | 151 | If you want to debug this action, first generate `dist/` then execute own 152 | action. 153 | 154 | ```yaml 155 | - uses: actions/checkout@v3 156 | - uses: denoland/setup-deno@v1 157 | - run: deno task bundle 158 | - uses: ./ 159 | ``` 160 | 161 | # LICENSE 162 | 163 | MIT 164 | -------------------------------------------------------------------------------- /src/workflow_gantt.ts: -------------------------------------------------------------------------------- 1 | import { sumOf } from "@std/collections"; 2 | import type { WorkflowJobs, WorkflowRun } from "@kesin11/gha-utils"; 3 | import { 4 | convertStepToStatus, 5 | diffSec, 6 | escapeName, 7 | formatElapsedTime, 8 | formatName, 9 | formatSection, 10 | } from "./format_util.ts"; 11 | import type { 12 | ganttJob, 13 | GanttOptions, 14 | ganttStep, 15 | StepConclusion, 16 | } from "./types.ts"; 17 | 18 | // ref: MAX_TEXTLENGTH https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/mermaidAPI.ts 19 | const MERMAID_MAX_CHAR = 50_000; 20 | 21 | type workflowJobSteps = NonNullable; 22 | 23 | // Skip steps that is not status:completed (ex. status:queued, status:in_progress) 24 | const filterSteps = (steps: workflowJobSteps): workflowJobSteps => { 25 | return steps.filter((step) => step.status === "completed"); 26 | }; 27 | 28 | // Skip jobs that is conclusion:skipped 29 | const filterJobs = (jobs: WorkflowJobs): WorkflowJobs => { 30 | return jobs.filter((job) => job.conclusion !== "skipped"); 31 | }; 32 | 33 | const createWaitingRunnerStep = ( 34 | workflow: WorkflowRun, 35 | job: WorkflowJobs[0], 36 | jobIndex: number, 37 | ): ganttStep | undefined => { 38 | const status: ganttStep["status"] = "active"; 39 | 40 | // job.created_at does not exist in < GHES v3.9. 41 | // So it is not possible to calculate the elapsed time the runner is waiting for a job, is not supported instead of the elapsed time. 42 | // Also, it is not possible to create an exact job start time position. So use job.started_at instead of job.created_at. 43 | if (job.created_at === undefined) { 44 | return undefined; 45 | } else { 46 | // >= GHES v3.9 or GitHub.com 47 | const startJobElapsedSec = diffSec( 48 | workflow.run_started_at, 49 | job.created_at, 50 | ); 51 | const waitingRunnerElapsedSec = diffSec(job.created_at, job.started_at); 52 | return { 53 | name: formatName("Waiting for a runner", waitingRunnerElapsedSec), 54 | id: `job${jobIndex}-0`, 55 | status, 56 | position: formatElapsedTime(startJobElapsedSec), 57 | sec: waitingRunnerElapsedSec, 58 | }; 59 | } 60 | }; 61 | 62 | export const createGanttJobs = ( 63 | workflow: WorkflowRun, 64 | workflowJobs: WorkflowJobs, 65 | showWaitingRunner = true, 66 | ): ganttJob[] => { 67 | return filterJobs(workflowJobs).map( 68 | (job, jobIndex, _jobs): ganttJob | undefined => { 69 | if (job.steps === undefined) return undefined; 70 | 71 | const section = escapeName(job.name); 72 | let firstStep: ganttStep; 73 | 74 | const waitingRunnerStep = createWaitingRunnerStep( 75 | workflow, 76 | job, 77 | jobIndex, 78 | ); 79 | if (!showWaitingRunner || waitingRunnerStep === undefined) { 80 | const rawFirstStep = job.steps.shift(); 81 | if (rawFirstStep === undefined) return undefined; 82 | 83 | const startJobElapsedSec = diffSec( 84 | workflow.run_started_at, 85 | job.started_at, 86 | ); 87 | const stepElapsedSec = diffSec( 88 | rawFirstStep.started_at, 89 | rawFirstStep.completed_at, 90 | ); 91 | firstStep = { 92 | name: formatName(rawFirstStep.name, stepElapsedSec), 93 | id: `job${jobIndex}-0`, 94 | status: convertStepToStatus( 95 | rawFirstStep.conclusion as StepConclusion, 96 | ), 97 | position: formatElapsedTime(startJobElapsedSec), 98 | sec: stepElapsedSec, 99 | }; 100 | } else { 101 | firstStep = waitingRunnerStep; 102 | } 103 | 104 | const steps = filterSteps(job.steps).map( 105 | (step, stepIndex, _steps): ganttStep => { 106 | const stepElapsedSec = diffSec(step.started_at, step.completed_at); 107 | return { 108 | name: formatName(step.name, stepElapsedSec), 109 | id: `job${jobIndex}-${stepIndex + 1}`, 110 | status: convertStepToStatus(step.conclusion as StepConclusion), 111 | position: `after job${jobIndex}-${stepIndex}`, 112 | sec: stepElapsedSec, 113 | }; 114 | }, 115 | ); 116 | 117 | return { section, steps: [firstStep, ...steps] }; 118 | }, 119 | ).filter((gantJobs): gantJobs is ganttJob => gantJobs !== undefined); 120 | }; 121 | 122 | /** 123 | * Creates Mermaid gantt diagrams from workflow jobs data. 124 | * 125 | * This function generates one or more Mermaid gantt chart strings, automatically 126 | * splitting them into multiple diagrams if the content exceeds the maxChar limit. 127 | * This prevents "Maximum text size in diagram exceeded" errors in Mermaid.js. 128 | * 129 | * @param title - The title to display in the gantt chart 130 | * @param ganttJobs - Array of processed job data containing sections and steps 131 | * @param maxChar - Maximum character limit per diagram (default: 50,000) 132 | * @returns Array of complete Mermaid diagram strings (markdown code blocks) 133 | */ 134 | export const createGanttDiagrams = ( 135 | title: string, 136 | ganttJobs: ganttJob[], 137 | maxChar: number = MERMAID_MAX_CHAR, // For test argument 138 | ): string[] => { 139 | const header = ` 140 | \`\`\`mermaid 141 | gantt 142 | title ${escapeName(title)} 143 | dateFormat HH:mm:ss 144 | axisFormat %H:%M:%S 145 | `; 146 | const footer = "\n\`\`\`"; 147 | const headerFooterLength = header.length + footer.length; 148 | 149 | // Split mermaid body by maxChar to avoid exceeding Mermaid.js text size limit 150 | const mermaids = []; 151 | let sections: string[] = []; 152 | for (const job of ganttJobs) { 153 | const newSection = formatSection(job); 154 | 155 | // Calculate total length of existing sections including newlines between them 156 | // sections.join("\n") adds (sections.length - 1) newline characters 157 | // This fix addresses issue #222 where newlines were not counted in the original calculation 158 | const sectionsSumLength = sumOf(sections, (section) => section.length) + 159 | Math.max(0, sections.length - 1); 160 | 161 | // Check if adding the new section would exceed maxChar limit 162 | if (headerFooterLength + sectionsSumLength + newSection.length > maxChar) { 163 | // Exceeds limit: finalize current diagram and start a new one 164 | mermaids.push(header + sections.join("\n") + footer); 165 | sections = [newSection]; 166 | } else { 167 | // Within limit: add section to current diagram 168 | sections.push(newSection); 169 | } 170 | } 171 | // Add the final diagram 172 | mermaids.push(header + sections.join("\n") + footer); 173 | 174 | return mermaids; 175 | }; 176 | 177 | export const createMermaid = ( 178 | workflow: WorkflowRun, 179 | workflowJobs: WorkflowJobs, 180 | options: GanttOptions, 181 | ): string => { 182 | const title = workflow.name ?? ""; 183 | const jobs = createGanttJobs( 184 | workflow, 185 | workflowJobs, 186 | options.showWaitingRunner, 187 | ); 188 | return createGanttDiagrams(title, jobs).join("\n"); 189 | }; 190 | -------------------------------------------------------------------------------- /tests/workflow_gantt_1.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { createMermaid } from "../src/workflow_gantt.ts"; 3 | import { WorkflowJobs, WorkflowRun } from "@kesin11/gha-utils"; 4 | 5 | const commonWorkflow = { 6 | "id": 5977929222, 7 | "name": "POC", 8 | "run_number": 5, 9 | "event": "push", 10 | "status": "completed", 11 | "conclusion": "success", 12 | "workflow_id": 66989622, 13 | "created_at": "2023-08-25T15:57:51Z", 14 | "updated_at": "2023-08-25T15:58:19Z", 15 | "run_started_at": "2023-08-25T15:57:51Z", 16 | } as unknown as WorkflowRun; 17 | 18 | Deno.test("1 section gantt", async (t) => { 19 | await t.step("all steps are success", () => { 20 | const workflow = { ...commonWorkflow }; 21 | const workflowJobs = [{ 22 | "id": 16218974194, 23 | "run_id": 5977929222, 24 | "workflow_name": "POC", 25 | "status": "completed", 26 | "conclusion": "success", 27 | "created_at": "2023-08-25T15:57:52Z", 28 | "started_at": "2023-08-25T15:57:59Z", 29 | "completed_at": "2023-08-25T15:58:17Z", 30 | "name": "run_self", 31 | "steps": [ 32 | { 33 | "name": "Set up job", 34 | "status": "completed", 35 | "conclusion": "success", 36 | "number": 1, 37 | "started_at": "2023-08-26T00:57:58.000+09:00", 38 | "completed_at": "2023-08-26T00:58:00.000+09:00", 39 | }, 40 | { 41 | "name": "Run actions/checkout@v3", 42 | "status": "completed", 43 | "conclusion": "success", 44 | "number": 2, 45 | "started_at": "2023-08-26T00:58:00.000+09:00", 46 | "completed_at": "2023-08-26T00:58:01.000+09:00", 47 | }, 48 | { 49 | "name": "Run denoland/setup-deno@v1", 50 | "status": "completed", 51 | "conclusion": "success", 52 | "number": 3, 53 | "started_at": "2023-08-26T00:58:02.000+09:00", 54 | "completed_at": "2023-08-26T00:58:03.000+09:00", 55 | }, 56 | { 57 | "name": "Run deno task bundle", 58 | "status": "completed", 59 | "conclusion": "success", 60 | "number": 4, 61 | "started_at": "2023-08-26T00:58:04.000+09:00", 62 | "completed_at": "2023-08-26T00:58:15.000+09:00", 63 | }, 64 | { 65 | "name": "Run self action", 66 | "status": "completed", 67 | "conclusion": "success", 68 | "number": 5, 69 | "started_at": "2023-08-26T00:58:15.000+09:00", 70 | "completed_at": "2023-08-26T00:58:15.000+09:00", 71 | }, 72 | { 73 | "name": "Post Run self action", 74 | "status": "completed", 75 | "conclusion": "success", 76 | "number": 9, 77 | "started_at": "2023-08-26T00:58:15.000+09:00", 78 | "completed_at": "2023-08-26T00:58:15.000+09:00", 79 | }, 80 | { 81 | "name": "Post Run actions/checkout@v3", 82 | "status": "completed", 83 | "conclusion": "success", 84 | "number": 10, 85 | "started_at": "2023-08-26T00:58:15.000+09:00", 86 | "completed_at": "2023-08-26T00:58:15.000+09:00", 87 | }, 88 | { 89 | "name": "Complete job", 90 | "status": "completed", 91 | "conclusion": "success", 92 | "number": 11, 93 | "started_at": "2023-08-26T00:58:15.000+09:00", 94 | "completed_at": "2023-08-26T00:58:15.000+09:00", 95 | }, 96 | ], 97 | }] as unknown as WorkflowJobs; 98 | 99 | // deno-fmt-ignore 100 | const expect = ` 101 | \`\`\`mermaid 102 | gantt 103 | title ${workflowJobs[0].workflow_name} 104 | dateFormat HH:mm:ss 105 | axisFormat %H:%M:%S 106 | section ${workflowJobs[0].name} 107 | Waiting for a runner (7s) :active, job0-0, 00:00:01, 7s 108 | ${workflowJobs[0].steps![0].name} (2s) :job0-1, after job0-0, 2s 109 | ${workflowJobs[0].steps![1].name} (1s) :job0-2, after job0-1, 1s 110 | ${workflowJobs[0].steps![2].name} (1s) :job0-3, after job0-2, 1s 111 | ${workflowJobs[0].steps![3].name} (11s) :job0-4, after job0-3, 11s 112 | ${workflowJobs[0].steps![4].name} (0s) :job0-5, after job0-4, 0s 113 | ${workflowJobs[0].steps![5].name} (0s) :job0-6, after job0-5, 0s 114 | ${workflowJobs[0].steps![6].name} (0s) :job0-7, after job0-6, 0s 115 | ${workflowJobs[0].steps![7].name} (0s) :job0-8, after job0-7, 0s 116 | \`\`\``; 117 | 118 | assertEquals(createMermaid(workflow, workflowJobs, {}), expect); 119 | }); 120 | 121 | await t.step("job has skipped and failure steps", () => { 122 | const workflow = { ...commonWorkflow }; 123 | const workflowJobs = [{ 124 | "id": 16218974194, 125 | "run_id": 5977929222, 126 | "workflow_name": "POC", 127 | "status": "completed", 128 | "conclusion": "failure", 129 | "created_at": "2023-08-25T15:57:52Z", 130 | "started_at": "2023-08-25T15:57:59Z", 131 | "completed_at": "2023-08-25T15:58:17Z", 132 | "name": "run_self", 133 | "steps": [ 134 | { 135 | "name": "Set up job", 136 | "status": "completed", 137 | "conclusion": "skipped", 138 | "number": 1, 139 | "started_at": "2023-08-26T00:57:58.000+09:00", 140 | "completed_at": "2023-08-26T00:58:00.000+09:00", 141 | }, 142 | { 143 | "name": "Run actions/checkout@v3", 144 | "status": "completed", 145 | "conclusion": "failure", 146 | "number": 2, 147 | "started_at": "2023-08-26T00:58:00.000+09:00", 148 | "completed_at": "2023-08-26T00:58:01.000+09:00", 149 | }, 150 | { 151 | "name": "Post Run actions/checkout@v3", 152 | "status": "completed", 153 | "conclusion": "success", 154 | "number": 10, 155 | "started_at": "2023-08-26T00:58:15.000+09:00", 156 | "completed_at": "2023-08-26T00:58:15.000+09:00", 157 | }, 158 | { 159 | "name": "Complete job", 160 | "status": "completed", 161 | "conclusion": "success", 162 | "number": 11, 163 | "started_at": "2023-08-26T00:58:15.000+09:00", 164 | "completed_at": "2023-08-26T00:58:15.000+09:00", 165 | }, 166 | ], 167 | }] as unknown as WorkflowJobs; 168 | 169 | // deno-fmt-ignore 170 | const expect = ` 171 | \`\`\`mermaid 172 | gantt 173 | title ${workflowJobs[0].workflow_name} 174 | dateFormat HH:mm:ss 175 | axisFormat %H:%M:%S 176 | section ${workflowJobs[0].name} 177 | Waiting for a runner (7s) :active, job0-0, 00:00:01, 7s 178 | ${workflowJobs[0].steps![0].name} (2s) :done, job0-1, after job0-0, 2s 179 | ${workflowJobs[0].steps![1].name} (1s) :crit, job0-2, after job0-1, 1s 180 | ${workflowJobs[0].steps![2].name} (0s) :job0-3, after job0-2, 0s 181 | ${workflowJobs[0].steps![3].name} (0s) :job0-4, after job0-3, 0s 182 | \`\`\``; 183 | 184 | assertEquals(createMermaid(workflow, workflowJobs, {}), expect); 185 | }); 186 | 187 | await t.step("Hide not completed steps", () => { 188 | const workflow = { 189 | "id": 6290960492, 190 | "name": "CI", 191 | "run_number": 53, 192 | "event": "pull_request", 193 | "status": "in_progress", 194 | "conclusion": null, 195 | "created_at": "2023-09-24T15:41:23Z", 196 | "updated_at": "2023-09-24T15:46:01Z", 197 | "run_started_at": "2023-09-24T15:41:23Z", 198 | } as unknown as WorkflowRun; 199 | 200 | const workflowJobs = [{ 201 | "id": 17078901763, 202 | "run_id": 6290960492, 203 | "workflow_name": "CI", 204 | "status": "in_progress", 205 | "conclusion": null, 206 | "created_at": "2023-09-24T15:45:54Z", 207 | "started_at": "2023-09-24T15:46:00Z", 208 | "completed_at": null, 209 | "name": "run_self", 210 | "steps": [ 211 | { 212 | "name": "Set up job", 213 | "status": "completed", 214 | "conclusion": "success", 215 | "number": 1, 216 | "started_at": "2023-09-24T15:46:00.000Z", 217 | "completed_at": "2023-09-24T15:46:01.000Z", 218 | }, 219 | { 220 | "name": "Download bundled dist", 221 | "status": "in_progress", 222 | "conclusion": null, 223 | "number": 2, 224 | "started_at": "2023-09-24T15:46:01.000Z", 225 | "completed_at": null, 226 | }, 227 | { 228 | "name": "Run self action", 229 | "status": "queued", 230 | "conclusion": null, 231 | "number": 3, 232 | "started_at": null, 233 | "completed_at": null, 234 | }, 235 | ], 236 | }] as unknown as WorkflowJobs; 237 | 238 | // deno-fmt-ignore 239 | const expect = ` 240 | \`\`\`mermaid 241 | gantt 242 | title ${workflowJobs[0].workflow_name} 243 | dateFormat HH:mm:ss 244 | axisFormat %H:%M:%S 245 | section ${workflowJobs[0].name} 246 | Waiting for a runner (6s) :active, job0-0, 00:04:31, 6s 247 | ${workflowJobs[0].steps![0].name} (1s) :job0-1, after job0-0, 1s 248 | \`\`\``; 249 | 250 | assertEquals(createMermaid(workflow, workflowJobs, {}), expect); 251 | }); 252 | }); 253 | -------------------------------------------------------------------------------- /tests/workflow_gantt_2.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { createMermaid } from "../src/workflow_gantt.ts"; 3 | import { WorkflowJobs, WorkflowRun } from "@kesin11/gha-utils"; 4 | 5 | const commonWorkflow = { 6 | "id": 5833450919, 7 | "name": "Check self-hosted runner", 8 | "run_number": 128, 9 | "event": "workflow_dispatch", 10 | "status": "completed", 11 | "conclusion": "success", 12 | "workflow_id": 10970418, 13 | "created_at": "2023-08-11T14:00:48Z", 14 | "updated_at": "2023-08-11T14:01:56Z", 15 | "run_started_at": "2023-08-11T14:00:48Z", 16 | } as unknown as WorkflowRun; 17 | 18 | Deno.test("2 section gantt", async (t) => { 19 | await t.step("all steps are success", () => { 20 | const workflow = { ...commonWorkflow }; 21 | const workflowJobs = [ 22 | { 23 | "id": 15820938470, 24 | "run_id": 5833450919, 25 | "workflow_name": "Check self-hosted runner", 26 | "status": "completed", 27 | "conclusion": "success", 28 | "created_at": "2023-08-11T14:00:50Z", 29 | "started_at": "2023-08-11T14:01:31Z", 30 | "completed_at": "2023-08-11T14:01:36Z", 31 | "name": "node", 32 | "steps": [ 33 | { 34 | "name": "Set up job", 35 | "status": "completed", 36 | "conclusion": "success", 37 | "number": 1, 38 | "started_at": "2023-08-11T23:01:30.000+09:00", 39 | "completed_at": "2023-08-11T23:01:32.000+09:00", 40 | }, 41 | { 42 | "name": "Set up runner", 43 | "status": "completed", 44 | "conclusion": "success", 45 | "number": 2, 46 | "started_at": "2023-08-11T23:01:32.000+09:00", 47 | "completed_at": "2023-08-11T23:01:32.000+09:00", 48 | }, 49 | { 50 | "name": "Run actions/checkout@v3", 51 | "status": "completed", 52 | "conclusion": "success", 53 | "number": 3, 54 | "started_at": "2023-08-11T23:01:34.000+09:00", 55 | "completed_at": "2023-08-11T23:01:34.000+09:00", 56 | }, 57 | { 58 | "name": "Run actions/setup-node@v3", 59 | "status": "completed", 60 | "conclusion": "success", 61 | "number": 4, 62 | "started_at": "2023-08-11T23:01:35.000+09:00", 63 | "completed_at": "2023-08-11T23:01:35.000+09:00", 64 | }, 65 | { 66 | "name": "Post Run actions/setup-node@v3", 67 | "status": "completed", 68 | "conclusion": "success", 69 | "number": 6, 70 | "started_at": "2023-08-11T23:01:35.000+09:00", 71 | "completed_at": "2023-08-11T23:01:35.000+09:00", 72 | }, 73 | { 74 | "name": "Post Run actions/checkout@v3", 75 | "status": "completed", 76 | "conclusion": "success", 77 | "number": 7, 78 | "started_at": "2023-08-11T23:01:35.000+09:00", 79 | "completed_at": "2023-08-11T23:01:35.000+09:00", 80 | }, 81 | { 82 | "name": "Complete runner", 83 | "status": "completed", 84 | "conclusion": "success", 85 | "number": 8, 86 | "started_at": "2023-08-11T23:01:36.000+09:00", 87 | "completed_at": "2023-08-11T23:01:36.000+09:00", 88 | }, 89 | { 90 | "name": "Complete job", 91 | "status": "completed", 92 | "conclusion": "success", 93 | "number": 9, 94 | "started_at": "2023-08-11T23:01:35.000+09:00", 95 | "completed_at": "2023-08-11T23:01:35.000+09:00", 96 | }, 97 | ], 98 | }, 99 | { 100 | "id": 15820938790, 101 | "run_id": 5833450919, 102 | "workflow_name": "Check self-hosted runner", 103 | "status": "completed", 104 | "conclusion": "success", 105 | "created_at": "2023-08-11T14:00:51Z", 106 | "started_at": "2023-08-11T14:01:30Z", 107 | "completed_at": "2023-08-11T14:01:50Z", 108 | "name": "go", 109 | "steps": [ 110 | { 111 | "name": "Set up job", 112 | "status": "completed", 113 | "conclusion": "success", 114 | "number": 1, 115 | "started_at": "2023-08-11T23:01:29.000+09:00", 116 | "completed_at": "2023-08-11T23:01:32.000+09:00", 117 | }, 118 | { 119 | "name": "Set up runner", 120 | "status": "completed", 121 | "conclusion": "success", 122 | "number": 2, 123 | "started_at": "2023-08-11T23:01:32.000+09:00", 124 | "completed_at": "2023-08-11T23:01:32.000+09:00", 125 | }, 126 | { 127 | "name": "Run actions/setup-go@v4", 128 | "status": "completed", 129 | "conclusion": "success", 130 | "number": 3, 131 | "started_at": "2023-08-11T23:01:33.000+09:00", 132 | "completed_at": "2023-08-11T23:01:49.000+09:00", 133 | }, 134 | { 135 | "name": "Run go version", 136 | "status": "completed", 137 | "conclusion": "success", 138 | "number": 4, 139 | "started_at": "2023-08-11T23:01:49.000+09:00", 140 | "completed_at": "2023-08-11T23:01:49.000+09:00", 141 | }, 142 | { 143 | "name": "Post Run actions/setup-go@v4", 144 | "status": "completed", 145 | "conclusion": "success", 146 | "number": 7, 147 | "started_at": "2023-08-11T23:01:49.000+09:00", 148 | "completed_at": "2023-08-11T23:01:49.000+09:00", 149 | }, 150 | { 151 | "name": "Complete runner", 152 | "status": "completed", 153 | "conclusion": "success", 154 | "number": 8, 155 | "started_at": "2023-08-11T23:01:50.000+09:00", 156 | "completed_at": "2023-08-11T23:01:50.000+09:00", 157 | }, 158 | { 159 | "name": "Complete job", 160 | "status": "completed", 161 | "conclusion": "success", 162 | "number": 9, 163 | "started_at": "2023-08-11T23:01:49.000+09:00", 164 | "completed_at": "2023-08-11T23:01:49.000+09:00", 165 | }, 166 | ], 167 | }, 168 | ] as unknown as WorkflowJobs; 169 | 170 | // deno-fmt-ignore 171 | const expect = ` 172 | \`\`\`mermaid 173 | gantt 174 | title ${workflowJobs[0].workflow_name} 175 | dateFormat HH:mm:ss 176 | axisFormat %H:%M:%S 177 | section ${workflowJobs[0].name} 178 | Waiting for a runner (41s) :active, job0-0, 00:00:02, 41s 179 | ${workflowJobs[0].steps![0].name} (2s) :job0-1, after job0-0, 2s 180 | ${workflowJobs[0].steps![1].name} (0s) :job0-2, after job0-1, 0s 181 | ${workflowJobs[0].steps![2].name} (0s) :job0-3, after job0-2, 0s 182 | ${workflowJobs[0].steps![3].name} (0s) :job0-4, after job0-3, 0s 183 | ${workflowJobs[0].steps![4].name} (0s) :job0-5, after job0-4, 0s 184 | ${workflowJobs[0].steps![5].name} (0s) :job0-6, after job0-5, 0s 185 | ${workflowJobs[0].steps![6].name} (0s) :job0-7, after job0-6, 0s 186 | ${workflowJobs[0].steps![7].name} (0s) :job0-8, after job0-7, 0s 187 | section ${workflowJobs[1].name} 188 | Waiting for a runner (39s) :active, job1-0, 00:00:03, 39s 189 | ${workflowJobs[1].steps![0].name} (3s) :job1-1, after job1-0, 3s 190 | ${workflowJobs[1].steps![1].name} (0s) :job1-2, after job1-1, 0s 191 | ${workflowJobs[1].steps![2].name} (16s) :job1-3, after job1-2, 16s 192 | ${workflowJobs[1].steps![3].name} (0s) :job1-4, after job1-3, 0s 193 | ${workflowJobs[1].steps![4].name} (0s) :job1-5, after job1-4, 0s 194 | ${workflowJobs[1].steps![5].name} (0s) :job1-6, after job1-5, 0s 195 | ${workflowJobs[1].steps![6].name} (0s) :job1-7, after job1-6, 0s 196 | \`\`\``; 197 | 198 | assertEquals(createMermaid(workflow, workflowJobs, {}), expect); 199 | }); 200 | 201 | await t.step("Hide skipped jobs", () => { 202 | const workflow = { ...commonWorkflow }; 203 | const workflowJobs = [ 204 | { 205 | "id": 15820938470, 206 | "run_id": 5833450919, 207 | "workflow_name": "Check self-hosted runner", 208 | "status": "completed", 209 | "conclusion": "success", 210 | "created_at": "2023-08-11T14:00:50Z", 211 | "started_at": "2023-08-11T14:01:31Z", 212 | "completed_at": "2023-08-11T14:01:36Z", 213 | "name": "node", 214 | "steps": [ 215 | { 216 | "name": "Set up job", 217 | "status": "completed", 218 | "conclusion": "success", 219 | "number": 1, 220 | "started_at": "2023-08-11T23:01:30.000+09:00", 221 | "completed_at": "2023-08-11T23:01:32.000+09:00", 222 | }, 223 | { 224 | "name": "Set up runner", 225 | "status": "completed", 226 | "conclusion": "success", 227 | "number": 2, 228 | "started_at": "2023-08-11T23:01:32.000+09:00", 229 | "completed_at": "2023-08-11T23:01:32.000+09:00", 230 | }, 231 | { 232 | "name": "Run actions/checkout@v3", 233 | "status": "completed", 234 | "conclusion": "success", 235 | "number": 3, 236 | "started_at": "2023-08-11T23:01:34.000+09:00", 237 | "completed_at": "2023-08-11T23:01:34.000+09:00", 238 | }, 239 | ], 240 | }, 241 | { 242 | "id": 15820938790, 243 | "run_id": 5833450919, 244 | "workflow_name": "Check self-hosted runner", 245 | "status": "completed", 246 | "conclusion": "skipped", 247 | "created_at": "2023-08-11T14:00:51Z", 248 | "started_at": "2023-08-11T14:00:51Z", 249 | "completed_at": "2023-08-11T14:01:50Z", 250 | "name": "skipped test", 251 | "steps": [], 252 | }, 253 | ] as unknown as WorkflowJobs; 254 | 255 | // deno-fmt-ignore 256 | const expect = ` 257 | \`\`\`mermaid 258 | gantt 259 | title ${workflowJobs[0].workflow_name} 260 | dateFormat HH:mm:ss 261 | axisFormat %H:%M:%S 262 | section ${workflowJobs[0].name} 263 | Waiting for a runner (41s) :active, job0-0, 00:00:02, 41s 264 | ${workflowJobs[0].steps![0].name} (2s) :job0-1, after job0-0, 2s 265 | ${workflowJobs[0].steps![1].name} (0s) :job0-2, after job0-1, 0s 266 | ${workflowJobs[0].steps![2].name} (0s) :job0-3, after job0-2, 0s 267 | \`\`\``; 268 | 269 | assertEquals(createMermaid(workflow, workflowJobs, {}), expect); 270 | }); 271 | }); 272 | -------------------------------------------------------------------------------- /tests/fixture/workflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 5977929222, 3 | "name": "POC", 4 | "node_id": "WFR_kwLOKKIKiM8AAAABZE_2Bg", 5 | "head_branch": "main", 6 | "head_sha": "d9132dafd385889b4bd21730d4782c095b86394b", 7 | "path": ".github/workflows/poc.yml", 8 | "display_title": "fixup! Add debug log", 9 | "run_number": 5, 10 | "event": "push", 11 | "status": "completed", 12 | "conclusion": "success", 13 | "workflow_id": 66989622, 14 | "check_suite_id": 15487066448, 15 | "check_suite_node_id": "CS_kwDOKKIKiM8AAAADmxnhUA", 16 | "url": "https://api.github.com/repos/Kesin11/actions-timeline/actions/runs/5977929222", 17 | "html_url": "https://github.com/Kesin11/actions-timeline/actions/runs/5977929222", 18 | "pull_requests": [], 19 | "created_at": "2023-08-25T15:57:51Z", 20 | "updated_at": "2023-08-25T15:58:19Z", 21 | "actor": { 22 | "login": "Kesin11", 23 | "id": 1324862, 24 | "node_id": "MDQ6VXNlcjEzMjQ4NjI=", 25 | "avatar_url": "https://avatars.githubusercontent.com/u/1324862?v=4", 26 | "gravatar_id": "", 27 | "url": "https://api.github.com/users/Kesin11", 28 | "html_url": "https://github.com/Kesin11", 29 | "followers_url": "https://api.github.com/users/Kesin11/followers", 30 | "following_url": "https://api.github.com/users/Kesin11/following{/other_user}", 31 | "gists_url": "https://api.github.com/users/Kesin11/gists{/gist_id}", 32 | "starred_url": "https://api.github.com/users/Kesin11/starred{/owner}{/repo}", 33 | "subscriptions_url": "https://api.github.com/users/Kesin11/subscriptions", 34 | "organizations_url": "https://api.github.com/users/Kesin11/orgs", 35 | "repos_url": "https://api.github.com/users/Kesin11/repos", 36 | "events_url": "https://api.github.com/users/Kesin11/events{/privacy}", 37 | "received_events_url": "https://api.github.com/users/Kesin11/received_events", 38 | "type": "User", 39 | "site_admin": false 40 | }, 41 | "run_attempt": 1, 42 | "referenced_workflows": [], 43 | "run_started_at": "2023-08-25T15:57:51Z", 44 | "triggering_actor": { 45 | "login": "Kesin11", 46 | "id": 1324862, 47 | "node_id": "MDQ6VXNlcjEzMjQ4NjI=", 48 | "avatar_url": "https://avatars.githubusercontent.com/u/1324862?v=4", 49 | "gravatar_id": "", 50 | "url": "https://api.github.com/users/Kesin11", 51 | "html_url": "https://github.com/Kesin11", 52 | "followers_url": "https://api.github.com/users/Kesin11/followers", 53 | "following_url": "https://api.github.com/users/Kesin11/following{/other_user}", 54 | "gists_url": "https://api.github.com/users/Kesin11/gists{/gist_id}", 55 | "starred_url": "https://api.github.com/users/Kesin11/starred{/owner}{/repo}", 56 | "subscriptions_url": "https://api.github.com/users/Kesin11/subscriptions", 57 | "organizations_url": "https://api.github.com/users/Kesin11/orgs", 58 | "repos_url": "https://api.github.com/users/Kesin11/repos", 59 | "events_url": "https://api.github.com/users/Kesin11/events{/privacy}", 60 | "received_events_url": "https://api.github.com/users/Kesin11/received_events", 61 | "type": "User", 62 | "site_admin": false 63 | }, 64 | "jobs_url": "https://api.github.com/repos/Kesin11/actions-timeline/actions/runs/5977929222/jobs", 65 | "logs_url": "https://api.github.com/repos/Kesin11/actions-timeline/actions/runs/5977929222/logs", 66 | "check_suite_url": "https://api.github.com/repos/Kesin11/actions-timeline/check-suites/15487066448", 67 | "artifacts_url": "https://api.github.com/repos/Kesin11/actions-timeline/actions/runs/5977929222/artifacts", 68 | "cancel_url": "https://api.github.com/repos/Kesin11/actions-timeline/actions/runs/5977929222/cancel", 69 | "rerun_url": "https://api.github.com/repos/Kesin11/actions-timeline/actions/runs/5977929222/rerun", 70 | "previous_attempt_url": null, 71 | "workflow_url": "https://api.github.com/repos/Kesin11/actions-timeline/actions/workflows/66989622", 72 | "head_commit": { 73 | "id": "d9132dafd385889b4bd21730d4782c095b86394b", 74 | "tree_id": "d312c608c6913c5f66b4c9f1f9baa085e5c51914", 75 | "message": "fixup! Add debug log", 76 | "timestamp": "2023-08-25T15:57:42Z", 77 | "author": { 78 | "name": "Kenta Kase", 79 | "email": "kesin1202000@gmail.com" 80 | }, 81 | "committer": { 82 | "name": "Kenta Kase", 83 | "email": "kesin1202000@gmail.com" 84 | } 85 | }, 86 | "repository": { 87 | "id": 681708168, 88 | "node_id": "R_kgDOKKIKiA", 89 | "name": "actions-timeline", 90 | "full_name": "Kesin11/actions-timeline", 91 | "private": false, 92 | "owner": { 93 | "login": "Kesin11", 94 | "id": 1324862, 95 | "node_id": "MDQ6VXNlcjEzMjQ4NjI=", 96 | "avatar_url": "https://avatars.githubusercontent.com/u/1324862?v=4", 97 | "gravatar_id": "", 98 | "url": "https://api.github.com/users/Kesin11", 99 | "html_url": "https://github.com/Kesin11", 100 | "followers_url": "https://api.github.com/users/Kesin11/followers", 101 | "following_url": "https://api.github.com/users/Kesin11/following{/other_user}", 102 | "gists_url": "https://api.github.com/users/Kesin11/gists{/gist_id}", 103 | "starred_url": "https://api.github.com/users/Kesin11/starred{/owner}{/repo}", 104 | "subscriptions_url": "https://api.github.com/users/Kesin11/subscriptions", 105 | "organizations_url": "https://api.github.com/users/Kesin11/orgs", 106 | "repos_url": "https://api.github.com/users/Kesin11/repos", 107 | "events_url": "https://api.github.com/users/Kesin11/events{/privacy}", 108 | "received_events_url": "https://api.github.com/users/Kesin11/received_events", 109 | "type": "User", 110 | "site_admin": false 111 | }, 112 | "html_url": "https://github.com/Kesin11/actions-timeline", 113 | "description": null, 114 | "fork": false, 115 | "url": "https://api.github.com/repos/Kesin11/actions-timeline", 116 | "forks_url": "https://api.github.com/repos/Kesin11/actions-timeline/forks", 117 | "keys_url": "https://api.github.com/repos/Kesin11/actions-timeline/keys{/key_id}", 118 | "collaborators_url": "https://api.github.com/repos/Kesin11/actions-timeline/collaborators{/collaborator}", 119 | "teams_url": "https://api.github.com/repos/Kesin11/actions-timeline/teams", 120 | "hooks_url": "https://api.github.com/repos/Kesin11/actions-timeline/hooks", 121 | "issue_events_url": "https://api.github.com/repos/Kesin11/actions-timeline/issues/events{/number}", 122 | "events_url": "https://api.github.com/repos/Kesin11/actions-timeline/events", 123 | "assignees_url": "https://api.github.com/repos/Kesin11/actions-timeline/assignees{/user}", 124 | "branches_url": "https://api.github.com/repos/Kesin11/actions-timeline/branches{/branch}", 125 | "tags_url": "https://api.github.com/repos/Kesin11/actions-timeline/tags", 126 | "blobs_url": "https://api.github.com/repos/Kesin11/actions-timeline/git/blobs{/sha}", 127 | "git_tags_url": "https://api.github.com/repos/Kesin11/actions-timeline/git/tags{/sha}", 128 | "git_refs_url": "https://api.github.com/repos/Kesin11/actions-timeline/git/refs{/sha}", 129 | "trees_url": "https://api.github.com/repos/Kesin11/actions-timeline/git/trees{/sha}", 130 | "statuses_url": "https://api.github.com/repos/Kesin11/actions-timeline/statuses/{sha}", 131 | "languages_url": "https://api.github.com/repos/Kesin11/actions-timeline/languages", 132 | "stargazers_url": "https://api.github.com/repos/Kesin11/actions-timeline/stargazers", 133 | "contributors_url": "https://api.github.com/repos/Kesin11/actions-timeline/contributors", 134 | "subscribers_url": "https://api.github.com/repos/Kesin11/actions-timeline/subscribers", 135 | "subscription_url": "https://api.github.com/repos/Kesin11/actions-timeline/subscription", 136 | "commits_url": "https://api.github.com/repos/Kesin11/actions-timeline/commits{/sha}", 137 | "git_commits_url": "https://api.github.com/repos/Kesin11/actions-timeline/git/commits{/sha}", 138 | "comments_url": "https://api.github.com/repos/Kesin11/actions-timeline/comments{/number}", 139 | "issue_comment_url": "https://api.github.com/repos/Kesin11/actions-timeline/issues/comments{/number}", 140 | "contents_url": "https://api.github.com/repos/Kesin11/actions-timeline/contents/{+path}", 141 | "compare_url": "https://api.github.com/repos/Kesin11/actions-timeline/compare/{base}...{head}", 142 | "merges_url": "https://api.github.com/repos/Kesin11/actions-timeline/merges", 143 | "archive_url": "https://api.github.com/repos/Kesin11/actions-timeline/{archive_format}{/ref}", 144 | "downloads_url": "https://api.github.com/repos/Kesin11/actions-timeline/downloads", 145 | "issues_url": "https://api.github.com/repos/Kesin11/actions-timeline/issues{/number}", 146 | "pulls_url": "https://api.github.com/repos/Kesin11/actions-timeline/pulls{/number}", 147 | "milestones_url": "https://api.github.com/repos/Kesin11/actions-timeline/milestones{/number}", 148 | "notifications_url": "https://api.github.com/repos/Kesin11/actions-timeline/notifications{?since,all,participating}", 149 | "labels_url": "https://api.github.com/repos/Kesin11/actions-timeline/labels{/name}", 150 | "releases_url": "https://api.github.com/repos/Kesin11/actions-timeline/releases{/id}", 151 | "deployments_url": "https://api.github.com/repos/Kesin11/actions-timeline/deployments" 152 | }, 153 | "head_repository": { 154 | "id": 681708168, 155 | "node_id": "R_kgDOKKIKiA", 156 | "name": "actions-timeline", 157 | "full_name": "Kesin11/actions-timeline", 158 | "private": false, 159 | "owner": { 160 | "login": "Kesin11", 161 | "id": 1324862, 162 | "node_id": "MDQ6VXNlcjEzMjQ4NjI=", 163 | "avatar_url": "https://avatars.githubusercontent.com/u/1324862?v=4", 164 | "gravatar_id": "", 165 | "url": "https://api.github.com/users/Kesin11", 166 | "html_url": "https://github.com/Kesin11", 167 | "followers_url": "https://api.github.com/users/Kesin11/followers", 168 | "following_url": "https://api.github.com/users/Kesin11/following{/other_user}", 169 | "gists_url": "https://api.github.com/users/Kesin11/gists{/gist_id}", 170 | "starred_url": "https://api.github.com/users/Kesin11/starred{/owner}{/repo}", 171 | "subscriptions_url": "https://api.github.com/users/Kesin11/subscriptions", 172 | "organizations_url": "https://api.github.com/users/Kesin11/orgs", 173 | "repos_url": "https://api.github.com/users/Kesin11/repos", 174 | "events_url": "https://api.github.com/users/Kesin11/events{/privacy}", 175 | "received_events_url": "https://api.github.com/users/Kesin11/received_events", 176 | "type": "User", 177 | "site_admin": false 178 | }, 179 | "html_url": "https://github.com/Kesin11/actions-timeline", 180 | "description": null, 181 | "fork": false, 182 | "url": "https://api.github.com/repos/Kesin11/actions-timeline", 183 | "forks_url": "https://api.github.com/repos/Kesin11/actions-timeline/forks", 184 | "keys_url": "https://api.github.com/repos/Kesin11/actions-timeline/keys{/key_id}", 185 | "collaborators_url": "https://api.github.com/repos/Kesin11/actions-timeline/collaborators{/collaborator}", 186 | "teams_url": "https://api.github.com/repos/Kesin11/actions-timeline/teams", 187 | "hooks_url": "https://api.github.com/repos/Kesin11/actions-timeline/hooks", 188 | "issue_events_url": "https://api.github.com/repos/Kesin11/actions-timeline/issues/events{/number}", 189 | "events_url": "https://api.github.com/repos/Kesin11/actions-timeline/events", 190 | "assignees_url": "https://api.github.com/repos/Kesin11/actions-timeline/assignees{/user}", 191 | "branches_url": "https://api.github.com/repos/Kesin11/actions-timeline/branches{/branch}", 192 | "tags_url": "https://api.github.com/repos/Kesin11/actions-timeline/tags", 193 | "blobs_url": "https://api.github.com/repos/Kesin11/actions-timeline/git/blobs{/sha}", 194 | "git_tags_url": "https://api.github.com/repos/Kesin11/actions-timeline/git/tags{/sha}", 195 | "git_refs_url": "https://api.github.com/repos/Kesin11/actions-timeline/git/refs{/sha}", 196 | "trees_url": "https://api.github.com/repos/Kesin11/actions-timeline/git/trees{/sha}", 197 | "statuses_url": "https://api.github.com/repos/Kesin11/actions-timeline/statuses/{sha}", 198 | "languages_url": "https://api.github.com/repos/Kesin11/actions-timeline/languages", 199 | "stargazers_url": "https://api.github.com/repos/Kesin11/actions-timeline/stargazers", 200 | "contributors_url": "https://api.github.com/repos/Kesin11/actions-timeline/contributors", 201 | "subscribers_url": "https://api.github.com/repos/Kesin11/actions-timeline/subscribers", 202 | "subscription_url": "https://api.github.com/repos/Kesin11/actions-timeline/subscription", 203 | "commits_url": "https://api.github.com/repos/Kesin11/actions-timeline/commits{/sha}", 204 | "git_commits_url": "https://api.github.com/repos/Kesin11/actions-timeline/git/commits{/sha}", 205 | "comments_url": "https://api.github.com/repos/Kesin11/actions-timeline/comments{/number}", 206 | "issue_comment_url": "https://api.github.com/repos/Kesin11/actions-timeline/issues/comments{/number}", 207 | "contents_url": "https://api.github.com/repos/Kesin11/actions-timeline/contents/{+path}", 208 | "compare_url": "https://api.github.com/repos/Kesin11/actions-timeline/compare/{base}...{head}", 209 | "merges_url": "https://api.github.com/repos/Kesin11/actions-timeline/merges", 210 | "archive_url": "https://api.github.com/repos/Kesin11/actions-timeline/{archive_format}{/ref}", 211 | "downloads_url": "https://api.github.com/repos/Kesin11/actions-timeline/downloads", 212 | "issues_url": "https://api.github.com/repos/Kesin11/actions-timeline/issues{/number}", 213 | "pulls_url": "https://api.github.com/repos/Kesin11/actions-timeline/pulls{/number}", 214 | "milestones_url": "https://api.github.com/repos/Kesin11/actions-timeline/milestones{/number}", 215 | "notifications_url": "https://api.github.com/repos/Kesin11/actions-timeline/notifications{?since,all,participating}", 216 | "labels_url": "https://api.github.com/repos/Kesin11/actions-timeline/labels{/name}", 217 | "releases_url": "https://api.github.com/repos/Kesin11/actions-timeline/releases{/id}", 218 | "deployments_url": "https://api.github.com/repos/Kesin11/actions-timeline/deployments" 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /tests/workflow_gantt_sp.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { 3 | createGanttDiagrams, 4 | createGanttJobs, 5 | createMermaid, 6 | } from "../src/workflow_gantt.ts"; 7 | import { formatSection } from "../src/format_util.ts"; 8 | import { WorkflowJobs, WorkflowRun } from "@kesin11/gha-utils"; 9 | 10 | const commonWorkflow = { 11 | "id": 5833450919, 12 | "name": "Check self-hosted runner", 13 | "run_number": 128, 14 | "event": "workflow_dispatch", 15 | "status": "completed", 16 | "conclusion": "success", 17 | "workflow_id": 10970418, 18 | "created_at": "2023-08-11T14:00:48Z", 19 | "updated_at": "2023-08-11T14:01:56Z", 20 | "run_started_at": "2023-08-11T14:00:48Z", 21 | } as unknown as WorkflowRun; 22 | 23 | Deno.test("Special case gantt", async (t) => { 24 | await t.step( 25 | "Escape colon char in job name or step name", 26 | () => { 27 | const workflow = { 28 | "id": 6301810753, 29 | "name": "CI", 30 | "run_number": 60, 31 | "event": "pull_request", 32 | "status": "in_progress", 33 | "conclusion": null, 34 | "workflow_id": 69674074, 35 | "created_at": "2023-09-25T15:55:47Z", 36 | "updated_at": "2023-09-25T15:57:36Z", 37 | "run_started_at": "2023-09-25T15:55:47Z", 38 | } as unknown as WorkflowRun; 39 | 40 | const workflowJobs = [{ 41 | "id": 17107722147, 42 | "run_id": 6301810753, 43 | "workflow_name": "CI", 44 | "status": "completed", 45 | "conclusion": "success", 46 | "created_at": "2023-09-25T15:55:50Z", 47 | "started_at": "2023-09-25T15:55:56Z", 48 | "completed_at": "2023-09-25T15:56:06Z", 49 | "name": "check: deno 1.36.1", 50 | "steps": [ 51 | { 52 | "name": "Set up job", 53 | "status": "completed", 54 | "conclusion": "success", 55 | "number": 1, 56 | "started_at": "2023-09-25T15:55:56.000Z", 57 | "completed_at": "2023-09-25T15:55:57.000Z", 58 | }, 59 | { 60 | "name": "check: deno", 61 | "status": "completed", 62 | "conclusion": "success", 63 | "number": 2, 64 | "started_at": "2023-09-25T15:55:57.000Z", 65 | "completed_at": "2023-09-25T15:55:58.000Z", 66 | }, 67 | { 68 | "name": "Complete job", 69 | "status": "completed", 70 | "conclusion": "success", 71 | "number": 3, 72 | "started_at": "2023-09-25T15:56:03.000Z", 73 | "completed_at": "2023-09-25T15:56:03.000Z", 74 | }, 75 | ], 76 | }] as unknown as WorkflowJobs; 77 | 78 | const expectJobName = "check deno 1.36.1"; 79 | const expectStepName = "check deno"; 80 | // deno-fmt-ignore 81 | const expect = ` 82 | \`\`\`mermaid 83 | gantt 84 | title ${workflowJobs[0].workflow_name} 85 | dateFormat HH:mm:ss 86 | axisFormat %H:%M:%S 87 | section ${expectJobName} 88 | Waiting for a runner (6s) :active, job0-0, 00:00:03, 6s 89 | ${workflowJobs[0].steps![0].name} (1s) :job0-1, after job0-0, 1s 90 | ${expectStepName} (1s) :job0-2, after job0-1, 1s 91 | ${workflowJobs[0].steps![2].name} (0s) :job0-3, after job0-2, 0s 92 | \`\`\``; 93 | 94 | assertEquals(createMermaid(workflow, workflowJobs, {}), expect); 95 | }, 96 | ); 97 | 98 | await t.step("Retried job", () => { 99 | const workflow = { 100 | ...commonWorkflow, 101 | // Retried job does not changed created_at but changed run_started_at. 102 | // This dummy simulate to retry job after 1 hour. 103 | "created_at": "2023-08-11T13:00:48Z", 104 | "updated_at": "2023-08-11T14:01:56Z", 105 | "run_started_at": "2023-08-11T14:00:48Z", 106 | "run_attempt": 2, 107 | }; 108 | 109 | const workflowJobs = [ 110 | { 111 | "id": 15820938470, 112 | "run_id": 5833450919, 113 | "workflow_name": "Check self-hosted runner", 114 | "status": "completed", 115 | "conclusion": "success", 116 | "created_at": "2023-08-11T14:00:50Z", 117 | "started_at": "2023-08-11T14:01:31Z", 118 | "completed_at": "2023-08-11T14:01:36Z", 119 | "name": "node", 120 | "steps": [ 121 | { 122 | "name": "Set up job", 123 | "status": "completed", 124 | "conclusion": "success", 125 | "number": 1, 126 | "started_at": "2023-08-11T23:01:30.000+09:00", 127 | "completed_at": "2023-08-11T23:01:32.000+09:00", 128 | }, 129 | { 130 | "name": "Set up runner", 131 | "status": "completed", 132 | "conclusion": "success", 133 | "number": 2, 134 | "started_at": "2023-08-11T23:01:32.000+09:00", 135 | "completed_at": "2023-08-11T23:01:32.000+09:00", 136 | }, 137 | { 138 | "name": "Run actions/checkout@v3", 139 | "status": "completed", 140 | "conclusion": "success", 141 | "number": 3, 142 | "started_at": "2023-08-11T23:01:34.000+09:00", 143 | "completed_at": "2023-08-11T23:01:34.000+09:00", 144 | }, 145 | ], 146 | }, 147 | ] as unknown as WorkflowJobs; 148 | 149 | // deno-fmt-ignore 150 | const expect = ` 151 | \`\`\`mermaid 152 | gantt 153 | title ${workflowJobs[0].workflow_name} 154 | dateFormat HH:mm:ss 155 | axisFormat %H:%M:%S 156 | section ${workflowJobs[0].name} 157 | Waiting for a runner (41s) :active, job0-0, 00:00:02, 41s 158 | ${workflowJobs[0].steps![0].name} (2s) :job0-1, after job0-0, 2s 159 | ${workflowJobs[0].steps![1].name} (0s) :job0-2, after job0-1, 0s 160 | ${workflowJobs[0].steps![2].name} (0s) :job0-3, after job0-2, 0s 161 | \`\`\``; 162 | 163 | assertEquals(createMermaid(workflow, workflowJobs, {}), expect); 164 | }); 165 | 166 | await t.step( 167 | "'Waiting for a runner' step duration is ommited if workflow_job has not 'created_at' field (< GHES v3.9)", 168 | () => { 169 | const workflow = { ...commonWorkflow }; 170 | const workflowJobs = [ 171 | { 172 | "id": 15820938470, 173 | "run_id": 5833450919, 174 | "workflow_name": "Check self-hosted runner", 175 | "status": "completed", 176 | "conclusion": "success", 177 | // "created_at" field does not exists before GHES v3.9. 178 | // GHES v3.8 https://docs.github.com/en/enterprise-server@3.8/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run-attempt 179 | // GHES v3.9 https://docs.github.com/en/enterprise-server@3.9/rest/actions/workflow-jobs?apiVersion=2022-11-28#list-jobs-for-a-workflow-run-attempt 180 | // To emulate < GHES v3.9, just comment out this fixture. 181 | // "created_at": "2023-08-11T14:00:50Z", 182 | "started_at": "2023-08-11T14:01:31Z", 183 | "completed_at": "2023-08-11T14:01:36Z", 184 | "name": "node", 185 | "steps": [ 186 | { 187 | "name": "Set up job", 188 | "status": "completed", 189 | "conclusion": "success", 190 | "number": 1, 191 | "started_at": "2023-08-11T23:01:30.000+09:00", 192 | "completed_at": "2023-08-11T23:01:32.000+09:00", 193 | }, 194 | { 195 | "name": "Set up runner", 196 | "status": "completed", 197 | "conclusion": "success", 198 | "number": 2, 199 | "started_at": "2023-08-11T23:01:32.000+09:00", 200 | "completed_at": "2023-08-11T23:01:32.000+09:00", 201 | }, 202 | { 203 | "name": "Run actions/checkout@v3", 204 | "status": "completed", 205 | "conclusion": "success", 206 | "number": 3, 207 | "started_at": "2023-08-11T23:01:34.000+09:00", 208 | "completed_at": "2023-08-11T23:01:34.000+09:00", 209 | }, 210 | ], 211 | }, 212 | ] as unknown as WorkflowJobs; 213 | 214 | // deno-fmt-ignore 215 | const expect = ` 216 | \`\`\`mermaid 217 | gantt 218 | title ${workflowJobs[0].workflow_name} 219 | dateFormat HH:mm:ss 220 | axisFormat %H:%M:%S 221 | section ${workflowJobs[0].name} 222 | ${workflowJobs[0].steps![0].name} (2s) :job0-0, 00:00:43, 2s 223 | ${workflowJobs[0].steps![1].name} (0s) :job0-1, after job0-0, 0s 224 | ${workflowJobs[0].steps![2].name} (0s) :job0-2, after job0-1, 0s 225 | \`\`\``; 226 | 227 | assertEquals(createMermaid(workflow, workflowJobs, {}), expect); 228 | }, 229 | ); 230 | 231 | await t.step( 232 | "'Waiting for a runner' step duration is ommited if option 'showWaitingRunner' === false ", 233 | () => { 234 | const workflow = { ...commonWorkflow }; 235 | const workflowJobs = [ 236 | { 237 | "id": 15820938470, 238 | "run_id": 5833450919, 239 | "workflow_name": "Check self-hosted runner", 240 | "status": "completed", 241 | "conclusion": "success", 242 | "created_at": "2023-08-11T14:00:50Z", 243 | "started_at": "2023-08-11T14:01:31Z", 244 | "completed_at": "2023-08-11T14:01:36Z", 245 | "name": "node", 246 | "steps": [ 247 | { 248 | "name": "Set up job", 249 | "status": "completed", 250 | "conclusion": "success", 251 | "number": 1, 252 | "started_at": "2023-08-11T23:01:30.000+09:00", 253 | "completed_at": "2023-08-11T23:01:32.000+09:00", 254 | }, 255 | { 256 | "name": "Set up runner", 257 | "status": "completed", 258 | "conclusion": "success", 259 | "number": 2, 260 | "started_at": "2023-08-11T23:01:32.000+09:00", 261 | "completed_at": "2023-08-11T23:01:32.000+09:00", 262 | }, 263 | { 264 | "name": "Run actions/checkout@v3", 265 | "status": "completed", 266 | "conclusion": "success", 267 | "number": 3, 268 | "started_at": "2023-08-11T23:01:34.000+09:00", 269 | "completed_at": "2023-08-11T23:01:34.000+09:00", 270 | }, 271 | ], 272 | }, 273 | ] as unknown as WorkflowJobs; 274 | 275 | // deno-fmt-ignore 276 | const expect = ` 277 | \`\`\`mermaid 278 | gantt 279 | title ${workflowJobs[0].workflow_name} 280 | dateFormat HH:mm:ss 281 | axisFormat %H:%M:%S 282 | section ${workflowJobs[0].name} 283 | ${workflowJobs[0].steps![0].name} (2s) :job0-0, 00:00:43, 2s 284 | ${workflowJobs[0].steps![1].name} (0s) :job0-1, after job0-0, 0s 285 | ${workflowJobs[0].steps![2].name} (0s) :job0-2, after job0-1, 0s 286 | \`\`\``; 287 | 288 | assertEquals( 289 | createMermaid(workflow, workflowJobs, { showWaitingRunner: false }), 290 | expect, 291 | ); 292 | }, 293 | ); 294 | 295 | await t.step( 296 | "Split gantt when generated gantt characters reached max limit of mermaid.js", 297 | () => { 298 | const workflow = { ...commonWorkflow }; 299 | const workflowJobs = [ 300 | { 301 | "id": 15820938470, 302 | "run_id": 5833450919, 303 | "workflow_name": "Check self-hosted runner", 304 | "status": "completed", 305 | "conclusion": "success", 306 | "created_at": "2023-08-11T14:00:50Z", 307 | "started_at": "2023-08-11T14:01:31Z", 308 | "completed_at": "2023-08-11T14:01:36Z", 309 | "name": "node", 310 | "steps": [ 311 | { 312 | "name": "Set up job", 313 | "status": "completed", 314 | "conclusion": "success", 315 | "number": 1, 316 | "started_at": "2023-08-11T23:01:30.000+09:00", 317 | "completed_at": "2023-08-11T23:01:32.000+09:00", 318 | }, 319 | { 320 | "name": "Set up runner", 321 | "status": "completed", 322 | "conclusion": "success", 323 | "number": 2, 324 | "started_at": "2023-08-11T23:01:32.000+09:00", 325 | "completed_at": "2023-08-11T23:01:32.000+09:00", 326 | }, 327 | { 328 | "name": "Complete runner", 329 | "status": "completed", 330 | "conclusion": "success", 331 | "number": 3, 332 | "started_at": "2023-08-11T23:01:36.000+09:00", 333 | "completed_at": "2023-08-11T23:01:36.000+09:00", 334 | }, 335 | { 336 | "name": "Complete job", 337 | "status": "completed", 338 | "conclusion": "success", 339 | "number": 4, 340 | "started_at": "2023-08-11T23:01:35.000+09:00", 341 | "completed_at": "2023-08-11T23:01:35.000+09:00", 342 | }, 343 | ], 344 | }, 345 | { 346 | "id": 15820938790, 347 | "run_id": 5833450919, 348 | "workflow_name": "Check self-hosted runner", 349 | "status": "completed", 350 | "conclusion": "success", 351 | "created_at": "2023-08-11T14:00:51Z", 352 | "started_at": "2023-08-11T14:01:30Z", 353 | "completed_at": "2023-08-11T14:01:50Z", 354 | "name": "go", 355 | "steps": [ 356 | { 357 | "name": "Set up job", 358 | "status": "completed", 359 | "conclusion": "success", 360 | "number": 1, 361 | "started_at": "2023-08-11T23:01:29.000+09:00", 362 | "completed_at": "2023-08-11T23:01:32.000+09:00", 363 | }, 364 | { 365 | "name": "Set up runner", 366 | "status": "completed", 367 | "conclusion": "success", 368 | "number": 2, 369 | "started_at": "2023-08-11T23:01:32.000+09:00", 370 | "completed_at": "2023-08-11T23:01:32.000+09:00", 371 | }, 372 | { 373 | "name": "Complete runner", 374 | "status": "completed", 375 | "conclusion": "success", 376 | "number": 3, 377 | "started_at": "2023-08-11T23:01:50.000+09:00", 378 | "completed_at": "2023-08-11T23:01:50.000+09:00", 379 | }, 380 | { 381 | "name": "Complete job", 382 | "status": "completed", 383 | "conclusion": "success", 384 | "number": 4, 385 | "started_at": "2023-08-11T23:01:49.000+09:00", 386 | "completed_at": "2023-08-11T23:01:49.000+09:00", 387 | }, 388 | ], 389 | }, 390 | ] as unknown as WorkflowJobs; 391 | 392 | // deno-fmt-ignore 393 | const mermaid1 = ` 394 | \`\`\`mermaid 395 | gantt 396 | title ${workflowJobs[0].workflow_name} 397 | dateFormat HH:mm:ss 398 | axisFormat %H:%M:%S 399 | section ${workflowJobs[0].name} 400 | Waiting for a runner (41s) :active, job0-0, 00:00:02, 41s 401 | ${workflowJobs[0].steps![0].name} (2s) :job0-1, after job0-0, 2s 402 | ${workflowJobs[0].steps![1].name} (0s) :job0-2, after job0-1, 0s 403 | ${workflowJobs[0].steps![2].name} (0s) :job0-3, after job0-2, 0s 404 | ${workflowJobs[0].steps![3].name} (0s) :job0-4, after job0-3, 0s 405 | \`\`\`` 406 | 407 | // deno-fmt-ignore 408 | const mermaid2 = ` 409 | \`\`\`mermaid 410 | gantt 411 | title ${workflowJobs[0].workflow_name} 412 | dateFormat HH:mm:ss 413 | axisFormat %H:%M:%S 414 | section ${workflowJobs[1].name} 415 | Waiting for a runner (39s) :active, job1-0, 00:00:03, 39s 416 | ${workflowJobs[1].steps![0].name} (3s) :job1-1, after job1-0, 3s 417 | ${workflowJobs[1].steps![1].name} (0s) :job1-2, after job1-1, 0s 418 | ${workflowJobs[1].steps![2].name} (0s) :job1-3, after job1-2, 0s 419 | ${workflowJobs[1].steps![3].name} (0s) :job1-4, after job1-3, 0s 420 | \`\`\``; 421 | const expect = [mermaid1, mermaid2]; 422 | 423 | const title = workflow.name ?? ""; 424 | const ganttJobs = createGanttJobs(workflow, workflowJobs); 425 | const maxCharForTest = mermaid1.length; 426 | const actual = createGanttDiagrams(title, ganttJobs, maxCharForTest); 427 | 428 | assertEquals(actual, expect); 429 | }, 430 | ); 431 | 432 | await t.step( 433 | "Newline characters properly counted in split gantt calculation (issue #222)", 434 | () => { 435 | const workflow = { ...commonWorkflow }; 436 | const workflowJobs = [ 437 | { 438 | "id": 1, 439 | "run_id": 5833450919, 440 | "workflow_name": "Test", 441 | "status": "completed", 442 | "conclusion": "success", 443 | "created_at": "2023-08-11T14:00:50Z", 444 | "started_at": "2023-08-11T14:01:31Z", 445 | "completed_at": "2023-08-11T14:01:36Z", 446 | "name": "a", 447 | "steps": [ 448 | { 449 | "name": "x", 450 | "status": "completed", 451 | "conclusion": "success", 452 | "number": 1, 453 | "started_at": "2023-08-11T23:01:30.000+09:00", 454 | "completed_at": "2023-08-11T23:01:32.000+09:00", 455 | }, 456 | ], 457 | }, 458 | { 459 | "id": 2, 460 | "run_id": 5833450919, 461 | "workflow_name": "Test", 462 | "status": "completed", 463 | "conclusion": "success", 464 | "created_at": "2023-08-11T14:00:51Z", 465 | "started_at": "2023-08-11T14:01:30Z", 466 | "completed_at": "2023-08-11T14:01:50Z", 467 | "name": "b", 468 | "steps": [ 469 | { 470 | "name": "y", 471 | "status": "completed", 472 | "conclusion": "success", 473 | "number": 1, 474 | "started_at": "2023-08-11T23:01:29.000+09:00", 475 | "completed_at": "2023-08-11T23:01:32.000+09:00", 476 | }, 477 | ], 478 | }, 479 | ] as unknown as WorkflowJobs; 480 | 481 | const title = workflow.name ?? ""; 482 | const ganttJobs = createGanttJobs(workflow, workflowJobs); 483 | 484 | const header = ` 485 | \`\`\`mermaid 486 | gantt 487 | title Check self-hosted runner 488 | dateFormat HH:mm:ss 489 | axisFormat %H:%M:%S 490 | `; 491 | const footer = "\n\`\`\`"; 492 | const headerFooterLength = header.length + footer.length; 493 | 494 | const section1 = formatSection(ganttJobs[0]); 495 | const section2 = formatSection(ganttJobs[1]); 496 | 497 | // Set maxChar to be just less than the total including newlines 498 | // This should now properly trigger splitting 499 | const totalWithNewlines = headerFooterLength + section1.length + 1 + 500 | section2.length; 501 | const maxCharForTest = totalWithNewlines - 5; // A bit less to trigger split 502 | 503 | const result = createGanttDiagrams(title, ganttJobs, maxCharForTest); 504 | 505 | assertEquals( 506 | result.length, 507 | 2, 508 | "Properly splits when including newline calculations", 509 | ); 510 | 511 | // Each diagram should be within the limit 512 | assertEquals( 513 | result[0].length <= maxCharForTest, 514 | true, 515 | "First diagram within limit", 516 | ); 517 | assertEquals( 518 | result[1].length <= maxCharForTest, 519 | true, 520 | "Second diagram within limit", 521 | ); 522 | }, 523 | ); 524 | }); 525 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "specifiers": { 4 | "jsr:@david/code-block-writer@^13.0.3": "13.0.3", 5 | "jsr:@deno/dnt@0.42.3": "0.42.3", 6 | "jsr:@std/fmt@1": "1.0.8", 7 | "jsr:@std/fs@1": "1.0.19", 8 | "jsr:@std/internal@^1.0.10": "1.0.10", 9 | "jsr:@std/internal@^1.0.9": "1.0.10", 10 | "jsr:@std/path@1": "1.1.2", 11 | "jsr:@std/path@^1.1.1": "1.1.2", 12 | "jsr:@ts-morph/bootstrap@0.27": "0.27.0", 13 | "jsr:@ts-morph/common@0.27": "0.27.0", 14 | "npm:@actions/core@1.11.1": "1.11.1", 15 | "npm:@actions/github@6.0.1": "6.0.1_@octokit+core@5.2.1", 16 | "npm:@types/node@*": "22.15.15", 17 | "npm:date-fns@4.1.0": "4.1.0", 18 | "npm:esbuild@0.25.5": "0.25.5", 19 | "npm:esbuild@0.25.9": "0.25.9" 20 | }, 21 | "jsr": { 22 | "@david/code-block-writer@13.0.3": { 23 | "integrity": "f98c77d320f5957899a61bfb7a9bead7c6d83ad1515daee92dbacc861e13bb7f" 24 | }, 25 | "@deno/dnt@0.42.3": { 26 | "integrity": "62a917a0492f3c8af002dce90605bb0d41f7d29debc06aca40dba72ab65d8ae3", 27 | "dependencies": [ 28 | "jsr:@david/code-block-writer", 29 | "jsr:@std/fmt", 30 | "jsr:@std/fs", 31 | "jsr:@std/path@1", 32 | "jsr:@ts-morph/bootstrap" 33 | ] 34 | }, 35 | "@std/fmt@1.0.8": { 36 | "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" 37 | }, 38 | "@std/fs@1.0.19": { 39 | "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", 40 | "dependencies": [ 41 | "jsr:@std/internal@^1.0.9", 42 | "jsr:@std/path@^1.1.1" 43 | ] 44 | }, 45 | "@std/internal@1.0.10": { 46 | "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" 47 | }, 48 | "@std/path@1.1.2": { 49 | "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", 50 | "dependencies": [ 51 | "jsr:@std/internal@^1.0.10" 52 | ] 53 | }, 54 | "@ts-morph/bootstrap@0.27.0": { 55 | "integrity": "b8d7bc8f7942ce853dde4161b28f9aa96769cef3d8eebafb379a81800b9e2448", 56 | "dependencies": [ 57 | "jsr:@ts-morph/common" 58 | ] 59 | }, 60 | "@ts-morph/common@0.27.0": { 61 | "integrity": "c7b73592d78ce8479b356fd4f3d6ec3c460d77753a8680ff196effea7a939052", 62 | "dependencies": [ 63 | "jsr:@std/fs", 64 | "jsr:@std/path@1" 65 | ] 66 | } 67 | }, 68 | "npm": { 69 | "@actions/core@1.11.1": { 70 | "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", 71 | "dependencies": [ 72 | "@actions/exec", 73 | "@actions/http-client" 74 | ] 75 | }, 76 | "@actions/exec@1.1.1": { 77 | "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", 78 | "dependencies": [ 79 | "@actions/io" 80 | ] 81 | }, 82 | "@actions/github@6.0.1_@octokit+core@5.2.1": { 83 | "integrity": "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==", 84 | "dependencies": [ 85 | "@actions/http-client", 86 | "@octokit/core", 87 | "@octokit/plugin-paginate-rest", 88 | "@octokit/plugin-rest-endpoint-methods", 89 | "@octokit/request", 90 | "@octokit/request-error", 91 | "undici" 92 | ] 93 | }, 94 | "@actions/http-client@2.2.3": { 95 | "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", 96 | "dependencies": [ 97 | "tunnel", 98 | "undici" 99 | ] 100 | }, 101 | "@actions/io@1.1.3": { 102 | "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==" 103 | }, 104 | "@esbuild/aix-ppc64@0.25.5": { 105 | "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", 106 | "os": ["aix"], 107 | "cpu": ["ppc64"] 108 | }, 109 | "@esbuild/aix-ppc64@0.25.9": { 110 | "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", 111 | "os": ["aix"], 112 | "cpu": ["ppc64"] 113 | }, 114 | "@esbuild/android-arm64@0.25.5": { 115 | "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", 116 | "os": ["android"], 117 | "cpu": ["arm64"] 118 | }, 119 | "@esbuild/android-arm64@0.25.9": { 120 | "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", 121 | "os": ["android"], 122 | "cpu": ["arm64"] 123 | }, 124 | "@esbuild/android-arm@0.25.5": { 125 | "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", 126 | "os": ["android"], 127 | "cpu": ["arm"] 128 | }, 129 | "@esbuild/android-arm@0.25.9": { 130 | "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", 131 | "os": ["android"], 132 | "cpu": ["arm"] 133 | }, 134 | "@esbuild/android-x64@0.25.5": { 135 | "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", 136 | "os": ["android"], 137 | "cpu": ["x64"] 138 | }, 139 | "@esbuild/android-x64@0.25.9": { 140 | "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", 141 | "os": ["android"], 142 | "cpu": ["x64"] 143 | }, 144 | "@esbuild/darwin-arm64@0.25.5": { 145 | "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", 146 | "os": ["darwin"], 147 | "cpu": ["arm64"] 148 | }, 149 | "@esbuild/darwin-arm64@0.25.9": { 150 | "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", 151 | "os": ["darwin"], 152 | "cpu": ["arm64"] 153 | }, 154 | "@esbuild/darwin-x64@0.25.5": { 155 | "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", 156 | "os": ["darwin"], 157 | "cpu": ["x64"] 158 | }, 159 | "@esbuild/darwin-x64@0.25.9": { 160 | "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", 161 | "os": ["darwin"], 162 | "cpu": ["x64"] 163 | }, 164 | "@esbuild/freebsd-arm64@0.25.5": { 165 | "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", 166 | "os": ["freebsd"], 167 | "cpu": ["arm64"] 168 | }, 169 | "@esbuild/freebsd-arm64@0.25.9": { 170 | "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", 171 | "os": ["freebsd"], 172 | "cpu": ["arm64"] 173 | }, 174 | "@esbuild/freebsd-x64@0.25.5": { 175 | "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", 176 | "os": ["freebsd"], 177 | "cpu": ["x64"] 178 | }, 179 | "@esbuild/freebsd-x64@0.25.9": { 180 | "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", 181 | "os": ["freebsd"], 182 | "cpu": ["x64"] 183 | }, 184 | "@esbuild/linux-arm64@0.25.5": { 185 | "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", 186 | "os": ["linux"], 187 | "cpu": ["arm64"] 188 | }, 189 | "@esbuild/linux-arm64@0.25.9": { 190 | "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", 191 | "os": ["linux"], 192 | "cpu": ["arm64"] 193 | }, 194 | "@esbuild/linux-arm@0.25.5": { 195 | "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", 196 | "os": ["linux"], 197 | "cpu": ["arm"] 198 | }, 199 | "@esbuild/linux-arm@0.25.9": { 200 | "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", 201 | "os": ["linux"], 202 | "cpu": ["arm"] 203 | }, 204 | "@esbuild/linux-ia32@0.25.5": { 205 | "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", 206 | "os": ["linux"], 207 | "cpu": ["ia32"] 208 | }, 209 | "@esbuild/linux-ia32@0.25.9": { 210 | "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", 211 | "os": ["linux"], 212 | "cpu": ["ia32"] 213 | }, 214 | "@esbuild/linux-loong64@0.25.5": { 215 | "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", 216 | "os": ["linux"], 217 | "cpu": ["loong64"] 218 | }, 219 | "@esbuild/linux-loong64@0.25.9": { 220 | "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", 221 | "os": ["linux"], 222 | "cpu": ["loong64"] 223 | }, 224 | "@esbuild/linux-mips64el@0.25.5": { 225 | "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", 226 | "os": ["linux"], 227 | "cpu": ["mips64el"] 228 | }, 229 | "@esbuild/linux-mips64el@0.25.9": { 230 | "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", 231 | "os": ["linux"], 232 | "cpu": ["mips64el"] 233 | }, 234 | "@esbuild/linux-ppc64@0.25.5": { 235 | "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", 236 | "os": ["linux"], 237 | "cpu": ["ppc64"] 238 | }, 239 | "@esbuild/linux-ppc64@0.25.9": { 240 | "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", 241 | "os": ["linux"], 242 | "cpu": ["ppc64"] 243 | }, 244 | "@esbuild/linux-riscv64@0.25.5": { 245 | "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", 246 | "os": ["linux"], 247 | "cpu": ["riscv64"] 248 | }, 249 | "@esbuild/linux-riscv64@0.25.9": { 250 | "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", 251 | "os": ["linux"], 252 | "cpu": ["riscv64"] 253 | }, 254 | "@esbuild/linux-s390x@0.25.5": { 255 | "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", 256 | "os": ["linux"], 257 | "cpu": ["s390x"] 258 | }, 259 | "@esbuild/linux-s390x@0.25.9": { 260 | "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", 261 | "os": ["linux"], 262 | "cpu": ["s390x"] 263 | }, 264 | "@esbuild/linux-x64@0.25.5": { 265 | "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", 266 | "os": ["linux"], 267 | "cpu": ["x64"] 268 | }, 269 | "@esbuild/linux-x64@0.25.9": { 270 | "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", 271 | "os": ["linux"], 272 | "cpu": ["x64"] 273 | }, 274 | "@esbuild/netbsd-arm64@0.25.5": { 275 | "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", 276 | "os": ["netbsd"], 277 | "cpu": ["arm64"] 278 | }, 279 | "@esbuild/netbsd-arm64@0.25.9": { 280 | "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", 281 | "os": ["netbsd"], 282 | "cpu": ["arm64"] 283 | }, 284 | "@esbuild/netbsd-x64@0.25.5": { 285 | "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", 286 | "os": ["netbsd"], 287 | "cpu": ["x64"] 288 | }, 289 | "@esbuild/netbsd-x64@0.25.9": { 290 | "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", 291 | "os": ["netbsd"], 292 | "cpu": ["x64"] 293 | }, 294 | "@esbuild/openbsd-arm64@0.25.5": { 295 | "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", 296 | "os": ["openbsd"], 297 | "cpu": ["arm64"] 298 | }, 299 | "@esbuild/openbsd-arm64@0.25.9": { 300 | "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", 301 | "os": ["openbsd"], 302 | "cpu": ["arm64"] 303 | }, 304 | "@esbuild/openbsd-x64@0.25.5": { 305 | "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", 306 | "os": ["openbsd"], 307 | "cpu": ["x64"] 308 | }, 309 | "@esbuild/openbsd-x64@0.25.9": { 310 | "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", 311 | "os": ["openbsd"], 312 | "cpu": ["x64"] 313 | }, 314 | "@esbuild/openharmony-arm64@0.25.9": { 315 | "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", 316 | "os": ["openharmony"], 317 | "cpu": ["arm64"] 318 | }, 319 | "@esbuild/sunos-x64@0.25.5": { 320 | "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", 321 | "os": ["sunos"], 322 | "cpu": ["x64"] 323 | }, 324 | "@esbuild/sunos-x64@0.25.9": { 325 | "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", 326 | "os": ["sunos"], 327 | "cpu": ["x64"] 328 | }, 329 | "@esbuild/win32-arm64@0.25.5": { 330 | "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", 331 | "os": ["win32"], 332 | "cpu": ["arm64"] 333 | }, 334 | "@esbuild/win32-arm64@0.25.9": { 335 | "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", 336 | "os": ["win32"], 337 | "cpu": ["arm64"] 338 | }, 339 | "@esbuild/win32-ia32@0.25.5": { 340 | "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", 341 | "os": ["win32"], 342 | "cpu": ["ia32"] 343 | }, 344 | "@esbuild/win32-ia32@0.25.9": { 345 | "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", 346 | "os": ["win32"], 347 | "cpu": ["ia32"] 348 | }, 349 | "@esbuild/win32-x64@0.25.5": { 350 | "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", 351 | "os": ["win32"], 352 | "cpu": ["x64"] 353 | }, 354 | "@esbuild/win32-x64@0.25.9": { 355 | "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", 356 | "os": ["win32"], 357 | "cpu": ["x64"] 358 | }, 359 | "@fastify/busboy@2.1.1": { 360 | "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==" 361 | }, 362 | "@octokit/auth-token@4.0.0": { 363 | "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==" 364 | }, 365 | "@octokit/core@5.2.1": { 366 | "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", 367 | "dependencies": [ 368 | "@octokit/auth-token", 369 | "@octokit/graphql", 370 | "@octokit/request", 371 | "@octokit/request-error", 372 | "@octokit/types@13.10.0", 373 | "before-after-hook", 374 | "universal-user-agent" 375 | ] 376 | }, 377 | "@octokit/endpoint@9.0.6": { 378 | "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", 379 | "dependencies": [ 380 | "@octokit/types@13.10.0", 381 | "universal-user-agent" 382 | ] 383 | }, 384 | "@octokit/graphql@7.1.1": { 385 | "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", 386 | "dependencies": [ 387 | "@octokit/request", 388 | "@octokit/types@13.10.0", 389 | "universal-user-agent" 390 | ] 391 | }, 392 | "@octokit/openapi-types@20.0.0": { 393 | "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==" 394 | }, 395 | "@octokit/openapi-types@24.2.0": { 396 | "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==" 397 | }, 398 | "@octokit/plugin-paginate-rest@9.2.2_@octokit+core@5.2.1": { 399 | "integrity": "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==", 400 | "dependencies": [ 401 | "@octokit/core", 402 | "@octokit/types@12.6.0" 403 | ] 404 | }, 405 | "@octokit/plugin-rest-endpoint-methods@10.4.1_@octokit+core@5.2.1": { 406 | "integrity": "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==", 407 | "dependencies": [ 408 | "@octokit/core", 409 | "@octokit/types@12.6.0" 410 | ] 411 | }, 412 | "@octokit/request-error@5.1.1": { 413 | "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", 414 | "dependencies": [ 415 | "@octokit/types@13.10.0", 416 | "deprecation", 417 | "once" 418 | ] 419 | }, 420 | "@octokit/request@8.4.1": { 421 | "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", 422 | "dependencies": [ 423 | "@octokit/endpoint", 424 | "@octokit/request-error", 425 | "@octokit/types@13.10.0", 426 | "universal-user-agent" 427 | ] 428 | }, 429 | "@octokit/types@12.6.0": { 430 | "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", 431 | "dependencies": [ 432 | "@octokit/openapi-types@20.0.0" 433 | ] 434 | }, 435 | "@octokit/types@13.10.0": { 436 | "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", 437 | "dependencies": [ 438 | "@octokit/openapi-types@24.2.0" 439 | ] 440 | }, 441 | "@types/node@22.15.15": { 442 | "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", 443 | "dependencies": [ 444 | "undici-types" 445 | ] 446 | }, 447 | "before-after-hook@2.2.3": { 448 | "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" 449 | }, 450 | "date-fns@4.1.0": { 451 | "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==" 452 | }, 453 | "deprecation@2.3.1": { 454 | "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" 455 | }, 456 | "esbuild@0.25.5": { 457 | "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", 458 | "optionalDependencies": [ 459 | "@esbuild/aix-ppc64@0.25.5", 460 | "@esbuild/android-arm@0.25.5", 461 | "@esbuild/android-arm64@0.25.5", 462 | "@esbuild/android-x64@0.25.5", 463 | "@esbuild/darwin-arm64@0.25.5", 464 | "@esbuild/darwin-x64@0.25.5", 465 | "@esbuild/freebsd-arm64@0.25.5", 466 | "@esbuild/freebsd-x64@0.25.5", 467 | "@esbuild/linux-arm@0.25.5", 468 | "@esbuild/linux-arm64@0.25.5", 469 | "@esbuild/linux-ia32@0.25.5", 470 | "@esbuild/linux-loong64@0.25.5", 471 | "@esbuild/linux-mips64el@0.25.5", 472 | "@esbuild/linux-ppc64@0.25.5", 473 | "@esbuild/linux-riscv64@0.25.5", 474 | "@esbuild/linux-s390x@0.25.5", 475 | "@esbuild/linux-x64@0.25.5", 476 | "@esbuild/netbsd-arm64@0.25.5", 477 | "@esbuild/netbsd-x64@0.25.5", 478 | "@esbuild/openbsd-arm64@0.25.5", 479 | "@esbuild/openbsd-x64@0.25.5", 480 | "@esbuild/sunos-x64@0.25.5", 481 | "@esbuild/win32-arm64@0.25.5", 482 | "@esbuild/win32-ia32@0.25.5", 483 | "@esbuild/win32-x64@0.25.5" 484 | ], 485 | "scripts": true, 486 | "bin": true 487 | }, 488 | "esbuild@0.25.9": { 489 | "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", 490 | "optionalDependencies": [ 491 | "@esbuild/aix-ppc64@0.25.9", 492 | "@esbuild/android-arm@0.25.9", 493 | "@esbuild/android-arm64@0.25.9", 494 | "@esbuild/android-x64@0.25.9", 495 | "@esbuild/darwin-arm64@0.25.9", 496 | "@esbuild/darwin-x64@0.25.9", 497 | "@esbuild/freebsd-arm64@0.25.9", 498 | "@esbuild/freebsd-x64@0.25.9", 499 | "@esbuild/linux-arm@0.25.9", 500 | "@esbuild/linux-arm64@0.25.9", 501 | "@esbuild/linux-ia32@0.25.9", 502 | "@esbuild/linux-loong64@0.25.9", 503 | "@esbuild/linux-mips64el@0.25.9", 504 | "@esbuild/linux-ppc64@0.25.9", 505 | "@esbuild/linux-riscv64@0.25.9", 506 | "@esbuild/linux-s390x@0.25.9", 507 | "@esbuild/linux-x64@0.25.9", 508 | "@esbuild/netbsd-arm64@0.25.9", 509 | "@esbuild/netbsd-x64@0.25.9", 510 | "@esbuild/openbsd-arm64@0.25.9", 511 | "@esbuild/openbsd-x64@0.25.9", 512 | "@esbuild/openharmony-arm64", 513 | "@esbuild/sunos-x64@0.25.9", 514 | "@esbuild/win32-arm64@0.25.9", 515 | "@esbuild/win32-ia32@0.25.9", 516 | "@esbuild/win32-x64@0.25.9" 517 | ], 518 | "scripts": true, 519 | "bin": true 520 | }, 521 | "once@1.4.0": { 522 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 523 | "dependencies": [ 524 | "wrappy" 525 | ] 526 | }, 527 | "tunnel@0.0.6": { 528 | "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==" 529 | }, 530 | "undici-types@6.21.0": { 531 | "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" 532 | }, 533 | "undici@5.29.0": { 534 | "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", 535 | "dependencies": [ 536 | "@fastify/busboy" 537 | ] 538 | }, 539 | "universal-user-agent@6.0.1": { 540 | "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==" 541 | }, 542 | "wrappy@1.0.2": { 543 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" 544 | } 545 | }, 546 | "workspace": { 547 | "dependencies": [ 548 | "jsr:@cliffy/command@^1.0.0-rc.8", 549 | "jsr:@deno/dnt@0.42.3", 550 | "jsr:@kesin11/gha-utils@0.2.2", 551 | "jsr:@std/assert@^1.0.13", 552 | "jsr:@std/collections@^1.1.2", 553 | "npm:@actions/core@1.11.1", 554 | "npm:@actions/github@6.0.1", 555 | "npm:date-fns@4.1.0", 556 | "npm:esbuild@0.25.9" 557 | ] 558 | } 559 | } 560 | --------------------------------------------------------------------------------