Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Archive removed workflows without deleting run history

syntaqxcommitted Parent421cd4dBrowse files
3 files+70−90/3 viewed
+21−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+import { groupWorkflows, isArchivedWorkflow } from "./workflow-history.ts";
4+
5+test("only a removed file is archived, not a disabled, invalid or branch-only workflow", () => {
6+ const current = [
7+ { id: "active", error: null },
8+ { id: "disabled", error: null, state: "disabled" },
9+ { id: "invalid", error: "Invalid YAML" },
10+ { id: "branch", error: "Its file is not on the default branch." },
11+ ];
12+ const removed = { id: "removed", error: "Its file is no longer on the default branch." };
13+ assert.deepEqual(groupWorkflows([current[0], removed, ...current.slice(1)]), { current, archived: [removed] });
14+ assert.equal(isArchivedWorkflow({ ...removed, error: null }), false);
15+});
16+
17+test("an archive-only repository keeps its workflow history available", () => {
18+ const removed = { id: "old", error: "Its file is no longer on the default branch." };
19+ assert.deepEqual(groupWorkflows([removed]), { current: [], archived: [removed] });
20+ assert.deepEqual(groupWorkflows([]), { current: [], archived: [] });
21+});
+11−0
1+/** The sync service's explicit removal marker; parse errors and branch-only files are not archives. */
2+export function isArchivedWorkflow(workflow: { error: string | null }): boolean {
3+ return workflow.error === "Its file is no longer on the default branch.";
4+}
5+
6+export function groupWorkflows<T extends { error: string | null }>(workflows: readonly T[]): { current: T[]; archived: T[] } {
7+ const current: T[] = [];
8+ const archived: T[] = [];
9+ for (const workflow of workflows) (isArchivedWorkflow(workflow) ? archived : current).push(workflow);
10+ return { current, archived };
11+}
+38−9
1−import { AlertTriangle, BadgeCheck, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react";
1+import { Archive, ChevronRight, AlertTriangle, BadgeCheck, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react";
22 import { useEffect, useRef, useState } from "react";
33 import { Form, Link, useSearchParams } from "react-router";
44
55 import { type DispatchInput, type Workflow, type WorkflowRun, isManagedWorkflow } from "@g1t/contracts";
66
77 import type { Route } from "./+types/actions";
8+import { groupWorkflows, isArchivedWorkflow } from "../../lib/workflow-history";
89 import { page } from "../../lib/meta";
910 import { Duration, Notes, StatusIcon, shortRef } from "../../components/actions";
1011 import { AddCiPrompt } from "../../components/add-ci";
306307 }
307308
308309 function WorkflowHeader({ workflow, base, member, manage }: { workflow: Workflow; base: string; member: boolean; manage: boolean }) {
310+ const archived = isArchivedWorkflow(workflow);
309311 return (
310312 <div className="space-y-4">
311313 <div className="flex flex-wrap items-start justify-between gap-3">
312314 <div className="min-w-0">
313315 <h3 className="text-lg font-semibold tracking-tight">{workflow.name}</h3>
314− {isManagedWorkflow(workflow.path) ? (
316+ {archived ? (
317+ <span className="inline-flex items-center gap-1 font-mono text-xs text-faint"><FileCode2 size={12} />{workflow.path}</span>
318+ ) : isManagedWorkflow(workflow.path) ? (
315319 <ManagedLine workflow={workflow} />
316320 ) : (
317321 <Link to={`${base}/blob/HEAD/${workflow.path}`} className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg">
342346 </div>
343347 )}
344348 </div>
345− {workflow.error && <p className="rounded-lg bg-danger/10 px-3 py-2 text-sm text-danger">{workflow.error}</p>}
346− {workflow.state === "disabled" && (
349+ {archived ? (
350+ <p className="flex items-start gap-2 rounded-lg border border-line bg-surface px-3 py-3 text-sm text-muted">
351+ <Archive size={16} className="mt-0.5 shrink-0" />
352+ <span><strong className="font-medium text-fg">Archived workflow.</strong> Its file was removed from the default branch. Past runs remain available below. Restore the file on the default branch to return it to the workflow list.</span>
353+ </p>
354+ ) : workflow.error && <p className="rounded-lg bg-danger/10 px-3 py-2 text-sm text-danger">{workflow.error}</p>}
355+ {!archived && workflow.state === "disabled" && (
347356 <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">Turned off: nothing starts it until someone with the Maintain role turns it on.</p>
348357 )}
349− <Notes notes={workflow.notes} />
358+ {!archived && <Notes notes={workflow.notes} />}
350359 </div>
351360 );
352361 }
398407 const workflowsFailed = isFailed(loaderData.workflows);
399408 const workflows = isFailed(loaderData.workflows) ? [] : loaderData.workflows;
400409 const workflow = workflows.find((w) => w.id === selected || w.path.endsWith(`/${selected}`)) ?? null;
410+ const { current, archived } = groupWorkflows(workflows);
411+ const archiveSelected = workflow != null && isArchivedWorkflow(workflow);
401412 useLiveWhile(runs.some((run) => run.status !== "completed"));
402413 const started = actionData && "started" in actionData ? actionData.started : null;
403414 return (
425436 </div>
426437 )}
427438
428− {!workflowsFailed && workflows.length === 0 ? (
439+ {!workflowsFailed && workflows.length === 0 && runs.length === 0 ? (
429440 <div className="grid gap-6 lg:grid-cols-2">
430441 <div className="lg:col-span-2">
431442 <AddCiPrompt owner={params.owner} repo={params.repo} canAdd={canPush} />
455466 <Unavailable what="Workflows" compact />
456467 </div>
457468 )}
458− {workflows.map((w) => (
469+ {current.map((w) => (
459470 <Link
460471 key={w.id}
461472 to={`?workflow=${w.id}`}
479490 {w.state === "disabled" && <span className="ml-auto shrink-0 text-xs text-faint">off</span>}
480491 </Link>
481492 ))}
493+ {archived.length > 0 && (
494+ <details key={archiveSelected ? "selected-archive" : "archive"} open={archiveSelected || undefined} className="group mt-3 border-t border-line pt-3">
495+ <summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2.5 py-2 text-muted hover:bg-raised/60 hover:text-fg [&::-webkit-details-marker]:hidden">
496+ <ChevronRight size={14} className="shrink-0 transition-transform group-open:rotate-90" />
497+ <span className="grow">Archived workflows</span>
498+ <span className="text-xs tabular-nums text-faint">{archived.length}</span>
499+ </summary>
500+ <div className="mt-1 space-y-0.5">
501+ {archived.map((w) => (
502+ <Link key={w.id} to={`?workflow=${w.id}`} aria-current={w.id === workflow?.id ? "page" : undefined}
503+ className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${w.id === workflow?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}>
504+ <Archive size={13} className="shrink-0 text-faint" />
505+ <span className="min-w-0 truncate">{w.name}<span className="block truncate font-mono text-[0.6875rem] text-faint">{w.path}</span></span>
506+ </Link>
507+ ))}
508+ </div>
509+ </details>
510+ )}
482511 </nav>
483512 <div className="min-w-0 space-y-6">
484513 {workflow && <WorkflowHeader workflow={workflow} base={base} member={member} manage={manage} />}
493522 )}
494523 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
495524 {runs.length === 0 ? (
496− <EmptyState title="No runs yet">
497− {workflow?.dispatch ? "Run it by hand, or wait for what starts it." : "Runs appear here when something starts one."}
525+ <EmptyState title={archiveSelected ? "No retained runs" : "No runs yet"}>
526+ {archiveSelected ? "This archived workflow has no runs to display." : workflow?.dispatch ? "Run it by hand, or wait for what starts it." : "Runs appear here when something starts one."}
498527 </EmptyState>
499528 ) : (
500529 <Card asChild className="overflow-hidden">

Comments

Sign in to comment.