opencode-gh-ci 0.1.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/package.json +44 -0
- package/server.ts +217 -0
- package/shared.ts +317 -0
- package/tui.tsx +327 -0
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "opencode-gh-ci",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "GitHub Actions CI status in the OpenCode sidebar with live elapsed timers.",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/antoinejeannot/opencode-gh-ci"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"opencode",
|
|
14
|
+
"opencode-plugin",
|
|
15
|
+
"opencode-tui-plugin",
|
|
16
|
+
"github",
|
|
17
|
+
"actions",
|
|
18
|
+
"ci",
|
|
19
|
+
"sidebar"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
"./server": {
|
|
23
|
+
"import": "./server.ts"
|
|
24
|
+
},
|
|
25
|
+
"./tui": {
|
|
26
|
+
"import": "./tui.tsx"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"server.ts",
|
|
31
|
+
"tui.tsx",
|
|
32
|
+
"shared.ts",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"opencode": ">=1.3.13"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@opencode-ai/plugin": "*",
|
|
40
|
+
"@opentui/core": "*",
|
|
41
|
+
"@opentui/solid": "*",
|
|
42
|
+
"solid-js": "*"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/server.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
3
|
+
import { dirname } from "node:path"
|
|
4
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
5
|
+
import {
|
|
6
|
+
PLUGIN_ID,
|
|
7
|
+
parseOptions,
|
|
8
|
+
buildCachePath,
|
|
9
|
+
buildRegistryPath,
|
|
10
|
+
registerSession,
|
|
11
|
+
deregisterSession,
|
|
12
|
+
cleanupOrphans,
|
|
13
|
+
cleanupSession,
|
|
14
|
+
emptyCache,
|
|
15
|
+
nowISO,
|
|
16
|
+
} from "./shared"
|
|
17
|
+
|
|
18
|
+
import type { WorkflowJob, WorkflowRun, CICache } from "./shared"
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Git branch detection
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
async function detectBranch(cwd: string): Promise<string> {
|
|
25
|
+
const proc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
26
|
+
cwd,
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
stderr: "pipe",
|
|
29
|
+
})
|
|
30
|
+
const out = await new Response(proc.stdout).text()
|
|
31
|
+
const code = await proc.exited
|
|
32
|
+
if (code !== 0) return ""
|
|
33
|
+
return out.trim()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// GitHub CLI data fetching
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
async function runCmd(args: string[], cwd: string): Promise<string> {
|
|
41
|
+
const proc = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
|
|
42
|
+
const out = await new Response(proc.stdout).text()
|
|
43
|
+
const code = await proc.exited
|
|
44
|
+
if (code !== 0) throw new Error(`exit ${code}: ${args.join(" ")}`)
|
|
45
|
+
return out.trim()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fetchJobsForRun(runId: number, cwd: string): Promise<WorkflowJob[]> {
|
|
49
|
+
const jobsRaw = await runCmd(
|
|
50
|
+
["gh", "api",
|
|
51
|
+
`repos/{owner}/{repo}/actions/runs/${runId}/jobs`,
|
|
52
|
+
"--jq", ".jobs[] | {name,status,conclusion,started_at,completed_at}"],
|
|
53
|
+
cwd,
|
|
54
|
+
)
|
|
55
|
+
return jobsRaw
|
|
56
|
+
.split("\n")
|
|
57
|
+
.filter((l: string) => l.trim())
|
|
58
|
+
.map((l: string) => { try { return JSON.parse(l) } catch { return null } })
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fetchRuns(
|
|
63
|
+
branch: string,
|
|
64
|
+
cwd: string,
|
|
65
|
+
maxRuns: number,
|
|
66
|
+
pushWindowMs: number,
|
|
67
|
+
): Promise<WorkflowRun[]> {
|
|
68
|
+
const raw = await runCmd(
|
|
69
|
+
["gh", "run", "list", "--branch", branch, "--limit", String(maxRuns),
|
|
70
|
+
"--json", "databaseId,name,status,conclusion,headBranch,createdAt"],
|
|
71
|
+
cwd,
|
|
72
|
+
)
|
|
73
|
+
const allRuns = JSON.parse(raw)
|
|
74
|
+
if (!allRuns?.length) return []
|
|
75
|
+
|
|
76
|
+
const latestTime = new Date(allRuns[0].createdAt).getTime()
|
|
77
|
+
const pushRuns = allRuns.filter(
|
|
78
|
+
(r: any) => Math.abs(new Date(r.createdAt).getTime() - latestTime) < pushWindowMs
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const results: WorkflowRun[] = await Promise.all(
|
|
82
|
+
pushRuns.map(async (r: any) => {
|
|
83
|
+
const jobs = await fetchJobsForRun(r.databaseId, cwd)
|
|
84
|
+
return {
|
|
85
|
+
id: r.databaseId,
|
|
86
|
+
name: r.name,
|
|
87
|
+
status: r.status,
|
|
88
|
+
conclusion: r.conclusion,
|
|
89
|
+
head_branch: r.headBranch,
|
|
90
|
+
jobs,
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return results
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Cache I/O
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
async function readCache(filePath: string): Promise<CICache> {
|
|
103
|
+
try {
|
|
104
|
+
const text = await readFile(filePath, "utf8")
|
|
105
|
+
const parsed = JSON.parse(text)
|
|
106
|
+
if (!parsed || typeof parsed !== "object") return emptyCache()
|
|
107
|
+
return {
|
|
108
|
+
version: 1,
|
|
109
|
+
updatedAt: parsed.updatedAt || nowISO(),
|
|
110
|
+
branch: parsed.branch || "",
|
|
111
|
+
runs: parsed.runs || (parsed.run ? [parsed.run] : []),
|
|
112
|
+
error: parsed.error || null,
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
return emptyCache()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function writeCache(filePath: string, cache: CICache): Promise<void> {
|
|
120
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
121
|
+
await writeFile(filePath, `${JSON.stringify(cache, null, 2)}\n`, "utf8")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Plugin entry
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
const server: Plugin = async (input, options) => {
|
|
129
|
+
const opts = parseOptions(options)
|
|
130
|
+
if (!opts.enabled) return {}
|
|
131
|
+
|
|
132
|
+
const cwd = input.directory
|
|
133
|
+
const cachePath = buildCachePath()
|
|
134
|
+
const registryPath = buildRegistryPath(cwd)
|
|
135
|
+
|
|
136
|
+
// Clean up orphaned sessions from dead processes
|
|
137
|
+
await cleanupOrphans(registryPath)
|
|
138
|
+
|
|
139
|
+
// Register this session
|
|
140
|
+
await registerSession(registryPath, {
|
|
141
|
+
cachePath,
|
|
142
|
+
pid: process.pid,
|
|
143
|
+
startedAt: nowISO(),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Global last-refresh timestamp — shared by poll timer and event handlers
|
|
147
|
+
let lastRefreshAt = 0
|
|
148
|
+
|
|
149
|
+
const refresh = async () => {
|
|
150
|
+
try {
|
|
151
|
+
const branch = await detectBranch(cwd)
|
|
152
|
+
if (!branch) {
|
|
153
|
+
await writeCache(cachePath, { ...emptyCache(), error: "Not a git repo" })
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const runs = await fetchRuns(branch, cwd, opts.max_runs, opts.push_window_ms)
|
|
158
|
+
await writeCache(cachePath, {
|
|
159
|
+
version: 1,
|
|
160
|
+
updatedAt: nowISO(),
|
|
161
|
+
branch,
|
|
162
|
+
runs,
|
|
163
|
+
error: null,
|
|
164
|
+
})
|
|
165
|
+
} catch (e: any) {
|
|
166
|
+
const prev = await readCache(cachePath)
|
|
167
|
+
await writeCache(cachePath, {
|
|
168
|
+
...prev,
|
|
169
|
+
updatedAt: nowISO(),
|
|
170
|
+
error: e?.message ?? "fetch failed",
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
lastRefreshAt = Date.now()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Global debounce — skips if any source (poll or event) already refreshed recently
|
|
177
|
+
const maybeRefresh = () => {
|
|
178
|
+
if (Date.now() - lastRefreshAt >= opts.debounce_ms) {
|
|
179
|
+
void refresh()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Initial fetch + periodic poll (also debounced)
|
|
184
|
+
void refresh()
|
|
185
|
+
const timer = setInterval(() => maybeRefresh(), opts.server_poll_ms)
|
|
186
|
+
timer.unref?.()
|
|
187
|
+
|
|
188
|
+
// Cleanup on graceful shutdown
|
|
189
|
+
const cleanup = async () => {
|
|
190
|
+
clearInterval(timer)
|
|
191
|
+
await cleanupSession(cachePath)
|
|
192
|
+
await deregisterSession(registryPath, cachePath)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const onExit = () => { void cleanup() }
|
|
196
|
+
process.on("exit", onExit)
|
|
197
|
+
process.on("SIGINT", onExit)
|
|
198
|
+
process.on("SIGTERM", onExit)
|
|
199
|
+
|
|
200
|
+
// Build return object with dynamic event handlers
|
|
201
|
+
const handlers: Record<string, unknown> = { dispose: cleanup }
|
|
202
|
+
|
|
203
|
+
if (opts.refresh_on_events) {
|
|
204
|
+
for (const event of opts.refresh_on_events) {
|
|
205
|
+
handlers[event] = async () => {
|
|
206
|
+
maybeRefresh()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return handlers
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export default {
|
|
215
|
+
id: PLUGIN_ID,
|
|
216
|
+
server,
|
|
217
|
+
}
|
package/shared.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdir, readFile, writeFile, rm } from "node:fs/promises"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
export const PLUGIN_ID = "gh-ci"
|
|
7
|
+
|
|
8
|
+
const BASE_DIR = path.join(os.tmpdir(), "opencode-gh-ci")
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Options
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
// All plugin trigger events that can be subscribed to
|
|
15
|
+
const ALL_EVENTS = [
|
|
16
|
+
"chat.message",
|
|
17
|
+
"tool.execute.before",
|
|
18
|
+
"tool.execute.after",
|
|
19
|
+
"command.execute.before",
|
|
20
|
+
"shell.env",
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
const DEFAULT_EVENTS = [...ALL_EVENTS] as string[]
|
|
24
|
+
|
|
25
|
+
const DEFAULTS = {
|
|
26
|
+
enabled: true,
|
|
27
|
+
server_poll_ms: 10_000,
|
|
28
|
+
tui_poll_ms: 5_000,
|
|
29
|
+
debounce_ms: 10_000,
|
|
30
|
+
push_window_ms: 60_000,
|
|
31
|
+
max_runs: 10,
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
export interface HideFilters {
|
|
35
|
+
workflows: RegExp[]
|
|
36
|
+
jobs: RegExp[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PluginOptions {
|
|
40
|
+
enabled: boolean
|
|
41
|
+
server_poll_ms: number
|
|
42
|
+
tui_poll_ms: number
|
|
43
|
+
debounce_ms: number
|
|
44
|
+
push_window_ms: number
|
|
45
|
+
max_runs: number
|
|
46
|
+
refresh_on_events: string[] | false
|
|
47
|
+
hide: HideFilters
|
|
48
|
+
collapse_single_workflow: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const toRecord = (v: unknown): Record<string, unknown> | undefined =>
|
|
52
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
53
|
+
? Object.fromEntries(Object.entries(v))
|
|
54
|
+
: undefined
|
|
55
|
+
|
|
56
|
+
const toBool = (v: unknown, fallback: boolean): boolean =>
|
|
57
|
+
typeof v === "boolean" ? v : fallback
|
|
58
|
+
|
|
59
|
+
const toRegexList = (v: unknown): RegExp[] => {
|
|
60
|
+
if (!Array.isArray(v)) return []
|
|
61
|
+
return v
|
|
62
|
+
.filter((s): s is string => typeof s === "string")
|
|
63
|
+
.map((s) => { try { return new RegExp(s, "i") } catch { return null } })
|
|
64
|
+
.filter((r): r is RegExp => r !== null)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const toNum = (v: unknown, fallback: number, min = 0): number => {
|
|
68
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return fallback
|
|
69
|
+
return Math.max(min, v)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseOptions(raw: unknown): PluginOptions {
|
|
73
|
+
const rec = toRecord(raw)
|
|
74
|
+
if (!rec) return { ...DEFAULTS, refresh_on_events: [...DEFAULT_EVENTS], hide: { workflows: [], jobs: [] }, collapse_single_workflow: true }
|
|
75
|
+
|
|
76
|
+
// Parse refresh_on_events:
|
|
77
|
+
// false → disabled
|
|
78
|
+
// true → all available events
|
|
79
|
+
// undefined → default events (chat.message)
|
|
80
|
+
// ["chat.message", "tool.execute.after"] → specific events
|
|
81
|
+
let refreshOnEvents: string[] | false
|
|
82
|
+
const roe = rec.refresh_on_events
|
|
83
|
+
if (roe === false) {
|
|
84
|
+
refreshOnEvents = false
|
|
85
|
+
} else if (roe === true) {
|
|
86
|
+
refreshOnEvents = [...ALL_EVENTS]
|
|
87
|
+
} else if (Array.isArray(roe)) {
|
|
88
|
+
refreshOnEvents = roe.filter((e): e is string => typeof e === "string")
|
|
89
|
+
} else {
|
|
90
|
+
refreshOnEvents = [...DEFAULT_EVENTS]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Parse hide: { workflows: [...], jobs: [...] }
|
|
94
|
+
const hideRec = toRecord(rec.hide)
|
|
95
|
+
const hide: HideFilters = {
|
|
96
|
+
workflows: toRegexList(hideRec?.workflows),
|
|
97
|
+
jobs: toRegexList(hideRec?.jobs),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
enabled: toBool(rec.enabled, DEFAULTS.enabled),
|
|
102
|
+
server_poll_ms: toNum(rec.server_poll_ms, DEFAULTS.server_poll_ms, 5000),
|
|
103
|
+
tui_poll_ms: toNum(rec.tui_poll_ms, DEFAULTS.tui_poll_ms, 1000),
|
|
104
|
+
debounce_ms: toNum(rec.debounce_ms, DEFAULTS.debounce_ms, 1000),
|
|
105
|
+
push_window_ms: toNum(rec.push_window_ms, DEFAULTS.push_window_ms, 10_000),
|
|
106
|
+
max_runs: toNum(rec.max_runs, DEFAULTS.max_runs, 1),
|
|
107
|
+
refresh_on_events: refreshOnEvents,
|
|
108
|
+
hide,
|
|
109
|
+
collapse_single_workflow: toBool(rec.collapse_single_workflow, true),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Filtering
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** Filter runs and jobs based on hide regexes. Returns a new array (no mutation). */
|
|
118
|
+
export function filterRuns(runs: WorkflowRun[] | undefined, hide: HideFilters): WorkflowRun[] {
|
|
119
|
+
if (!runs?.length) return []
|
|
120
|
+
if (!hide.workflows.length && !hide.jobs.length) return runs
|
|
121
|
+
|
|
122
|
+
return runs
|
|
123
|
+
.filter((r) => !hide.workflows.some((re) => re.test(r.name)))
|
|
124
|
+
.map((r) => {
|
|
125
|
+
if (!hide.jobs.length) return r
|
|
126
|
+
const filteredJobs = r.jobs.filter((j) => !hide.jobs.some((re) => re.test(j.name)))
|
|
127
|
+
return filteredJobs.length === r.jobs.length ? r : { ...r, jobs: filteredJobs }
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Domain types
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
export interface WorkflowJob {
|
|
136
|
+
name: string
|
|
137
|
+
status: "queued" | "in_progress" | "completed" | "waiting"
|
|
138
|
+
conclusion: string | null
|
|
139
|
+
started_at: string | null
|
|
140
|
+
completed_at: string | null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface WorkflowRun {
|
|
144
|
+
id: number
|
|
145
|
+
name: string
|
|
146
|
+
status: string
|
|
147
|
+
conclusion: string | null
|
|
148
|
+
head_branch: string
|
|
149
|
+
jobs: WorkflowJob[]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface CICache {
|
|
153
|
+
version: 1
|
|
154
|
+
updatedAt: string
|
|
155
|
+
branch: string
|
|
156
|
+
runs: WorkflowRun[]
|
|
157
|
+
error: string | null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface RegistryEntry {
|
|
161
|
+
cachePath: string
|
|
162
|
+
pid: number
|
|
163
|
+
startedAt: string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Cache path helpers
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export const nowISO = () => new Date().toISOString()
|
|
171
|
+
|
|
172
|
+
export const emptyCache = (): CICache => ({
|
|
173
|
+
version: 1,
|
|
174
|
+
updatedAt: nowISO(),
|
|
175
|
+
branch: "",
|
|
176
|
+
runs: [],
|
|
177
|
+
error: null,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
/** Random per-session cache directory: <tmpdir>/opencode-gh-ci/<uuid>/ci.json */
|
|
181
|
+
export function buildCachePath(): string {
|
|
182
|
+
return path.join(BASE_DIR, randomUUID(), "ci.json")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Deterministic registry file per project: <tmpdir>/opencode-gh-ci/<hash>.registry.json */
|
|
186
|
+
export function buildRegistryPath(root: string): string {
|
|
187
|
+
const digest = createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 12)
|
|
188
|
+
return path.join(BASE_DIR, `${digest}.registry.json`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Registry helpers
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
export async function readRegistry(registryPath: string): Promise<RegistryEntry[]> {
|
|
196
|
+
try {
|
|
197
|
+
const text = await readFile(registryPath, "utf8")
|
|
198
|
+
const parsed = JSON.parse(text)
|
|
199
|
+
return Array.isArray(parsed) ? parsed : []
|
|
200
|
+
} catch {
|
|
201
|
+
return []
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function writeRegistry(registryPath: string, entries: RegistryEntry[]): Promise<void> {
|
|
206
|
+
await mkdir(path.dirname(registryPath), { recursive: true })
|
|
207
|
+
await writeFile(registryPath, JSON.stringify(entries, null, 2) + "\n", "utf8")
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isPidAlive(pid: number): boolean {
|
|
211
|
+
try {
|
|
212
|
+
process.kill(pid, 0)
|
|
213
|
+
return true
|
|
214
|
+
} catch {
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Register this session in the registry */
|
|
220
|
+
export async function registerSession(
|
|
221
|
+
registryPath: string,
|
|
222
|
+
entry: RegistryEntry,
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
const entries = await readRegistry(registryPath)
|
|
225
|
+
entries.push(entry)
|
|
226
|
+
await writeRegistry(registryPath, entries)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Remove this session from the registry */
|
|
230
|
+
export async function deregisterSession(
|
|
231
|
+
registryPath: string,
|
|
232
|
+
cachePath: string,
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
const entries = await readRegistry(registryPath)
|
|
235
|
+
const filtered = entries.filter((e) => e.cachePath !== cachePath)
|
|
236
|
+
await writeRegistry(registryPath, filtered)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Scan registry, remove entries with dead PIDs, delete their cache directories */
|
|
240
|
+
export async function cleanupOrphans(registryPath: string): Promise<void> {
|
|
241
|
+
const entries = await readRegistry(registryPath)
|
|
242
|
+
const alive: RegistryEntry[] = []
|
|
243
|
+
|
|
244
|
+
for (const entry of entries) {
|
|
245
|
+
if (isPidAlive(entry.pid)) {
|
|
246
|
+
alive.push(entry)
|
|
247
|
+
} else {
|
|
248
|
+
try {
|
|
249
|
+
const dir = path.dirname(entry.cachePath)
|
|
250
|
+
await rm(dir, { recursive: true, force: true })
|
|
251
|
+
} catch {
|
|
252
|
+
// ignore
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await writeRegistry(registryPath, alive)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Delete this session's cache directory */
|
|
261
|
+
export async function cleanupSession(cachePath: string): Promise<void> {
|
|
262
|
+
try {
|
|
263
|
+
const dir = path.dirname(cachePath)
|
|
264
|
+
await rm(dir, { recursive: true, force: true })
|
|
265
|
+
} catch {
|
|
266
|
+
// ignore
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Cache read/parse
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
export function readCacheText(text: string): CICache {
|
|
275
|
+
try {
|
|
276
|
+
const parsed = JSON.parse(text)
|
|
277
|
+
if (!parsed || typeof parsed !== "object") return emptyCache()
|
|
278
|
+
return {
|
|
279
|
+
version: 1,
|
|
280
|
+
updatedAt: parsed.updatedAt || nowISO(),
|
|
281
|
+
branch: parsed.branch || "",
|
|
282
|
+
runs: parsed.runs || (parsed.run ? [parsed.run] : []),
|
|
283
|
+
error: parsed.error || null,
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
return emptyCache()
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Display helpers
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
export const DOT = "\u2022"
|
|
295
|
+
export const PULSE_FRAMES = ["\u2022", "\u25E6"]
|
|
296
|
+
|
|
297
|
+
export function formatElapsed(seconds: number): string {
|
|
298
|
+
if (seconds < 0) seconds = 0
|
|
299
|
+
const m = Math.floor(seconds / 60)
|
|
300
|
+
const s = seconds % 60
|
|
301
|
+
if (m > 0) return `${m}m ${s}s`
|
|
302
|
+
return `${s}s`
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function getJobElapsed(job: WorkflowJob, nowSec: number): string {
|
|
306
|
+
if (!job.started_at) return ""
|
|
307
|
+
const started = Math.floor(new Date(job.started_at).getTime() / 1000)
|
|
308
|
+
if (job.completed_at) {
|
|
309
|
+
const completed = Math.floor(new Date(job.completed_at).getTime() / 1000)
|
|
310
|
+
return formatElapsed(completed - started)
|
|
311
|
+
}
|
|
312
|
+
return formatElapsed(nowSec - started)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function truncate(s: string, max: number): string {
|
|
316
|
+
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s
|
|
317
|
+
}
|
package/tui.tsx
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/** @jsxImportSource @opentui/solid */
|
|
3
|
+
import { readFile } from "node:fs/promises"
|
|
4
|
+
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
5
|
+
import { Show, Index, For } from "solid-js"
|
|
6
|
+
import { createSignal, createEffect, onCleanup, onMount, createMemo } from "solid-js"
|
|
7
|
+
import {
|
|
8
|
+
PLUGIN_ID,
|
|
9
|
+
parseOptions,
|
|
10
|
+
filterRuns,
|
|
11
|
+
DOT,
|
|
12
|
+
PULSE_FRAMES,
|
|
13
|
+
buildRegistryPath,
|
|
14
|
+
readRegistry,
|
|
15
|
+
emptyCache,
|
|
16
|
+
readCacheText,
|
|
17
|
+
getJobElapsed,
|
|
18
|
+
truncate,
|
|
19
|
+
} from "./shared"
|
|
20
|
+
import type { CICache, HideFilters, WorkflowJob, WorkflowRun } from "./shared"
|
|
21
|
+
|
|
22
|
+
type Api = Parameters<TuiPlugin>[0]
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Color helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function dotColor(theme: any, status: string, conclusion: string | null) {
|
|
29
|
+
if (status === "completed" && conclusion === "success") return theme.success
|
|
30
|
+
if (status === "completed" && conclusion === "failure") return theme.error
|
|
31
|
+
if (status === "completed" && conclusion === "skipped") return theme.textMuted
|
|
32
|
+
if (status === "completed" && conclusion === "cancelled") return theme.textMuted
|
|
33
|
+
if (status === "in_progress") return theme.warning
|
|
34
|
+
if (status === "queued" || status === "waiting") return theme.textMuted
|
|
35
|
+
return theme.textMuted
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function overallStatus(runs: WorkflowRun[]): { status: string; conclusion: string | null } {
|
|
39
|
+
if (runs.some((r) => r.status === "in_progress")) return { status: "in_progress", conclusion: null }
|
|
40
|
+
if (runs.some((r) => r.status === "queued")) return { status: "queued", conclusion: null }
|
|
41
|
+
if (runs.every((r) => r.status === "completed" && r.conclusion === "success"))
|
|
42
|
+
return { status: "completed", conclusion: "success" }
|
|
43
|
+
if (runs.some((r) => r.status === "completed" && r.conclusion === "failure"))
|
|
44
|
+
return { status: "completed", conclusion: "failure" }
|
|
45
|
+
return { status: "completed", conclusion: runs[0]?.conclusion ?? null }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Read cache from disk
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
async function readCache(filePath: string): Promise<CICache> {
|
|
53
|
+
try {
|
|
54
|
+
return readCacheText(await readFile(filePath, "utf8"))
|
|
55
|
+
} catch {
|
|
56
|
+
return emptyCache()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Find this session's cache file via the registry (matched by PID) */
|
|
61
|
+
async function discoverCachePath(registryPath: string): Promise<string | null> {
|
|
62
|
+
const entries = await readRegistry(registryPath)
|
|
63
|
+
const mine = entries.find((e) => e.pid === process.pid)
|
|
64
|
+
return mine?.cachePath ?? null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Per-workflow row (stateless — toggle state lifted to parent)
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const WorkflowRow = (props: {
|
|
72
|
+
run: WorkflowRun
|
|
73
|
+
theme: any
|
|
74
|
+
nowSec: number
|
|
75
|
+
pulseFrame: number
|
|
76
|
+
expanded: boolean
|
|
77
|
+
onToggle: () => void
|
|
78
|
+
}) => {
|
|
79
|
+
const arrowColor = () => dotColor(props.theme, props.run.status, props.run.conclusion)
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<box flexDirection="column" gap={0}>
|
|
83
|
+
<text onMouseDown={() => props.onToggle()}>
|
|
84
|
+
{" "}
|
|
85
|
+
<span style={{ fg: arrowColor() }}>
|
|
86
|
+
{props.expanded ? "▼" : "▶"}
|
|
87
|
+
</span>
|
|
88
|
+
<span style={{ fg: props.theme.text }}>
|
|
89
|
+
{" "}{truncate(props.run.name, 28)}
|
|
90
|
+
</span>
|
|
91
|
+
</text>
|
|
92
|
+
<Show when={props.expanded}>
|
|
93
|
+
<Index each={props.run.jobs}>
|
|
94
|
+
{(job) => {
|
|
95
|
+
const elapsed = () => getJobElapsed(job(), props.nowSec)
|
|
96
|
+
const isJobActive = () => job().status === "in_progress"
|
|
97
|
+
const jobDot = () => isJobActive() ? PULSE_FRAMES[props.pulseFrame % PULSE_FRAMES.length] : DOT
|
|
98
|
+
return (
|
|
99
|
+
<box flexDirection="row" width="100%" gap={0}>
|
|
100
|
+
<text flexGrow={1}>
|
|
101
|
+
<span style={{ fg: dotColor(props.theme, job().status, job().conclusion) }}>
|
|
102
|
+
{" "}{jobDot()}
|
|
103
|
+
</span>
|
|
104
|
+
<span style={{ fg: props.theme.text }}>
|
|
105
|
+
{" "}{truncate(job().name, 22)}
|
|
106
|
+
</span>
|
|
107
|
+
</text>
|
|
108
|
+
<Show when={elapsed()}>
|
|
109
|
+
<text fg={props.theme.textMuted}>{elapsed()}</text>
|
|
110
|
+
</Show>
|
|
111
|
+
</box>
|
|
112
|
+
)
|
|
113
|
+
}}
|
|
114
|
+
</Index>
|
|
115
|
+
</Show>
|
|
116
|
+
</box>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Main sidebar card
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
const CICard = (props: { api: Api; theme: any; tuiPollMs: number; hide: HideFilters; collapseSingleWorkflow: boolean }) => {
|
|
125
|
+
const [cache, setCache] = createSignal<CICache>(emptyCache())
|
|
126
|
+
const [nowSec, setNowSec] = createSignal(Math.floor(Date.now() / 1000))
|
|
127
|
+
const [collapsed, setCollapsed] = createSignal(false)
|
|
128
|
+
const [pulseFrame, setPulseFrame] = createSignal(0)
|
|
129
|
+
const [cachePath, setCachePath] = createSignal<string | null>(null)
|
|
130
|
+
|
|
131
|
+
// Toggle state keyed by run ID — persists across cache updates
|
|
132
|
+
const [expandedMap, setExpandedMap] = createSignal<Record<number, boolean>>({})
|
|
133
|
+
|
|
134
|
+
const toggleRun = (runId: number) => {
|
|
135
|
+
setExpandedMap((prev) => ({
|
|
136
|
+
...prev,
|
|
137
|
+
[runId]: prev[runId] === undefined ? false : !prev[runId],
|
|
138
|
+
}))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const isRunExpanded = (runId: number) => {
|
|
142
|
+
const map = expandedMap()
|
|
143
|
+
return map[runId] === undefined ? true : map[runId]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const registryPath = buildRegistryPath(props.api.state.path.directory)
|
|
147
|
+
|
|
148
|
+
const load = async () => {
|
|
149
|
+
// Discover cache path if not yet found
|
|
150
|
+
let path = cachePath()
|
|
151
|
+
if (!path) {
|
|
152
|
+
path = await discoverCachePath(registryPath)
|
|
153
|
+
if (path) setCachePath(path)
|
|
154
|
+
}
|
|
155
|
+
if (!path) return
|
|
156
|
+
|
|
157
|
+
setCache(await readCache(path))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
onMount(() => {
|
|
161
|
+
void load()
|
|
162
|
+
const pollTimer = setInterval(() => void load(), props.tuiPollMs)
|
|
163
|
+
const tickTimer = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000)
|
|
164
|
+
onCleanup(() => {
|
|
165
|
+
clearInterval(pollTimer)
|
|
166
|
+
clearInterval(tickTimer)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// Pulse animation — only runs when something is in progress
|
|
171
|
+
const hasActive = createMemo(() =>
|
|
172
|
+
(cache().runs ?? []).some((r) =>
|
|
173
|
+
r.status === "in_progress" || r.jobs.some((j) => j.status === "in_progress")
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
createEffect(() => {
|
|
178
|
+
if (!hasActive()) return
|
|
179
|
+
const id = setInterval(() => setPulseFrame((f) => f + 1), 500)
|
|
180
|
+
onCleanup(() => clearInterval(id))
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const runs = createMemo(() => filterRuns(cache().runs, props.hide))
|
|
184
|
+
const error = createMemo(() => cache().error)
|
|
185
|
+
const hasRuns = createMemo(() => runs().length > 0)
|
|
186
|
+
const overall = createMemo(() => hasRuns() ? overallStatus(runs()) : null)
|
|
187
|
+
|
|
188
|
+
const isSingleCollapsed = createMemo(() =>
|
|
189
|
+
props.collapseSingleWorkflow && runs().length === 1
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
function countItems(items: { status: string; conclusion: string | null }[]) {
|
|
193
|
+
const pending = items.filter((i) => i.status === "in_progress" || i.status === "queued" || i.status === "waiting").length
|
|
194
|
+
const success = items.filter((i) => i.status === "completed" && i.conclusion === "success").length
|
|
195
|
+
const failed = items.filter((i) => i.status === "completed" && i.conclusion === "failure").length
|
|
196
|
+
const skipped = items.filter((i) => i.status === "completed" && (i.conclusion === "skipped" || i.conclusion === "cancelled")).length
|
|
197
|
+
const parts: string[] = []
|
|
198
|
+
if (pending) parts.push(`${pending} pending`)
|
|
199
|
+
if (success) parts.push(`${success} passed`)
|
|
200
|
+
if (failed) parts.push(`${failed} failed`)
|
|
201
|
+
if (skipped) parts.push(`${skipped} skipped`)
|
|
202
|
+
return parts.join(", ")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const summary = createMemo(() => {
|
|
206
|
+
const all = runs()
|
|
207
|
+
if (!all.length) return ""
|
|
208
|
+
const text = isSingleCollapsed() ? countItems(all[0].jobs) : countItems(all)
|
|
209
|
+
return `(${text})`
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Global status icon: checkmark, cross, or spinner
|
|
213
|
+
const globalStatusIcon = createMemo(() => {
|
|
214
|
+
const o = overall()
|
|
215
|
+
if (!o) return { icon: "", color: "" }
|
|
216
|
+
if (o.status === "in_progress" || o.status === "queued")
|
|
217
|
+
return { icon: PULSE_FRAMES[pulseFrame() % PULSE_FRAMES.length], color: props.theme.warning }
|
|
218
|
+
if (o.status === "completed" && o.conclusion === "success")
|
|
219
|
+
return { icon: "\u2713", color: props.theme.success }
|
|
220
|
+
if (o.status === "completed" && o.conclusion === "failure")
|
|
221
|
+
return { icon: "\u2717", color: props.theme.error }
|
|
222
|
+
if (o.status === "completed" && (o.conclusion === "skipped" || o.conclusion === "cancelled"))
|
|
223
|
+
return { icon: "-", color: props.theme.textMuted }
|
|
224
|
+
return { icon: "?", color: props.theme.textMuted }
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Flat list of displayable jobs for single-collapsed mode
|
|
228
|
+
const flatJobs = createMemo(() => {
|
|
229
|
+
if (!hasRuns() || collapsed() || !isSingleCollapsed()) return []
|
|
230
|
+
return runs()[0]?.jobs ?? []
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// Runs to display in multi-workflow mode
|
|
234
|
+
const displayRuns = createMemo(() => {
|
|
235
|
+
if (!hasRuns() || collapsed() || isSingleCollapsed()) return []
|
|
236
|
+
return runs()
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// Status messages as a computed array (empty = no node rendered)
|
|
240
|
+
const statusMessages = createMemo(() => {
|
|
241
|
+
if (error()) return [{ text: ` ${error()}`, color: props.theme.error }]
|
|
242
|
+
if (!hasRuns()) return [{ text: " waiting...", color: props.theme.textMuted }]
|
|
243
|
+
return [] as { text: string; color: any }[]
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<box flexDirection="column" gap={0}>
|
|
248
|
+
<box flexDirection="row" width="100%" gap={0} onMouseDown={() => hasRuns() && setCollapsed((c) => !c)}>
|
|
249
|
+
<text flexGrow={1} fg={props.theme.text}>
|
|
250
|
+
<Show when={hasRuns()} fallback={<b>CI</b>}>
|
|
251
|
+
<span>
|
|
252
|
+
{collapsed() ? "▶" : "▼"}{" "}<b>CI</b>
|
|
253
|
+
<span style={{ fg: props.theme.textMuted }}>
|
|
254
|
+
{" "}{summary()}
|
|
255
|
+
</span>
|
|
256
|
+
</span>
|
|
257
|
+
</Show>
|
|
258
|
+
</text>
|
|
259
|
+
<Show when={hasRuns()}>
|
|
260
|
+
<text fg={globalStatusIcon().color}>{globalStatusIcon().icon}</text>
|
|
261
|
+
</Show>
|
|
262
|
+
</box>
|
|
263
|
+
<Index each={statusMessages()}>
|
|
264
|
+
{(msg) => <text fg={msg().color}>{msg().text}</text>}
|
|
265
|
+
</Index>
|
|
266
|
+
<Index each={flatJobs()}>
|
|
267
|
+
{(job) => {
|
|
268
|
+
const elapsed = () => getJobElapsed(job(), nowSec())
|
|
269
|
+
const isJobActive = () => job().status === "in_progress"
|
|
270
|
+
const jobDot = () => isJobActive() ? PULSE_FRAMES[pulseFrame() % PULSE_FRAMES.length] : DOT
|
|
271
|
+
return (
|
|
272
|
+
<box flexDirection="row" width="100%" gap={0}>
|
|
273
|
+
<text flexGrow={1}>
|
|
274
|
+
<span style={{ fg: dotColor(props.theme, job().status, job().conclusion) }}>
|
|
275
|
+
{jobDot()}
|
|
276
|
+
</span>
|
|
277
|
+
<span style={{ fg: props.theme.text }}>
|
|
278
|
+
{" "}{truncate(job().name, 28)}
|
|
279
|
+
</span>
|
|
280
|
+
</text>
|
|
281
|
+
<Show when={elapsed()}>
|
|
282
|
+
<text fg={props.theme.textMuted}>{elapsed()}</text>
|
|
283
|
+
</Show>
|
|
284
|
+
</box>
|
|
285
|
+
)
|
|
286
|
+
}}
|
|
287
|
+
</Index>
|
|
288
|
+
<For each={displayRuns()}>
|
|
289
|
+
{(run) => (
|
|
290
|
+
<WorkflowRow
|
|
291
|
+
run={run}
|
|
292
|
+
theme={props.theme}
|
|
293
|
+
nowSec={nowSec()}
|
|
294
|
+
pulseFrame={pulseFrame()}
|
|
295
|
+
expanded={isRunExpanded(run.id)}
|
|
296
|
+
onToggle={() => toggleRun(run.id)}
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
299
|
+
</For>
|
|
300
|
+
</box>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Plugin entry
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
const tui: TuiPlugin = async (api, options) => {
|
|
309
|
+
const opts = parseOptions(options)
|
|
310
|
+
if (!opts.enabled) return
|
|
311
|
+
|
|
312
|
+
api.slots.register({
|
|
313
|
+
order: 350,
|
|
314
|
+
slots: {
|
|
315
|
+
sidebar_content(ctx) {
|
|
316
|
+
return <CICard api={api} theme={ctx.theme.current} tuiPollMs={opts.tui_poll_ms} hide={opts.hide} collapseSingleWorkflow={opts.collapse_single_workflow} />
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
323
|
+
id: PLUGIN_ID,
|
|
324
|
+
tui,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export default plugin
|