@hellcoder/companion 0.96.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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface GitHubCheckStatus {
|
|
6
|
+
name: string;
|
|
7
|
+
status: string;
|
|
8
|
+
conclusion: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface GitHubPRInfo {
|
|
12
|
+
number: number;
|
|
13
|
+
title: string;
|
|
14
|
+
url: string;
|
|
15
|
+
state: "OPEN" | "CLOSED" | "MERGED";
|
|
16
|
+
isDraft: boolean;
|
|
17
|
+
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
|
|
18
|
+
additions: number;
|
|
19
|
+
deletions: number;
|
|
20
|
+
changedFiles: number;
|
|
21
|
+
checks: GitHubCheckStatus[];
|
|
22
|
+
checksSummary: {
|
|
23
|
+
total: number;
|
|
24
|
+
success: number;
|
|
25
|
+
failure: number;
|
|
26
|
+
pending: number;
|
|
27
|
+
};
|
|
28
|
+
reviewThreads: {
|
|
29
|
+
total: number;
|
|
30
|
+
resolved: number;
|
|
31
|
+
unresolved: number;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PRStatusResponse {
|
|
36
|
+
available: boolean;
|
|
37
|
+
pr: GitHubPRInfo | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── gh CLI Detection ────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
let _ghAvailable: boolean | null = null;
|
|
43
|
+
|
|
44
|
+
export function isGhAvailable(): boolean {
|
|
45
|
+
if (_ghAvailable !== null) return _ghAvailable;
|
|
46
|
+
try {
|
|
47
|
+
execSync("which gh", { stdio: "pipe", timeout: 5_000 });
|
|
48
|
+
_ghAvailable = true;
|
|
49
|
+
} catch {
|
|
50
|
+
_ghAvailable = false;
|
|
51
|
+
}
|
|
52
|
+
return _ghAvailable;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Exported for testing
|
|
56
|
+
export function _resetGhAvailable() {
|
|
57
|
+
_ghAvailable = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Repo Slug Resolution ────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const repoSlugCache = new Map<string, { slug: string | null; timestamp: number }>();
|
|
63
|
+
const REPO_SLUG_TTL = 5 * 60_000; // 5 minutes
|
|
64
|
+
|
|
65
|
+
function getRepoSlug(cwd: string): string | null {
|
|
66
|
+
const cached = repoSlugCache.get(cwd);
|
|
67
|
+
if (cached && Date.now() - cached.timestamp < REPO_SLUG_TTL) {
|
|
68
|
+
return cached.slug;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const slug = execSync("gh repo view --json nameWithOwner --jq .nameWithOwner", {
|
|
72
|
+
cwd,
|
|
73
|
+
stdio: "pipe",
|
|
74
|
+
timeout: 10_000,
|
|
75
|
+
})
|
|
76
|
+
.toString()
|
|
77
|
+
.trim();
|
|
78
|
+
const result = slug || null;
|
|
79
|
+
repoSlugCache.set(cwd, { slug: result, timestamp: Date.now() });
|
|
80
|
+
return result;
|
|
81
|
+
} catch {
|
|
82
|
+
repoSlugCache.set(cwd, { slug: null, timestamp: Date.now() });
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function getRepoSlugAsync(cwd: string): Promise<string | null> {
|
|
88
|
+
const cached = repoSlugCache.get(cwd);
|
|
89
|
+
if (cached && Date.now() - cached.timestamp < REPO_SLUG_TTL) {
|
|
90
|
+
return cached.slug;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const proc = Bun.spawn(
|
|
94
|
+
["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
|
|
95
|
+
{ cwd, stdout: "pipe", stderr: "pipe" },
|
|
96
|
+
);
|
|
97
|
+
const timeout = setTimeout(() => proc.kill(), 10_000);
|
|
98
|
+
const exitCode = await proc.exited;
|
|
99
|
+
clearTimeout(timeout);
|
|
100
|
+
if (exitCode !== 0) {
|
|
101
|
+
repoSlugCache.set(cwd, { slug: null, timestamp: Date.now() });
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const slug = (await new Response(proc.stdout).text()).trim();
|
|
105
|
+
const result = slug || null;
|
|
106
|
+
repoSlugCache.set(cwd, { slug: result, timestamp: Date.now() });
|
|
107
|
+
return result;
|
|
108
|
+
} catch {
|
|
109
|
+
repoSlugCache.set(cwd, { slug: null, timestamp: Date.now() });
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── PR Data Cache ───────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
const prCache = new Map<string, { data: GitHubPRInfo | null; timestamp: number; ttl: number }>();
|
|
117
|
+
const PR_CACHE_TTL = 30_000; // 30 seconds (default / legacy)
|
|
118
|
+
|
|
119
|
+
// Exported for testing
|
|
120
|
+
export function _clearCaches() {
|
|
121
|
+
prCache.clear();
|
|
122
|
+
repoSlugCache.clear();
|
|
123
|
+
_ghAvailable = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Adaptive TTL ───────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/** Compute polling interval based on PR state. */
|
|
129
|
+
export function computeAdaptiveTTL(pr: GitHubPRInfo | null): number {
|
|
130
|
+
if (!pr) return 60_000; // No PR found — check again in 60s
|
|
131
|
+
|
|
132
|
+
// Merged or closed — terminal state, rarely changes
|
|
133
|
+
if (pr.state === "MERGED" || pr.state === "CLOSED") return 300_000; // 5 minutes
|
|
134
|
+
|
|
135
|
+
// CI actively running (pending checks) — user is watching
|
|
136
|
+
if (pr.checksSummary.pending > 0) return 10_000; // 10 seconds
|
|
137
|
+
|
|
138
|
+
// CI failed — user likely pushing fixes
|
|
139
|
+
if (pr.checksSummary.failure > 0) return 30_000; // 30 seconds
|
|
140
|
+
|
|
141
|
+
// Changes requested — moderate frequency
|
|
142
|
+
if (pr.reviewDecision === "CHANGES_REQUESTED") return 30_000; // 30 seconds
|
|
143
|
+
|
|
144
|
+
// Approved, all checks passed — stable
|
|
145
|
+
if (pr.reviewDecision === "APPROVED" && pr.checksSummary.pending === 0) return 120_000; // 2 minutes
|
|
146
|
+
|
|
147
|
+
// Review pending, checks passed — waiting on human reviewer
|
|
148
|
+
if (pr.reviewDecision === "REVIEW_REQUIRED" || pr.reviewDecision === null) return 45_000; // 45 seconds
|
|
149
|
+
|
|
150
|
+
// Default fallback
|
|
151
|
+
return 30_000;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── GraphQL Query ───────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
const PR_QUERY = `
|
|
157
|
+
query($owner: String!, $name: String!, $branch: String!) {
|
|
158
|
+
repository(owner: $owner, name: $name) {
|
|
159
|
+
pullRequests(headRefName: $branch, first: 5, orderBy: {field: UPDATED_AT, direction: DESC}, states: [OPEN, MERGED]) {
|
|
160
|
+
nodes {
|
|
161
|
+
number
|
|
162
|
+
title
|
|
163
|
+
url
|
|
164
|
+
state
|
|
165
|
+
isDraft
|
|
166
|
+
isCrossRepository
|
|
167
|
+
reviewDecision
|
|
168
|
+
additions
|
|
169
|
+
deletions
|
|
170
|
+
changedFiles
|
|
171
|
+
reviewThreads(first: 100) {
|
|
172
|
+
totalCount
|
|
173
|
+
nodes {
|
|
174
|
+
isResolved
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
commits(last: 1) {
|
|
178
|
+
nodes {
|
|
179
|
+
commit {
|
|
180
|
+
statusCheckRollup {
|
|
181
|
+
contexts(first: 50) {
|
|
182
|
+
nodes {
|
|
183
|
+
__typename
|
|
184
|
+
... on CheckRun {
|
|
185
|
+
name
|
|
186
|
+
status
|
|
187
|
+
conclusion
|
|
188
|
+
}
|
|
189
|
+
... on StatusContext {
|
|
190
|
+
context
|
|
191
|
+
state
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}`;
|
|
203
|
+
|
|
204
|
+
// ─── Response Parsing ────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
interface GraphQLCheckRunNode {
|
|
207
|
+
__typename: "CheckRun";
|
|
208
|
+
name: string;
|
|
209
|
+
status: string;
|
|
210
|
+
conclusion: string | null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface GraphQLStatusContextNode {
|
|
214
|
+
__typename: "StatusContext";
|
|
215
|
+
context: string;
|
|
216
|
+
state: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
type GraphQLContextNode = GraphQLCheckRunNode | GraphQLStatusContextNode;
|
|
220
|
+
|
|
221
|
+
export function parseGraphQLResponse(data: unknown): GitHubPRInfo | null {
|
|
222
|
+
try {
|
|
223
|
+
const repo = (data as any)?.data?.repository;
|
|
224
|
+
const nodes = repo?.pullRequests?.nodes;
|
|
225
|
+
if (!nodes || nodes.length === 0) return null;
|
|
226
|
+
|
|
227
|
+
// Filter out cross-repository (fork) PRs — we only want same-repo PRs
|
|
228
|
+
const sameRepoPRs = nodes.filter((n: any) => !n.isCrossRepository);
|
|
229
|
+
if (sameRepoPRs.length === 0) return null;
|
|
230
|
+
|
|
231
|
+
const pr = sameRepoPRs[0];
|
|
232
|
+
|
|
233
|
+
// Normalize checks
|
|
234
|
+
const rawContexts: GraphQLContextNode[] =
|
|
235
|
+
pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ?? [];
|
|
236
|
+
|
|
237
|
+
const checks: GitHubCheckStatus[] = rawContexts.map((node) => {
|
|
238
|
+
if (node.__typename === "CheckRun") {
|
|
239
|
+
return {
|
|
240
|
+
name: node.name,
|
|
241
|
+
status: node.status,
|
|
242
|
+
conclusion: node.conclusion,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
// StatusContext
|
|
246
|
+
return {
|
|
247
|
+
name: node.context,
|
|
248
|
+
status: node.state === "PENDING" ? "IN_PROGRESS" : "COMPLETED",
|
|
249
|
+
conclusion: node.state === "SUCCESS" ? "SUCCESS" : (node.state === "FAILURE" || node.state === "ERROR") ? "FAILURE" : null,
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Compute summary
|
|
254
|
+
let success = 0;
|
|
255
|
+
let failure = 0;
|
|
256
|
+
let pending = 0;
|
|
257
|
+
for (const check of checks) {
|
|
258
|
+
if (check.conclusion === "SUCCESS" || check.conclusion === "NEUTRAL" || check.conclusion === "SKIPPED") {
|
|
259
|
+
success++;
|
|
260
|
+
} else if (check.conclusion === "FAILURE" || check.conclusion === "CANCELLED" || check.conclusion === "TIMED_OUT") {
|
|
261
|
+
failure++;
|
|
262
|
+
} else {
|
|
263
|
+
pending++;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Compute review threads
|
|
268
|
+
const threadNodes: { isResolved: boolean }[] = pr.reviewThreads?.nodes ?? [];
|
|
269
|
+
const resolved = threadNodes.filter((t) => t.isResolved).length;
|
|
270
|
+
const unresolved = threadNodes.filter((t) => !t.isResolved).length;
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
number: pr.number,
|
|
274
|
+
title: pr.title,
|
|
275
|
+
url: pr.url,
|
|
276
|
+
state: pr.state,
|
|
277
|
+
isDraft: pr.isDraft ?? false,
|
|
278
|
+
reviewDecision: pr.reviewDecision || null,
|
|
279
|
+
additions: pr.additions ?? 0,
|
|
280
|
+
deletions: pr.deletions ?? 0,
|
|
281
|
+
changedFiles: pr.changedFiles ?? 0,
|
|
282
|
+
checks,
|
|
283
|
+
checksSummary: { total: checks.length, success, failure, pending },
|
|
284
|
+
reviewThreads: {
|
|
285
|
+
total: pr.reviewThreads?.totalCount ?? 0,
|
|
286
|
+
resolved,
|
|
287
|
+
unresolved,
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── Main Fetch Function (sync — legacy, used by tests) ─────────────────────
|
|
296
|
+
|
|
297
|
+
export async function fetchPRInfo(cwd: string, branch: string): Promise<GitHubPRInfo | null> {
|
|
298
|
+
if (!isGhAvailable()) return null;
|
|
299
|
+
|
|
300
|
+
const cacheKey = `${cwd}:${branch}`;
|
|
301
|
+
const cached = prCache.get(cacheKey);
|
|
302
|
+
if (cached && Date.now() - cached.timestamp < cached.ttl) {
|
|
303
|
+
return cached.data;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const slug = getRepoSlug(cwd);
|
|
307
|
+
if (!slug) {
|
|
308
|
+
prCache.set(cacheKey, { data: null, timestamp: Date.now(), ttl: PR_CACHE_TTL });
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const [owner, name] = slug.split("/");
|
|
313
|
+
if (!owner || !name) return null;
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const result = execFileSync(
|
|
317
|
+
"gh",
|
|
318
|
+
["api", "graphql", "-f", `query=${PR_QUERY}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-f", `branch=${branch}`],
|
|
319
|
+
{ cwd, stdio: "pipe", timeout: 15_000 },
|
|
320
|
+
)
|
|
321
|
+
.toString()
|
|
322
|
+
.trim();
|
|
323
|
+
|
|
324
|
+
const parsed = JSON.parse(result);
|
|
325
|
+
const prInfo = parseGraphQLResponse(parsed);
|
|
326
|
+
const ttl = computeAdaptiveTTL(prInfo);
|
|
327
|
+
prCache.set(cacheKey, { data: prInfo, timestamp: Date.now(), ttl });
|
|
328
|
+
return prInfo;
|
|
329
|
+
} catch {
|
|
330
|
+
prCache.set(cacheKey, { data: null, timestamp: Date.now(), ttl: PR_CACHE_TTL });
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ─── Async Fetch Function (non-blocking, uses Bun.spawn) ────────────────────
|
|
336
|
+
|
|
337
|
+
export async function fetchPRInfoAsync(cwd: string, branch: string): Promise<GitHubPRInfo | null> {
|
|
338
|
+
if (!isGhAvailable()) return null;
|
|
339
|
+
|
|
340
|
+
const cacheKey = `${cwd}:${branch}`;
|
|
341
|
+
const cached = prCache.get(cacheKey);
|
|
342
|
+
if (cached && Date.now() - cached.timestamp < cached.ttl) {
|
|
343
|
+
return cached.data;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const slug = await getRepoSlugAsync(cwd);
|
|
347
|
+
if (!slug) {
|
|
348
|
+
prCache.set(cacheKey, { data: null, timestamp: Date.now(), ttl: 60_000 });
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const [owner, name] = slug.split("/");
|
|
353
|
+
if (!owner || !name) return null;
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const proc = Bun.spawn(
|
|
357
|
+
["gh", "api", "graphql", "-f", `query=${PR_QUERY}`, "-f", `owner=${owner}`, "-f", `name=${name}`, "-f", `branch=${branch}`],
|
|
358
|
+
{ cwd, stdout: "pipe", stderr: "pipe" },
|
|
359
|
+
);
|
|
360
|
+
const timeout = setTimeout(() => proc.kill(), 15_000);
|
|
361
|
+
const exitCode = await proc.exited;
|
|
362
|
+
clearTimeout(timeout);
|
|
363
|
+
|
|
364
|
+
if (exitCode !== 0) {
|
|
365
|
+
prCache.set(cacheKey, { data: null, timestamp: Date.now(), ttl: PR_CACHE_TTL });
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const stdout = (await new Response(proc.stdout).text()).trim();
|
|
370
|
+
const parsed = JSON.parse(stdout);
|
|
371
|
+
const prInfo = parseGraphQLResponse(parsed);
|
|
372
|
+
const ttl = computeAdaptiveTTL(prInfo);
|
|
373
|
+
prCache.set(cacheKey, { data: prInfo, timestamp: Date.now(), ttl });
|
|
374
|
+
return prInfo;
|
|
375
|
+
} catch {
|
|
376
|
+
prCache.set(cacheKey, { data: null, timestamp: Date.now(), ttl: PR_CACHE_TTL });
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock container-manager
|
|
4
|
+
const mockImageExists = vi.hoisted(() => vi.fn((_image: string) => false));
|
|
5
|
+
const mockPullImage = vi.hoisted(() => vi.fn(async (_remote: string, _local: string, _onProgress?: (line: string) => void) => true));
|
|
6
|
+
const mockBuildImage = vi.hoisted(() => vi.fn((_path: string, _tag?: string) => "ok"));
|
|
7
|
+
const mockGetRegistryImage = vi.hoisted(() => vi.fn((tag: string) => {
|
|
8
|
+
if (tag === "the-companion:latest") return "docker.io/stangirard/the-companion:latest";
|
|
9
|
+
return null as string | null;
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("./container-manager.js", () => ({
|
|
13
|
+
containerManager: {
|
|
14
|
+
imageExists: mockImageExists,
|
|
15
|
+
pullImage: mockPullImage,
|
|
16
|
+
buildImage: mockBuildImage,
|
|
17
|
+
},
|
|
18
|
+
ContainerManager: {
|
|
19
|
+
getRegistryImage: mockGetRegistryImage,
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Mock env-manager
|
|
24
|
+
const mockListEnvs = vi.hoisted(() => vi.fn(() => [] as Array<{ name: string; slug: string; baseImage?: string; imageTag?: string; variables: Record<string, string>; createdAt: number; updatedAt: number }>));
|
|
25
|
+
vi.mock("./env-manager.js", () => ({
|
|
26
|
+
listEnvs: mockListEnvs,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Mock existsSync for Dockerfile fallback
|
|
30
|
+
const mockExistsSync = vi.hoisted(() => vi.fn(() => true));
|
|
31
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
32
|
+
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
33
|
+
return { ...actual, existsSync: mockExistsSync };
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// We need to re-import for each test to get a fresh singleton.
|
|
37
|
+
// Instead we use a factory approach.
|
|
38
|
+
async function createManager() {
|
|
39
|
+
// Clear module cache so we get a fresh singleton
|
|
40
|
+
vi.resetModules();
|
|
41
|
+
const mod = await import("./image-pull-manager.js");
|
|
42
|
+
return mod.imagePullManager;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("ImagePullManager", () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
mockImageExists.mockReturnValue(false);
|
|
49
|
+
mockPullImage.mockResolvedValue(true);
|
|
50
|
+
mockBuildImage.mockReturnValue("ok");
|
|
51
|
+
mockExistsSync.mockReturnValue(true);
|
|
52
|
+
// Restore default registry mapping (clearAllMocks removes it)
|
|
53
|
+
mockGetRegistryImage.mockImplementation((tag: string) => {
|
|
54
|
+
if (tag === "the-companion:latest") return "docker.io/stangirard/the-companion:latest";
|
|
55
|
+
return null as string | null;
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("getState", () => {
|
|
60
|
+
it("returns 'ready' when image exists locally", async () => {
|
|
61
|
+
mockImageExists.mockReturnValue(true);
|
|
62
|
+
const manager = await createManager();
|
|
63
|
+
const state = manager.getState("the-companion:latest");
|
|
64
|
+
expect(state.status).toBe("ready");
|
|
65
|
+
expect(state.image).toBe("the-companion:latest");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns 'idle' when image does not exist locally", async () => {
|
|
69
|
+
mockImageExists.mockReturnValue(false);
|
|
70
|
+
const manager = await createManager();
|
|
71
|
+
const state = manager.getState("the-companion:latest");
|
|
72
|
+
expect(state.status).toBe("idle");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("isReady", () => {
|
|
77
|
+
it("returns true when image exists locally", async () => {
|
|
78
|
+
mockImageExists.mockReturnValue(true);
|
|
79
|
+
const manager = await createManager();
|
|
80
|
+
expect(manager.isReady("the-companion:latest")).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns false when image does not exist locally", async () => {
|
|
84
|
+
mockImageExists.mockReturnValue(false);
|
|
85
|
+
const manager = await createManager();
|
|
86
|
+
expect(manager.isReady("the-companion:latest")).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("ensureImage", () => {
|
|
91
|
+
it("starts a background pull when image is missing", async () => {
|
|
92
|
+
mockImageExists.mockReturnValue(false);
|
|
93
|
+
const manager = await createManager();
|
|
94
|
+
|
|
95
|
+
manager.ensureImage("the-companion:latest");
|
|
96
|
+
|
|
97
|
+
const state = manager.getState("the-companion:latest");
|
|
98
|
+
expect(state.status).toBe("pulling");
|
|
99
|
+
expect(state.startedAt).toBeGreaterThan(0);
|
|
100
|
+
|
|
101
|
+
// Wait for the async pull to complete
|
|
102
|
+
await vi.waitFor(() => {
|
|
103
|
+
expect(manager.getState("the-companion:latest").status).toBe("ready");
|
|
104
|
+
});
|
|
105
|
+
expect(mockPullImage).toHaveBeenCalledOnce();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("is a no-op when image is already ready", async () => {
|
|
109
|
+
mockImageExists.mockReturnValue(true);
|
|
110
|
+
const manager = await createManager();
|
|
111
|
+
|
|
112
|
+
manager.ensureImage("the-companion:latest");
|
|
113
|
+
|
|
114
|
+
expect(mockPullImage).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("is a no-op when image is already being pulled", async () => {
|
|
118
|
+
mockImageExists.mockReturnValue(false);
|
|
119
|
+
// Make pull hang
|
|
120
|
+
mockPullImage.mockImplementation(() => new Promise(() => {}));
|
|
121
|
+
const manager = await createManager();
|
|
122
|
+
|
|
123
|
+
manager.ensureImage("the-companion:latest");
|
|
124
|
+
manager.ensureImage("the-companion:latest"); // second call
|
|
125
|
+
|
|
126
|
+
expect(mockPullImage).toHaveBeenCalledOnce();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("waitForReady", () => {
|
|
131
|
+
it("resolves immediately when image already exists", async () => {
|
|
132
|
+
mockImageExists.mockReturnValue(true);
|
|
133
|
+
const manager = await createManager();
|
|
134
|
+
|
|
135
|
+
const result = await manager.waitForReady("the-companion:latest");
|
|
136
|
+
expect(result).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("waits for an in-progress pull to complete", async () => {
|
|
140
|
+
mockImageExists.mockReturnValue(false);
|
|
141
|
+
const manager = await createManager();
|
|
142
|
+
|
|
143
|
+
manager.ensureImage("the-companion:latest");
|
|
144
|
+
const result = await manager.waitForReady("the-companion:latest", 5000);
|
|
145
|
+
expect(result).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns false when pull fails", async () => {
|
|
149
|
+
mockImageExists.mockReturnValue(false);
|
|
150
|
+
mockPullImage.mockResolvedValue(false);
|
|
151
|
+
mockExistsSync.mockReturnValue(false); // no Dockerfile fallback
|
|
152
|
+
const manager = await createManager();
|
|
153
|
+
|
|
154
|
+
const result = await manager.waitForReady("the-companion:latest", 5000);
|
|
155
|
+
expect(result).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("starts a pull if image is idle and not present", async () => {
|
|
159
|
+
mockImageExists.mockReturnValue(false);
|
|
160
|
+
const manager = await createManager();
|
|
161
|
+
|
|
162
|
+
// Calling waitForReady on an idle image should trigger a pull
|
|
163
|
+
const result = await manager.waitForReady("the-companion:latest", 5000);
|
|
164
|
+
expect(result).toBe(true);
|
|
165
|
+
expect(mockPullImage).toHaveBeenCalledOnce();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("times out when pull takes too long", async () => {
|
|
169
|
+
mockImageExists.mockReturnValue(false);
|
|
170
|
+
mockPullImage.mockImplementation(() => new Promise(() => {})); // never resolves
|
|
171
|
+
const manager = await createManager();
|
|
172
|
+
|
|
173
|
+
const result = await manager.waitForReady("the-companion:latest", 50);
|
|
174
|
+
expect(result).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("pull (force re-pull)", () => {
|
|
179
|
+
it("triggers a pull even when image is already present", async () => {
|
|
180
|
+
mockImageExists.mockReturnValue(true);
|
|
181
|
+
const manager = await createManager();
|
|
182
|
+
|
|
183
|
+
manager.pull("the-companion:latest");
|
|
184
|
+
|
|
185
|
+
// Should have started pulling despite image being present
|
|
186
|
+
const state = manager.getState("the-companion:latest");
|
|
187
|
+
expect(state.status).toBe("pulling");
|
|
188
|
+
expect(mockPullImage).toHaveBeenCalledOnce();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("is a no-op when a pull is already in progress", async () => {
|
|
192
|
+
mockImageExists.mockReturnValue(false);
|
|
193
|
+
mockPullImage.mockImplementation(() => new Promise(() => {}));
|
|
194
|
+
const manager = await createManager();
|
|
195
|
+
|
|
196
|
+
manager.pull("the-companion:latest");
|
|
197
|
+
manager.pull("the-companion:latest");
|
|
198
|
+
|
|
199
|
+
expect(mockPullImage).toHaveBeenCalledOnce();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("onProgress", () => {
|
|
204
|
+
it("fires callback for each progress line during pull", async () => {
|
|
205
|
+
mockImageExists.mockReturnValue(false);
|
|
206
|
+
const lines: string[] = [];
|
|
207
|
+
mockPullImage.mockImplementation(async (_remote: string, _local: string, onProgress?: (line: string) => void) => {
|
|
208
|
+
onProgress?.("Downloading layer 1/3");
|
|
209
|
+
onProgress?.("Downloading layer 2/3");
|
|
210
|
+
onProgress?.("Downloading layer 3/3");
|
|
211
|
+
return true;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const manager = await createManager();
|
|
215
|
+
const unsub = manager.onProgress("the-companion:latest", (line) => lines.push(line));
|
|
216
|
+
|
|
217
|
+
manager.ensureImage("the-companion:latest");
|
|
218
|
+
|
|
219
|
+
await vi.waitFor(() => {
|
|
220
|
+
expect(manager.getState("the-companion:latest").status).toBe("ready");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Should have received the pull lines plus "Image ready"
|
|
224
|
+
expect(lines).toContain("Downloading layer 1/3");
|
|
225
|
+
expect(lines).toContain("Downloading layer 2/3");
|
|
226
|
+
expect(lines).toContain("Downloading layer 3/3");
|
|
227
|
+
expect(lines).toContain("Image ready");
|
|
228
|
+
|
|
229
|
+
unsub();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("fallback to local build", () => {
|
|
234
|
+
it("falls back to local build when pull fails for the-companion:latest", async () => {
|
|
235
|
+
mockImageExists.mockReturnValue(false);
|
|
236
|
+
mockPullImage.mockResolvedValue(false);
|
|
237
|
+
mockExistsSync.mockReturnValue(true);
|
|
238
|
+
|
|
239
|
+
const manager = await createManager();
|
|
240
|
+
const result = await manager.waitForReady("the-companion:latest", 5000);
|
|
241
|
+
|
|
242
|
+
expect(result).toBe(true);
|
|
243
|
+
expect(mockPullImage).toHaveBeenCalledOnce();
|
|
244
|
+
expect(mockBuildImage).toHaveBeenCalledOnce();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("errors when pull fails for non-default image", async () => {
|
|
248
|
+
mockImageExists.mockReturnValue(false);
|
|
249
|
+
mockGetRegistryImage.mockReturnValue(null);
|
|
250
|
+
|
|
251
|
+
const manager = await createManager();
|
|
252
|
+
const result = await manager.waitForReady("custom-image:v1", 5000);
|
|
253
|
+
|
|
254
|
+
expect(result).toBe(false);
|
|
255
|
+
const state = manager.getState("custom-image:v1");
|
|
256
|
+
expect(state.status).toBe("error");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("errors when pull fails and no Dockerfile exists", async () => {
|
|
260
|
+
mockImageExists.mockReturnValue(false);
|
|
261
|
+
mockPullImage.mockResolvedValue(false);
|
|
262
|
+
mockExistsSync.mockReturnValue(false); // no Dockerfile
|
|
263
|
+
|
|
264
|
+
const manager = await createManager();
|
|
265
|
+
const result = await manager.waitForReady("the-companion:latest", 5000);
|
|
266
|
+
|
|
267
|
+
expect(result).toBe(false);
|
|
268
|
+
const state = manager.getState("the-companion:latest");
|
|
269
|
+
expect(state.status).toBe("error");
|
|
270
|
+
expect(state.error).toContain("Dockerfile not found");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("initFromEnvironments", () => {
|
|
275
|
+
it("is a no-op since environments no longer carry Docker fields", async () => {
|
|
276
|
+
mockImageExists.mockReturnValue(false);
|
|
277
|
+
mockListEnvs.mockReturnValue([
|
|
278
|
+
{ name: "env1", slug: "env1", baseImage: "the-companion:latest", variables: {}, createdAt: 0, updatedAt: 0 },
|
|
279
|
+
{ name: "env2", slug: "env2", imageTag: "custom:v1", variables: {}, createdAt: 0, updatedAt: 0 },
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
const manager = await createManager();
|
|
283
|
+
manager.initFromEnvironments();
|
|
284
|
+
|
|
285
|
+
// initFromEnvironments is now a no-op — Docker fields moved to Sandboxes.
|
|
286
|
+
// No image pulls should be triggered regardless of environment config.
|
|
287
|
+
expect(mockPullImage).not.toHaveBeenCalled();
|
|
288
|
+
expect(mockListEnvs).not.toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("skips images that are already available", async () => {
|
|
292
|
+
mockImageExists.mockReturnValue(true);
|
|
293
|
+
mockListEnvs.mockReturnValue([
|
|
294
|
+
{ name: "env1", slug: "env1", baseImage: "the-companion:latest", variables: {}, createdAt: 0, updatedAt: 0 },
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
const manager = await createManager();
|
|
298
|
+
manager.initFromEnvironments();
|
|
299
|
+
|
|
300
|
+
expect(mockPullImage).not.toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
});
|