@stigmer/runner 3.1.5 → 3.1.7
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/.build-fingerprint +1 -1
- package/dist/activities/discover-mcp-server.js +23 -0
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +39 -1
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-provision.d.ts +23 -4
- package/dist/activities/execute-cursor/workspace-provision.js +17 -6
- package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +8 -1
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/inline-publisher.d.ts +1 -1
- package/dist/activities/execute-deep-agent/post-stream.d.ts +1 -1
- package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +1 -1
- package/dist/activities/execute-deep-agent/streaming-terminal.d.ts +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.d.ts +1 -1
- package/dist/shared/execution-status-writer.d.ts +31 -0
- package/dist/shared/execution-status-writer.js +43 -0
- package/dist/shared/execution-status-writer.js.map +1 -0
- package/dist/shared/mcp-oauth-detect.d.ts +53 -0
- package/dist/shared/mcp-oauth-detect.js +99 -0
- package/dist/shared/mcp-oauth-detect.js.map +1 -0
- package/dist/shared/workspace/writeback-coordinator.d.ts +120 -0
- package/dist/shared/workspace/writeback-coordinator.js +393 -0
- package/dist/shared/workspace/writeback-coordinator.js.map +1 -0
- package/package.json +2 -2
- package/src/activities/discover-mcp-server.ts +25 -0
- package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +23 -15
- package/src/activities/execute-cursor/index.ts +41 -1
- package/src/activities/execute-cursor/workspace-provision.ts +39 -7
- package/src/activities/execute-deep-agent/__tests__/post-stream.test.ts +1 -1
- package/src/activities/execute-deep-agent/index.ts +8 -1
- package/src/activities/execute-deep-agent/inline-publisher.ts +1 -1
- package/src/activities/execute-deep-agent/post-stream.ts +1 -1
- package/src/activities/execute-deep-agent/status-builder.ts +1 -1
- package/src/activities/execute-deep-agent/streaming-side-effects.ts +1 -1
- package/src/activities/execute-deep-agent/streaming-terminal.ts +1 -1
- package/src/activities/execute-deep-agent/streaming.ts +1 -1
- package/src/activities/execute-deep-agent/v3-status-builder.ts +1 -1
- package/src/shared/__tests__/mcp-oauth-detect.test.ts +147 -0
- package/src/shared/execution-status-writer.ts +56 -0
- package/src/shared/mcp-oauth-detect.ts +112 -0
- package/src/shared/workspace/__tests__/writeback-coordinator.integration.test.ts +230 -0
- package/src/shared/workspace/__tests__/writeback-coordinator.test.ts +477 -0
- package/src/{activities/execute-deep-agent → shared/workspace}/writeback-coordinator.ts +206 -94
- package/dist/activities/execute-deep-agent/execution-status-writer.d.ts +0 -17
- package/dist/activities/execute-deep-agent/execution-status-writer.js +0 -9
- package/dist/activities/execute-deep-agent/execution-status-writer.js.map +0 -1
- package/dist/activities/execute-deep-agent/writeback-coordinator.d.ts +0 -71
- package/dist/activities/execute-deep-agent/writeback-coordinator.js +0 -299
- package/dist/activities/execute-deep-agent/writeback-coordinator.js.map +0 -1
- package/src/activities/execute-deep-agent/__tests__/writeback-coordinator.test.ts +0 -426
- package/src/activities/execute-deep-agent/execution-status-writer.ts +0 -19
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
4
|
+
import { WorkspaceWriteBackPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
|
|
5
|
+
import { GitWriteBackMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
|
|
6
|
+
import {
|
|
7
|
+
statusProtoWriter,
|
|
8
|
+
type ExecutionStatusWriter,
|
|
9
|
+
} from "../../execution-status-writer.js";
|
|
10
|
+
import {
|
|
11
|
+
WriteBackCoordinator,
|
|
12
|
+
parseGithubRepo,
|
|
13
|
+
} from "../writeback-coordinator.js";
|
|
14
|
+
import type { WorkspaceBackend, ProvisionResult } from "../types.js";
|
|
15
|
+
import { SourceType } from "../types.js";
|
|
16
|
+
import {
|
|
17
|
+
AGENT_GIT_AUTHOR_NAME,
|
|
18
|
+
AGENT_GIT_AUTHOR_EMAIL,
|
|
19
|
+
} from "../git-identity.js";
|
|
20
|
+
|
|
21
|
+
const SESSION_ID = "ses-01test";
|
|
22
|
+
const SESSION_BRANCH = `stigmer/${SESSION_ID}`;
|
|
23
|
+
|
|
24
|
+
// The shared proto-backed writer — the same implementation the Cursor harness
|
|
25
|
+
// wires in, and upsert-compatible with the deep-agent StatusBuilder.
|
|
26
|
+
function makeStatusBuilder(): ExecutionStatusWriter {
|
|
27
|
+
return statusProtoWriter(create(AgentExecutionStatusSchema, {}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeProvisionResult(overrides: Partial<ProvisionResult> = {}): ProvisionResult {
|
|
31
|
+
return {
|
|
32
|
+
rootDir: "/workspace/my-app",
|
|
33
|
+
sourceType: SourceType.GIT_REPO,
|
|
34
|
+
consumedKeys: [],
|
|
35
|
+
workspaceDescription: "test",
|
|
36
|
+
entryName: "my-app",
|
|
37
|
+
gitMetadata: {
|
|
38
|
+
// Token-stripped, as extractGitMetadata always records it.
|
|
39
|
+
repoUrl: "https://github.com/acme/my-app.git",
|
|
40
|
+
branch: "main",
|
|
41
|
+
baseCommit: "abc123",
|
|
42
|
+
gitCredentialsConfigured: true,
|
|
43
|
+
},
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeWorkspaceEntry(name: string, writeBackMode: GitWriteBackMode = GitWriteBackMode.GIT_WRITE_BACK_BRANCH_AND_PR) {
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
source: {
|
|
52
|
+
source: {
|
|
53
|
+
case: "gitRepo" as const,
|
|
54
|
+
value: { writeBackMode },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
$typeName: "ai.stigmer.agentic.session.v1.WorkspaceEntry" as const,
|
|
58
|
+
} as any;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mockWorkspaceBackend(responses: Record<string, string> = {}): WorkspaceBackend {
|
|
62
|
+
const defaultResponses: Record<string, string> = {
|
|
63
|
+
"git diff --stat": " 1 file changed, 1 insertion(+)",
|
|
64
|
+
"git diff --cached --stat": "",
|
|
65
|
+
"git ls-files --others --exclude-standard": "",
|
|
66
|
+
// Fresh clone on the base branch: the session branch exists nowhere yet.
|
|
67
|
+
"git branch --show-current": "main",
|
|
68
|
+
"git rev-parse --verify --quiet": "",
|
|
69
|
+
"git ls-remote --heads origin": "",
|
|
70
|
+
"git checkout": "",
|
|
71
|
+
"git add -A": "",
|
|
72
|
+
"commit -m": "",
|
|
73
|
+
"git rev-parse HEAD": "abc123def456",
|
|
74
|
+
"git push": "",
|
|
75
|
+
"git diff --stat main...HEAD": " 1 file changed",
|
|
76
|
+
...responses,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
rootDir: "/workspace",
|
|
81
|
+
execute: vi.fn(async (cmd: string) => {
|
|
82
|
+
for (const [pattern, response] of Object.entries(defaultResponses)) {
|
|
83
|
+
if (cmd.includes(pattern)) return response;
|
|
84
|
+
}
|
|
85
|
+
return "";
|
|
86
|
+
}),
|
|
87
|
+
readFile: vi.fn(),
|
|
88
|
+
writeFile: vi.fn(),
|
|
89
|
+
writeFileBuffer: vi.fn(),
|
|
90
|
+
exists: vi.fn(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeCoordinator(opts: {
|
|
95
|
+
sb: ExecutionStatusWriter;
|
|
96
|
+
backend?: WorkspaceBackend;
|
|
97
|
+
executionId?: string;
|
|
98
|
+
sessionId?: string;
|
|
99
|
+
githubToken?: string;
|
|
100
|
+
provisionResults?: ProvisionResult[];
|
|
101
|
+
workspaceEntries?: any[];
|
|
102
|
+
}): WriteBackCoordinator {
|
|
103
|
+
return new WriteBackCoordinator({
|
|
104
|
+
statusWriter: opts.sb,
|
|
105
|
+
executionId: opts.executionId ?? "exec-12345678rest",
|
|
106
|
+
sessionId: opts.sessionId ?? SESSION_ID,
|
|
107
|
+
githubToken: opts.githubToken ?? "ghp_plumbed_token",
|
|
108
|
+
provisionResults: opts.provisionResults ?? [makeProvisionResult()],
|
|
109
|
+
workspaceEntries: opts.workspaceEntries ?? [makeWorkspaceEntry("my-app")],
|
|
110
|
+
workspaceBackend: opts.backend ?? mockWorkspaceBackend(),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* URL-aware GitHub API mock: listing open PRs for the head branch returns
|
|
116
|
+
* `openPrs`; creating a PR returns `created`. Records calls for assertions.
|
|
117
|
+
*/
|
|
118
|
+
function mockGithubApi(opts: {
|
|
119
|
+
openPrs?: Array<{ html_url: string; number: number }>;
|
|
120
|
+
created?: { html_url: string; number: number };
|
|
121
|
+
createStatus?: number;
|
|
122
|
+
} = {}) {
|
|
123
|
+
const calls: Array<{ url: string; method: string; headers: Record<string, string> }> = [];
|
|
124
|
+
globalThis.fetch = vi.fn(async (url: any, init?: any) => {
|
|
125
|
+
const method = init?.method ?? "GET";
|
|
126
|
+
calls.push({ url: String(url), method, headers: init?.headers ?? {} });
|
|
127
|
+
if (method === "GET") {
|
|
128
|
+
return new Response(JSON.stringify(opts.openPrs ?? []), { status: 200 });
|
|
129
|
+
}
|
|
130
|
+
const status = opts.createStatus ?? 201;
|
|
131
|
+
const body = status < 300
|
|
132
|
+
? JSON.stringify(opts.created ?? { html_url: "https://github.com/acme/my-app/pull/42", number: 42 })
|
|
133
|
+
: JSON.stringify({ message: "Forbidden" });
|
|
134
|
+
return new Response(body, { status });
|
|
135
|
+
}) as typeof fetch;
|
|
136
|
+
return calls;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const originalFetch = globalThis.fetch;
|
|
140
|
+
|
|
141
|
+
describe("WriteBackCoordinator", () => {
|
|
142
|
+
let sb: ExecutionStatusWriter;
|
|
143
|
+
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
sb = makeStatusBuilder();
|
|
146
|
+
mockGithubApi();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
globalThis.fetch = originalFetch;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── Eligibility ─────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
it("filters out non-git workspace entries", () => {
|
|
156
|
+
const coord = makeCoordinator({
|
|
157
|
+
sb,
|
|
158
|
+
provisionResults: [makeProvisionResult({ sourceType: SourceType.LOCAL_PATH })],
|
|
159
|
+
});
|
|
160
|
+
expect(coord.hasEligibleEntries).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("filters out entries without git credentials", () => {
|
|
164
|
+
const coord = makeCoordinator({
|
|
165
|
+
sb,
|
|
166
|
+
provisionResults: [makeProvisionResult({
|
|
167
|
+
gitMetadata: {
|
|
168
|
+
repoUrl: "https://github.com/acme/my-app.git",
|
|
169
|
+
branch: "main",
|
|
170
|
+
baseCommit: "abc",
|
|
171
|
+
gitCredentialsConfigured: false,
|
|
172
|
+
},
|
|
173
|
+
})],
|
|
174
|
+
});
|
|
175
|
+
expect(coord.hasEligibleEntries).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("accepts entries with UNSPECIFIED write-back mode (platform decides)", () => {
|
|
179
|
+
const coord = makeCoordinator({
|
|
180
|
+
sb,
|
|
181
|
+
workspaceEntries: [makeWorkspaceEntry("my-app", GitWriteBackMode.GIT_WRITE_BACK_MODE_UNSPECIFIED)],
|
|
182
|
+
});
|
|
183
|
+
expect(coord.hasEligibleEntries).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── Full cycle ──────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
it("performs full cycle on the SESSION branch: branch -> commit -> push -> PR", async () => {
|
|
189
|
+
const backend = mockWorkspaceBackend();
|
|
190
|
+
const coord = makeCoordinator({ sb, backend });
|
|
191
|
+
|
|
192
|
+
await coord.onFileModified("src/main.ts");
|
|
193
|
+
|
|
194
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
195
|
+
expect(wbs).toHaveLength(1);
|
|
196
|
+
expect(wbs[0].branchName).toBe(SESSION_BRANCH);
|
|
197
|
+
expect(wbs[0].baseBranch).toBe("main");
|
|
198
|
+
expect(wbs[0].commitSha).toBe("abc123def456");
|
|
199
|
+
expect(wbs[0].pullRequestUrl).toBe("https://github.com/acme/my-app/pull/42");
|
|
200
|
+
expect(wbs[0].pullRequestNumber).toBe(42);
|
|
201
|
+
expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED);
|
|
202
|
+
expect(wbs[0].error).toBe("");
|
|
203
|
+
|
|
204
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
205
|
+
.map((c: any) => c[0] as string);
|
|
206
|
+
expect(commands.some((c: string) => c.includes(`git checkout -b ${SESSION_BRANCH}`))).toBe(true);
|
|
207
|
+
expect(commands.some((c: string) => c.includes("git add -A"))).toBe(true);
|
|
208
|
+
expect(commands.some((c: string) => c.includes("commit -m"))).toBe(true);
|
|
209
|
+
expect(commands.some((c: string) => c.includes(`git push -u origin ${SESSION_BRANCH}`))).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("commits with the agent identity pinned via -c flags and the execution id in the message", async () => {
|
|
213
|
+
const backend = mockWorkspaceBackend();
|
|
214
|
+
const coord = makeCoordinator({ sb, backend, executionId: "exec-12345678rest" });
|
|
215
|
+
|
|
216
|
+
await coord.onFileModified("src/main.ts");
|
|
217
|
+
|
|
218
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
219
|
+
.map((c: any) => c[0] as string);
|
|
220
|
+
const commitCommand = commands.find((c: string) => c.includes("git") && c.includes("commit -m"));
|
|
221
|
+
expect(commitCommand,
|
|
222
|
+
"commit must not depend on ambient git identity — the cloud sandbox has none",
|
|
223
|
+
).toBeDefined();
|
|
224
|
+
expect(commitCommand).toContain(`-c user.name='${AGENT_GIT_AUTHOR_NAME}'`);
|
|
225
|
+
expect(commitCommand).toContain(`-c user.email='${AGENT_GIT_AUTHOR_EMAIL}'`);
|
|
226
|
+
expect(commitCommand).toContain('commit -m "agent changes (exec-12345678rest)"');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("skips when there are no changes", async () => {
|
|
230
|
+
const backend = mockWorkspaceBackend({
|
|
231
|
+
"git diff --stat": "",
|
|
232
|
+
"git ls-files --others --exclude-standard": "",
|
|
233
|
+
});
|
|
234
|
+
const coord = makeCoordinator({ sb, backend });
|
|
235
|
+
|
|
236
|
+
await coord.onFileModified("src/main.ts");
|
|
237
|
+
|
|
238
|
+
expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── Session-branch idempotency ──────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
it("on second call, commits to the existing branch without re-creating", async () => {
|
|
244
|
+
const backend = mockWorkspaceBackend();
|
|
245
|
+
const coord = makeCoordinator({ sb, backend });
|
|
246
|
+
|
|
247
|
+
await coord.onFileModified("first.ts");
|
|
248
|
+
await coord.onFileModified("second.ts");
|
|
249
|
+
|
|
250
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
251
|
+
.map((c: any) => c[0] as string);
|
|
252
|
+
const checkoutCalls = commands.filter((c: string) => c.includes("git checkout -b"));
|
|
253
|
+
expect(checkoutCalls).toHaveLength(1);
|
|
254
|
+
|
|
255
|
+
const pushCalls = commands.filter((c: string) => c.includes("git push"));
|
|
256
|
+
expect(pushCalls.length).toBeGreaterThanOrEqual(2);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("a later execution reuses the session branch HEAD already sits on (no checkout)", async () => {
|
|
260
|
+
// Turn 2 of the same session: the previous cycle left HEAD on the branch.
|
|
261
|
+
const backend = mockWorkspaceBackend({
|
|
262
|
+
"git branch --show-current": SESSION_BRANCH,
|
|
263
|
+
});
|
|
264
|
+
const coord = makeCoordinator({ sb, backend, executionId: "exec-turn2" });
|
|
265
|
+
|
|
266
|
+
await coord.onFileModified("src/more.ts");
|
|
267
|
+
|
|
268
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
269
|
+
.map((c: any) => c[0] as string);
|
|
270
|
+
expect(commands.some((c: string) => c.includes("git checkout"))).toBe(false);
|
|
271
|
+
expect(commands.some((c: string) => c.includes(`git push -u origin ${SESSION_BRANCH}`))).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("checks out the existing local session branch instead of creating it", async () => {
|
|
275
|
+
const backend = mockWorkspaceBackend({
|
|
276
|
+
"git branch --show-current": "main",
|
|
277
|
+
"git rev-parse --verify --quiet": "deadbeef",
|
|
278
|
+
});
|
|
279
|
+
const coord = makeCoordinator({ sb, backend });
|
|
280
|
+
|
|
281
|
+
await coord.onFileModified("src/main.ts");
|
|
282
|
+
|
|
283
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
284
|
+
.map((c: any) => c[0] as string);
|
|
285
|
+
expect(commands.some((c: string) =>
|
|
286
|
+
c.includes(`git checkout ${SESSION_BRANCH}`) && !c.includes("-b"),
|
|
287
|
+
)).toBe(true);
|
|
288
|
+
expect(commands.some((c: string) => c.includes("git checkout -b"))).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("after a re-provision, fetches and tracks the remote session branch", async () => {
|
|
292
|
+
const backend = mockWorkspaceBackend({
|
|
293
|
+
"git branch --show-current": "main",
|
|
294
|
+
"git rev-parse --verify --quiet": "",
|
|
295
|
+
"git ls-remote --heads origin": `deadbeef\trefs/heads/${SESSION_BRANCH}`,
|
|
296
|
+
});
|
|
297
|
+
const coord = makeCoordinator({ sb, backend });
|
|
298
|
+
|
|
299
|
+
await coord.onFileModified("src/main.ts");
|
|
300
|
+
|
|
301
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
302
|
+
.map((c: any) => c[0] as string);
|
|
303
|
+
expect(commands.some((c: string) => c.includes(`git fetch origin ${SESSION_BRANCH}`))).toBe(true);
|
|
304
|
+
expect(commands.some((c: string) =>
|
|
305
|
+
c.includes(`git checkout -b ${SESSION_BRANCH} origin/${SESSION_BRANCH}`),
|
|
306
|
+
)).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("adopts an already-open PR for the session branch instead of creating a duplicate", async () => {
|
|
310
|
+
const calls = mockGithubApi({
|
|
311
|
+
openPrs: [{ html_url: "https://github.com/acme/my-app/pull/7", number: 7 }],
|
|
312
|
+
});
|
|
313
|
+
const coord = makeCoordinator({ sb });
|
|
314
|
+
|
|
315
|
+
await coord.onFileModified("src/main.ts");
|
|
316
|
+
|
|
317
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
318
|
+
expect(wbs[0].pullRequestNumber).toBe(7);
|
|
319
|
+
expect(wbs[0].pullRequestUrl).toBe("https://github.com/acme/my-app/pull/7");
|
|
320
|
+
expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED);
|
|
321
|
+
expect(calls.filter((c) => c.method === "POST")).toHaveLength(0);
|
|
322
|
+
const listCall = calls.find((c) => c.method === "GET");
|
|
323
|
+
expect(listCall?.url).toContain("state=open");
|
|
324
|
+
expect(listCall?.url).toContain(encodeURIComponent(`acme:${SESSION_BRANCH}`));
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("uses the plumbed token for the GitHub API (never the repo URL or process.env)", async () => {
|
|
328
|
+
const calls = mockGithubApi();
|
|
329
|
+
const coord = makeCoordinator({ sb, githubToken: "ghp_plumbed_token" });
|
|
330
|
+
|
|
331
|
+
await coord.onFileModified("src/main.ts");
|
|
332
|
+
|
|
333
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
334
|
+
for (const call of calls) {
|
|
335
|
+
expect(call.headers["Authorization"]).toBe("Bearer ghp_plumbed_token");
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ── Failure semantics ───────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
it("sets FAILED phase on a commit/push error", async () => {
|
|
342
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
343
|
+
const backend = mockWorkspaceBackend();
|
|
344
|
+
(backend.execute as ReturnType<typeof vi.fn>).mockImplementation(async (cmd: string) => {
|
|
345
|
+
if (cmd.includes("git diff --stat") && !cmd.includes("...HEAD")) return " changed";
|
|
346
|
+
if (cmd.includes("git diff --cached")) return "";
|
|
347
|
+
if (cmd.includes("git branch --show-current")) return "main";
|
|
348
|
+
if (cmd.includes("commit -m")) throw new Error("commit failed: lock");
|
|
349
|
+
return "";
|
|
350
|
+
});
|
|
351
|
+
const coord = makeCoordinator({ sb, backend });
|
|
352
|
+
|
|
353
|
+
await coord.onFileModified("src/main.ts");
|
|
354
|
+
|
|
355
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
356
|
+
expect(wbs).toHaveLength(1);
|
|
357
|
+
expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED);
|
|
358
|
+
expect(wbs[0].error).toContain("commit failed");
|
|
359
|
+
warnSpy.mockRestore();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("reports PUSHED with the PR error when PR creation fails after a successful push", async () => {
|
|
363
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
364
|
+
mockGithubApi({ createStatus: 403 });
|
|
365
|
+
const coord = makeCoordinator({ sb });
|
|
366
|
+
|
|
367
|
+
await coord.onFileModified("src/main.ts");
|
|
368
|
+
|
|
369
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
370
|
+
expect(wbs).toHaveLength(1);
|
|
371
|
+
expect(wbs[0].phase,
|
|
372
|
+
"a pushed branch must never be reported FAILED for a PR-step error",
|
|
373
|
+
).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PUSHED);
|
|
374
|
+
expect(wbs[0].branchName).toBe(SESSION_BRANCH);
|
|
375
|
+
expect(wbs[0].commitSha).toBe("abc123def456");
|
|
376
|
+
expect(wbs[0].error).toContain("GitHub API error");
|
|
377
|
+
warnSpy.mockRestore();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("reports PUSHED with an actionable error when no GitHub token is available", async () => {
|
|
381
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
382
|
+
const coord = makeCoordinator({ sb, githubToken: "" });
|
|
383
|
+
|
|
384
|
+
await coord.onFileModified("src/main.ts");
|
|
385
|
+
|
|
386
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
387
|
+
expect(wbs).toHaveLength(1);
|
|
388
|
+
expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PUSHED);
|
|
389
|
+
expect(wbs[0].error).toContain("No GitHub token");
|
|
390
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
391
|
+
warnSpy.mockRestore();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ── finalize / path resolution ──────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
it("finalize catches remaining uncommitted changes", async () => {
|
|
397
|
+
const coord = makeCoordinator({ sb });
|
|
398
|
+
|
|
399
|
+
await coord.finalize();
|
|
400
|
+
|
|
401
|
+
expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(1);
|
|
402
|
+
expect(sb.currentStatus.workspaceWriteBacks[0].phase).toBe(
|
|
403
|
+
WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED,
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("resolves path to single entry without prefix matching", async () => {
|
|
408
|
+
const coord = makeCoordinator({ sb });
|
|
409
|
+
|
|
410
|
+
await coord.onFileModified("any/path/file.ts");
|
|
411
|
+
|
|
412
|
+
expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(1);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("resolves path to correct entry in multi-entry workspace", async () => {
|
|
416
|
+
const coord = makeCoordinator({
|
|
417
|
+
sb,
|
|
418
|
+
provisionResults: [
|
|
419
|
+
makeProvisionResult({ entryName: "frontend", rootDir: "/workspace/frontend" }),
|
|
420
|
+
makeProvisionResult({ entryName: "backend", rootDir: "/workspace/backend" }),
|
|
421
|
+
],
|
|
422
|
+
workspaceEntries: [
|
|
423
|
+
makeWorkspaceEntry("frontend"),
|
|
424
|
+
makeWorkspaceEntry("backend"),
|
|
425
|
+
],
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await coord.onFileModified("frontend/src/app.tsx");
|
|
429
|
+
|
|
430
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
431
|
+
expect(wbs).toHaveLength(1);
|
|
432
|
+
expect(wbs[0].workspaceEntryName).toBe("frontend");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("ignores paths outside eligible entries in multi-entry mode", async () => {
|
|
436
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
437
|
+
const coord = makeCoordinator({
|
|
438
|
+
sb,
|
|
439
|
+
provisionResults: [
|
|
440
|
+
makeProvisionResult({ entryName: "fe", rootDir: "/workspace/fe" }),
|
|
441
|
+
makeProvisionResult({ entryName: "be", rootDir: "/workspace/be" }),
|
|
442
|
+
],
|
|
443
|
+
workspaceEntries: [makeWorkspaceEntry("fe"), makeWorkspaceEntry("be")],
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await coord.onFileModified("unknown/file.ts");
|
|
447
|
+
expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(0);
|
|
448
|
+
warnSpy.mockRestore();
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe("parseGithubRepo", () => {
|
|
453
|
+
it("parses HTTPS URL with .git suffix", () => {
|
|
454
|
+
const result = parseGithubRepo("https://github.com/acme/my-app.git");
|
|
455
|
+
expect(result).toEqual({ owner: "acme", repo: "my-app" });
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("parses HTTPS URL without .git suffix", () => {
|
|
459
|
+
const result = parseGithubRepo("https://github.com/acme/my-app");
|
|
460
|
+
expect(result).toEqual({ owner: "acme", repo: "my-app" });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("parses HTTPS URL with token", () => {
|
|
464
|
+
const result = parseGithubRepo("https://ghp_token@github.com/acme/my-app.git");
|
|
465
|
+
expect(result).toEqual({ owner: "acme", repo: "my-app" });
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("parses SSH URL", () => {
|
|
469
|
+
const result = parseGithubRepo("git@github.com:acme/my-app.git");
|
|
470
|
+
expect(result).toEqual({ owner: "acme", repo: "my-app" });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("throws for non-GitHub URLs", () => {
|
|
474
|
+
expect(() => parseGithubRepo("https://gitlab.com/acme/my-app.git"))
|
|
475
|
+
.toThrow("Cannot parse GitHub owner/repo");
|
|
476
|
+
});
|
|
477
|
+
});
|