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 ADDED
@@ -0,0 +1,5 @@
1
+ interface Props {
2
+ interval: number;
3
+ }
4
+ export declare function App({ interval }: Props): import("react/jsx-runtime").JSX.Element | null;
5
+ export {};
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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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,6 @@
1
+ import type { View } from "../lib/types.js";
2
+ interface Props {
3
+ view: View;
4
+ }
5
+ export declare function Footer({ view }: Props): import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -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
+ interface Props {
2
+ repo: string;
3
+ loading: boolean;
4
+ }
5
+ export declare function Header({ repo, loading }: Props): import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -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,6 @@
1
+ export declare function useRepo(): {
2
+ repo: string | null;
3
+ setRepo: import("react").Dispatch<import("react").SetStateAction<string | null>>;
4
+ error: string | null;
5
+ loading: boolean;
6
+ };
@@ -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,3 @@
1
+ export declare function relativeTime(dateStr: string): string;
2
+ export declare function duration(startStr: string, endStr: string | null): string;
3
+ export declare function truncate(str: string, maxLen: number): string;
@@ -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
+ }
@@ -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
+ }