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 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: [_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.0",
4
4
  "description": "Terminal TUI for monitoring GitHub Actions workflow runs",
5
5
  "type": "module",
6
6
  "bin": {