github-actions-watcher 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +5 -0
- package/dist/app.js +111 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +27 -0
- package/dist/components/footer.d.ts +6 -0
- package/dist/components/footer.js +10 -0
- package/dist/components/header.d.ts +6 -0
- package/dist/components/header.js +6 -0
- package/dist/components/repo-input.d.ts +8 -0
- package/dist/components/repo-input.js +18 -0
- package/dist/components/run-detail.d.ts +14 -0
- package/dist/components/run-detail.js +46 -0
- package/dist/components/run-list.d.ts +10 -0
- package/dist/components/run-list.js +26 -0
- package/dist/components/status-badge.d.ts +7 -0
- package/dist/components/status-badge.js +26 -0
- package/dist/hooks/use-polling.d.ts +7 -0
- package/dist/hooks/use-polling.js +41 -0
- package/dist/hooks/use-repo.d.ts +6 -0
- package/dist/hooks/use-repo.js +19 -0
- package/dist/lib/format.d.ts +3 -0
- package/dist/lib/format.js +36 -0
- package/dist/lib/gh.d.ts +4 -0
- package/dist/lib/gh.js +63 -0
- package/dist/lib/types.d.ts +52 -0
- package/dist/lib/types.js +1 -0
- package/package.json +46 -0
package/dist/app.d.ts
ADDED
package/dist/app.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback, useEffect } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
import { fetchRuns, fetchRunDetail } from "./lib/gh.js";
|
|
6
|
+
import { useRepo } from "./hooks/use-repo.js";
|
|
7
|
+
import { usePolling } from "./hooks/use-polling.js";
|
|
8
|
+
import { Header } from "./components/header.js";
|
|
9
|
+
import { Footer } from "./components/footer.js";
|
|
10
|
+
import { RunList } from "./components/run-list.js";
|
|
11
|
+
import { RunDetailView } from "./components/run-detail.js";
|
|
12
|
+
import { RepoInput } from "./components/repo-input.js";
|
|
13
|
+
export function App({ interval }) {
|
|
14
|
+
const { exit } = useApp();
|
|
15
|
+
const { repo, setRepo, error: repoError, loading: repoLoading } = useRepo();
|
|
16
|
+
const [view, setView] = useState("list");
|
|
17
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
18
|
+
const [selectedRunId, setSelectedRunId] = useState(null);
|
|
19
|
+
const [detailScrollOffset, setDetailScrollOffset] = useState(0);
|
|
20
|
+
// Detail state
|
|
21
|
+
const [detail, setDetail] = useState(null);
|
|
22
|
+
const [detailLoading, setDetailLoading] = useState(false);
|
|
23
|
+
const [detailError, setDetailError] = useState(null);
|
|
24
|
+
const runsFetcher = useCallback(() => {
|
|
25
|
+
if (!repo)
|
|
26
|
+
return Promise.resolve([]);
|
|
27
|
+
return fetchRuns(repo);
|
|
28
|
+
}, [repo]);
|
|
29
|
+
const { data: runs, setData: setRuns, loading: runsLoading, error: runsError, refresh: refreshRuns, } = usePolling(runsFetcher, interval, !!repo);
|
|
30
|
+
// Fetch detail
|
|
31
|
+
const fetchDetail = useCallback(async () => {
|
|
32
|
+
if (!repo || selectedRunId === null)
|
|
33
|
+
return;
|
|
34
|
+
setDetailLoading(true);
|
|
35
|
+
setDetailError(null);
|
|
36
|
+
try {
|
|
37
|
+
const d = await fetchRunDetail(repo, selectedRunId);
|
|
38
|
+
setDetail(d);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
setDetailError(err instanceof Error ? err.message : "Failed to fetch detail");
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
setDetailLoading(false);
|
|
45
|
+
}
|
|
46
|
+
}, [repo, selectedRunId]);
|
|
47
|
+
// Auto-fetch detail when entering detail view
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (view === "detail" && selectedRunId !== null) {
|
|
50
|
+
fetchDetail();
|
|
51
|
+
}
|
|
52
|
+
}, [view, selectedRunId, fetchDetail]);
|
|
53
|
+
// Poll detail view
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (view !== "detail" || selectedRunId === null)
|
|
56
|
+
return;
|
|
57
|
+
const id = setInterval(fetchDetail, interval);
|
|
58
|
+
return () => clearInterval(id);
|
|
59
|
+
}, [view, selectedRunId, interval, fetchDetail]);
|
|
60
|
+
// Global key handler for keys that don't belong to child components
|
|
61
|
+
useInput((input, key) => {
|
|
62
|
+
if (input === "q") {
|
|
63
|
+
exit();
|
|
64
|
+
}
|
|
65
|
+
else if (input === "s" && view === "list") {
|
|
66
|
+
setView("repo-input");
|
|
67
|
+
}
|
|
68
|
+
else if (input === "r" && view === "list") {
|
|
69
|
+
refreshRuns();
|
|
70
|
+
}
|
|
71
|
+
}, { isActive: view === "list" || view === "detail" });
|
|
72
|
+
const handleDrillIn = useCallback((run) => {
|
|
73
|
+
setSelectedRunId(run.databaseId);
|
|
74
|
+
setDetail(null);
|
|
75
|
+
setDetailScrollOffset(0);
|
|
76
|
+
setView("detail");
|
|
77
|
+
}, []);
|
|
78
|
+
const handleBack = useCallback(() => {
|
|
79
|
+
setView("list");
|
|
80
|
+
setSelectedRunId(null);
|
|
81
|
+
setDetail(null);
|
|
82
|
+
}, []);
|
|
83
|
+
const handleOpenInBrowser = useCallback(async () => {
|
|
84
|
+
if (!detail?.url)
|
|
85
|
+
return;
|
|
86
|
+
const openModule = await import("open");
|
|
87
|
+
openModule.default(detail.url);
|
|
88
|
+
}, [detail]);
|
|
89
|
+
const handleRepoConfirm = useCallback((newRepo) => {
|
|
90
|
+
setRepo(newRepo);
|
|
91
|
+
setRuns(null);
|
|
92
|
+
setSelectedIndex(0);
|
|
93
|
+
setView("list");
|
|
94
|
+
}, [setRepo, setRuns]);
|
|
95
|
+
const handleRepoCancel = useCallback(() => {
|
|
96
|
+
setView("list");
|
|
97
|
+
}, []);
|
|
98
|
+
// Loading state
|
|
99
|
+
if (repoLoading) {
|
|
100
|
+
return (_jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Detecting repository..."] }));
|
|
101
|
+
}
|
|
102
|
+
if (repoError && !repo) {
|
|
103
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["Error: ", repoError] }), _jsx(Text, { dimColor: true, children: "Run from a directory with a git remote, or press s to enter a repo manually." }), _jsx(RepoInput, { currentRepo: "", onConfirm: (r) => {
|
|
104
|
+
setRepo(r);
|
|
105
|
+
setView("list");
|
|
106
|
+
}, onCancel: () => exit(), isActive: true })] }));
|
|
107
|
+
}
|
|
108
|
+
if (!repo)
|
|
109
|
+
return null;
|
|
110
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { repo: repo, loading: runsLoading || detailLoading }), runsError && view === "list" && (_jsxs(Text, { color: "red", children: ["Error: ", runsError] })), view === "list" && runs && (_jsx(RunList, { runs: runs, selectedIndex: selectedIndex, onSelect: setSelectedIndex, onEnter: handleDrillIn, isActive: view === "list" })), view === "detail" && (_jsx(RunDetailView, { detail: detail, loading: detailLoading, error: detailError, onBack: handleBack, onOpen: handleOpenInBrowser, onRefresh: fetchDetail, isActive: view === "detail", scrollOffset: detailScrollOffset, onScroll: setDetailScrollOffset })), view === "repo-input" && (_jsx(RepoInput, { currentRepo: repo, onConfirm: handleRepoConfirm, onCancel: handleRepoCancel, isActive: view === "repo-input" })), _jsx(Footer, { view: view })] }));
|
|
111
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import meow from "meow";
|
|
5
|
+
import { App } from "./app.js";
|
|
6
|
+
const cli = meow(`
|
|
7
|
+
Usage
|
|
8
|
+
$ gaw
|
|
9
|
+
|
|
10
|
+
Options
|
|
11
|
+
--interval, -i Polling interval in seconds (default: 10)
|
|
12
|
+
|
|
13
|
+
Examples
|
|
14
|
+
$ gaw
|
|
15
|
+
$ gaw --interval 5
|
|
16
|
+
$ gaw -i 30
|
|
17
|
+
`, {
|
|
18
|
+
importMeta: import.meta,
|
|
19
|
+
flags: {
|
|
20
|
+
interval: {
|
|
21
|
+
type: "number",
|
|
22
|
+
shortFlag: "i",
|
|
23
|
+
default: 10,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
render(_jsx(App, { interval: cli.flags.interval * 1000 }));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
const keys = {
|
|
4
|
+
list: "up/down: navigate | enter: details | s: switch repo | r: refresh | q: quit",
|
|
5
|
+
detail: "up/down: scroll | esc: back | o: open in browser | r: refresh | q: quit",
|
|
6
|
+
"repo-input": "enter: confirm | esc: cancel",
|
|
7
|
+
};
|
|
8
|
+
export function Footer({ view }) {
|
|
9
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: keys[view] }) }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
export function Header({ repo, loading }) {
|
|
5
|
+
return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "GitHub Actions" }), _jsx(Text, { children: " - " }), _jsx(Text, { bold: true, children: repo }), loading && (_jsxs(Text, { children: [" ", _jsx(Spinner, { type: "dots" })] }))] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
currentRepo: string;
|
|
3
|
+
onConfirm: (repo: string) => void;
|
|
4
|
+
onCancel: () => void;
|
|
5
|
+
isActive: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function RepoInput({ currentRepo, onConfirm, onCancel, isActive }: Props): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
export function RepoInput({ currentRepo, onConfirm, onCancel, isActive }) {
|
|
6
|
+
const [value, setValue] = useState(currentRepo);
|
|
7
|
+
useInput((_input, key) => {
|
|
8
|
+
if (key.escape) {
|
|
9
|
+
onCancel();
|
|
10
|
+
}
|
|
11
|
+
}, { isActive });
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Switch repository:" }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "owner/repo:" }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: (val) => {
|
|
13
|
+
const trimmed = val.trim();
|
|
14
|
+
if (trimmed && trimmed.includes("/")) {
|
|
15
|
+
onConfirm(trimmed);
|
|
16
|
+
}
|
|
17
|
+
}, focus: isActive })] })] }));
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RunDetail } from "../lib/types.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
detail: RunDetail | null;
|
|
4
|
+
loading: boolean;
|
|
5
|
+
error: string | null;
|
|
6
|
+
onBack: () => void;
|
|
7
|
+
onOpen: () => void;
|
|
8
|
+
onRefresh: () => void;
|
|
9
|
+
isActive: boolean;
|
|
10
|
+
scrollOffset: number;
|
|
11
|
+
onScroll: (offset: number) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function RunDetailView({ detail, loading, error, onBack, onOpen, onRefresh, isActive, scrollOffset, onScroll, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import { StatusBadge } from "./status-badge.js";
|
|
5
|
+
import { duration } from "../lib/format.js";
|
|
6
|
+
export function RunDetailView({ detail, loading, error, onBack, onOpen, onRefresh, isActive, scrollOffset, onScroll, }) {
|
|
7
|
+
useInput((input, key) => {
|
|
8
|
+
if (key.escape) {
|
|
9
|
+
onBack();
|
|
10
|
+
}
|
|
11
|
+
else if (input === "o") {
|
|
12
|
+
onOpen();
|
|
13
|
+
}
|
|
14
|
+
else if (input === "r") {
|
|
15
|
+
onRefresh();
|
|
16
|
+
}
|
|
17
|
+
else if (key.upArrow) {
|
|
18
|
+
onScroll(Math.max(0, scrollOffset - 1));
|
|
19
|
+
}
|
|
20
|
+
else if (key.downArrow) {
|
|
21
|
+
onScroll(scrollOffset + 1);
|
|
22
|
+
}
|
|
23
|
+
}, { isActive });
|
|
24
|
+
if (loading && !detail) {
|
|
25
|
+
return (_jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Loading run details..."] }));
|
|
26
|
+
}
|
|
27
|
+
if (error) {
|
|
28
|
+
return _jsxs(Text, { color: "red", children: ["Error: ", error] });
|
|
29
|
+
}
|
|
30
|
+
if (!detail) {
|
|
31
|
+
return _jsx(Text, { dimColor: true, children: "No detail available." });
|
|
32
|
+
}
|
|
33
|
+
// Build flat list of lines to render, then apply scroll offset
|
|
34
|
+
const lines = [];
|
|
35
|
+
lines.push(_jsxs(Box, { gap: 1, children: [_jsx(StatusBadge, { status: detail.status, conclusion: detail.conclusion }), _jsx(Text, { bold: true, children: detail.displayTitle })] }, "title"));
|
|
36
|
+
lines.push(_jsxs(Box, { gap: 1, children: [_jsxs(Text, { dimColor: true, children: [detail.workflowName, " #", detail.number, " on ", detail.headBranch, " (", detail.event, ")"] }), loading && (_jsx(Text, { children: _jsx(Spinner, { type: "dots" }) }))] }, "meta"));
|
|
37
|
+
lines.push(_jsx(Text, { dimColor: true, children: "---" }, "sep"));
|
|
38
|
+
for (const job of detail.jobs) {
|
|
39
|
+
lines.push(_jsxs(Box, { gap: 1, marginTop: 1, children: [_jsx(StatusBadge, { status: job.status, conclusion: job.conclusion }), _jsx(Text, { bold: true, children: job.name }), job.startedAt && (_jsxs(Text, { dimColor: true, children: ["(", duration(job.startedAt, job.completedAt), ")"] }))] }, `job-${job.databaseId}`));
|
|
40
|
+
for (const step of job.steps) {
|
|
41
|
+
lines.push(_jsxs(Box, { gap: 1, paddingLeft: 2, children: [_jsx(StatusBadge, { status: step.status, conclusion: step.conclusion }), _jsx(Text, { children: step.name }), step.startedAt && step.completedAt && (_jsxs(Text, { dimColor: true, children: ["(", duration(step.startedAt, step.completedAt), ")"] }))] }, `step-${job.databaseId}-${step.number}`));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const visibleLines = lines.slice(scrollOffset);
|
|
45
|
+
return _jsx(Box, { flexDirection: "column", children: visibleLines });
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { WorkflowRun } from "../lib/types.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
runs: WorkflowRun[];
|
|
4
|
+
selectedIndex: number;
|
|
5
|
+
onSelect: (index: number) => void;
|
|
6
|
+
onEnter: (run: WorkflowRun) => void;
|
|
7
|
+
isActive: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function RunList({ runs, selectedIndex, onSelect, onEnter, isActive }: Props): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { StatusBadge } from "./status-badge.js";
|
|
4
|
+
import { relativeTime, truncate } from "../lib/format.js";
|
|
5
|
+
export function RunList({ runs, selectedIndex, onSelect, onEnter, isActive }) {
|
|
6
|
+
useInput((input, key) => {
|
|
7
|
+
if (key.upArrow) {
|
|
8
|
+
onSelect(Math.max(0, selectedIndex - 1));
|
|
9
|
+
}
|
|
10
|
+
else if (key.downArrow) {
|
|
11
|
+
onSelect(Math.min(runs.length - 1, selectedIndex + 1));
|
|
12
|
+
}
|
|
13
|
+
else if (key.return) {
|
|
14
|
+
const run = runs[selectedIndex];
|
|
15
|
+
if (run)
|
|
16
|
+
onEnter(run);
|
|
17
|
+
}
|
|
18
|
+
}, { isActive });
|
|
19
|
+
if (runs.length === 0) {
|
|
20
|
+
return _jsx(Text, { dimColor: true, children: "No workflow runs found." });
|
|
21
|
+
}
|
|
22
|
+
return (_jsx(Box, { flexDirection: "column", children: runs.map((run, i) => {
|
|
23
|
+
const selected = i === selectedIndex;
|
|
24
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { children: selected ? ">" : " " }), _jsx(Box, { width: 14, children: _jsx(StatusBadge, { status: run.status, conclusion: run.conclusion }) }), _jsx(Box, { width: 20, children: _jsx(Text, { color: "blue", children: truncate(run.workflowName, 20) }) }), _jsx(Box, { width: 18, children: _jsx(Text, { color: "magenta", children: truncate(run.headBranch, 18) }) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: truncate(run.displayTitle, 40) }) }), _jsx(Box, { width: 8, children: _jsx(Text, { dimColor: true, children: relativeTime(run.createdAt) }) })] }, run.databaseId));
|
|
25
|
+
}) }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RunStatus, RunConclusion, StepStatus, StepConclusion } from "../lib/types.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
status: RunStatus | StepStatus;
|
|
4
|
+
conclusion: RunConclusion | StepConclusion;
|
|
5
|
+
}
|
|
6
|
+
export declare function StatusBadge({ status, conclusion }: Props): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
export function StatusBadge({ status, conclusion }) {
|
|
4
|
+
if (status === "in_progress") {
|
|
5
|
+
return _jsx(Text, { color: "yellow", children: "* running" });
|
|
6
|
+
}
|
|
7
|
+
if (status === "queued" || status === "waiting" || status === "pending" || status === "requested") {
|
|
8
|
+
return _jsx(Text, { color: "gray", children: "~ queued" });
|
|
9
|
+
}
|
|
10
|
+
switch (conclusion) {
|
|
11
|
+
case "success":
|
|
12
|
+
return _jsx(Text, { color: "green", children: "+ passed" });
|
|
13
|
+
case "failure":
|
|
14
|
+
return _jsx(Text, { color: "red", children: "x failed" });
|
|
15
|
+
case "cancelled":
|
|
16
|
+
return _jsx(Text, { color: "gray", children: "- cancelled" });
|
|
17
|
+
case "skipped":
|
|
18
|
+
return _jsx(Text, { color: "gray", children: "- skipped" });
|
|
19
|
+
case "timed_out":
|
|
20
|
+
return _jsx(Text, { color: "red", children: "! timed out" });
|
|
21
|
+
case "action_required":
|
|
22
|
+
return _jsx(Text, { color: "yellow", children: "! action req" });
|
|
23
|
+
default:
|
|
24
|
+
return _jsx(Text, { color: "gray", children: "? unknown" });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function usePolling<T>(fetcher: () => Promise<T>, intervalMs: number, enabled: boolean): {
|
|
2
|
+
data: T | null;
|
|
3
|
+
setData: import("react").Dispatch<import("react").SetStateAction<T | null>>;
|
|
4
|
+
loading: boolean;
|
|
5
|
+
error: string | null;
|
|
6
|
+
refresh: () => Promise<void>;
|
|
7
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
export function usePolling(fetcher, intervalMs, enabled) {
|
|
3
|
+
const [data, setData] = useState(null);
|
|
4
|
+
const [loading, setLoading] = useState(false);
|
|
5
|
+
const [error, setError] = useState(null);
|
|
6
|
+
const mountedRef = useRef(true);
|
|
7
|
+
const refresh = useCallback(async () => {
|
|
8
|
+
setLoading(true);
|
|
9
|
+
setError(null);
|
|
10
|
+
try {
|
|
11
|
+
const result = await fetcher();
|
|
12
|
+
if (mountedRef.current) {
|
|
13
|
+
setData(result);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
if (mountedRef.current) {
|
|
18
|
+
setError(err instanceof Error ? err.message : "Fetch failed");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
if (mountedRef.current) {
|
|
23
|
+
setLoading(false);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}, [fetcher]);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
mountedRef.current = true;
|
|
29
|
+
return () => {
|
|
30
|
+
mountedRef.current = false;
|
|
31
|
+
};
|
|
32
|
+
}, []);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!enabled)
|
|
35
|
+
return;
|
|
36
|
+
refresh();
|
|
37
|
+
const id = setInterval(refresh, intervalMs);
|
|
38
|
+
return () => clearInterval(id);
|
|
39
|
+
}, [enabled, intervalMs, refresh]);
|
|
40
|
+
return { data, setData, loading, error, refresh };
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { detectRepo } from "../lib/gh.js";
|
|
3
|
+
export function useRepo() {
|
|
4
|
+
const [repo, setRepo] = useState(null);
|
|
5
|
+
const [error, setError] = useState(null);
|
|
6
|
+
const [loading, setLoading] = useState(true);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
detectRepo()
|
|
9
|
+
.then((r) => {
|
|
10
|
+
setRepo(r);
|
|
11
|
+
setLoading(false);
|
|
12
|
+
})
|
|
13
|
+
.catch((err) => {
|
|
14
|
+
setError(err instanceof Error ? err.message : "Failed to detect repo");
|
|
15
|
+
setLoading(false);
|
|
16
|
+
});
|
|
17
|
+
}, []);
|
|
18
|
+
return { repo, setRepo, error, loading };
|
|
19
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function relativeTime(dateStr) {
|
|
2
|
+
const now = Date.now();
|
|
3
|
+
const then = new Date(dateStr).getTime();
|
|
4
|
+
const diffSec = Math.floor((now - then) / 1000);
|
|
5
|
+
if (diffSec < 60)
|
|
6
|
+
return `${diffSec}s ago`;
|
|
7
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
8
|
+
if (diffMin < 60)
|
|
9
|
+
return `${diffMin}m ago`;
|
|
10
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
11
|
+
if (diffHr < 24)
|
|
12
|
+
return `${diffHr}h ago`;
|
|
13
|
+
const diffDay = Math.floor(diffHr / 24);
|
|
14
|
+
return `${diffDay}d ago`;
|
|
15
|
+
}
|
|
16
|
+
export function duration(startStr, endStr) {
|
|
17
|
+
if (!startStr)
|
|
18
|
+
return "";
|
|
19
|
+
const start = new Date(startStr).getTime();
|
|
20
|
+
const end = endStr ? new Date(endStr).getTime() : Date.now();
|
|
21
|
+
const diffSec = Math.floor((end - start) / 1000);
|
|
22
|
+
if (diffSec < 60)
|
|
23
|
+
return `${diffSec}s`;
|
|
24
|
+
const min = Math.floor(diffSec / 60);
|
|
25
|
+
const sec = diffSec % 60;
|
|
26
|
+
if (min < 60)
|
|
27
|
+
return `${min}m ${sec}s`;
|
|
28
|
+
const hr = Math.floor(min / 60);
|
|
29
|
+
const remMin = min % 60;
|
|
30
|
+
return `${hr}h ${remMin}m`;
|
|
31
|
+
}
|
|
32
|
+
export function truncate(str, maxLen) {
|
|
33
|
+
if (str.length <= maxLen)
|
|
34
|
+
return str;
|
|
35
|
+
return str.slice(0, maxLen - 1) + "\u2026";
|
|
36
|
+
}
|
package/dist/lib/gh.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { WorkflowRun, RunDetail } from "./types.js";
|
|
2
|
+
export declare function fetchRuns(repo: string): Promise<WorkflowRun[]>;
|
|
3
|
+
export declare function fetchRunDetail(repo: string, runId: number): Promise<RunDetail>;
|
|
4
|
+
export declare function detectRepo(): Promise<string>;
|
package/dist/lib/gh.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
async function gh(args) {
|
|
5
|
+
const { stdout } = await execFileAsync("gh", args, {
|
|
6
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
7
|
+
});
|
|
8
|
+
return JSON.parse(stdout);
|
|
9
|
+
}
|
|
10
|
+
const RUN_FIELDS = [
|
|
11
|
+
"databaseId",
|
|
12
|
+
"displayTitle",
|
|
13
|
+
"event",
|
|
14
|
+
"headBranch",
|
|
15
|
+
"name",
|
|
16
|
+
"number",
|
|
17
|
+
"status",
|
|
18
|
+
"conclusion",
|
|
19
|
+
"createdAt",
|
|
20
|
+
"updatedAt",
|
|
21
|
+
"url",
|
|
22
|
+
"workflowName",
|
|
23
|
+
].join(",");
|
|
24
|
+
export async function fetchRuns(repo) {
|
|
25
|
+
return gh([
|
|
26
|
+
"run",
|
|
27
|
+
"list",
|
|
28
|
+
"--repo",
|
|
29
|
+
repo,
|
|
30
|
+
"--json",
|
|
31
|
+
RUN_FIELDS,
|
|
32
|
+
"--limit",
|
|
33
|
+
"20",
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
export async function fetchRunDetail(repo, runId) {
|
|
37
|
+
return gh([
|
|
38
|
+
"run",
|
|
39
|
+
"view",
|
|
40
|
+
String(runId),
|
|
41
|
+
"--repo",
|
|
42
|
+
repo,
|
|
43
|
+
"--json",
|
|
44
|
+
`${RUN_FIELDS},jobs`,
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
47
|
+
export async function detectRepo() {
|
|
48
|
+
const { stdout } = await execFileAsync("git", [
|
|
49
|
+
"remote",
|
|
50
|
+
"get-url",
|
|
51
|
+
"origin",
|
|
52
|
+
]);
|
|
53
|
+
const url = stdout.trim();
|
|
54
|
+
// Handle SSH: git@github.com:owner/repo.git
|
|
55
|
+
const sshMatch = url.match(/git@github\.com:(.+?)(?:\.git)?$/);
|
|
56
|
+
if (sshMatch)
|
|
57
|
+
return sshMatch[1];
|
|
58
|
+
// Handle HTTPS: https://github.com/owner/repo.git
|
|
59
|
+
const httpsMatch = url.match(/github\.com\/(.+?)(?:\.git)?$/);
|
|
60
|
+
if (httpsMatch)
|
|
61
|
+
return httpsMatch[1];
|
|
62
|
+
throw new Error(`Could not parse repo from remote URL: ${url}`);
|
|
63
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type RunStatus = "completed" | "in_progress" | "queued" | "requested" | "waiting" | "pending";
|
|
2
|
+
export type RunConclusion = "success" | "failure" | "cancelled" | "skipped" | "timed_out" | "action_required" | "neutral" | "stale" | null;
|
|
3
|
+
export type StepStatus = "completed" | "in_progress" | "queued" | "pending" | "waiting";
|
|
4
|
+
export type StepConclusion = "success" | "failure" | "cancelled" | "skipped" | null;
|
|
5
|
+
export interface WorkflowRun {
|
|
6
|
+
databaseId: number;
|
|
7
|
+
displayTitle: string;
|
|
8
|
+
event: string;
|
|
9
|
+
headBranch: string;
|
|
10
|
+
name: string;
|
|
11
|
+
number: number;
|
|
12
|
+
status: RunStatus;
|
|
13
|
+
conclusion: RunConclusion;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
url: string;
|
|
17
|
+
workflowName: string;
|
|
18
|
+
}
|
|
19
|
+
export interface Step {
|
|
20
|
+
name: string;
|
|
21
|
+
status: StepStatus;
|
|
22
|
+
conclusion: StepConclusion;
|
|
23
|
+
number: number;
|
|
24
|
+
startedAt: string;
|
|
25
|
+
completedAt: string;
|
|
26
|
+
}
|
|
27
|
+
export interface Job {
|
|
28
|
+
name: string;
|
|
29
|
+
status: StepStatus;
|
|
30
|
+
conclusion: StepConclusion;
|
|
31
|
+
startedAt: string;
|
|
32
|
+
completedAt: string;
|
|
33
|
+
steps: Step[];
|
|
34
|
+
url: string;
|
|
35
|
+
databaseId: number;
|
|
36
|
+
}
|
|
37
|
+
export interface RunDetail {
|
|
38
|
+
databaseId: number;
|
|
39
|
+
displayTitle: string;
|
|
40
|
+
event: string;
|
|
41
|
+
headBranch: string;
|
|
42
|
+
name: string;
|
|
43
|
+
number: number;
|
|
44
|
+
status: RunStatus;
|
|
45
|
+
conclusion: RunConclusion;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
updatedAt: string;
|
|
48
|
+
url: string;
|
|
49
|
+
workflowName: string;
|
|
50
|
+
jobs: Job[];
|
|
51
|
+
}
|
|
52
|
+
export type View = "list" | "detail" | "repo-input";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "github-actions-watcher",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Terminal TUI for monitoring GitHub Actions workflow runs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ghaw": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "tsx src/cli.tsx",
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"prepublishOnly": "npm run build",
|
|
16
|
+
"start": "node dist/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"github",
|
|
20
|
+
"actions",
|
|
21
|
+
"ci",
|
|
22
|
+
"tui",
|
|
23
|
+
"terminal",
|
|
24
|
+
"workflow",
|
|
25
|
+
"cli"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+ssh://git@github.com/dzoba/github-actions-watcher.git"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"ink": "^5.1.0",
|
|
34
|
+
"ink-spinner": "^5.0.0",
|
|
35
|
+
"ink-text-input": "^6.0.0",
|
|
36
|
+
"meow": "^13.0.0",
|
|
37
|
+
"open": "^10.1.0",
|
|
38
|
+
"react": "^18.3.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^25.1.0",
|
|
42
|
+
"@types/react": "^18.3.12",
|
|
43
|
+
"tsx": "^4.19.0",
|
|
44
|
+
"typescript": "^5.6.0"
|
|
45
|
+
}
|
|
46
|
+
}
|