github-actions-watcher 0.1.1 → 0.2.0
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/README.md +69 -0
- package/dist/app.js +8 -4
- package/dist/components/footer.d.ts +2 -1
- package/dist/components/footer.js +4 -3
- package/dist/components/run-list.js +10 -2
- package/dist/hooks/use-countdown.d.ts +4 -0
- package/dist/hooks/use-countdown.js +26 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# GitHub Actions Watcher
|
|
2
|
+
|
|
3
|
+
A terminal UI for monitoring GitHub Actions workflow runs. Auto-detects the repo from your current directory, shows recent runs, and lets you drill into jobs and steps -- all without leaving the terminal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g github-actions-watcher
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires the [GitHub CLI](https://cli.github.com/) (`gh`) to be installed and authenticated.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Run from any directory with a GitHub remote
|
|
17
|
+
ghaw
|
|
18
|
+
|
|
19
|
+
# Custom polling interval (default: 10s)
|
|
20
|
+
ghaw --interval 5
|
|
21
|
+
ghaw -i 30
|
|
22
|
+
|
|
23
|
+
# Or use without installing
|
|
24
|
+
npx github-actions-watcher
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Auto-detects repo** from git remote (SSH or HTTPS)
|
|
30
|
+
- **Live polling** with countdown timer showing next refresh
|
|
31
|
+
- **Drill into runs** to see individual jobs and steps with durations
|
|
32
|
+
- **Switch repos** on the fly with `s`
|
|
33
|
+
- **Open in browser** with `o` from the detail view
|
|
34
|
+
- **Responsive layout** -- columns adapt to terminal width
|
|
35
|
+
|
|
36
|
+
## Keybindings
|
|
37
|
+
|
|
38
|
+
### List view
|
|
39
|
+
|
|
40
|
+
| Key | Action |
|
|
41
|
+
|-----|--------|
|
|
42
|
+
| Up/Down | Navigate runs |
|
|
43
|
+
| Enter | View jobs and steps |
|
|
44
|
+
| s | Switch repository |
|
|
45
|
+
| r | Refresh now |
|
|
46
|
+
| q | Quit |
|
|
47
|
+
|
|
48
|
+
### Detail view
|
|
49
|
+
|
|
50
|
+
| Key | Action |
|
|
51
|
+
|-----|--------|
|
|
52
|
+
| Up/Down | Scroll |
|
|
53
|
+
| Esc | Back to list |
|
|
54
|
+
| o | Open run in browser |
|
|
55
|
+
| r | Refresh |
|
|
56
|
+
| q | Quit |
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone git@github.com:dzoba/github-actions-watcher.git
|
|
62
|
+
cd github-actions-watcher
|
|
63
|
+
npm install
|
|
64
|
+
npx tsx src/cli.tsx
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
package/dist/app.js
CHANGED
|
@@ -5,6 +5,7 @@ import Spinner from "ink-spinner";
|
|
|
5
5
|
import { fetchRuns, fetchRunDetail } from "./lib/gh.js";
|
|
6
6
|
import { useRepo } from "./hooks/use-repo.js";
|
|
7
7
|
import { usePolling } from "./hooks/use-polling.js";
|
|
8
|
+
import { useCountdown } from "./hooks/use-countdown.js";
|
|
8
9
|
import { Header } from "./components/header.js";
|
|
9
10
|
import { Footer } from "./components/footer.js";
|
|
10
11
|
import { RunList } from "./components/run-list.js";
|
|
@@ -27,6 +28,7 @@ export function App({ interval }) {
|
|
|
27
28
|
return fetchRuns(repo);
|
|
28
29
|
}, [repo]);
|
|
29
30
|
const { data: runs, setData: setRuns, loading: runsLoading, error: runsError, refresh: refreshRuns, } = usePolling(runsFetcher, interval, !!repo);
|
|
31
|
+
const { secondsLeft, reset: resetCountdown } = useCountdown(interval, !!repo);
|
|
30
32
|
// Fetch detail
|
|
31
33
|
const fetchDetail = useCallback(async () => {
|
|
32
34
|
if (!repo || selectedRunId === null)
|
|
@@ -67,6 +69,7 @@ export function App({ interval }) {
|
|
|
67
69
|
}
|
|
68
70
|
else if (input === "r" && view === "list") {
|
|
69
71
|
refreshRuns();
|
|
72
|
+
resetCountdown();
|
|
70
73
|
}
|
|
71
74
|
}, { isActive: view === "list" || view === "detail" });
|
|
72
75
|
const handleDrillIn = useCallback((run) => {
|
|
@@ -90,8 +93,9 @@ export function App({ interval }) {
|
|
|
90
93
|
setRepo(newRepo);
|
|
91
94
|
setRuns(null);
|
|
92
95
|
setSelectedIndex(0);
|
|
96
|
+
resetCountdown();
|
|
93
97
|
setView("list");
|
|
94
|
-
}, [setRepo, setRuns]);
|
|
98
|
+
}, [setRepo, setRuns, resetCountdown]);
|
|
95
99
|
const handleRepoCancel = useCallback(() => {
|
|
96
100
|
setView("list");
|
|
97
101
|
}, []);
|
|
@@ -100,12 +104,12 @@ export function App({ interval }) {
|
|
|
100
104
|
return (_jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Detecting repository..."] }));
|
|
101
105
|
}
|
|
102
106
|
if (repoError && !repo) {
|
|
103
|
-
return (_jsxs(Box, { flexDirection: "column", children: [
|
|
107
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "GitHub Actions Watcher" }) }), _jsx(Text, { color: "yellow", children: "No GitHub repository detected in this directory." }), _jsx(Text, { dimColor: true, children: "To get started, either:" }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [" cd into a directory with a GitHub remote and run ", _jsx(Text, { bold: true, children: "ghaw" })] }), _jsxs(Text, { children: [" or type a repo below (e.g. ", _jsx(Text, { bold: true, children: "owner/repo" }), ")"] })] }), _jsx(RepoInput, { currentRepo: "", onConfirm: (r) => {
|
|
104
108
|
setRepo(r);
|
|
105
109
|
setView("list");
|
|
106
|
-
}, onCancel: () => exit(), isActive: true })] }));
|
|
110
|
+
}, onCancel: () => exit(), isActive: true }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "enter: confirm | esc/q: quit" }) })] }));
|
|
107
111
|
}
|
|
108
112
|
if (!repo)
|
|
109
113
|
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 })] }));
|
|
114
|
+
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, secondsLeft: secondsLeft })] }));
|
|
111
115
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { View } from "../lib/types.js";
|
|
2
2
|
interface Props {
|
|
3
3
|
view: View;
|
|
4
|
+
secondsLeft?: number;
|
|
4
5
|
}
|
|
5
|
-
export declare function Footer({ view }: Props): import("react/jsx-runtime").JSX.Element;
|
|
6
|
+
export declare function Footer({ view, secondsLeft }: Props): import("react/jsx-runtime").JSX.Element;
|
|
6
7
|
export {};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
const keys = {
|
|
4
4
|
list: "up/down: navigate | enter: details | s: switch repo | r: refresh | q: quit",
|
|
5
5
|
detail: "up/down: scroll | esc: back | o: open in browser | r: refresh | q: quit",
|
|
6
6
|
"repo-input": "enter: confirm | esc: cancel",
|
|
7
7
|
};
|
|
8
|
-
export function Footer({ view }) {
|
|
9
|
-
|
|
8
|
+
export function Footer({ view, secondsLeft }) {
|
|
9
|
+
const showCountdown = secondsLeft !== undefined && (view === "list" || view === "detail");
|
|
10
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: keys[view] }), showCountdown && (_jsxs(Text, { dimColor: true, children: [" | refresh in ", secondsLeft, "s"] }))] }));
|
|
10
11
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
3
3
|
import { StatusBadge } from "./status-badge.js";
|
|
4
4
|
import { relativeTime, truncate } from "../lib/format.js";
|
|
5
5
|
export function RunList({ runs, selectedIndex, onSelect, onEnter, isActive }) {
|
|
6
|
+
const { stdout } = useStdout();
|
|
7
|
+
const cols = stdout?.columns ?? 120;
|
|
6
8
|
useInput((input, key) => {
|
|
7
9
|
if (key.upArrow) {
|
|
8
10
|
onSelect(Math.max(0, selectedIndex - 1));
|
|
@@ -19,8 +21,14 @@ export function RunList({ runs, selectedIndex, onSelect, onEnter, isActive }) {
|
|
|
19
21
|
if (runs.length === 0) {
|
|
20
22
|
return _jsx(Text, { dimColor: true, children: "No workflow runs found." });
|
|
21
23
|
}
|
|
24
|
+
// Progressive column hiding based on terminal width
|
|
25
|
+
// Full: selector(2) + status(14) + workflow(20) + branch(18) + title(flex) + time(8) ~ 62+ cols
|
|
26
|
+
const showTime = cols >= 70;
|
|
27
|
+
const showBranch = cols >= 55;
|
|
28
|
+
const showWorkflow = cols >= 40;
|
|
29
|
+
const titleMax = Math.max(15, cols - (2 + 14 + (showWorkflow ? 21 : 0) + (showBranch ? 19 : 0) + (showTime ? 9 : 0)));
|
|
22
30
|
return (_jsx(Box, { flexDirection: "column", children: runs.map((run, i) => {
|
|
23
31
|
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,
|
|
32
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { children: selected ? ">" : " " }), _jsx(Box, { width: 14, children: _jsx(StatusBadge, { status: run.status, conclusion: run.conclusion }) }), showWorkflow && (_jsx(Box, { width: 20, children: _jsx(Text, { color: "blue", children: truncate(run.workflowName, 20) }) })), showBranch && (_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, titleMax) }) }), showTime && (_jsx(Box, { width: 8, children: _jsx(Text, { dimColor: true, children: relativeTime(run.createdAt) }) }))] }, run.databaseId));
|
|
25
33
|
}) }));
|
|
26
34
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
export function useCountdown(intervalMs, enabled) {
|
|
3
|
+
const [secondsLeft, setSecondsLeft] = useState(Math.floor(intervalMs / 1000));
|
|
4
|
+
const lastResetRef = useRef(Date.now());
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
if (!enabled)
|
|
7
|
+
return;
|
|
8
|
+
lastResetRef.current = Date.now();
|
|
9
|
+
setSecondsLeft(Math.floor(intervalMs / 1000));
|
|
10
|
+
const tick = setInterval(() => {
|
|
11
|
+
const elapsed = Date.now() - lastResetRef.current;
|
|
12
|
+
const remaining = Math.max(0, Math.ceil((intervalMs - elapsed) / 1000));
|
|
13
|
+
setSecondsLeft(remaining);
|
|
14
|
+
if (remaining === 0) {
|
|
15
|
+
lastResetRef.current = Date.now();
|
|
16
|
+
setSecondsLeft(Math.floor(intervalMs / 1000));
|
|
17
|
+
}
|
|
18
|
+
}, 1000);
|
|
19
|
+
return () => clearInterval(tick);
|
|
20
|
+
}, [intervalMs, enabled]);
|
|
21
|
+
const reset = () => {
|
|
22
|
+
lastResetRef.current = Date.now();
|
|
23
|
+
setSecondsLeft(Math.floor(intervalMs / 1000));
|
|
24
|
+
};
|
|
25
|
+
return { secondsLeft, reset };
|
|
26
|
+
}
|