diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -53,6 +53,23 @@ return `${Math.floor(duration)}y ${suffix}`; }; +// the appview's durationFmt: non-zero chunks, largest first ("1h 3m 20s"). +// under a second reads "0s", so an instant run still differs from one that never ran. +export const formatDuration = (ms: number): string => { + if (!Number.isFinite(ms) || ms < 0) return ""; + const total = Math.floor(ms / 1000); + const chunks: [number, string][] = [ + [Math.floor(total / 86400), "d"], + [Math.floor(total / 3600) % 24, "h"], + [Math.floor(total / 60) % 60, "m"], + [total % 60, "s"] + ]; + const parts = chunks + .filter(([amount]) => amount !== 0) + .map(([amount, unit]) => `${amount}${unit}`); + return parts.length > 0 ? parts.join(" ") : "0s"; +}; + export const formatDate = (input: string | Date): string => { const date = typeof input === "string" ? new Date(input) : input; return Number.isNaN(date.getTime()) ? "" : dtf.format(date); diff --git a/web/src/lib/components/repo/types.ts b/web/src/lib/components/repo/types.ts --- a/web/src/lib/components/repo/types.ts +++ b/web/src/lib/components/repo/types.ts @@ -49,3 +49,39 @@ createdAt: string; commentCount: number; } + +/** the spindle's workflow states, same strings as sh.tangled.ci.pipeline */ +export type PipelineStatus = "pending" | "running" | "success" | "failed" | "timeout" | "cancelled"; + +export interface WorkflowSummary { + name: string; + status: PipelineStatus; + /** wall time in ms; 0 until the workflow finishes */ + duration: number; + /** first line of the spindle's error, when it failed before running */ + error?: string; +} + +export type PipelineTrigger = + | { kind: "push"; targetRef: string } + | { + kind: "pull_request"; + targetRef: string; + /** where the head came from — `handle/repo:branch` when it is a fork */ + sourceLabel: string; + /** the pull page, absent when the pull record is gone */ + pullPath?: string; + } + | { kind: "manual" }; + +export type PipelineTriggerKind = PipelineTrigger["kind"]; + +export interface PipelineSummary { + id: string; + /** the commit the spindle checked out */ + sha: string; + createdAt: string; + trigger: PipelineTrigger; + /** empty while the spindle has not reported back yet */ + workflows: WorkflowSummary[]; +} diff --git a/web/src/lib/components/ui/Button.svelte b/web/src/lib/components/ui/Button.svelte --- a/web/src/lib/components/ui/Button.svelte +++ b/web/src/lib/components/ui/Button.svelte @@ -126,6 +126,7 @@ class?: string; loading?: boolean; spinnerClass?: string; + ref?: HTMLAnchorElement | HTMLButtonElement; children?: Snippet; } @@ -141,6 +142,7 @@ class: className, loading = false, spinnerClass, + ref = $bindable(), children, ...rest }: Props = $props(); @@ -176,6 +178,7 @@ {#if href} {:else} + + + + + + {total} pipeline {total === 1 ? "run" : "runs"} + + diff --git a/web/src/lib/components/repo/pipelines/PipelineWorkflows.svelte b/web/src/lib/components/repo/pipelines/PipelineWorkflows.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/PipelineWorkflows.svelte @@ -0,0 +1,81 @@ + + + + +{#if count === 0} + + + +{:else} + + {#snippet trigger()} + + {label} + + {/snippet} + + {#each pipeline.workflows as workflow (workflow.name)} + + + + + + {workflow.name} + + + {workflow.duration > 0 + ? formatDuration(workflow.duration) + : STATUS_LABELS[workflow.status]} + + + + {/each} + +{/if} diff --git a/web/src/lib/components/repo/pipelines/mock.ts b/web/src/lib/components/repo/pipelines/mock.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/mock.ts @@ -0,0 +1,88 @@ +// fake data for the pipelines tab until it is wired to bobbin + +import type { PipelineSummary } from "$lib/components/repo/types"; + +const minutes = (n: number) => n * 60_000; + +// fixed base so the times do not depend on when this runs +const base = Date.parse("2026-08-04T09:00:00Z"); +const ago = (mins: number) => new Date(base - minutes(mins)).toISOString(); + +export const pipelines: PipelineSummary[] = [ + // a run still in flight + { + id: "01K1XJ4WPQ0000000000000001", + sha: "9f2c1ab4d8e7350f6b1c2d9e4a5b6c7d8e9f0a1b", + createdAt: ago(3), + trigger: { kind: "push", targetRef: "master" }, + workflows: [ + { name: "build", status: "success", duration: minutes(1) + 12_000 }, + { name: "test", status: "running", duration: 0 }, + { name: "lint", status: "pending", duration: 0 } + ] + }, + // mixed outcome + { + id: "01K1XJ4WPQ0000000000000002", + sha: "3d7e91c05fa2b8461d0e5c7a9b3f2e1d4c6a8b0f", + createdAt: ago(47), + trigger: { + kind: "pull_request", + targetRef: "master", + sourceLabel: "oppi.li/core:fix-packfile-timeout", + pullPath: "/tangled.org/core/pulls/42" + }, + workflows: [ + { name: "build", status: "success", duration: minutes(2) + 4_000 }, + { name: "test", status: "failed", duration: minutes(6) + 31_000, error: "3 tests failed" }, + { name: "lint", status: "success", duration: 41_000 } + ] + }, + // all green + { + id: "01K1XJ4WPQ0000000000000003", + sha: "c1b4a7e2f9308d65b2a1c4e7f0d3b6a9c2e5f8b1", + createdAt: ago(190), + trigger: { kind: "push", targetRef: "master" }, + workflows: [ + { name: "build", status: "success", duration: minutes(1) + 58_000 }, + { name: "test", status: "success", duration: minutes(5) + 12_000 }, + { name: "lint", status: "success", duration: 39_000 } + ] + }, + // a spindle that never answered + { + id: "01K1XJ4WPQ0000000000000004", + sha: "7a0f3b8c1d2e4f5a6b7c8d9e0f1a2b3c4d5e6f70", + createdAt: ago(320), + trigger: { kind: "manual" }, + workflows: [] + }, + // timed out + { + id: "01K1XJ4WPQ0000000000000005", + sha: "e5d4c3b2a1908f7e6d5c4b3a2918f7e6d5c4b3a2", + createdAt: ago(1_450), + trigger: { + kind: "pull_request", + targetRef: "release-1.4", + sourceLabel: "bump-deps" + }, + workflows: [ + { name: "build", status: "success", duration: minutes(2) + 9_000 }, + { name: "e2e", status: "timeout", duration: minutes(30) } + ] + }, + // cancelled mid-run + { + id: "01K1XJ4WPQ0000000000000006", + sha: "b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9", + createdAt: ago(2_880), + // the appview strips refs/heads/ and refs/tags/ before it reaches the card + trigger: { kind: "push", targetRef: "v1.3.0" }, + workflows: [ + { name: "build", status: "cancelled", duration: 18_000 }, + { name: "release", status: "cancelled", duration: 0 } + ] + } +]; diff --git a/web/src/lib/components/repo/pipelines/pipeline.ts b/web/src/lib/components/repo/pipelines/pipeline.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/pipeline.ts @@ -0,0 +1,89 @@ +// mirrors the helpers on types.Pipeline in types/pipeline.go + +import type { + PipelineStatus, + PipelineSummary, + PipelineTriggerKind +} from "$lib/components/repo/types"; + +/** the trigger tabs */ +export type PipelineFilter = "all" | PipelineTriggerKind; + +/** what search looks at. a real bobbin query has to cover the same fields. */ +export const matchesQuery = (pipeline: PipelineSummary, query: string): boolean => { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + + const haystack = [ + pipeline.sha, + pipeline.trigger.kind === "manual" ? "manual dispatch" : pipeline.trigger.targetRef, + pipeline.trigger.kind === "pull_request" ? pipeline.trigger.sourceLabel : "", + ...pipeline.workflows.map((workflow) => workflow.name) + ]; + return haystack.some((field) => field.toLowerCase().includes(needle)); +}; + +/** the order the breakdown lists statuses in */ +export const STATUS_ORDER: PipelineStatus[] = [ + "success", + "failed", + "timeout", + "cancelled", + "running", + "pending" +]; + +export const STATUS_LABELS: Record = { + pending: "Pending", + running: "Running", + success: "Passed", + failed: "Failed", + timeout: "Timed out", + cancelled: "Cancelled" +}; + +export const statusCounts = (pipeline: PipelineSummary): Record => { + const counts = { + pending: 0, + running: 0, + success: 0, + failed: 0, + timeout: 0, + cancelled: 0 + }; + for (const workflow of pipeline.workflows) counts[workflow.status]++; + return counts; +}; + +/** one status for the whole run. anything still going wins, so an in-flight run says so. */ +export const aggregateStatus = (pipeline: PipelineSummary): PipelineStatus => { + // no workflows yet means no spindle has picked it up + if (pipeline.workflows.length === 0) return "pending"; + + const counts = statusCounts(pipeline); + if (counts.running > 0) return "running"; + if (counts.pending > 0) return "pending"; + if (counts.failed > 0) return "failed"; + if (counts.timeout > 0) return "timeout"; + if (counts.cancelled > 0) return "cancelled"; + return "success"; +}; + +/** how many workflows are done — everything except pending and running */ +export const finishedCount = (pipeline: PipelineSummary): number => { + const counts = statusCounts(pipeline); + return counts.success + counts.failed + counts.timeout + counts.cancelled; +}; + +/** "2/3 passed, 1/3 failed" */ +export const longSummary = (pipeline: PipelineSummary): string => { + const counts = statusCounts(pipeline); + const total = pipeline.workflows.length; + return STATUS_ORDER.filter((status) => counts[status] > 0) + .map((status) => `${counts[status]}/${total} ${STATUS_LABELS[status].toLowerCase()}`) + .join(", "); +}; + +/** sum of the workflows, not wall time across the run */ +export const totalDuration = (pipeline: PipelineSummary): number => + pipeline.workflows.reduce((sum, workflow) => sum + workflow.duration, 0); diff --git a/web/src/routes/[handle]/[repo]/pipelines/+page.svelte b/web/src/routes/[handle]/[repo]/pipelines/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/pipelines/+page.svelte @@ -0,0 +1,25 @@ + + + + + + +
+ +
diff --git a/web/src/routes/[handle]/[repo]/pipelines/+page.ts b/web/src/routes/[handle]/[repo]/pipelines/+page.ts new file mode 100644 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/pipelines/+page.ts @@ -0,0 +1,25 @@ +// TODO: mocked. swap for sh.tangled.pipeline.listPipelines via bobbin, the way +// issues/+page.ts does. a real load hands `q` to the server instead of matching here. +import { pipelines as mockPipelines } from "$lib/components/repo/pipelines/mock"; +import { matchesQuery, type PipelineFilter } from "$lib/components/repo/pipelines/pipeline"; +import type { PageLoad } from "./$types"; + +const FILTERS: PipelineFilter[] = ["all", "push", "pull_request"]; + +export const load: PageLoad = async (event) => { + const requested = event.url.searchParams.get("trigger") as PipelineFilter | null; + const filter = requested && FILTERS.includes(requested) ? requested : "all"; + const query = event.url.searchParams.get("q") ?? ""; + + const pipelines = mockPipelines + .filter((pipeline) => filter === "all" || pipeline.trigger.kind === filter) + .filter((pipeline) => matchesQuery(pipeline, query)); + + return { + filter, + query, + pipelines, + // lets the list tell "no runs at all" from "nothing matched" + hasAny: mockPipelines.length > 0 + }; +};