github-actions-watcher 0.1.1 → 0.2.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/README.md ADDED
@@ -0,0 +1,77 @@
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
+ ## Quick start
6
+
7
+ ```
8
+ npx github-actions-watcher
9
+ ```
10
+
11
+ ## Install
12
+
13
+ ```
14
+ npm install -g github-actions-watcher
15
+ ```
16
+
17
+ Then run `ghaw` from any directory with a GitHub remote.
18
+
19
+ Requires the [GitHub CLI](https://cli.github.com/) (`gh`) to be installed and authenticated.
20
+
21
+ ## Usage
22
+
23
+ ```bash
24
+ # Run from any directory with a GitHub remote
25
+ ghaw
26
+
27
+ # Custom polling interval (default: 10s)
28
+ ghaw --interval 5
29
+ ghaw -i 30
30
+
31
+ # Or use without installing
32
+ npx github-actions-watcher
33
+ ```
34
+
35
+ ## Features
36
+
37
+ - **Auto-detects repo** from git remote (SSH or HTTPS)
38
+ - **Live polling** with countdown timer showing next refresh
39
+ - **Drill into runs** to see individual jobs and steps with durations
40
+ - **Switch repos** on the fly with `s`
41
+ - **Open in browser** with `o` from the detail view
42
+ - **Responsive layout** -- columns adapt to terminal width
43
+
44
+ ## Keybindings
45
+
46
+ ### List view
47
+
48
+ | Key | Action |
49
+ |-----|--------|
50
+ | Up/Down | Navigate runs |
51
+ | Enter | View jobs and steps |
52
+ | s | Switch repository |
53
+ | r | Refresh now |
54
+ | q | Quit |
55
+
56
+ ### Detail view
57
+
58
+ | Key | Action |
59
+ |-----|--------|
60
+ | Up/Down | Scroll |
61
+ | Esc | Back to list |
62
+ | o | Open run in browser |
63
+ | r | Refresh |
64
+ | q | Quit |
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ git clone git@github.com:dzoba/github-actions-watcher.git
70
+ cd github-actions-watcher
71
+ npm install
72
+ npx tsx src/cli.tsx
73
+ ```
74
+
75
+ ## License
76
+
77
+ 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: [_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) => {
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
- return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: keys[view] }) }));
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, 40) }) }), _jsx(Box, { width: 8, children: _jsx(Text, { dimColor: true, children: relativeTime(run.createdAt) }) })] }, run.databaseId));
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,4 @@
1
+ export declare function useCountdown(intervalMs: number, enabled: boolean): {
2
+ secondsLeft: number;
3
+ reset: () => void;
4
+ };
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-actions-watcher",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Terminal TUI for monitoring GitHub Actions workflow runs",
5
5
  "type": "module",
6
6
  "bin": {