diff --git a/plans/README.md b/plans/README.md index 6dadd45..bdce916 100644 --- a/plans/README.md +++ b/plans/README.md @@ -22,3 +22,4 @@ When a plan ships, leave the file in place and update the **Status** line so we - [`sqlite-cache.md`](./sqlite-cache.md) — persistent SQLite cache for queues, hydrated details, comments, and optional diffs. - [`cache-v2.md`](./cache-v2.md) — audit-driven follow-up: diff cache, per-repo metadata persistence, `--cache-info` / `--cache-clear`. - [`comments-pane-redesign.md`](./comments-pane-redesign.md) — living design doc exploring how the Comments pane should render. Multiple styles, fully specced, iterate freely. +- [`actions-view.md`](./actions-view.md) — full-screen Actions view for workflow runs, job graph, logs, and live refresh. diff --git a/plans/actions-view.md b/plans/actions-view.md new file mode 100644 index 0000000..8b39e5b --- /dev/null +++ b/plans/actions-view.md @@ -0,0 +1,45 @@ +# Actions View + +- **Why** + - ghui already shows check rollups, but there is no way to inspect workflow runs, job status, or logs without leaving the TUI. + - PR review flow needs quick insight into failing checks and in-progress pipelines. + +- **What we'd ship** + - A full-screen Actions view opened from PR detail with `a`. + - Workflow run list for the PR head SHA. + - Drill-down to workflow jobs, with an ASCII dependency overview derived from workflow YAML `needs`. + - Drill-down to job logs (scrollable, job-level logs). + - Live refresh while any run is still queued/in-progress. + +- **API / architecture mapping** + - `src/services/GitHubService.ts` + - Extend check GraphQL fragment with `databaseId`, `detailsUrl`, workflow run metadata. + - Add Actions REST calls: + - `listWorkflowRunsForPullRequest(repository, headSha)` + - `getWorkflowRunJobs(repository, runId)` + - `getWorkflowJobLog(repository, jobId)` + - `getWorkflowRunDependencies(repository, runId, headSha)` + - `src/domain.ts` + - Add `WorkflowRun`, `WorkflowJob`, `WorkflowStep`, `WorkflowJobDependency`. + - Extend `CheckItem` with optional workflow linkage metadata. + - `src/App.tsx` + - Add actions-view atoms/state and navigation stack (`runs` -> `jobs` -> `logs`). + - Add live-refresh polling interval for in-flight runs. + - `src/ui/ActionsPane.tsx` + - New full-screen actions UI. + - `src/ui/workflowGraph.ts` + - Render ASCII dependency rows from parsed workflow dependencies. + - `src/keymap/actionsView.ts` + - Key bindings for navigation, open, refresh, back. + +- **Open questions** + - Should we support per-step log filtering (failed-only) in v2? + - Should we open selected job URL directly (if available) instead of run/PR URL fallback? + +- **Out of scope (for v1)** + - Re-run/cancel workflow actions. + - Step-level log folding UI. + - True graph-layout engine beyond the compact ASCII dependency rows. + +- **Status** + - In progress. diff --git a/src/App.tsx b/src/App.tsx index c0319a0..b8042cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,9 @@ import { type PullRequestReviewComment, type RepositoryMergeMethods, type SubmitPullRequestReviewInput, + type WorkflowJob, + type WorkflowJobDependency, + type WorkflowRun, } from "./domain.js" import { allowedMergeMethodList, pullRequestMergeMethods } from "./domain.js" import { formatShortDate, formatTimestamp } from "./date.js" @@ -111,6 +114,8 @@ import { import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHints.js" import { LoadingLogoPane } from "./ui/LoadingLogo.js" import { Divider, Filler, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js" +import { ActionsPane, buildActionsLogRows, parseActionsLogSteps } from "./ui/ActionsPane.js" +import { renderWorkflowGraph, workflowGraphMaxScrollOffset } from "./ui/workflowGraph.js" import { CommandPalette } from "./ui/CommandPalette.js" import { ChangedFilesModal, @@ -175,9 +180,9 @@ const pullRequestPageSize = Math.min(100, parseOptionalPositiveInt(process.env.G const githubServiceLayer = mockPrCount !== null ? (await import("./services/MockGitHubService.js")).MockGitHubService.layer({ - prCount: mockPrCount, - repoCount: parseOptionalPositiveInt(process.env.GHUI_MOCK_REPO_COUNT, 4) ?? 4, - }) + prCount: mockPrCount, + repoCount: parseOptionalPositiveInt(process.env.GHUI_MOCK_REPO_COUNT, 4) ?? 4, + }) : GitHubService.layerNoDeps const cacheServiceLayer = mockPrCount !== null ? CacheService.disabledLayer : CacheService.layerFromPath(config.cachePath) @@ -253,6 +258,7 @@ const DETAIL_PREFETCH_BEHIND = 1 const DETAIL_PREFETCH_AHEAD = 3 const DETAIL_PREFETCH_CONCURRENCY = 3 const DETAIL_PREFETCH_DELAY_MS = 120 +const ACTIONS_LIVE_REFRESH_MS = 5_000 const appendPullRequestPage = (existing: readonly PullRequestItem[], incoming: readonly PullRequestItem[]) => { const seen = new Set(existing.map((pullRequest) => pullRequest.url)) const mergedIncoming = mergeCachedDetails(incoming, existing) @@ -341,7 +347,22 @@ const detailFullViewAtom = Atom.make(false) const detailScrollOffsetAtom = Atom.make(0) const diffFullViewAtom = Atom.make(false) const commentsViewActiveAtom = Atom.make(false) +const actionsViewActiveAtom = Atom.make(false) const commentsViewSelectionAtom = Atom.make(0) +const actionsRunSelectionAtom = Atom.make(0) +const actionsJobSelectionAtom = Atom.make(0) +const actionsStepSelectionAtom = Atom.make(0) +const actionsExpandedStepAtom = Atom.make(null) +const actionsGraphScrollOffsetAtom = Atom.make(0) +const actionsGraphModalVerticalScrollAtom = Atom.make(0) +const actionsLogScrollOffsetAtom = Atom.make(0) +const actionsLogWrapModeAtom = Atom.make(false) +const actionsLogHorizontalScrollAtom = Atom.make(0) +const actionsLogFilterQueryAtom = Atom.make("") +const actionsLogFilterDraftAtom = Atom.make("") +const actionsLogFilterModeAtom = Atom.make(false) +const actionsLogMatchIndexAtom = Atom.make(-1) +const actionsGraphModalActiveAtom = Atom.make(false) const diffFileIndexAtom = Atom.make(0) const diffScrollTopAtom = Atom.make(0) const diffRenderViewAtom = Atom.make("split") @@ -487,6 +508,18 @@ const listPullRequestReviewCommentsAtom = githubRuntime.fn<{ readonly repository const listPullRequestCommentsAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) => GitHubService.use((github) => github.listPullRequestComments(input.repository, input.number)), ) +const listWorkflowRunsForPullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly headSha: string }>()((input) => + GitHubService.use((github) => github.listWorkflowRunsForPullRequest(input.repository, input.headSha)), +) +const getWorkflowRunJobsAtom = githubRuntime.fn<{ readonly repository: string; readonly runId: number }>()((input) => + GitHubService.use((github) => github.getWorkflowRunJobs(input.repository, input.runId)), +) +const getWorkflowJobLogAtom = githubRuntime.fn<{ readonly repository: string; readonly jobId: number }>()((input) => + GitHubService.use((github) => github.getWorkflowJobLog(input.repository, input.jobId)), +) +const getWorkflowRunDependenciesAtom = githubRuntime.fn<{ readonly repository: string; readonly runId: number; readonly headSha: string }>()((input) => + GitHubService.use((github) => github.getWorkflowRunDependencies(input.repository, input.runId, input.headSha)), +) const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) => GitHubService.use((github) => github.getPullRequestMergeInfo(input.repository, input.number)), ) @@ -709,7 +742,22 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const setDetailScrollOffset = useAtomSet(detailScrollOffsetAtom) const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom) const [commentsViewActive, setCommentsViewActive] = useAtom(commentsViewActiveAtom) + const [actionsViewActive, setActionsViewActive] = useAtom(actionsViewActiveAtom) const [commentsViewSelection, setCommentsViewSelection] = useAtom(commentsViewSelectionAtom) + const [actionsRunSelection, setActionsRunSelection] = useAtom(actionsRunSelectionAtom) + const [actionsJobSelection, setActionsJobSelection] = useAtom(actionsJobSelectionAtom) + const [actionsStepSelection, setActionsStepSelection] = useAtom(actionsStepSelectionAtom) + const [actionsExpandedStep, setActionsExpandedStep] = useAtom(actionsExpandedStepAtom) + const [actionsGraphScrollOffset, setActionsGraphScrollOffset] = useAtom(actionsGraphScrollOffsetAtom) + const [actionsGraphModalVerticalScroll, setActionsGraphModalVerticalScroll] = useAtom(actionsGraphModalVerticalScrollAtom) + const [actionsLogScrollOffset, setActionsLogScrollOffset] = useAtom(actionsLogScrollOffsetAtom) + const [actionsLogWrapMode, setActionsLogWrapMode] = useAtom(actionsLogWrapModeAtom) + const [actionsLogHorizontalScroll, setActionsLogHorizontalScroll] = useAtom(actionsLogHorizontalScrollAtom) + const [actionsLogFilterQuery, setActionsLogFilterQuery] = useAtom(actionsLogFilterQueryAtom) + const [actionsLogFilterDraft, setActionsLogFilterDraft] = useAtom(actionsLogFilterDraftAtom) + const [actionsLogFilterMode, setActionsLogFilterMode] = useAtom(actionsLogFilterModeAtom) + const [actionsLogMatchIndex, setActionsLogMatchIndex] = useAtom(actionsLogMatchIndexAtom) + const [actionsGraphModalActive, setActionsGraphModalActive] = useAtom(actionsGraphModalActiveAtom) const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom) const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom) const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom) @@ -754,16 +802,16 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const openRepositoryModal: OpenRepositoryModalState = openRepositoryModalActive ? activeModal : initialOpenRepositoryModalState const makeModalSetter = >(tag: Tag) => - (next: ModalState | ((prev: ModalState) => ModalState)) => - setActiveModal((current) => { - const ctor = Modal[tag] as unknown as (args: ModalState) => Modal - if (typeof next === "function") { - const updater = next as (prev: ModalState) => ModalState - if (current._tag !== tag) return current - return ctor(updater(current as unknown as ModalState)) - } - return ctor(next) - }) + (next: ModalState | ((prev: ModalState) => ModalState)) => + setActiveModal((current) => { + const ctor = Modal[tag] as unknown as (args: ModalState) => Modal + if (typeof next === "function") { + const updater = next as (prev: ModalState) => ModalState + if (current._tag !== tag) return current + return ctor(updater(current as unknown as ModalState)) + } + return ctor(next) + }) const setLabelModal = makeModalSetter("Label") const setCloseModal = makeModalSetter("Close") const setPullRequestStateModal = makeModalSetter("PullRequestState") @@ -791,6 +839,13 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const setRecentlyCompletedPullRequests = useAtomSet(recentlyCompletedPullRequestsAtom) const retryProgress = useAtomValue(retryProgressAtom) const [loadingFrame, setLoadingFrame] = useState(0) + const [actionsLevel, setActionsLevel] = useState<"runs" | "jobs" | "logs">("runs") + const [actionsRuns, setActionsRuns] = useState([]) + const [actionsJobsByRun, setActionsJobsByRun] = useState>({}) + const [actionsDependenciesByRun, setActionsDependenciesByRun] = useState>({}) + const [actionsLogsByJob, setActionsLogsByJob] = useState>({}) + const [actionsLoading, setActionsLoading] = useState(false) + const [actionsError, setActionsError] = useState(null) const [refreshCompletionMessage, setRefreshCompletionMessage] = useState(null) const [refreshStartedAt, setRefreshStartedAt] = useState(null) const [terminalFocused, setTerminalFocused] = useState(true) @@ -804,6 +859,10 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" }) const listPullRequestReviewComments = useAtomSet(listPullRequestReviewCommentsAtom, { mode: "promise" }) const listPullRequestComments = useAtomSet(listPullRequestCommentsAtom, { mode: "promise" }) + const listWorkflowRunsForPullRequest = useAtomSet(listWorkflowRunsForPullRequestAtom, { mode: "promise" }) + const getWorkflowRunJobs = useAtomSet(getWorkflowRunJobsAtom, { mode: "promise" }) + const getWorkflowJobLog = useAtomSet(getWorkflowJobLogAtom, { mode: "promise" }) + const getWorkflowRunDependencies = useAtomSet(getWorkflowRunDependenciesAtom, { mode: "promise" }) const readCachedPullRequest = useAtomSet(readCachedPullRequestAtom, { mode: "promise" }) const writeCachedPullRequest = useAtomSet(writeCachedPullRequestAtom, { mode: "promise" }) const writeQueueCache = useAtomSet(writeQueueCacheAtom, { mode: "promise" }) @@ -834,6 +893,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const dividerJunctionAt = Math.max(1, leftPaneWidth) const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 2) : Math.max(24, contentWidth - sectionPadding * 2) const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2) + const fullscreenContentWidth = Math.max(24, contentWidth - 2) const wideDetailLines = Math.max(8, terminalHeight - 8) const wideBodyHeight = Math.max(8, terminalHeight - 4) const noticeTimeoutRef = useRef | null>(null) @@ -846,8 +906,9 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const terminalFocusedRef = useRef(true) const terminalWasBlurredRef = useRef(false) const pullRequestStatusRef = useRef("loading") - const refreshPullRequestsRef = useRef<(message?: string, options?: { readonly resetTransientState?: boolean }) => void>(() => {}) - const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {}) + const actionsRefreshGenerationRef = useRef(0) + const refreshPullRequestsRef = useRef<(message?: string, options?: { readonly resetTransientState?: boolean }) => void>(() => { }) + const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => { }) const detailScrollRef = useRef(null) const detailPreviewScrollRef = useRef(null) const cachedDetailKeysRef = useRef(new Set()) @@ -932,6 +993,22 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const visibleGroups = useAtomValue(visibleGroupsAtom) const visiblePullRequests = useAtomValue(visiblePullRequestsAtom) const selectedPullRequest = useAtomValue(selectedPullRequestAtom) + const selectedActionsRun = actionsRuns[actionsRunSelection] ?? null + const selectedActionsJobs = selectedActionsRun ? (actionsJobsByRun[selectedActionsRun.id] ?? []) : [] + const selectedActionsDependencies = selectedActionsRun ? (actionsDependenciesByRun[selectedActionsRun.id] ?? []) : [] + const selectedActionsJob = selectedActionsJobs[actionsJobSelection] ?? null + const selectedActionsJobLog = selectedActionsJob ? (actionsLogsByJob[selectedActionsJob.id] ?? "") : "" + const selectedActionLogSteps = useMemo(() => parseActionsLogSteps(selectedActionsJobLog, selectedActionsJob?.steps ?? []), [selectedActionsJobLog, selectedActionsJob?.id]) + const selectedActionLogRows = useMemo( + () => + buildActionsLogRows({ + steps: selectedActionLogSteps, + expandedStepIndex: actionsExpandedStep, + wrapMode: actionsLogWrapMode, + contentWidth: fullscreenContentWidth, + }), + [selectedActionLogSteps, actionsExpandedStep, actionsLogWrapMode, fullscreenContentWidth], + ) const pullRequestComments = useAtomValue(pullRequestCommentsAtom) const pullRequestCommentsLoaded = useAtomValue(pullRequestCommentsLoadedAtom) const selectedRepository = viewRepository(activeView) @@ -1138,7 +1215,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { } }) const viewer = cacheViewerFor(activeView, username) - if (viewer) void writeQueueCache({ viewer, load: persistedLoad }).catch(() => {}) + if (viewer) void writeQueueCache({ viewer, load: persistedLoad }).catch(() => { }) }) .catch((error) => { flashNotice(errorMessage(error)) @@ -1186,7 +1263,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { cachedDetailKeysRef.current.add(detailKey) applyPullRequestDetail(cached) }) - .catch(() => {}) + .catch(() => { }) } const atom = pullRequestDetailsAtom(pullRequestDetailAtomKey(pullRequest)) if (forceRefresh) registry.refresh(atom) @@ -1195,7 +1272,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { if (generation === refreshGenerationRef.current && detailHydrationRef.current.get(detailKey) === entry) { cachedDetailKeysRef.current.delete(detailKey) applyPullRequestDetail(detail) - void writeCachedPullRequest(detail).catch(() => {}) + void writeCachedPullRequest(detail).catch(() => { }) } }) .catch((error) => { @@ -1271,7 +1348,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { // but a session that only browses cached state (or stays offline) never prunes. // Firing once at mount keeps the cache bounded for those sessions. useEffect(() => { - void pruneCache().catch(() => {}) + void pruneCache().catch(() => { }) }, [pruneCache]) useEffect(() => { @@ -1307,6 +1384,16 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { return () => globalThis.clearTimeout(timeout) }, [terminalFocused, pullRequestLoad?.fetchedAt]) + useEffect(() => { + if (!actionsViewActive || !selectedPullRequest) return + const hasInFlight = actionsRuns.some((run) => run.status === "in_progress" || run.status === "queued" || run.status === "pending") + if (!hasInFlight) return + const interval = globalThis.setInterval(() => { + loadActionsRuns(selectedPullRequest) + }, ACTIONS_LIVE_REFRESH_MS) + return () => globalThis.clearInterval(interval) + }, [actionsViewActive, selectedPullRequest?.url, selectedPullRequest?.headRefOid, actionsRuns, actionsRunSelection]) + useEffect(() => { setSelectedIndex((current) => { if (visiblePullRequests.length === 0) return 0 @@ -1325,7 +1412,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { }, [selectedIndex, visiblePullRequests.length, filterMode, filterQuery, hasMorePullRequests, isLoadingMorePullRequests, currentQueueCacheKey]) useEffect(() => { - if (filterMode || filterQuery.length > 0 || visiblePullRequests.length === 0 || detailFullView || diffFullView) return + if (filterMode || filterQuery.length > 0 || visiblePullRequests.length === 0 || detailFullView || diffFullView || commentsViewActive || actionsViewActive) return if (!hasMorePullRequests || isLoadingMorePullRequests) return const checkScroll = () => { const scroll = prListScrollRef.current @@ -1336,7 +1423,18 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { checkScroll() const interval = globalThis.setInterval(checkScroll, 120) return () => globalThis.clearInterval(interval) - }, [visiblePullRequests.length, filterMode, filterQuery, detailFullView, diffFullView, hasMorePullRequests, isLoadingMorePullRequests, currentQueueCacheKey]) + }, [ + visiblePullRequests.length, + filterMode, + filterQuery, + detailFullView, + diffFullView, + commentsViewActive, + actionsViewActive, + hasMorePullRequests, + isLoadingMorePullRequests, + currentQueueCacheKey, + ]) useEffect(() => { const scroll = prListScrollRef.current @@ -1353,9 +1451,51 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { setDiffCommentAnchorIndex(0) setDiffPreferredSide(null) setDiffCommentRangeStartIndex(null) + setActionsRuns([]) + setActionsJobsByRun({}) + setActionsDependenciesByRun({}) + setActionsLogsByJob({}) + setActionsRunSelection(0) + setActionsJobSelection(0) + setActionsStepSelection(0) + setActionsExpandedStep(null) + setActionsGraphScrollOffset(0) + setActionsGraphModalVerticalScroll(0) + setActionsLogScrollOffset(0) + setActionsLogWrapMode(false) + setActionsLogHorizontalScroll(0) + setActionsLogFilterQuery("") + setActionsLogFilterDraft("") + setActionsLogFilterMode(false) + setActionsLogMatchIndex(-1) + setActionsGraphModalActive(false) + setActionsLevel("runs") detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 }) }, [selectedIndex]) + useEffect(() => { + setActionsStepSelection(0) + setActionsExpandedStep(null) + setActionsGraphScrollOffset(0) + setActionsGraphModalVerticalScroll(0) + setActionsLogScrollOffset(0) + setActionsLogWrapMode(false) + setActionsLogHorizontalScroll(0) + setActionsLogFilterQuery("") + setActionsLogFilterDraft("") + setActionsLogFilterMode(false) + setActionsLogMatchIndex(-1) + }, [selectedActionsJob?.id]) + + useEffect(() => { + setActionsLogMatchIndex(-1) + }, [actionsLogFilterQuery]) + + useEffect(() => { + setActionsGraphScrollOffset(0) + setActionsGraphModalVerticalScroll(0) + }, [selectedActionsRun?.id]) + useEffect(() => { setDiffFileIndex((current) => safeDiffFileIndex(readyDiffFiles, current)) }, [readyDiffFiles.length]) @@ -1444,6 +1584,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { pullRequestResult.waiting || isHydratingPullRequestDetails || isLoadingMorePullRequests || + actionsLoading || selectedCommentsStatus === "loading" || labelModal.loading || closeModal.running || @@ -1609,6 +1750,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { setDiffFullView(true) setDetailFullView(false) setCommentsViewActive(false) + setActionsViewActive(false) setDiffFileIndex(0) setDiffScrollTop(0) setDiffCommentAnchorIndex(0) @@ -1625,6 +1767,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { setCommentsViewActive(true) setDetailFullView(false) setDiffFullView(false) + setActionsViewActive(false) setCommentsViewSelection(0) } @@ -1671,6 +1814,342 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { loadPullRequestComments(selectedPullRequest, true) } + const loadActionsRuns = (pullRequest: PullRequestItem, options: { readonly force?: boolean } = {}) => { + const force = options.force ?? false + const generation = ++actionsRefreshGenerationRef.current + if (force) { + setActionsLogsByJob({}) + } + setActionsLoading(true) + setActionsError(null) + void listWorkflowRunsForPullRequest({ repository: pullRequest.repository, headSha: pullRequest.headRefOid }) + .then((runs) => { + if (generation !== actionsRefreshGenerationRef.current) return + setActionsRuns(runs) + setActionsRunSelection((current) => (runs.length === 0 ? 0 : Math.max(0, Math.min(current, runs.length - 1)))) + const selectedRun = runs[Math.max(0, Math.min(actionsRunSelection, runs.length - 1))] + if (!selectedRun) { + setActionsJobsByRun({}) + setActionsDependenciesByRun({}) + setActionsLoading(false) + return + } + return Promise.all([ + getWorkflowRunJobs({ repository: pullRequest.repository, runId: selectedRun.id }), + getWorkflowRunDependencies({ repository: pullRequest.repository, runId: selectedRun.id, headSha: pullRequest.headRefOid }).catch(() => []), + ]).then(([jobs, deps]) => { + if (generation !== actionsRefreshGenerationRef.current) return + setActionsJobsByRun((current) => ({ ...current, [selectedRun.id]: jobs })) + setActionsDependenciesByRun((current) => ({ ...current, [selectedRun.id]: deps })) + setActionsJobSelection((current) => (jobs.length === 0 ? 0 : Math.max(0, Math.min(current, jobs.length - 1)))) + setActionsLoading(false) + }) + }) + .catch((error) => { + if (generation !== actionsRefreshGenerationRef.current) return + setActionsLoading(false) + setActionsError(errorMessage(error)) + }) + } + + const openActionsView = () => { + if (!selectedPullRequest) return + setActionsViewActive(true) + setCommentsViewActive(false) + setDetailFullView(false) + setDiffFullView(false) + setActionsLevel("runs") + setActionsRunSelection(0) + setActionsJobSelection(0) + setActionsStepSelection(0) + setActionsExpandedStep(null) + setActionsGraphScrollOffset(0) + setActionsGraphModalVerticalScroll(0) + setActionsLogScrollOffset(0) + setActionsLogWrapMode(false) + setActionsLogHorizontalScroll(0) + setActionsLogFilterQuery("") + setActionsLogFilterDraft("") + setActionsLogFilterMode(false) + setActionsLogMatchIndex(-1) + setActionsGraphModalActive(false) + loadActionsRuns(selectedPullRequest, { force: true }) + } + + const closeActionsView = () => { + setActionsViewActive(false) + setActionsLevel("runs") + setActionsLogFilterQuery("") + setActionsLogFilterDraft("") + setActionsLogFilterMode(false) + setActionsLogWrapMode(false) + setActionsLogHorizontalScroll(0) + setActionsExpandedStep(null) + setActionsLogMatchIndex(-1) + setActionsGraphModalActive(false) + setActionsGraphModalVerticalScroll(0) + } + + const openSelectedActionInBrowser = () => { + const url = actionsLevel === "runs" ? selectedActionsRun?.url : selectedPullRequest?.url + if (!url) return + void openUrl(url) + .then(() => flashNotice(`Opened ${url}`)) + .catch((error) => flashNotice(errorMessage(error))) + } + + const refreshActionsView = () => { + if (!selectedPullRequest) return + loadActionsRuns(selectedPullRequest, { force: true }) + } + + const confirmActionsSelection = () => { + if (!selectedPullRequest) return + if (actionsLevel === "runs") { + const run = selectedActionsRun + if (!run) return + setActionsLoading(true) + setActionsError(null) + void Promise.all([ + getWorkflowRunJobs({ repository: selectedPullRequest.repository, runId: run.id }), + getWorkflowRunDependencies({ repository: selectedPullRequest.repository, runId: run.id, headSha: selectedPullRequest.headRefOid }).catch(() => []), + ]) + .then(([jobs, deps]) => { + setActionsJobsByRun((current) => ({ ...current, [run.id]: jobs })) + setActionsDependenciesByRun((current) => ({ ...current, [run.id]: deps })) + setActionsJobSelection(0) + setActionsGraphScrollOffset(0) + setActionsLevel("jobs") + setActionsLoading(false) + }) + .catch((error) => { + setActionsLoading(false) + setActionsError(errorMessage(error)) + }) + return + } + if (actionsLevel === "jobs") { + const job = selectedActionsJob + if (!job) return + setActionsLoading(true) + setActionsError(null) + void getWorkflowJobLog({ repository: selectedPullRequest.repository, jobId: job.id }) + .then((log) => { + setActionsLogsByJob((current) => ({ ...current, [job.id]: log })) + const parsed = parseActionsLogSteps(log, job.steps) + const failedStepIndex = parsed.findIndex((step) => step.conclusion === "failure") + const initialStepIndex = failedStepIndex >= 0 ? failedStepIndex : 0 + setActionsExpandedStep(null) + setActionsStepSelection(initialStepIndex) + setActionsLogScrollOffset(0) + setActionsLogWrapMode(false) + setActionsLogHorizontalScroll(0) + setActionsLogFilterQuery("") + setActionsLogFilterDraft("") + setActionsLogFilterMode(false) + setActionsLogMatchIndex(-1) + setActionsLevel("logs") + setActionsLoading(false) + }) + .catch((error) => { + setActionsLoading(false) + setActionsError(errorMessage(error)) + }) + } + } + + const closeOrBackActionsView = () => { + if (actionsGraphModalActive) { + setActionsGraphModalActive(false) + setActionsGraphModalVerticalScroll(0) + return + } + if (actionsLogFilterMode) { + setActionsLogFilterQuery("") + setActionsLogFilterDraft("") + setActionsLogFilterMode(false) + setActionsLogWrapMode(false) + setActionsLogHorizontalScroll(0) + setActionsExpandedStep(null) + setActionsLogMatchIndex(-1) + return + } + if (actionsLevel === "logs") { + setActionsLogFilterQuery("") + setActionsLogFilterDraft("") + setActionsLogFilterMode(false) + setActionsLogWrapMode(false) + setActionsLogHorizontalScroll(0) + setActionsExpandedStep(null) + setActionsLogMatchIndex(-1) + setActionsLevel("jobs") + return + } + if (actionsLevel === "jobs") { + setActionsLevel("runs") + return + } + closeActionsView() + } + + const moveActionsRunSelection = (delta: number) => { + setActionsRunSelection((current) => { + if (actionsRuns.length === 0) return 0 + return Math.max(0, Math.min(actionsRuns.length - 1, current + delta)) + }) + } + + const setActionsRunSelectionIndex = (index: number) => { + setActionsRunSelection(actionsRuns.length === 0 ? 0 : Math.max(0, Math.min(actionsRuns.length - 1, index))) + } + + const moveActionsJobSelection = (delta: number) => { + setActionsJobSelection((current) => { + if (selectedActionsJobs.length === 0) return 0 + return Math.max(0, Math.min(selectedActionsJobs.length - 1, current + delta)) + }) + } + + const setActionsJobSelectionIndex = (index: number) => { + setActionsJobSelection(selectedActionsJobs.length === 0 ? 0 : Math.max(0, Math.min(selectedActionsJobs.length - 1, index))) + } + + const actionsGraphModalViewportMetrics = () => { + const modalWidth = Math.max(30, contentWidth - 4) + const modalContentWidth = Math.max(1, modalWidth - 2) + const modalGraph = renderWorkflowGraph({ + dependencies: selectedActionsDependencies, + jobs: selectedActionsJobs, + contentWidth: modalContentWidth, + scrollOffset: actionsGraphScrollOffset, + }) + const modalHeight = Math.min(Math.max(5, modalGraph.length + 4), wideBodyHeight - 4) + const modalInnerHeight = Math.max(1, modalHeight - 2) + const modalTitleRows = selectedActionsRun ? 1 : 0 + const modalDividerRows = selectedActionsRun ? 1 : 0 + const visible = Math.max(1, modalInnerHeight - modalTitleRows - modalDividerRows) + const max = Math.max(0, modalGraph.length - visible) + return { visible, max } + } + + const actionsLogViewportMetrics = () => { + const rows = selectedActionLogRows + const selectedJobRows = selectedActionsJob ? 1 : 0 + const showFilterBar = actionsLogFilterMode || actionsLogFilterQuery.length > 0 + const filterBarRows = showFilterBar ? 1 : 0 + const visible = Math.max(1, wideBodyHeight - 2 - selectedJobRows - filterBarRows) + const max = Math.max(0, rows.length - visible) + return { rows, visible, max } + } + + const jumpActionsLogMatch = (direction: 1 | -1) => { + if (actionsLevel !== "logs") return + if (actionsLogFilterMode) return + if (actionsExpandedStep === null) return + const query = actionsLogFilterQuery.trim().toLowerCase() + if (query.length === 0) return + const { rows, visible, max } = actionsLogViewportMetrics() + if (rows.length === 0) return + const matches = rows + .map((row, index) => (row.kind === "line" && row.stepIndex === actionsExpandedStep && row.line.toLowerCase().includes(query) ? index : -1)) + .filter((index) => index >= 0) + if (matches.length === 0) return + const center = Math.max(0, Math.min(max, actionsLogScrollOffset)) + Math.floor(visible / 2) + let anchorIndex = actionsLogMatchIndex + if (anchorIndex < 0 || anchorIndex >= matches.length) { + anchorIndex = 0 + let closestDistance = Math.abs(matches[0]! - center) + for (let index = 1; index < matches.length; index++) { + const distance = Math.abs(matches[index]! - center) + if (distance < closestDistance) { + closestDistance = distance + anchorIndex = index + } + } + } + const nextIndex = (((anchorIndex + direction) % matches.length) + matches.length) % matches.length + const nextMatch = matches[nextIndex]! + const centeredTop = Math.max(0, Math.min(max, nextMatch - Math.floor(visible / 2))) + setActionsLogMatchIndex(nextIndex) + setActionsStepSelection(nextMatch) + setActionsLogScrollOffset(centeredTop) + } + + const moveActionsStepSelection = (delta: number) => { + if (actionsLevel === "jobs") { + const max = workflowGraphMaxScrollOffset({ dependencies: selectedActionsDependencies, jobs: selectedActionsJobs, contentWidth: fullscreenContentWidth }) + setActionsGraphScrollOffset((current) => Math.max(0, Math.min(max, current + delta * 6))) + return + } + if (actionsLevel !== "logs") return + const { rows, visible, max } = actionsLogViewportMetrics() + setActionsStepSelection((current) => { + if (rows.length === 0) return 0 + const next = Math.max(0, Math.min(rows.length - 1, current + delta)) + if (next !== current) { + setActionsLogMatchIndex(-1) + setActionsLogScrollOffset((scroll) => { + if (next < scroll) return next + if (next >= scroll + visible) return Math.max(0, Math.min(max, next - visible + 1)) + return Math.max(0, Math.min(max, scroll)) + }) + } + return next + }) + } + + const stepHeaderRowIndex = (stepIndex: number, expandedStepIndex: number | null) => { + let row = 0 + for (let index = 0; index < selectedActionLogSteps.length; index++) { + if (index === stepIndex) return row + row += 1 + if (expandedStepIndex === index) row += selectedActionLogSteps[index]?.lines.length ?? 0 + } + return 0 + } + + const collapseActionsStep = () => { + if (actionsLevel === "jobs") { + const max = workflowGraphMaxScrollOffset({ dependencies: selectedActionsDependencies, jobs: selectedActionsJobs, contentWidth: fullscreenContentWidth }) + setActionsGraphScrollOffset((current) => Math.max(0, Math.min(max, current - 6))) + return + } + if (actionsLevel !== "logs") return + const row = selectedActionLogRows[Math.max(0, Math.min(selectedActionLogRows.length - 1, actionsStepSelection))] + if (!row) { + setActionsLevel("jobs") + return + } + if (row.kind === "line") { + const headerIndex = stepHeaderRowIndex(row.stepIndex, actionsExpandedStep) + setActionsStepSelection(headerIndex) + setActionsExpandedStep(null) + setActionsLogMatchIndex(-1) + setActionsLogScrollOffset((current) => Math.min(current, headerIndex)) + return + } + if (row.expanded) { + setActionsExpandedStep(null) + setActionsLogMatchIndex(-1) + return + } + closeOrBackActionsView() + } + + const expandActionsStep = () => { + if (actionsLevel === "jobs") { + const max = workflowGraphMaxScrollOffset({ dependencies: selectedActionsDependencies, jobs: selectedActionsJobs, contentWidth: fullscreenContentWidth }) + setActionsGraphScrollOffset((current) => Math.max(0, Math.min(max, current + 6))) + return + } + if (actionsLevel !== "logs") return + const row = selectedActionLogRows[Math.max(0, Math.min(selectedActionLogRows.length - 1, actionsStepSelection))] + if (!row || row.kind !== "step") return + setActionsExpandedStep(row.stepIndex) + setActionsStepSelection(stepHeaderRowIndex(row.stepIndex, row.stepIndex)) + setActionsLogMatchIndex(-1) + } + const setDiffRenderableRef = (index: number, diff: DiffRenderable | null) => { if (diff) diffRenderableRefs.current.set(index, diff) else diffRenderableRefs.current.delete(index) @@ -2812,6 +3291,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { detailFullView, diffFullView, commentsViewActive, + actionsViewActive, hasSelectedComment: selectedCommentsStatus === "ready" && selectedOrderedComment !== null, canEditSelectedComment: canEditComment(selectedOrderedComment), diffReady: selectedDiffState?._tag === "Ready", @@ -2842,6 +3322,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { switchViewTo, openDetails: () => { setDetailFullView(true) + setActionsViewActive(false) setDetailScrollOffset(0) }, closeDetails: () => { @@ -2855,6 +3336,10 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { }, openCommentsView, closeCommentsView, + openActionsView, + closeActionsView, + refreshActionsView, + openSelectedActionInBrowser, openNewIssueCommentModal, openReplyToSelectedComment, openEditSelectedComment, @@ -2918,11 +3403,11 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { // user's typed input so they shouldn't be filtered by fuzzy score against themselves. const staticPaletteCommands = commandPaletteActive ? filterCommands( - appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), - commandPalette.query, - ) + appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), + commandPalette.query, + ) : [] - const activePaletteScope: CommandScope | null = commentsViewActive ? "Comments" : diffFullView ? "Diff" : detailFullView ? "Pull request" : null + const activePaletteScope: CommandScope | null = actionsViewActive ? "Actions" : commentsViewActive ? "Comments" : diffFullView ? "Diff" : detailFullView ? "Pull request" : null const commandPaletteCommands = commandPaletteActive ? [...dynamicPaletteCommands, ...(commandPalette.query.trim().length > 0 ? staticPaletteCommands : sortCommandsByActiveScope(staticPaletteCommands, activePaletteScope))] : [] @@ -3030,6 +3515,10 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { closeActiveModal() return } + if (actionsViewActive) { + closeOrBackActionsView() + return + } runCommandById("app.quit") } @@ -3051,6 +3540,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { diffFullView, detailFullView, commentsViewActive, + actionsViewActive, textInputActive: commentModalActive || commandPaletteActive || @@ -3058,6 +3548,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { changedFilesModalActive || submitReviewModalActive || labelModalActive || + actionsLogFilterMode || filterMode || (themeModalActive && themeModal.filterMode), closeModal: { @@ -3205,6 +3696,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { closeDetail: () => runCommandById("detail.close"), openTheme: () => runCommandById("theme.open"), openDiff: () => runCommandById("diff.open"), + openActions: () => runCommandById("actions.open"), openComments: () => runCommandById("comments.open"), closePullRequest: () => runCommandById("pull.close"), openLabels: () => runCommandById("pull.labels"), @@ -3229,6 +3721,94 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { editSelected: () => runCommandById("comments.edit"), deleteSelected: () => runCommandById("comments.delete"), }, + actionsView: { + halfPage, + scrollBy: (delta) => { + if (actionsGraphModalActive) { + const { max } = actionsGraphModalViewportMetrics() + setActionsGraphModalVerticalScroll((current) => Math.max(0, Math.min(max, current + delta))) + return + } + if (actionsLevel === "runs") moveActionsRunSelection(delta) + else if (actionsLevel === "jobs") moveActionsJobSelection(delta) + else moveActionsStepSelection(delta) + }, + scrollTo: (line) => { + if (actionsGraphModalActive) { + const { max } = actionsGraphModalViewportMetrics() + setActionsGraphModalVerticalScroll(Math.max(0, Math.min(max, line))) + return + } + if (actionsLevel === "runs") setActionsRunSelectionIndex(line) + else if (actionsLevel === "jobs") setActionsJobSelectionIndex(line) + else { + const { rows, max } = actionsLogViewportMetrics() + const clamped = rows.length === 0 ? 0 : Math.max(0, Math.min(rows.length - 1, line)) + setActionsStepSelection(clamped) + setActionsLogScrollOffset(Math.max(0, Math.min(max, clamped))) + setActionsLogMatchIndex(-1) + } + }, + closeOrBack: closeOrBackActionsView, + confirmSelection: () => { + if (actionsGraphModalActive) return + confirmActionsSelection() + }, + refresh: refreshActionsView, + openInBrowser: openSelectedActionInBrowser, + stepBy: (delta) => { + if (delta < 0) collapseActionsStep() + else expandActionsStep() + }, + toggleWrap: () => { + if (actionsLevel !== "logs") return + setActionsLogWrapMode((current) => !current) + setActionsLogHorizontalScroll(0) + setActionsLogMatchIndex(-1) + setActionsLogScrollOffset(0) + }, + scrollHorizontal: (delta, halfPage = false) => { + if (actionsLevel !== "logs") return + if (actionsLogWrapMode) return + const amount = halfPage ? Math.max(1, Math.floor(fullscreenContentWidth / 2)) : Math.abs(delta) + const signed = delta < 0 ? -amount : amount + setActionsLogHorizontalScroll((current) => Math.max(0, current + signed)) + }, + enterFilter: () => { + if (actionsLevel !== "logs") return + setActionsLogFilterDraft(actionsLogFilterQuery) + setActionsLogFilterMode(true) + }, + applyFilter: () => { + setActionsLogFilterQuery(actionsLogFilterDraft) + setActionsLogFilterMode(false) + setActionsLogMatchIndex(-1) + }, + cancelFilter: () => { + setActionsLogFilterQuery("") + setActionsLogFilterDraft("") + setActionsLogFilterMode(false) + setActionsLogMatchIndex(-1) + }, + filterActive: actionsLogFilterMode, + canFilterLogs: actionsLevel === "logs", + toggleGraphModal: () => { + setActionsGraphModalActive((current) => { + if (current) setActionsGraphModalVerticalScroll(0) + return !current + }) + }, + canShowGraph: actionsLevel === "jobs", + jumpToNextMatch: () => { + if (actionsGraphModalActive) return + jumpActionsLogMatch(1) + }, + jumpToPreviousMatch: () => { + if (actionsGraphModalActive) return + jumpActionsLogMatch(-1) + }, + hasFilterQuery: actionsLevel === "logs" && actionsLogFilterQuery.trim().length > 0, + }, listNav: { halfPage, visibleCount: visiblePullRequests.length, @@ -3326,6 +3906,13 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { return } + if (actionsLogFilterMode) { + if (isSingleLineInputKey(key)) { + setActionsLogFilterDraft((current) => editSingleLineInput(current, key) ?? current) + } + return + } + if (filterMode) { if (isSingleLineInputKey(key)) { setFilterDraft((current) => editSingleLineInput(current, key) ?? current) @@ -3341,7 +3928,6 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { ) } - const fullscreenContentWidth = Math.max(24, contentWidth - 2) const fullscreenBodyLines = Math.max(8, terminalHeight - 8) const fullscreenDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, contentWidth, isWideLayout, selectedComments, selectedCommentsStatus) const fullscreenDetailBodyViewportHeight = Math.max(1, wideBodyHeight - fullscreenDetailHeaderHeight) @@ -3364,12 +3950,12 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows({ - pullRequest: selectedPullRequest, - paneWidth: rightPaneWidth, - showChecks: true, - comments: selectedComments, - commentsStatus: selectedCommentsStatus, - }) + pullRequest: selectedPullRequest, + paneWidth: rightPaneWidth, + showChecks: true, + comments: selectedComments, + commentsStatus: selectedCommentsStatus, + }) const prListProps = { groups: visibleGroups, @@ -3475,12 +4061,41 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { - {isWideLayout && !detailFullView && !diffFullView && !commentsViewActive ? ( + {isWideLayout && !detailFullView && !diffFullView && !commentsViewActive && !actionsViewActive ? ( ) : ( )} - {commentsViewActive && selectedPullRequest ? ( + {actionsViewActive && selectedPullRequest ? ( + + ) : commentsViewActive && selectedPullRequest ? ( { )} - {isWideLayout && !detailFullView && !diffFullView && !commentsViewActive ? ( + {isWideLayout && !detailFullView && !diffFullView && !commentsViewActive && !actionsViewActive ? ( ) : ( @@ -3703,12 +4318,16 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { ) : ( 0} + filterEditing={filterMode || (actionsViewActive && actionsLevel === "logs" && actionsLogFilterMode)} + showFilterClear={filterMode || filterQuery.length > 0 || (actionsViewActive && actionsLevel === "logs" && (actionsLogFilterMode || actionsLogFilterQuery.length > 0))} detailFullView={detailFullView} diffFullView={diffFullView} diffRangeActive={diffCommentRangeActive} commentsViewActive={commentsViewActive} + actionsViewActive={actionsViewActive} + actionsLevel={actionsLevel} + actionsLogFilterActive={actionsViewActive && actionsLevel === "logs" && actionsLogFilterMode} + actionsLogHasQuery={actionsViewActive && actionsLevel === "logs" && actionsLogFilterQuery.trim().length > 0} commentsViewOnRealComment={commentsViewActive && selectedCommentsStatus === "ready" && selectedOrderedComment !== null} commentsViewCanEditSelected={canEditComment(selectedOrderedComment)} commentsViewCount={selectedComments.length} @@ -3718,6 +4337,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { pullRequestStatus === "loading" || isRefreshingPullRequests || isHydratingPullRequestDetails || + actionsLoading || closeModal.running || pullRequestStateModal.running || mergeModal.running || diff --git a/src/appCommands.ts b/src/appCommands.ts index abb6557..44479f0 100644 --- a/src/appCommands.ts +++ b/src/appCommands.ts @@ -19,6 +19,10 @@ interface AppCommandActions { readonly closeDiffView: () => void readonly openCommentsView: () => void readonly closeCommentsView: () => void + readonly openActionsView: () => void + readonly closeActionsView: () => void + readonly refreshActionsView: () => void + readonly openSelectedActionInBrowser: () => void readonly openNewIssueCommentModal: () => void readonly openReplyToSelectedComment: () => void readonly openEditSelectedComment: () => void @@ -57,6 +61,7 @@ interface BuildAppCommandsInput { readonly detailFullView: boolean readonly diffFullView: boolean readonly commentsViewActive: boolean + readonly actionsViewActive: boolean readonly hasSelectedComment: boolean readonly canEditSelectedComment: boolean readonly diffReady: boolean @@ -86,6 +91,7 @@ export const buildAppCommands = ({ detailFullView, diffFullView, commentsViewActive, + actionsViewActive, hasSelectedComment, canEditSelectedComment, diffReady, @@ -227,6 +233,41 @@ export const buildAppCommands = ({ keywords: ["conversation", "discussion", "review"], run: actions.openCommentsView, }), + forSelected({ + id: "actions.open", + title: "Open actions", + scope: "Actions", + shortcut: "a", + keywords: ["checks", "workflows", "jobs", "logs"], + run: actions.openActionsView, + }), + defineCommand({ + id: "actions.close", + title: "Close actions view", + scope: "Actions", + subtitle: "Return to pull request detail", + shortcut: "esc", + disabledReason: actionsViewActive ? null : "Actions view is not open.", + run: actions.closeActionsView, + }), + defineCommand({ + id: "actions.refresh", + title: "Refresh actions", + scope: "Actions", + subtitle: selectedPullRequestLabel, + shortcut: "r", + disabledReason: actionsViewActive && selectedPullRequest ? null : "Open actions first.", + run: actions.refreshActionsView, + }), + defineCommand({ + id: "actions.open-browser", + title: "Open selected action in browser", + scope: "Actions", + subtitle: selectedPullRequestLabel, + shortcut: "o", + disabledReason: actionsViewActive && selectedPullRequest ? null : "Open actions first.", + run: actions.openSelectedActionInBrowser, + }), forSelected({ id: "comments.new", title: "New comment", diff --git a/src/commands.ts b/src/commands.ts index a62cc23..fc247dc 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,6 @@ -export type CommandScope = "Global" | "View" | "Pull request" | "Diff" | "Comments" | "Navigation" | "System" +export type CommandScope = "Global" | "View" | "Pull request" | "Diff" | "Comments" | "Actions" | "Navigation" | "System" -const SCOPE_ORDER: readonly CommandScope[] = ["Global", "View", "Pull request", "Diff", "Comments", "Navigation", "System"] +const SCOPE_ORDER: readonly CommandScope[] = ["Global", "View", "Pull request", "Diff", "Comments", "Actions", "Navigation", "System"] export const sortCommandsByScope = (commands: readonly AppCommand[]) => [...commands].sort((left, right) => SCOPE_ORDER.indexOf(left.scope) - SCOPE_ORDER.indexOf(right.scope)) diff --git a/src/domain.ts b/src/domain.ts index 9a8c0ba..c7ead04 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -79,6 +79,46 @@ export interface CheckItem { readonly name: string readonly status: CheckRunStatus readonly conclusion: CheckConclusion | null + readonly databaseId?: number + readonly detailsUrl?: string | null + readonly workflowRunId?: number + readonly workflowName?: string | null +} + +export interface WorkflowStep { + readonly number: number + readonly name: string + readonly status: CheckRunStatus + readonly conclusion: CheckConclusion | null +} + +export interface WorkflowJob { + readonly id: number + readonly name: string + readonly status: CheckRunStatus + readonly conclusion: CheckConclusion | null + readonly startedAt: Date | null + readonly completedAt: Date | null + readonly steps: readonly WorkflowStep[] +} + +export interface WorkflowRun { + readonly id: number + readonly name: string + readonly status: CheckRunStatus + readonly conclusion: CheckConclusion | null + readonly url: string + readonly event: string + readonly branch: string + readonly createdAt: Date | null + readonly updatedAt: Date | null + readonly jobs: readonly WorkflowJob[] +} + +export interface WorkflowJobDependency { + readonly id: string + readonly name: string + readonly needs: readonly string[] } export interface PullRequestLabel { diff --git a/src/keymap/actionsView.ts b/src/keymap/actionsView.ts new file mode 100644 index 0000000..867de6b --- /dev/null +++ b/src/keymap/actionsView.ts @@ -0,0 +1,56 @@ +import { context, type Scrollable, scrollCommands } from "@ghui/keymap" + +export interface ActionsViewCtx extends Scrollable { + readonly closeOrBack: () => void + readonly confirmSelection: () => void + readonly refresh: () => void + readonly openInBrowser: () => void + readonly stepBy: (delta: number) => void + readonly toggleWrap: () => void + readonly scrollHorizontal: (delta: number, halfPage?: boolean) => void + readonly enterFilter: () => void + readonly applyFilter: () => void + readonly cancelFilter: () => void + readonly filterActive: boolean + readonly canFilterLogs: boolean + readonly toggleGraphModal: () => void + readonly canShowGraph: boolean + readonly jumpToNextMatch: () => void + readonly jumpToPreviousMatch: () => void + readonly hasFilterQuery: boolean +} + +const Actions = context() + +export const actionsViewKeymap = Actions( + scrollCommands(), + { id: "actions.previous-step", title: "Collapse step", keys: ["left", "h"], run: (s) => s.stepBy(-1) }, + { id: "actions.next-step", title: "Expand step", keys: ["right", "l"], run: (s) => s.stepBy(1) }, + { id: "actions.toggle-wrap", title: "Toggle log wrap", keys: ["w"], when: (s) => s.canFilterLogs && !s.filterActive, run: (s) => s.toggleWrap() }, + { id: "actions.scroll-horizontal-left", title: "Scroll log left", keys: ["z h"], when: (s) => s.canFilterLogs && !s.filterActive, run: (s) => s.scrollHorizontal(-4) }, + { id: "actions.scroll-horizontal-right", title: "Scroll log right", keys: ["z l"], when: (s) => s.canFilterLogs && !s.filterActive, run: (s) => s.scrollHorizontal(4) }, + { + id: "actions.scroll-horizontal-half-left", + title: "Scroll log half left", + keys: ["z shift+h"], + when: (s) => s.canFilterLogs && !s.filterActive, + run: (s) => s.scrollHorizontal(-1, true), + }, + { + id: "actions.scroll-horizontal-half-right", + title: "Scroll log half right", + keys: ["z shift+l"], + when: (s) => s.canFilterLogs && !s.filterActive, + run: (s) => s.scrollHorizontal(1, true), + }, + { id: "actions.filter", title: "Filter logs", keys: ["/"], when: (s) => s.canFilterLogs && !s.filterActive, run: (s) => s.enterFilter() }, + { id: "actions.cancel-filter", title: "Cancel log filter", keys: ["escape"], when: (s) => s.filterActive, run: (s) => s.cancelFilter() }, + { id: "actions.apply-filter", title: "Apply log filter", keys: ["return"], when: (s) => s.filterActive, run: (s) => s.applyFilter() }, + { id: "actions.next-match", title: "Next match", keys: ["n"], when: (s) => s.hasFilterQuery && !s.filterActive, run: (s) => s.jumpToNextMatch() }, + { id: "actions.previous-match", title: "Previous match", keys: ["shift+n"], when: (s) => s.hasFilterQuery && !s.filterActive, run: (s) => s.jumpToPreviousMatch() }, + { id: "actions.close", title: "Back / close actions", keys: ["escape", "a"], when: (s) => !s.filterActive, run: (s) => s.closeOrBack() }, + { id: "actions.confirm", title: "Open selected action", keys: ["return"], when: (s) => !s.filterActive, run: (s) => s.confirmSelection() }, + { id: "actions.refresh", title: "Refresh actions", keys: ["r"], when: (s) => !s.filterActive, run: (s) => s.refresh() }, + { id: "actions.open-browser", title: "Open in browser", keys: ["o"], when: (s) => !s.filterActive, run: (s) => s.openInBrowser() }, + { id: "actions.graph-modal", title: "Show graph", keys: ["s"], when: (s) => s.canShowGraph && !s.filterActive, run: (s) => s.toggleGraphModal() }, +) diff --git a/src/keymap/all.ts b/src/keymap/all.ts index 74a8635..316861f 100644 --- a/src/keymap/all.ts +++ b/src/keymap/all.ts @@ -1,5 +1,6 @@ import { context } from "@ghui/keymap" import { changedFilesModalKeymap, type ChangedFilesModalCtx } from "./changedFilesModal.ts" +import { actionsViewKeymap, type ActionsViewCtx } from "./actionsView.ts" import { closeModalKeymap, type CloseModalCtx } from "./closeModal.ts" import { commandPaletteKeymap, type CommandPaletteCtx } from "./commandPalette.ts" import { commentModalKeymap, type CommentModalCtx } from "./commentModal.ts" @@ -35,6 +36,7 @@ export interface AppCtx { readonly diffFullView: boolean readonly detailFullView: boolean readonly commentsViewActive: boolean + readonly actionsViewActive: boolean // True whenever a modal/mode swallows raw text input (so q-quit, etc. are // disabled inside text-editing contexts). @@ -57,6 +59,7 @@ export interface AppCtx { readonly diff: DiffViewCtx readonly detail: DetailViewCtx readonly commentsView: CommentsViewCtx + readonly actionsView: ActionsViewCtx readonly listNav: ListNavCtx // Always-on / app-level @@ -80,7 +83,7 @@ const modalActive = (a: AppCtx): boolean => a.deleteCommentModalActive || a.commandPaletteActive -const inListMode = (a: AppCtx): boolean => !modalActive(a) && !a.filterMode && !a.diffFullView && !a.detailFullView && !a.commentsViewActive +const inListMode = (a: AppCtx): boolean => !modalActive(a) && !a.filterMode && !a.diffFullView && !a.detailFullView && !a.commentsViewActive && !a.actionsViewActive export const appKeymap = App( // Always-on: command palette opener @@ -121,6 +124,7 @@ export const appKeymap = App( diffViewKeymap.scope((a) => a.diffFullView && !modalActive(a) && a.diff), detailViewKeymap.scope((a) => a.detailFullView && !modalActive(a) && a.detail), commentsViewKeymap.scope((a) => a.commentsViewActive && !modalActive(a) && a.commentsView), + actionsViewKeymap.scope((a) => a.actionsViewActive && !modalActive(a) && a.actionsView), // PR list nav listNavKeymap.scope((a) => inListMode(a) && a.listNav), diff --git a/src/keymap/detailView.ts b/src/keymap/detailView.ts index 8840375..ce52da5 100644 --- a/src/keymap/detailView.ts +++ b/src/keymap/detailView.ts @@ -6,6 +6,7 @@ export interface DetailViewCtx extends Scrollable { readonly openDiff: () => void readonly openComments: () => void readonly openReview: () => void + readonly openActions: () => void readonly closePullRequest: () => void readonly openLabels: () => void readonly openMerge: () => void @@ -22,6 +23,7 @@ export const detailViewKeymap = Detail( { id: "detail.close", title: "Close detail", keys: ["escape", "return"], run: (s) => s.closeDetail() }, { id: "detail.theme", title: "Open theme", keys: ["t"], run: (s) => s.openTheme() }, { id: "detail.diff", title: "Open diff", keys: ["d"], run: (s) => s.openDiff() }, + { id: "detail.actions", title: "Open actions", keys: ["a"], run: (s) => s.openActions() }, { id: "detail.comments", title: "Open comments", keys: ["c"], run: (s) => s.openComments() }, { id: "detail.review", title: "Review pull request", keys: ["shift+r"], run: (s) => s.openReview() }, { id: "detail.close-pr", title: "Close pull request", keys: ["x"], run: (s) => s.closePullRequest() }, diff --git a/src/services/GitHubService.ts b/src/services/GitHubService.ts index 9525b1f..98428c5 100644 --- a/src/services/GitHubService.ts +++ b/src/services/GitHubService.ts @@ -4,6 +4,8 @@ import { DiffCommentSide, pullRequestQueueSearchQualifier, type CheckItem, + type CheckRunStatus, + type CheckConclusion, type CreatePullRequestCommentInput, type ListPullRequestPageInput, type Mergeable, @@ -17,6 +19,9 @@ import { type RepositoryMergeMethods, type ReviewStatus, type SubmitPullRequestReviewInput, + type WorkflowJob, + type WorkflowJobDependency, + type WorkflowRun, } from "../domain.js" import { mergeActionCliArgs } from "../mergeActions.js" import { CommandError, CommandRunner, type JsonParseError } from "./CommandRunner.js" @@ -31,6 +36,22 @@ const RawCheckContextSchema = Schema.Union([ name: OptionalNullableString, status: OptionalNullableString, conclusion: OptionalNullableString, + databaseId: OptionalNullableNumber, + detailsUrl: OptionalNullableString, + checkSuite: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + workflowRun: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + databaseId: OptionalNullableNumber, + workflow: Schema.optionalKey(Schema.NullOr(Schema.Struct({ name: OptionalNullableString }))), + }), + ), + ), + }), + ), + ), }), Schema.Struct({ __typename: Schema.tag("StatusContext"), @@ -186,6 +207,51 @@ const RepoLabelsResponseSchema = Schema.Array( }), ) +const WorkflowRunsResponseSchema = Schema.Struct({ + workflow_runs: Schema.Array( + Schema.Struct({ + id: Schema.Number, + name: OptionalNullableString, + status: OptionalNullableString, + conclusion: OptionalNullableString, + html_url: Schema.String, + event: OptionalNullableString, + head_branch: OptionalNullableString, + created_at: OptionalNullableString, + updated_at: OptionalNullableString, + }), + ), +}) + +const WorkflowRunJobsResponseSchema = Schema.Struct({ + jobs: Schema.Array( + Schema.Struct({ + id: Schema.Number, + name: OptionalNullableString, + status: OptionalNullableString, + conclusion: OptionalNullableString, + started_at: OptionalNullableString, + completed_at: OptionalNullableString, + steps: Schema.optionalKey( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + number: Schema.Number, + name: OptionalNullableString, + status: OptionalNullableString, + conclusion: OptionalNullableString, + }), + ), + ), + ), + }), + ), +}) + +const WorkflowRunInfoResponseSchema = Schema.Struct({ + path: OptionalNullableString, +}) + type RawPullRequestSummaryNode = Schema.Schema.Type type RawPullRequestNode = Schema.Schema.Type type RawCheckContext = Schema.Schema.Type @@ -217,7 +283,19 @@ const STATUS_CHECK_FRAGMENT = ` contexts(first: 100) { nodes { __typename - ... on CheckRun { name status conclusion } + ... on CheckRun { + name + status + conclusion + databaseId + detailsUrl + checkSuite { + workflowRun { + databaseId + workflow { name } + } + } + } ... on StatusContext { context state } } } @@ -345,6 +423,10 @@ const normalizeCheckStatus = (raw: string | null | undefined): CheckItem["status const normalizeCheckConclusion = (raw: string | null | undefined): CheckItem["conclusion"] => (raw ? (CHECK_CONCLUSION_BY_RAW[raw] ?? null) : null) +const toCheckStatus = (raw: string | null | undefined): CheckRunStatus => (raw ? (CHECK_STATUS_BY_RAW[raw.toUpperCase()] ?? "pending") : "pending") + +const toCheckConclusion = (raw: string | null | undefined): CheckConclusion | null => (raw ? (CHECK_CONCLUSION_BY_RAW[raw.toUpperCase()] ?? null) : null) + const getContextStatus = (context: RawCheckContext): CheckItem["status"] => RawCheckContextSchema.match(context, { CheckRun: (run) => normalizeCheckStatus(run.status), @@ -379,7 +461,19 @@ const getCheckInfoFromContexts = (contexts: readonly RawCheckContext[]): Pick(response: readonly Item[] | readonly (readonl const parsePullRequestFiles = (response: Schema.Schema.Type): readonly RawPullRequestFile[] => flattenSlurpedPages(response) +const parseWorkflowRunJobs = (response: Schema.Schema.Type): readonly WorkflowJob[] => + response.jobs.map((job) => ({ + id: job.id, + name: job.name ?? `job-${job.id}`, + status: toCheckStatus(job.status), + conclusion: toCheckConclusion(job.conclusion), + startedAt: normalizeDate(job.started_at), + completedAt: normalizeDate(job.completed_at), + steps: (job.steps ?? []).map((step) => ({ + number: step.number, + name: step.name ?? `step-${step.number}`, + status: toCheckStatus(step.status), + conclusion: toCheckConclusion(step.conclusion), + })), + })) + +const parseWorkflowRuns = (response: Schema.Schema.Type): readonly WorkflowRun[] => + response.workflow_runs.map((run) => ({ + id: run.id, + name: run.name ?? `run-${run.id}`, + status: toCheckStatus(run.status), + conclusion: toCheckConclusion(run.conclusion), + url: run.html_url, + event: run.event ?? "pull_request", + branch: run.head_branch ?? "", + createdAt: normalizeDate(run.created_at), + updatedAt: normalizeDate(run.updated_at), + jobs: [], + })) + +const parseWorkflowDependenciesFromYaml = (source: string): readonly WorkflowJobDependency[] => { + const lines = source.replace(/\t/g, " ").split("\n") + const jobsIndex = lines.findIndex((line) => /^jobs:\s*(#.*)?$/.test(line.trimEnd())) + if (jobsIndex < 0) return [] + + const dependencies: WorkflowJobDependency[] = [] + let currentId: string | null = null + let currentName: string | null = null + let currentNeeds: string[] = [] + + const flush = () => { + if (!currentId) return + dependencies.push({ id: currentId, name: currentName ?? currentId, needs: [...new Set(currentNeeds)] }) + currentId = null + currentName = null + currentNeeds = [] + } + + const parseInlineNeeds = (value: string): string[] => { + const trimmed = value.trim() + if (trimmed.startsWith("[")) { + return trimmed + .replace(/^\[/, "") + .replace(/\]$/, "") + .split(",") + .map((entry) => entry.trim().replace(/^['"]|['"]$/g, "")) + .filter((entry) => entry.length > 0) + } + return trimmed.length > 0 ? [trimmed.replace(/^['"]|['"]$/g, "")] : [] + } + + for (let index = jobsIndex + 1; index < lines.length; index++) { + const raw = lines[index] ?? "" + if (raw.trim().length === 0 || raw.trimStart().startsWith("#")) continue + const indent = raw.length - raw.trimStart().length + if (indent < 2) break + + const trimmed = raw.trim() + const jobMatch = /^([A-Za-z0-9_.-]+):\s*(?:#.*)?$/.exec(trimmed) + if (indent === 2 && jobMatch) { + flush() + currentId = jobMatch[1] ?? null + continue + } + + if (!currentId) continue + + if (indent >= 4) { + const nameMatch = /^name:\s*(.+)\s*$/.exec(trimmed) + if (nameMatch) { + currentName = nameMatch[1]?.trim().replace(/^['"]|['"]$/g, "") ?? null + continue + } + + const needsMatch = /^needs:\s*(.*)$/.exec(trimmed) + if (needsMatch) { + const value = needsMatch[1] ?? "" + if (value.trim().length === 0) { + for (let child = index + 1; child < lines.length; child++) { + const nextRaw = lines[child] ?? "" + if (nextRaw.trim().length === 0 || nextRaw.trimStart().startsWith("#")) continue + const childIndent = nextRaw.length - nextRaw.trimStart().length + if (childIndent <= indent) break + const childMatch = /^-\s*(.+)$/.exec(nextRaw.trim()) + if (childMatch?.[1]) currentNeeds.push(childMatch[1].trim().replace(/^['"]|['"]$/g, "")) + } + } else { + currentNeeds.push(...parseInlineNeeds(value)) + } + } + } + } + + flush() + return dependencies +} + const diffPath = (path: string) => (/\s|"/.test(path) ? JSON.stringify(path) : path) const prefixedDiffPath = (prefix: "a" | "b", path: string) => diffPath(`${prefix}/${path}`) @@ -605,6 +806,10 @@ export class GitHubService extends Context.Service< readonly listRepoLabels: (repository: string) => Effect.Effect readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect readonly removePullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect + readonly listWorkflowRunsForPullRequest: (repository: string, headSha: string) => Effect.Effect + readonly getWorkflowRunJobs: (repository: string, runId: number) => Effect.Effect + readonly getWorkflowJobLog: (repository: string, jobId: number) => Effect.Effect + readonly getWorkflowRunDependencies: (repository: string, runId: number, headSha: string) => Effect.Effect } >()("ghui/GitHubService") { static readonly layerNoDeps = Layer.effect( @@ -935,6 +1140,32 @@ export class GitHubService extends Context.Service< const removePullRequestLabel = (repository: string, number: number, label: string) => ghVoid("removePullRequestLabel", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label]) + const listWorkflowRunsForPullRequest = (repository: string, headSha: string) => + ghJson("listWorkflowRunsForPullRequest", WorkflowRunsResponseSchema, ["api", `repos/${repository}/actions/runs?head_sha=${encodeURIComponent(headSha)}&per_page=30`]).pipe( + Effect.map(parseWorkflowRuns), + ) + + const getWorkflowRunJobs = (repository: string, runId: number) => + ghJson("getWorkflowRunJobs", WorkflowRunJobsResponseSchema, ["api", `repos/${repository}/actions/runs/${runId}/jobs?per_page=100`]).pipe(Effect.map(parseWorkflowRunJobs)) + + const getWorkflowJobLog = (repository: string, jobId: number) => + command.run("gh", ["api", `repos/${repository}/actions/jobs/${jobId}/logs`]).pipe( + Effect.map((result) => result.stdout), + Effect.withSpan("GitHubService.getWorkflowJobLog"), + ) + + const getWorkflowRunDependencies = Effect.fn("GitHubService.getWorkflowRunDependencies")(function* (repository: string, runId: number, headSha: string) { + const runInfo = yield* ghJson("getWorkflowRunInfo", WorkflowRunInfoResponseSchema, ["api", `repos/${repository}/actions/runs/${runId}`]) + const workflowPath = runInfo.path + if (!workflowPath) return [] + const workflowFile = yield* command.runSchema(Schema.Struct({ content: Schema.String, encoding: Schema.String }), "gh", [ + "api", + `repos/${repository}/contents/${workflowPath}?ref=${encodeURIComponent(headSha)}`, + ]) + const source = workflowFile.encoding === "base64" ? Buffer.from(workflowFile.content, "base64").toString("utf8") : workflowFile.content + return parseWorkflowDependenciesFromYaml(source) + }) + return GitHubService.of({ listOpenPullRequests, listOpenPullRequestPage, @@ -960,6 +1191,10 @@ export class GitHubService extends Context.Service< listRepoLabels, addPullRequestLabel, removePullRequestLabel, + listWorkflowRunsForPullRequest, + getWorkflowRunJobs, + getWorkflowJobLog, + getWorkflowRunDependencies, }) }), ) diff --git a/src/services/MockGitHubService.ts b/src/services/MockGitHubService.ts index 92eccec..1301be0 100644 --- a/src/services/MockGitHubService.ts +++ b/src/services/MockGitHubService.ts @@ -11,6 +11,9 @@ import type { PullRequestQueueMode, PullRequestReviewComment, ReviewStatus, + WorkflowJob, + WorkflowJobDependency, + WorkflowRun, } from "../domain.js" import { mergeInfoFromPullRequest } from "../mergeActions.js" import { GitHubService } from "./GitHubService.js" @@ -187,6 +190,50 @@ export const MockGitHubService = { ] : [], ) + const workflowRunsFor = (_repository: string, _headSha: string): readonly WorkflowRun[] => [ + { + id: 101, + name: "CI", + status: "in_progress", + conclusion: null, + url: "https://github.com/mock-org/repo/actions/runs/101", + event: "pull_request", + branch: "mock-branch", + createdAt: new Date(Date.now() - 120_000), + updatedAt: new Date(), + jobs: [], + }, + ] + const workflowJobsFor = (_repository: string, _runId: number): readonly WorkflowJob[] => [ + { + id: 201, + name: "lint", + status: "completed", + conclusion: "success", + startedAt: new Date(Date.now() - 110_000), + completedAt: new Date(Date.now() - 90_000), + steps: [ + { number: 1, name: "setup", status: "completed", conclusion: "success" }, + { number: 2, name: "lint", status: "completed", conclusion: "success" }, + ], + }, + { + id: 202, + name: "test", + status: "in_progress", + conclusion: null, + startedAt: new Date(Date.now() - 80_000), + completedAt: null, + steps: [ + { number: 1, name: "setup", status: "completed", conclusion: "success" }, + { number: 2, name: "test", status: "in_progress", conclusion: null }, + ], + }, + ] + const workflowDependenciesFor = (): readonly WorkflowJobDependency[] => [ + { id: "lint", name: "lint", needs: [] }, + { id: "test", name: "test", needs: ["lint"] }, + ] return Layer.succeed( GitHubService, @@ -277,6 +324,21 @@ export const MockGitHubService = { listRepoLabels: () => Effect.succeed([]), addPullRequestLabel: () => Effect.void, removePullRequestLabel: () => Effect.void, + listWorkflowRunsForPullRequest: (repository, headSha) => Effect.succeed(workflowRunsFor(repository, headSha)), + getWorkflowRunJobs: (repository, runId) => Effect.succeed(workflowJobsFor(repository, runId)), + getWorkflowJobLog: (_repository, jobId) => + Effect.succeed( + [ + `2026-01-01T00:00:00.0000000Z ##[group]setup`, + `2026-01-01T00:00:00.1000000Z Preparing runner for job ${jobId}`, + `2026-01-01T00:00:00.2000000Z ##[endgroup]`, + `2026-01-01T00:00:01.0000000Z ##[group]lint`, + `2026-01-01T00:00:01.1000000Z bun run lint`, + `2026-01-01T00:00:01.2000000Z All checks passed`, + `2026-01-01T00:00:01.3000000Z ##[endgroup]`, + ].join("\n"), + ), + getWorkflowRunDependencies: () => Effect.succeed(workflowDependenciesFor()), }), ) }, diff --git a/src/ui/ActionsPane.tsx b/src/ui/ActionsPane.tsx new file mode 100644 index 0000000..ed73aae --- /dev/null +++ b/src/ui/ActionsPane.tsx @@ -0,0 +1,477 @@ +import { TextAttributes } from "@opentui/core" +import type { WorkflowJob, WorkflowJobDependency, WorkflowRun, WorkflowStep } from "../domain.js" +import { colors } from "./colors.js" +import { Divider, fitCell, Filler, ModalFrame, PaddedRow, PlainLine, TextLine } from "./primitives.js" +import { shortRepoName } from "./pullRequests.js" +import { renderWorkflowGraph } from "./workflowGraph.js" + +type ActionsLevel = "runs" | "jobs" | "logs" +type ActionsBarSegment = { readonly text: string; readonly fg: string; readonly bold?: boolean } + +const PASSING = new Set(["success", "neutral", "skipped"]) +const ESC = String.fromCharCode(27) + +const isControlChar = (code: number) => (code >= 0 && code <= 9) || (code >= 11 && code <= 31) || code === 127 + +const stateIcon = (status: WorkflowJob["status"], conclusion: WorkflowJob["conclusion"]) => { + if (status === "completed") { + if (conclusion === "skipped" || conclusion === "cancelled") return "○" + if (conclusion && PASSING.has(conclusion)) return "✓" + if (conclusion) return "✗" + return "·" + } + if (status === "in_progress") return "●" + if (status === "queued") return "○" + return "·" +} + +const stateColor = (status: WorkflowJob["status"], conclusion: WorkflowJob["conclusion"]) => { + if (status === "completed") { + if (conclusion === "skipped" || conclusion === "cancelled") return colors.muted + if (conclusion && PASSING.has(conclusion)) return colors.status.passing + if (conclusion) return colors.status.failing + return colors.muted + } + if (status === "in_progress") return colors.status.pending + if (status === "queued") return colors.muted + return colors.muted +} + +const sanitizeLogLine = (line: string) => { + let output = "" + for (let index = 0; index < line.length; index++) { + const char = line[index] + if (char === ESC && line[index + 1] === "[") { + index += 2 + while (index < line.length) { + const code = line.charCodeAt(index) + if (code >= 64 && code <= 126) break + index += 1 + } + continue + } + if (!isControlChar(line.charCodeAt(index))) output += char + } + return output +} + +const stripTimestampPrefix = (line: string) => { + const match = /^\d{4}-\d{2}-\d{2}T\S+Z\s+(.*)$/.exec(line) + return (match?.[1] ?? line).trimStart() +} + +export interface ParsedLogStep { + readonly name: string + readonly status: WorkflowStep["status"] + readonly conclusion: WorkflowStep["conclusion"] + readonly lines: readonly string[] +} + +export type ActionsLogRow = + | { + readonly kind: "step" + readonly stepIndex: number + readonly expanded: boolean + } + | { + readonly kind: "line" + readonly stepIndex: number + readonly lineIndex: number + readonly chunkIndex: number + readonly line: string + } + +export const parseActionsLogSteps = (rawLog: string, steps: readonly WorkflowStep[]): readonly ParsedLogStep[] => { + const normalizedLines = rawLog.split("\n").map(sanitizeLogLine) + const groups: Array<{ name: string; lines: string[] }> = [] + let activeGroup: { name: string; lines: string[] } | null = null + let fallbackLines: string[] = [] + + for (const line of normalizedLines) { + const stripped = stripTimestampPrefix(line) + const groupMatch = /^##\[group\](.*)$/.exec(stripped) + if (groupMatch) { + if (activeGroup) groups.push(activeGroup) + activeGroup = { name: (groupMatch[1] ?? "step").trim() || "step", lines: [] } + continue + } + if (stripped.startsWith("##[endgroup]")) { + if (activeGroup) { + groups.push(activeGroup) + activeGroup = null + } + continue + } + + if (activeGroup) { + activeGroup.lines.push(line) + } else if (groups.length > 0) { + groups[groups.length - 1]!.lines.push(line) + } else { + fallbackLines.push(line) + } + } + + if (activeGroup) groups.push(activeGroup) + if (groups.length > 0 && fallbackLines.length > 0) { + groups[0]!.lines.unshift(...fallbackLines) + } + + if (groups.length === 0) { + const name = steps[0]?.name ?? "log" + const step = steps[0] + return [{ name, status: step?.status ?? "completed", conclusion: step?.conclusion ?? null, lines: fallbackLines }] + } + + const stepByName = new Map(steps.map((step) => [step.name.toLowerCase(), step] as const)) + return groups.map((group, index) => { + const mapped = stepByName.get(group.name.toLowerCase()) ?? steps[index] ?? null + return { + name: group.name, + status: mapped?.status ?? "completed", + conclusion: mapped?.conclusion ?? null, + lines: group.lines, + } satisfies ParsedLogStep + }) +} +export const buildActionsLogRows = ({ + steps, + expandedStepIndex, + wrapMode, + contentWidth, +}: { + readonly steps: readonly ParsedLogStep[] + readonly expandedStepIndex: number | null + readonly wrapMode: boolean + readonly contentWidth: number +}): readonly ActionsLogRow[] => { + const rows: ActionsLogRow[] = [] + const wrapWidth = Math.max(1, contentWidth - 2) + for (let index = 0; index < steps.length; index++) { + const expanded = expandedStepIndex === index + rows.push({ kind: "step", stepIndex: index, expanded }) + if (!expanded) continue + const lines = steps[index]?.lines ?? [] + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex] ?? "" + if (!wrapMode) { + rows.push({ kind: "line", stepIndex: index, lineIndex, chunkIndex: 0, line }) + continue + } + if (line.length === 0) { + rows.push({ kind: "line", stepIndex: index, lineIndex, chunkIndex: 0, line: "" }) + continue + } + let chunkIndex = 0 + for (let start = 0; start < line.length; start += wrapWidth) { + rows.push({ + kind: "line", + stepIndex: index, + lineIndex, + chunkIndex, + line: line.slice(start, start + wrapWidth), + }) + chunkIndex += 1 + } + } + } + return rows +} + +const renderHighlightedLine = (line: string, contentWidth: number, query: string): readonly ActionsBarSegment[] => { + const fitted = fitCell(line, contentWidth) + const needle = query.trim().toLowerCase() + if (needle.length === 0) return [{ text: fitted, fg: colors.text }] + const index = fitted.toLowerCase().indexOf(needle) + if (index < 0) return [{ text: fitted, fg: colors.text }] + const end = Math.min(fitted.length, index + needle.length) + const segments: ActionsBarSegment[] = [] + if (index > 0) segments.push({ text: fitted.slice(0, index), fg: colors.text }) + segments.push({ text: fitted.slice(index, end), fg: colors.accent, bold: true }) + if (end < fitted.length) segments.push({ text: fitted.slice(end), fg: colors.text }) + return segments +} + +export const ActionsPane = ({ + repository, + number, + level, + workflowRuns, + selectedRunIndex, + selectedJobIndex, + selectedRunJobs, + selectedRunDependencies, + selectedJobLog, + selectedStepIndex, + expandedStepIndex, + graphScrollOffset, + logScrollOffset, + logWrapMode, + logHorizontalScroll, + logFilterQuery, + logFilterDraft, + logFilterActive, + graphModalActive, + graphModalVerticalScroll, + contentWidth, + paneWidth, + height, + loading, + loadingIndicator, + error, +}: { + repository: string + number: number + level: ActionsLevel + workflowRuns: readonly WorkflowRun[] + selectedRunIndex: number + selectedJobIndex: number + selectedRunJobs: readonly WorkflowJob[] + selectedRunDependencies: readonly WorkflowJobDependency[] + selectedJobLog: string + selectedStepIndex: number + expandedStepIndex: number | null + graphScrollOffset: number + logScrollOffset: number + logWrapMode: boolean + logHorizontalScroll: number + logFilterQuery: string + logFilterDraft: string + logFilterActive: boolean + graphModalActive: boolean + graphModalVerticalScroll: number + contentWidth: number + paneWidth: number + height: number + loading: boolean + loadingIndicator: string + error: string | null +}) => { + const selectedRun = workflowRuns[selectedRunIndex] ?? null + const selectedJob = selectedRunJobs[selectedJobIndex] ?? null + const headerRight = loading ? `${loadingIndicator} syncing` : error ? "error" : level === "logs" ? "logs" : level === "jobs" ? "jobs" : "runs" + const left = `Actions #${number} ${shortRepoName(repository)}` + const gap = Math.max(1, contentWidth - left.length - headerRight.length) + const bodyHeight = Math.max(1, height - 2) + + if (level === "logs") { + const steps = parseActionsLogSteps(selectedJobLog, selectedJob?.steps ?? []) + const rows = buildActionsLogRows({ + steps, + expandedStepIndex, + wrapMode: logWrapMode, + contentWidth, + }) + const clampedRowIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(rows.length - 1, selectedStepIndex)) + const expandedStep = expandedStepIndex === null ? null : (steps[expandedStepIndex] ?? null) + const lines = expandedStep?.lines ?? [] + const activeFilterText = logFilterActive ? logFilterDraft : logFilterQuery + const normalizedFilter = activeFilterText.trim().toLowerCase() + const matchCount = normalizedFilter.length === 0 ? 0 : lines.reduce((count, line) => (line.toLowerCase().includes(normalizedFilter) ? count + 1 : count), 0) + const showFilterBar = logFilterActive || logFilterQuery.length > 0 + const jobHeaderRows = selectedJob ? 1 : 0 + const filterBarRows = showFilterBar ? 1 : 0 + const logHeight = Math.max(1, bodyHeight - jobHeaderRows - filterBarRows) + const top = Math.max(0, Math.min(logScrollOffset, Math.max(0, rows.length - logHeight))) + const visible = rows.slice(top, top + logHeight) + const filterPrefix = logFilterActive ? "filter> " : "/ " + const filterValue = activeFilterText.length > 0 ? activeFilterText : "type to highlight..." + const matchLabel = normalizedFilter.length === 0 ? "" : matchCount === 1 ? "1 match" : `${matchCount} matches` + const modeLabel = logWrapMode ? "wrap" : `x:${logHorizontalScroll}` + const statusLabel = matchLabel.length > 0 ? `${modeLabel} ${matchLabel}` : modeLabel + const leftFilterText = `${filterPrefix}${filterValue}` + const leftWidth = Math.max(1, contentWidth - (statusLabel.length > 0 ? statusLabel.length + 1 : 0)) + const leftFilterCell = fitCell(leftFilterText, leftWidth) + const prefixCell = fitCell(filterPrefix, Math.min(filterPrefix.length, leftFilterCell.length)) + const valueCell = leftFilterCell.slice(prefixCell.length) + const rightFilterCell = fitCell(statusLabel, contentWidth - leftWidth, "right") + return ( + + + + + {left} + + {" ".repeat(gap)} + {headerRight} + + + + + {selectedJob ? ( + + + {stateIcon(selectedJob.status, selectedJob.conclusion)} + {` ${selectedJob.name}`} + + + ) : null} + {showFilterBar ? ( + + + {prefixCell} + {valueCell} + {rightFilterCell.length > 0 ? 0 ? colors.status.passing : colors.status.failing}>{rightFilterCell} : null} + + + ) : null} + {visible.length > 0 + ? visible.map((row, index) => { + const absoluteIndex = top + index + const selected = absoluteIndex === clampedRowIndex + if (row.kind === "step") { + const step = steps[row.stepIndex] + if (!step) return null + const disclosure = row.expanded ? "▾" : "▸" + const status = stateIcon(step.status, step.conclusion) + return ( + + + {`${disclosure} `} + {status} + {fitCell(` ${step.name}`, Math.max(1, contentWidth - 4))} + + + ) + } + const visibleLine = logWrapMode ? row.line : row.line.slice(logHorizontalScroll) + const indented = ` ${visibleLine}` + const segments = renderHighlightedLine(indented, contentWidth, activeFilterText) + return ( + + + {segments.map((segment, segmentIndex) => ( + + {segment.text} + + ))} + + + ) + }) + : []} + + + ) + } + + if (level === "jobs") { + const listHeight = Math.max(1, bodyHeight - (selectedRun ? 1 : 0)) + const top = Math.max(0, Math.min(selectedJobIndex - Math.floor(listHeight / 2), Math.max(0, selectedRunJobs.length - listHeight))) + const visible = selectedRunJobs.slice(top, top + listHeight) + + const modalWidth = Math.max(30, paneWidth - 4) + const modalContentWidth = Math.max(1, modalWidth - 2) + const modalGraph = renderWorkflowGraph({ + dependencies: selectedRunDependencies, + jobs: selectedRunJobs, + contentWidth: modalContentWidth, + scrollOffset: graphScrollOffset, + }) + const modalGraphRows = modalGraph.length + const modalHeight = Math.min(Math.max(5, modalGraphRows + 4), height - 4) + const modalLeft = Math.floor((paneWidth - modalWidth) / 2) + const modalTop = Math.floor((height - modalHeight) / 2) + const modalInnerHeight = Math.max(1, modalHeight - 2) + const modalTitleRows = selectedRun ? 1 : 0 + const modalDividerRows = selectedRun ? 1 : 0 + const modalGraphHeight = Math.max(1, modalInnerHeight - modalTitleRows - modalDividerRows) + const modalTopRow = Math.max(0, Math.min(graphModalVerticalScroll, Math.max(0, modalGraph.length - modalGraphHeight))) + const modalVisibleGraph = modalGraph.slice(modalTopRow, modalTopRow + modalGraphHeight) + const modalJunctionRows = selectedRun ? [modalTitleRows] : [] + + return ( + + + + + {left} + + {" ".repeat(gap)} + {headerRight} + + + + + {selectedRun ? ( + + + + {selectedRun.name} + + + + ) : null} + {visible.length > 0 + ? visible.map((job, index) => { + const absoluteIndex = top + index + const selected = absoluteIndex === selectedJobIndex + return ( + + + {stateIcon(job.status, job.conclusion)} + {` ${fitCell(job.name, Math.max(8, contentWidth - 2))}`} + + + ) + }) + : []} + + {graphModalActive ? ( + + {selectedRun ? : null} + {selectedRun ? : null} + {modalVisibleGraph.map((line, index) => ( + + + {line.segments.map((segment, segmentIndex) => ( + + {segment.text} + + ))} + + + ))} + {modalVisibleGraph.length < modalGraphHeight + ? Array.from({ length: modalGraphHeight - modalVisibleGraph.length }, (_, index) => ) + : null} + + ) : null} + + ) + } + + const listHeight = bodyHeight + const top = Math.max(0, Math.min(selectedRunIndex - Math.floor(listHeight / 2), Math.max(0, workflowRuns.length - listHeight))) + const visible = workflowRuns.slice(top, top + listHeight) + return ( + + + + + {left} + + {" ".repeat(gap)} + {headerRight} + + + + + {visible.length > 0 + ? visible.map((run, index) => { + const absoluteIndex = top + index + const selected = absoluteIndex === selectedRunIndex + return ( + + + {stateIcon(run.status, run.conclusion)} + {` ${fitCell(run.name, Math.max(8, contentWidth - 2))}`} + + + ) + }) + : []} + + + ) +} diff --git a/src/ui/CommandPalette.tsx b/src/ui/CommandPalette.tsx index 3d9e5dd..b32e188 100644 --- a/src/ui/CommandPalette.tsx +++ b/src/ui/CommandPalette.tsx @@ -14,6 +14,7 @@ const scopeLabels = { Comments: "Comments", Navigation: "Navigation", System: "System", + Actions: "Actions", } as const satisfies Record export type CommandPaletteRow = diff --git a/src/ui/FooterHints.tsx b/src/ui/FooterHints.tsx index 0ae4bec..83b14e1 100644 --- a/src/ui/FooterHints.tsx +++ b/src/ui/FooterHints.tsx @@ -17,6 +17,10 @@ interface HintsContext { readonly diffFullView: boolean readonly diffRangeActive: boolean readonly commentsViewActive: boolean + readonly actionsViewActive: boolean + readonly actionsLevel: "runs" | "jobs" | "logs" + readonly actionsLogFilterActive: boolean + readonly actionsLogHasQuery: boolean readonly commentsViewOnRealComment: boolean readonly commentsViewCanEditSelected: boolean readonly commentsViewCount: number @@ -50,6 +54,7 @@ const detailFullViewHints = (ctx: HintsContext): readonly HintItem[] => [ { key: "↑↓", label: "scroll" }, { key: "r", label: ctx.hasError ? "retry" : "refresh" }, { key: "d", label: "diff", when: ctx.hasSelection }, + { key: "a", label: "actions", when: ctx.hasSelection }, ] const commentsViewHints = (ctx: HintsContext): readonly HintItem[] => [ @@ -63,6 +68,22 @@ const commentsViewHints = (ctx: HintsContext): readonly HintItem[] => [ { key: "esc", label: "close" }, ] +const actionsViewHints = (ctx: HintsContext): readonly HintItem[] => [ + { key: "↑↓", label: "move" }, + { key: "←→", label: "graph", when: ctx.actionsLevel === "jobs" }, + { key: "←→", label: "collapse/expand", when: ctx.actionsLevel === "logs" }, + { key: "w", label: "wrap", when: ctx.actionsLevel === "logs" }, + { key: "zh/zl", label: "h-scroll", when: ctx.actionsLevel === "logs" }, + { key: "/", label: "filter", when: ctx.actionsLevel === "logs" && !ctx.actionsLogFilterActive }, + { key: "esc", label: "clear", when: ctx.actionsLevel === "logs" && ctx.actionsLogFilterActive }, + { key: "n/N", label: "match", when: ctx.actionsLevel === "logs" && ctx.actionsLogHasQuery && !ctx.actionsLogFilterActive }, + { key: "s", label: "graph", when: ctx.actionsLevel === "jobs" }, + { key: "enter", label: "open", when: ctx.actionsLevel !== "logs" }, + { key: "o", label: "browser" }, + { key: "r", label: "refresh" }, + { key: "esc", label: "back" }, +] + const defaultHints = (ctx: HintsContext): readonly HintItem[] => { const retrying = ctx.retryProgress._tag === "Retrying" return [ @@ -85,6 +106,7 @@ const defaultHints = (ctx: HintsContext): readonly HintItem[] => { const footerHints = (ctx: HintsContext): readonly HintItem[] => { if (ctx.filterEditing) return filterEditingHints if (ctx.commentsViewActive) return commentsViewHints(ctx) + if (ctx.actionsViewActive) return actionsViewHints(ctx) if (ctx.diffFullView) return diffViewHints(ctx) if (ctx.detailFullView) return detailFullViewHints(ctx) return defaultHints(ctx) diff --git a/src/ui/workflowGraph.ts b/src/ui/workflowGraph.ts new file mode 100644 index 0000000..2432ccd --- /dev/null +++ b/src/ui/workflowGraph.ts @@ -0,0 +1,667 @@ +import type { WorkflowJob, WorkflowJobDependency } from "../domain.js" +import { colors } from "./colors.js" + +const PASSING = new Set(["success", "neutral"]) + +export interface WorkflowGraphSegment { + readonly text: string + readonly fg: string +} + +export interface WorkflowGraphRow { + readonly segments: readonly WorkflowGraphSegment[] +} + +// --- child (sub-job inside a box) --- +interface GraphChild { + readonly name: string + readonly icon: string + readonly color: string +} + +// --- node (plain or box) --- +interface GraphNode { + readonly id: string + readonly name: string + readonly needs: readonly string[] + readonly icon: string + readonly color: string + readonly children: readonly GraphChild[] + column: number + /** absolute y position in the grid */ + yStart: number + /** rows this node occupies: 1 for plain, children.length + 2 for box */ + height: number +} + +interface GraphEdge { + readonly source: GraphNode + readonly target: GraphNode +} + +// ── helpers ── + +const normalize = (value: string) => value.trim().toLowerCase() + +const statusIcon = (job: WorkflowJob) => { + if (job.status === "completed") { + if (job.conclusion === "skipped" || job.conclusion === "cancelled") return "○" + if (job.conclusion && PASSING.has(job.conclusion)) return "✓" + if (job.conclusion) return "✗" + return "·" + } + if (job.status === "in_progress") return "●" + if (job.status === "queued") return "○" + return "·" +} + +const statusColor = (job: WorkflowJob) => { + if (job.status === "completed") { + if (job.conclusion === "skipped" || job.conclusion === "cancelled") return colors.muted + if (job.conclusion && PASSING.has(job.conclusion)) return colors.status.passing + if (job.conclusion) return colors.status.failing + return colors.muted + } + if (job.status === "in_progress") return colors.status.pending + if (job.status === "queued") return colors.muted + return colors.muted +} + +const aggregateIcon = (children: readonly GraphChild[]) => { + if (children.length === 0) return "?" + const icons = children.map((child) => child.icon) + if (icons.some((icon) => icon === "✗")) return "✗" + if (icons.some((icon) => icon === "●")) return "●" + if (icons.every((icon) => icon === "✓")) return "✓" + if (icons.every((icon) => icon === "○")) return "○" + return "○" +} + +const aggregateColor = (children: readonly GraphChild[]) => { + if (children.length === 0) return colors.muted + const cs = children.map((child) => child.color) + if (cs.some((color) => color === colors.status.failing)) return colors.status.failing + if (cs.some((color) => color === colors.status.pending)) return colors.status.pending + if (cs.every((color) => color === colors.status.passing)) return colors.status.passing + return colors.muted +} + +// ── build nodes ── + +const buildNodes = (dependencies: readonly WorkflowJobDependency[], jobs: readonly WorkflowJob[]): GraphNode[] => { + const jobsByName = new Map() + for (const job of jobs) jobsByName.set(normalize(job.name), job) + + // Build dependency-declared nodes + const nodeMap = new Map() + for (const dependency of dependencies) { + const id = normalize(dependency.id) + const resolvedJob = jobsByName.get(normalize(dependency.name)) ?? jobsByName.get(id) ?? null + nodeMap.set(id, { + id, + name: dependency.name, + needs: dependency.needs.map(normalize), + icon: resolvedJob ? statusIcon(resolvedJob) : "?", + color: resolvedJob ? statusColor(resolvedJob) : colors.muted, + children: [], + column: 0, + yStart: 0, + height: 1, + }) + } + + // Build name lookup from dependency name → id + const nameToId = new Map() + for (const dependency of dependencies) { + nameToId.set(normalize(dependency.name), normalize(dependency.id)) + } + + // Group unmatched API jobs as children of their parent dependency node + const childrenByParent = new Map() + const matchedJobNames = new Set() + + for (const dependency of dependencies) { + matchedJobNames.add(normalize(dependency.name)) + matchedJobNames.add(normalize(dependency.id)) + } + + for (const job of jobs) { + const normalizedName = normalize(job.name) + if (matchedJobNames.has(normalizedName)) continue + + // Try " / " split to find parent + const slashIndex = job.name.indexOf(" / ") + if (slashIndex < 0) continue + + const parentName = normalize(job.name.slice(0, slashIndex)) + const parentId = nameToId.get(parentName) + if (!parentId) continue + + const childName = job.name.slice(slashIndex + 3) + const existing = childrenByParent.get(parentId) + const child: GraphChild = { + name: childName, + icon: statusIcon(job), + color: statusColor(job), + } + if (existing) existing.push(child) + else childrenByParent.set(parentId, [child]) + } + + // Merge children into parent nodes; parents with children become box nodes + for (const [parentId, children] of childrenByParent) { + const parent = nodeMap.get(parentId) + if (!parent) continue + children.sort((a, b) => a.name.localeCompare(b.name)) + const node: GraphNode = { + ...parent, + icon: aggregateIcon(children), + color: aggregateColor(children), + children, + height: children.length + 2, + } + nodeMap.set(parentId, node) + } + + // If no dependencies, just list all jobs as plain root nodes + if (dependencies.length === 0) { + for (const job of jobs) { + const id = normalize(job.name) + if (nodeMap.has(id)) continue + nodeMap.set(id, { + id, + name: job.name, + needs: [], + icon: statusIcon(job), + color: statusColor(job), + children: [], + column: 0, + yStart: 0, + height: 1, + }) + } + } + + return [...nodeMap.values()] +} + +// ── layout ── + +const assignColumns = (nodes: GraphNode[]) => { + const byId = new Map(nodes.map((node) => [node.id, node] as const)) + const memo = new Map() + + const depth = (id: string, stack: Set): number => { + if (memo.has(id)) return memo.get(id) ?? 0 + const node = byId.get(id) + if (!node) return 0 + if (stack.has(id)) return 0 + if (node.needs.length === 0) { + memo.set(id, 0) + return 0 + } + stack.add(id) + const value = Math.max(...node.needs.map((need) => depth(need, stack))) + 1 + stack.delete(id) + memo.set(id, value) + return value + } + + for (const node of nodes) node.column = depth(node.id, new Set()) +} + +const assignYPositions = (nodes: GraphNode[]) => { + const byColumn = new Map() + for (const node of nodes) { + const column = byColumn.get(node.column) + if (column) column.push(node) + else byColumn.set(node.column, [node]) + } + + let totalHeight = 0 + for (const [, columnNodes] of byColumn) { + columnNodes.sort((a, b) => a.name.localeCompare(b.name)) + let y = 0 + for (const node of columnNodes) { + node.yStart = y + y += node.height + } + if (y > totalHeight) totalHeight = y + } + + return totalHeight +} + +const buildEdges = (nodes: readonly GraphNode[]) => { + const byId = new Map(nodes.map((node) => [node.id, node] as const)) + const edges: GraphEdge[] = [] + for (const target of nodes) { + for (const need of target.needs) { + const source = byId.get(need) + if (!source || source.id === target.id) continue + edges.push({ source, target }) + } + } + return edges +} + +/** Remove edges where the target is reachable from the source via other edges. */ +const reduceEdges = (edges: readonly GraphEdge[]): GraphEdge[] => { + const adj = new Map>() + for (const edge of edges) { + let set = adj.get(edge.source.id) + if (!set) { + set = new Set() + adj.set(edge.source.id, set) + } + set.add(edge.target.id) + } + + return edges.filter((edge) => { + const neighbors = adj.get(edge.source.id) + if (!neighbors) return true + neighbors.delete(edge.target.id) + + const visited = new Set([edge.source.id]) + const queue = [edge.source.id] + let reachable = false + while (queue.length > 0) { + const current = queue.shift()! + for (const neighbor of adj.get(current) ?? []) { + if (neighbor === edge.target.id) { + reachable = true + break + } + if (!visited.has(neighbor)) { + visited.add(neighbor) + queue.push(neighbor) + } + } + if (reachable) break + } + + neighbors.add(edge.target.id) + return !reachable + }) +} + +const computeColumnWidths = (nodes: readonly GraphNode[], maxColumn: number) => { + const widths: number[] = Array.from({ length: maxColumn + 1 }, () => 10) + for (const node of nodes) { + if (node.children.length > 0) { + // Box node: title is "┌ I name ─…─┐", children are "│ I name │" + // Column width needs to fit the widest line + const titleContent = `${node.icon} ${node.name}` + let maxInner = titleContent.length + for (const child of node.children) { + const childContent = `${child.icon} ${child.name}` + if (childContent.length > maxInner) maxInner = childContent.length + } + // Box adds "┌ " prefix (2) and " ─┐" suffix (2) → +4, but we treat the + // box border chars as part of the column width + const boxWidth = maxInner + 4 + if (boxWidth > widths[node.column]!) widths[node.column] = boxWidth + } else { + const labelWidth = `${node.icon} ${node.name}`.length + if (labelWidth > widths[node.column]!) widths[node.column] = labelWidth + } + } + return widths +} + +// ── connector routing ── + +// Each edge is routed: +// - horizontal at sourceY from source column gap outward +// - vertical at gap = source.column from sourceY to targetY +// - horizontal at targetY from gap source.column through gap target.column - 1 + +const anchorY = (node: GraphNode) => node.yStart + Math.floor(node.height / 2) + +interface DirectionFlags { + left: boolean + right: boolean + up: boolean + down: boolean +} + +const connectorChar = (directions: DirectionFlags): string => { + const { left, right, up, down } = directions + if (!left && !right && !up && !down) return " " + if (left && right && up && down) return "╋" + if (left && right && up) return "┻" + if (left && right && down) return "┳" + if (left && up && down) return "┫" + if (right && up && down) return "┣" + if (left && right) return "━" + if (up && down) return "┃" + if (right && down) return "┏" + if (right && up) return "┗" + if (left && down) return "┓" + if (left && up) return "┛" + if (left) return "━" + if (right) return "━" + if (up) return "┃" + if (down) return "┃" + return " " +} + +const buildConnectorGrid = (edges: readonly GraphEdge[], maxColumn: number, totalHeight: number) => { + // For each gap (0..maxColumn-1) × y (0..totalHeight-1), compute direction flags + const grid: DirectionFlags[][] = Array.from({ length: maxColumn }, () => Array.from({ length: totalHeight }, () => ({ left: false, right: false, up: false, down: false }))) + + for (const edge of edges) { + const sourceCol = edge.source.column + const targetCol = edge.target.column + const sourceAnchor = anchorY(edge.source) + const targetAnchor = anchorY(edge.target) + + if (sourceCol >= targetCol) continue // skip invalid/self edges + + if (sourceAnchor === targetAnchor) { + // Straight horizontal — no vertical turn needed + for (let gap = sourceCol; gap < targetCol; gap++) { + const cell = grid[gap]?.[sourceAnchor] + if (cell) { + cell.left = true + cell.right = true + } + } + continue + } + + // Adjacent columns: turn at source gap (compact fork/merge next to source). + // Multi-gap: turn at target gap (horizontal line is traceable, pass-through + // fills empty column cells so the line stays visually continuous). + const turnGap = targetCol - sourceCol === 1 ? sourceCol : targetCol - 1 + + // 1. Horizontal segment at sourceAnchor from sourceCol to turnGap-1 + for (let gap = sourceCol; gap < turnGap; gap++) { + const cell = grid[gap]?.[sourceAnchor] + if (cell) { + cell.left = true + cell.right = true + } + } + + // 2. At turnGap, sourceAnchor: arrive from left, turn vertically + const departCell = grid[turnGap]?.[sourceAnchor] + if (departCell) { + departCell.left = true + if (targetAnchor < sourceAnchor) departCell.up = true + else departCell.down = true + } + + // 3. Vertical segment at turnGap between sourceAnchor and targetAnchor + const minY = Math.min(sourceAnchor, targetAnchor) + const maxY = Math.max(sourceAnchor, targetAnchor) + for (let y = minY + 1; y < maxY; y++) { + const vertCell = grid[turnGap]?.[y] + if (vertCell) { + vertCell.up = true + vertCell.down = true + } + } + + // 4. At turnGap, targetAnchor: arrive from vertical, exit right to target + const arriveCell = grid[turnGap]?.[targetAnchor] + if (arriveCell) { + if (targetAnchor < sourceAnchor) arriveCell.down = true + else arriveCell.up = true + arriveCell.right = true + } + + // 5. Horizontal segment at targetAnchor from turnGap+1 through targetCol-1 + for (let gap = turnGap + 1; gap < targetCol; gap++) { + const horizCell = grid[gap]?.[targetAnchor] + if (horizCell) { + horizCell.left = true + horizCell.right = true + } + } + } + + return grid +} + +// ── rendering ── + +const clip = (value: string, offset: number, width: number) => { + if (width <= 0) return "" + const start = Math.max(0, offset) + const end = start + width + return value.slice(start, end).padEnd(width, " ") +} + +const compactSegments = (segments: WorkflowGraphSegment[]): WorkflowGraphSegment[] => { + if (segments.length <= 1) return segments + const output: WorkflowGraphSegment[] = [] + for (const segment of segments) { + const previous = output[output.length - 1] + if (previous && previous.fg === segment.fg) { + output[output.length - 1] = { text: `${previous.text}${segment.text}`, fg: previous.fg } + } else { + output.push(segment) + } + } + return output +} + +type NodeRowKind = "top" | "child" | "bottom" | "plain" | "empty" + +interface NodeRowInfo { + readonly kind: NodeRowKind + readonly node: GraphNode + readonly childIndex?: number +} + +const getNodeRowInfo = (node: GraphNode, y: number): NodeRowInfo | null => { + if (y < node.yStart || y >= node.yStart + node.height) return null + if (node.children.length === 0) { + return y === node.yStart ? { kind: "plain", node } : null + } + const offset = y - node.yStart + if (offset === 0) return { kind: "top", node } + if (offset === node.height - 1) return { kind: "bottom", node } + return { kind: "child", node, childIndex: offset - 1 } +} + +const renderNodeCell = (info: NodeRowInfo, columnWidth: number, trailingFill = " "): WorkflowGraphSegment[] => { + const { kind, node } = info + switch (kind) { + case "plain": { + const label = `${node.icon} ${node.name}` + const pad = Math.max(0, columnWidth - label.length) + return [ + { text: label, fg: node.color }, + { text: trailingFill.repeat(pad), fg: colors.muted }, + ] + } + case "top": { + const titleContent = `${node.icon} ${node.name}` + const innerWidth = columnWidth - 4 + const padded = titleContent.length >= innerWidth ? titleContent.slice(0, innerWidth) : titleContent + const fill = "─".repeat(Math.max(0, innerWidth - padded.length)) + const text = `┌ ${padded} ${fill}┐` + return [{ text, fg: node.color }] + } + case "child": { + const child = node.children[info.childIndex ?? 0] + if (!child) return [{ text: " ".repeat(columnWidth), fg: colors.muted }] + const innerWidth = columnWidth - 4 + const content = `${child.icon} ${child.name}` + const padded = content.length >= innerWidth ? content.slice(0, innerWidth) : content.padEnd(innerWidth, " ") + return [ + { text: "│ ", fg: node.color }, + { text: padded, fg: child.color }, + { text: " │", fg: node.color }, + ] + } + case "bottom": { + const innerWidth = columnWidth - 4 + const fill = "─".repeat(Math.max(0, innerWidth + 2)) + const text = `└${fill}┘` + return [{ text, fg: node.color }] + } + case "empty": + return [{ text: " ".repeat(columnWidth), fg: colors.muted }] + } +} + +const renderGapCell = (char: string, gapWidth: number, directions: DirectionFlags): WorkflowGraphSegment => { + if (char === " ") return { text: " ".repeat(gapWidth), fg: colors.muted } + + // Vertical-only: center the character with spaces + const isVerticalOnly = char === "┃" + if (isVerticalOnly) { + const pad = Math.floor((gapWidth - 1) / 2) + const rest = gapWidth - 1 - pad + return { text: `${" ".repeat(pad)}${char}${" ".repeat(rest)}`, fg: colors.muted } + } + + if (gapWidth === 1) return { text: char, fg: colors.muted } + + // Fill left/right sides based on whether the character has horizontal lines + // on that side. ━ if there's a line, space if not. + const leftFill = directions.left ? "━" : " " + const rightFill = directions.right ? "━" : " " + + if (gapWidth === 2) return { text: `${leftFill}${char}`, fg: colors.muted } + + // gapWidth >= 3 + const before = Math.floor((gapWidth - 1) / 2) + const after = gapWidth - 1 - before + return { text: `${leftFill.repeat(before)}${char}${rightFill.repeat(after)}`, fg: colors.muted } +} + +// ── main layout ── + +const graphLayout = (dependencies: readonly WorkflowJobDependency[], jobs: readonly WorkflowJob[]) => { + const nodes = buildNodes(dependencies, jobs) + if (nodes.length === 0) return null + assignColumns(nodes) + const totalHeight = Math.max(1, assignYPositions(nodes)) + const edges = reduceEdges(buildEdges(nodes)) + const maxColumn = Math.max(...nodes.map((node) => node.column)) + const columnWidths = computeColumnWidths(nodes, maxColumn) + const gapWidth = 3 + const totalWidth = columnWidths.reduce((sum, w) => sum + w, 0) + Math.max(0, maxColumn) * gapWidth + return { nodes, edges, totalHeight, maxColumn, columnWidths, gapWidth, totalWidth } +} + +export const renderWorkflowGraph = ({ + dependencies, + jobs, + contentWidth, + scrollOffset, +}: { + readonly dependencies: readonly WorkflowJobDependency[] + readonly jobs: readonly WorkflowJob[] + readonly contentWidth: number + readonly scrollOffset: number +}): readonly WorkflowGraphRow[] => { + const layout = graphLayout(dependencies, jobs) + if (!layout) return [] + + const { nodes, edges, totalHeight, maxColumn, columnWidths, gapWidth } = layout + const connectorGrid = buildConnectorGrid(edges, maxColumn, totalHeight) + + // Index nodes by column for fast lookup + const byColumn = new Map() + for (const node of nodes) { + const column = byColumn.get(node.column) + if (column) column.push(node) + else byColumn.set(node.column, [node]) + } + + const output: WorkflowGraphRow[] = [] + + for (let y = 0; y < totalHeight; y++) { + const fullSegments: WorkflowGraphSegment[] = [] + + for (let column = 0; column <= maxColumn; column++) { + const colWidth = columnWidths[column] ?? 10 + const columnNodes = byColumn.get(column) + let rendered = false + + if (columnNodes) { + for (const node of columnNodes) { + const info = getNodeRowInfo(node, y) + if (info) { + // Check if a horizontal edge continues to the right of this node + const rightGap = column < maxColumn ? connectorGrid[column]?.[y] : null + const trailingFill = rightGap?.left ? "━" : " " + fullSegments.push(...renderNodeCell(info, colWidth, trailingFill)) + rendered = true + break + } + } + } + + if (!rendered) { + // Check if a horizontal edge passes through this empty column cell. + // This happens when the gap on the left has a rightward segment and + // the gap on the right has a leftward segment at this y position. + const leftGap = column > 0 ? connectorGrid[column - 1]?.[y] : null + const rightGap = column < maxColumn ? connectorGrid[column]?.[y] : null + const hasPassThrough = leftGap?.right && rightGap?.left + if (hasPassThrough) { + fullSegments.push({ text: "━".repeat(colWidth), fg: colors.muted }) + } else { + fullSegments.push({ text: " ".repeat(colWidth), fg: colors.muted }) + } + } + + if (column < maxColumn) { + const directions = connectorGrid[column]?.[y] ?? { left: false, right: false, up: false, down: false } + const char = connectorChar(directions) + fullSegments.push(renderGapCell(char, gapWidth, directions)) + } + } + + // Build full line and color mask + const fullLine = fullSegments.map((segment) => segment.text).join("") + const colorMask: string[] = [] + for (const segment of fullSegments) { + for (let index = 0; index < segment.text.length; index++) colorMask.push(segment.fg) + } + + const clippedLine = clip(fullLine, scrollOffset, contentWidth) + const start = Math.max(0, scrollOffset) + const visibleMask = colorMask.slice(start, start + clippedLine.length) + while (visibleMask.length < clippedLine.length) visibleMask.push(colors.muted) + + const rowSegments: WorkflowGraphSegment[] = [] + let currentText = "" + let currentColor = visibleMask[0] ?? colors.muted + for (let index = 0; index < clippedLine.length; index++) { + const char = clippedLine[index]! + const color = visibleMask[index] ?? colors.muted + if (color !== currentColor) { + if (currentText.length > 0) rowSegments.push({ text: currentText, fg: currentColor }) + currentText = char + currentColor = color + } else { + currentText += char + } + } + if (currentText.length > 0) rowSegments.push({ text: currentText, fg: currentColor }) + output.push({ segments: compactSegments(rowSegments) }) + } + + return output +} + +export const workflowGraphMaxScrollOffset = ({ + dependencies, + jobs, + contentWidth, +}: { + readonly dependencies: readonly WorkflowJobDependency[] + readonly jobs: readonly WorkflowJob[] + readonly contentWidth: number +}) => { + const layout = graphLayout(dependencies, jobs) + if (!layout) return 0 + return Math.max(0, layout.totalWidth - Math.max(1, contentWidth)) +} diff --git a/test/appCommands.test.ts b/test/appCommands.test.ts index cbd5afc..442a740 100644 --- a/test/appCommands.test.ts +++ b/test/appCommands.test.ts @@ -43,6 +43,7 @@ const buildCommands = (overrides: Partial[0] detailFullView: false, diffFullView: true, commentsViewActive: false, + actionsViewActive: false, hasSelectedComment: false, canEditSelectedComment: false, diffReady: true, @@ -70,6 +71,10 @@ const buildCommands = (overrides: Partial[0] closeDiffView: noop, openCommentsView: noop, closeCommentsView: noop, + openActionsView: noop, + closeActionsView: noop, + refreshActionsView: noop, + openSelectedActionInBrowser: noop, openNewIssueCommentModal: noop, openReplyToSelectedComment: noop, openEditSelectedComment: noop, diff --git a/test/workflowGraph.test.ts b/test/workflowGraph.test.ts new file mode 100644 index 0000000..61c1304 --- /dev/null +++ b/test/workflowGraph.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, test } from "bun:test" +import type { WorkflowJob, WorkflowJobDependency } from "../src/domain.js" +import { renderWorkflowGraph, workflowGraphMaxScrollOffset } from "../src/ui/workflowGraph.js" + +const makeJob = (name: string, status: WorkflowJob["status"] = "completed", conclusion: WorkflowJob["conclusion"] = "success"): WorkflowJob => ({ + id: Math.random(), + name, + status, + conclusion, + startedAt: null, + completedAt: null, + steps: [], +}) + +const makeDep = (id: string, name: string, needs: string[] = []): WorkflowJobDependency => ({ id, name, needs }) + +const renderText = (deps: readonly WorkflowJobDependency[], jobs: readonly WorkflowJob[], width = 200) => { + const rows = renderWorkflowGraph({ dependencies: deps, jobs, contentWidth: width, scrollOffset: 0 }) + return rows.map((row) => row.segments.map((seg) => seg.text).join("")).map((line) => line.trimEnd()) +} + +const renderJoined = (deps: readonly WorkflowJobDependency[], jobs: readonly WorkflowJob[], width = 200) => renderText(deps, jobs, width).join("\n") + +describe("renderWorkflowGraph", () => { + test("empty inputs returns empty", () => { + expect(renderWorkflowGraph({ dependencies: [], jobs: [], contentWidth: 80, scrollOffset: 0 })).toEqual([]) + }) + + test("single plain node", () => { + const deps = [makeDep("lint", "lint")] + const jobs = [makeJob("lint")] + const lines = renderText(deps, jobs) + expect(lines.length).toBe(1) + expect(lines[0]).toContain("✓ lint") + }) + + test("linear pipeline A → B → C", () => { + const deps = [makeDep("a", "A"), makeDep("b", "B", ["a"]), makeDep("c", "C", ["b"])] + const jobs = [makeJob("A"), makeJob("B"), makeJob("C")] + const lines = renderText(deps, jobs) + expect(lines.length).toBe(1) + // Should have connectors between columns + const line = lines[0]! + expect(line).toContain("✓ A") + expect(line).toContain("✓ B") + expect(line).toContain("✓ C") + expect(line).toContain("━") + }) + + test("fan-out: A → B, A → C", () => { + const deps = [makeDep("a", "A"), makeDep("b", "B", ["a"]), makeDep("c", "C", ["a"])] + const jobs = [makeJob("A"), makeJob("B"), makeJob("C")] + const lines = renderText(deps, jobs) + expect(lines.length).toBe(2) + // Both B and C should appear + const text = lines.join("\n") + expect(text).toContain("✓ B") + expect(text).toContain("✓ C") + }) + + test("fan-in: A → C, B → C", () => { + const deps = [makeDep("a", "A"), makeDep("b", "B"), makeDep("c", "C", ["a", "b"])] + const jobs = [makeJob("A"), makeJob("B"), makeJob("C")] + const lines = renderText(deps, jobs) + expect(lines.length).toBe(2) + const text = lines.join("\n") + expect(text).toContain("✓ A") + expect(text).toContain("✓ B") + expect(text).toContain("✓ C") + }) + + test("sub-jobs grouped into box node", () => { + const deps = [makeDep("build", "Build"), makeDep("deploy", "Deploy", ["build"])] + const jobs = [makeJob("Build"), makeJob("Deploy / Deploy (dev)"), makeJob("Deploy / Deploy (prod)"), makeJob("Deploy / Deploy (stage)")] + const lines = renderText(deps, jobs) + // Should have box with 3 children (height = 5: top + 3 children + bottom) + // Plus Build is 1 row. Total height = max(1, 5) = 5 + expect(lines.length).toBe(5) + const text = lines.join("\n") + // Box should have border characters + expect(text).toContain("┌") + expect(text).toContain("┘") + expect(text).toContain("Deploy (dev)") + expect(text).toContain("Deploy (prod)") + expect(text).toContain("Deploy (stage)") + // Parent prefix should be stripped from child names + expect(text).not.toContain("Deploy / Deploy") + }) + + test("sub-job aggregate status: any failure → ✗", () => { + const deps = [makeDep("test", "Test")] + const jobs = [makeJob("Test / Test (unit)", "completed", "success"), makeJob("Test / Test (integration)", "completed", "failure")] + const lines = renderText(deps, jobs) + const text = lines.join("\n") + // Aggregate should show failure icon + expect(text).toContain("✗ Test") + }) + + test("sub-job aggregate status: all success → ✓", () => { + const deps = [makeDep("test", "Test")] + const jobs = [makeJob("Test / Test (unit)", "completed", "success"), makeJob("Test / Test (integration)", "completed", "success")] + const lines = renderText(deps, jobs) + const text = lines.join("\n") + expect(text).toContain("✓ Test") + }) + + test("sub-job aggregate status: any in_progress → ●", () => { + const deps = [makeDep("test", "Test")] + const jobs = [makeJob("Test / Test (unit)", "completed", "success"), makeJob("Test / Test (integration)", "in_progress", null)] + const lines = renderText(deps, jobs) + const text = lines.join("\n") + expect(text).toContain("● Test") + }) + + test("per-column widths: columns have different widths", () => { + const deps = [makeDep("a", "Short"), makeDep("b", "A Much Longer Name Here", ["a"])] + const jobs = [makeJob("Short"), makeJob("A Much Longer Name Here")] + const lines = renderText(deps, jobs) + const line = lines[0]! + // The first column should not be padded to the width of the second + // "✓ Short" is 7 chars; "✓ A Much Longer Name Here" is 27 chars + // With per-column widths, the gap should start around column 10 (min width) + // NOT at column 27 + const gapIndex = line.indexOf("━") + expect(gapIndex).toBeLessThan(27) + }) + + test("multi-gap edge: A (col 0) → D (col 3)", () => { + const deps = [makeDep("a", "A"), makeDep("b", "B", ["a"]), makeDep("c", "C", ["b"]), makeDep("d", "D", ["a", "c"])] + const jobs = [makeJob("A"), makeJob("B"), makeJob("C"), makeJob("D")] + const lines = renderText(deps, jobs) + // D depends on A (spans 3 gaps) and C (spans 1 gap) + // Should render connector lines across intermediate gaps + const text = lines.join("\n") + expect(text).toContain("✓ A") + expect(text).toContain("✓ D") + }) + + test("no dependencies: all jobs as independent roots", () => { + const deps: WorkflowJobDependency[] = [] + const jobs = [makeJob("lint"), makeJob("test"), makeJob("build")] + const lines = renderText(deps, jobs) + // All three should appear, all in column 0 + expect(lines.length).toBe(3) + const text = lines.join("\n") + expect(text).toContain("✓ lint") + expect(text).toContain("✓ test") + expect(text).toContain("✓ build") + }) + + test("scroll offset clips the output", () => { + const deps = [makeDep("a", "A"), makeDep("b", "B", ["a"])] + const jobs = [makeJob("A"), makeJob("B")] + const noScroll = renderWorkflowGraph({ dependencies: deps, jobs, contentWidth: 80, scrollOffset: 0 }) + const scrolled = renderWorkflowGraph({ dependencies: deps, jobs, contentWidth: 80, scrollOffset: 5 }) + const noScrollText = noScroll[0]?.segments.map((seg) => seg.text).join("") ?? "" + const scrolledText = scrolled[0]?.segments.map((seg) => seg.text).join("") ?? "" + // Scrolled output should not start with the same chars + expect(scrolledText).not.toBe(noScrollText) + }) + + test("maxScrollOffset is non-negative", () => { + const deps = [makeDep("a", "A"), makeDep("b", "B", ["a"])] + const jobs = [makeJob("A"), makeJob("B")] + const maxOffset = workflowGraphMaxScrollOffset({ dependencies: deps, jobs, contentWidth: 80 }) + expect(maxOffset).toBeGreaterThanOrEqual(0) + }) + + test("glue workflow: complex fan-out with sub-jobs", () => { + const deps = [ + makeDep("extract", "Extract Glue jobs from labels"), + makeDep("preview", "Preview Glue jobs", ["extract"]), + makeDep("cleanup", "Cleanup preview Glue jobs", ["extract"]), + makeDep("release", "Release Glue jobs", ["cleanup"]), + makeDep("prod-gate", "Glue prod deploy gate", ["extract", "preview", "cleanup", "release"]), + ] + const jobs = [ + makeJob("Extract Glue jobs from labels"), + makeJob("Preview Glue jobs", "queued", null), + // cleanup has sub-jobs + makeJob("Cleanup preview Glue jobs / Release Glue jobs (dev)"), + makeJob("Cleanup preview Glue jobs / Release Glue jobs (prod)"), + makeJob("Cleanup preview Glue jobs / Release Glue jobs (stage)"), + // release has sub-jobs + makeJob("Release Glue jobs / Release Glue jobs (dev)", "completed", "failure"), + // prod gate + makeJob("Glue prod deploy gate", "completed", "failure"), + ] + const lines = renderText(deps, jobs) + const text = lines.join("\n") + + // Should have box nodes for cleanup and release + expect(text).toContain("┌") + expect(text).toContain("Release Glue jobs (dev)") + expect(text).toContain("Release Glue jobs (prod)") + expect(text).toContain("Release Glue jobs (stage)") + // Should NOT show the full "Cleanup preview Glue jobs / Release Glue jobs (dev)" + expect(text).not.toContain("Cleanup preview Glue jobs / ") + // Plain nodes should appear + expect(text).toContain("Extract Glue jobs from labels") + expect(text).toContain("Preview Glue jobs") + expect(text).toContain("Glue prod deploy gate") + // Cleanup box should show aggregate success (all 3 sub-jobs succeeded) + expect(text).toContain("✓ Cleanup preview Glue jobs") + // Release box should show aggregate failure (sub-job failed) + expect(text).toContain("✗ Release Glue jobs") + + // The Preview row should have ┗ (arriving from extract above) and ┛ (turning up toward prod-gate) + const previewLine = lines.find((line) => line.includes("Preview Glue jobs")) + expect(previewLine).toBeDefined() + expect(previewLine).toContain("┗") + expect(previewLine).toContain("┛") + + // Preview's line should be continuous (horizontal pass-through fills empty column) + expect(previewLine).toContain("━━━━━━━━━━━━━━━━━━━") + + // Transitive reduction: no direct horizontal from extract to prod-gate at Y=0 + // Gap 0 should show ┓ (down only, no rightward horizontal), not ┳ + const firstLine = lines[0]! + expect(firstLine).toContain("┓") + expect(firstLine).not.toContain("┳") + + // Gap 1 between boxes at Y=0 should have no horizontal pass-through (three spaces) + // The cleanup→release connection should be a single clean line (┏ and ┛, no ┻) + expect(text).not.toContain("┻") + }) + + test("transitive reduction removes redundant edges", () => { + // A → B → C → D, plus A → C and A → D (both redundant) + const deps = [makeDep("a", "A"), makeDep("b", "B", ["a"]), makeDep("c", "C", ["b"]), makeDep("d", "D", ["c", "a", "b"])] + const jobs = [makeJob("A"), makeJob("B"), makeJob("C"), makeJob("D")] + const lines = renderText(deps, jobs) + const text = lines.join("\n") + // Should still show all nodes + expect(text).toContain("✓ A") + expect(text).toContain("✓ B") + expect(text).toContain("✓ C") + expect(text).toContain("✓ D") + // Should be a simple linear chain (1 row) since redundant edges are removed + expect(lines.length).toBe(1) + }) + + test("reference rendering: fan-out one-to-three", () => { + const deps = [makeDep("a", "Build"), makeDep("b", "Unit Tests", ["a"]), makeDep("c", "Integration Tests", ["a"]), makeDep("d", "E2E Tests", ["a"])] + const jobs = [makeJob("Build"), makeJob("Unit Tests"), makeJob("Integration Tests"), makeJob("E2E Tests")] + expect(renderJoined(deps, jobs)).toBe("✓ Build━━━━┳━✓ E2E Tests\n" + " ┣━✓ Integration Tests\n" + " ┗━✓ Unit Tests") + }) + + test("reference rendering: fan-in three-to-one", () => { + const deps = [makeDep("a", "Lint"), makeDep("b", "Test"), makeDep("c", "Typecheck"), makeDep("d", "Deploy", ["a", "b", "c"])] + const jobs = [makeJob("Lint"), makeJob("Test"), makeJob("Typecheck"), makeJob("Deploy")] + expect(renderJoined(deps, jobs)).toBe("✓ Lint━━━━━━┳━✓ Deploy\n✓ Test━━━━━━┫\n✓ Typecheck━┛") + }) + + test("reference rendering: diamond", () => { + const deps = [makeDep("a", "Build"), makeDep("b", "Test Linux", ["a"]), makeDep("c", "Test macOS", ["a"]), makeDep("d", "Publish", ["b", "c"])] + const jobs = [makeJob("Build"), makeJob("Test Linux"), makeJob("Test macOS"), makeJob("Publish")] + expect(renderJoined(deps, jobs)).toBe("✓ Build━━━━┳━✓ Test Linux━┳━✓ Publish\n ┗━✓ Test macOS━┛") + }) + + test("reference rendering: matrix box node", () => { + const deps = [makeDep("build", "Build"), makeDep("test", "Test", ["build"]), makeDep("deploy", "Deploy", ["test"])] + const jobs = [makeJob("Build"), makeJob("Test / Test (node-18)"), makeJob("Test / Test (node-20)"), makeJob("Test / Test (node-22)"), makeJob("Deploy")] + expect(renderJoined(deps, jobs)).toBe( + "✓ Build━━━━┓ ┌ ✓ Test ──────────┐ ┏━✓ Deploy\n" + + " ┃ │ ✓ Test (node-18) │ ┃\n" + + " ┗━│ ✓ Test (node-20) │━┛\n" + + " │ ✓ Test (node-22) │\n" + + " └──────────────────┘", + ) + }) + + test("reference rendering: sequential boxes with single clean bridge", () => { + const deps = [makeDep("lint", "Lint"), makeDep("test", "Test", ["lint"]), makeDep("deploy", "Deploy", ["test"])] + const jobs = [ + makeJob("Lint / Lint (eslint)"), + makeJob("Lint / Lint (prettier)"), + makeJob("Test / Test (unit)"), + makeJob("Test / Test (integration)", "completed", "failure"), + makeJob("Deploy"), + ] + expect(renderJoined(deps, jobs)).toBe( + "┌ ✓ Lint ───────────┐ ┌ ✗ Test ──────────────┐ ┏━✓ Deploy\n" + + "│ ✓ Lint (eslint) │ │ ✗ Test (integration) │ ┃\n" + + "│ ✓ Lint (prettier) │━━━│ ✓ Test (unit) │━┛\n" + + "└───────────────────┘ └──────────────────────┘", + ) + }) + + test("reference rendering: cancelled and skipped jobs stay grey", () => { + const deps = [makeDep("build", "Build"), makeDep("deploy", "Deploy", ["build"])] + const jobs = [ + makeJob("Build"), + makeJob("Deploy / Deploy (staging)", "completed", "success"), + makeJob("Deploy / Deploy (production)", "completed", "cancelled"), + makeJob("Deploy / Deploy (canary)", "completed", "skipped"), + ] + expect(renderJoined(deps, jobs)).toBe( + "✓ Build━━━━┓ ┌ ○ Deploy ─────────────┐\n" + + " ┃ │ ○ Deploy (canary) │\n" + + " ┗━│ ○ Deploy (production) │\n" + + " │ ✓ Deploy (staging) │\n" + + " └───────────────────────┘", + ) + }) + + test("reference rendering: full glue workflow keeps continuous preview line", () => { + const deps = [ + makeDep("extract", "Extract Glue jobs from labels"), + makeDep("preview", "Preview Glue jobs", ["extract"]), + makeDep("cleanup", "Cleanup preview Glue jobs", ["extract"]), + makeDep("release", "Release Glue jobs", ["cleanup"]), + makeDep("prod-gate", "Glue prod deploy gate", ["extract", "preview", "cleanup", "release"]), + ] + const jobs = [ + makeJob("Extract Glue jobs from labels"), + makeJob("Preview Glue jobs", "queued", null), + makeJob("Cleanup preview Glue jobs / Release Glue jobs (dev)"), + makeJob("Cleanup preview Glue jobs / Release Glue jobs (prod)"), + makeJob("Cleanup preview Glue jobs / Release Glue jobs (stage)"), + makeJob("Release Glue jobs / Release Glue jobs (dev)", "completed", "failure"), + makeJob("Glue prod deploy gate", "completed", "failure"), + ] + expect(renderJoined(deps, jobs)).toBe( + "✓ Extract Glue jobs from labels━┓ ┌ ✓ Cleanup preview Glue jobs ┐ ┌ ✗ Release Glue jobs ──────┐ ┏━✗ Glue prod deploy gate\n" + + " ┃ │ ✓ Release Glue jobs (dev) │ ┏━│ ✗ Release Glue jobs (dev) │━┫\n" + + " ┣━│ ✓ Release Glue jobs (prod) │━┛ └───────────────────────────┘ ┃\n" + + " ┃ │ ✓ Release Glue jobs (stage) │ ┃\n" + + " ┃ └─────────────────────────────┘ ┃\n" + + " ┗━○ Preview Glue jobs━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛", + ) + }) +})