@pushpalsdev/cli 1.0.40 → 1.0.42

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.
@@ -0,0 +1,359 @@
1
+ import { mkdirSync, mkdtempSync, rmSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { basename, dirname, join, resolve } from "path";
4
+
5
+ type LogFn = (stream: "stdout" | "stderr", line: string) => void;
6
+
7
+ type GitResult = {
8
+ ok: boolean;
9
+ stdout: string;
10
+ stderr: string;
11
+ };
12
+
13
+ type MergeConflictReviewContext = {
14
+ publicBranch: string;
15
+ baseBranch: string;
16
+ expectedHeadSha: string;
17
+ mergeError: string;
18
+ };
19
+
20
+ export type MergeConflictSandboxPreparation = {
21
+ repoPath: string;
22
+ cleanup: () => void;
23
+ conflictPaths: string[];
24
+ plannerGuidance: string;
25
+ rebasedCleanly: boolean;
26
+ currentHeadSha: string;
27
+ };
28
+
29
+ function asRecord(value: unknown): Record<string, unknown> | null {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
31
+ return value as Record<string, unknown>;
32
+ }
33
+
34
+ function normalizeBranchName(value: unknown): string {
35
+ const trimmed = String(value ?? "").trim().replace(/^refs\/heads\//, "");
36
+ const normalized = trimmed
37
+ .replace(/\\/g, "/")
38
+ .replace(/\/+/g, "/")
39
+ .replace(/^\/+|\/+$/g, "");
40
+ if (!normalized.startsWith("agent/")) return "";
41
+ if (
42
+ normalized.includes("..") ||
43
+ normalized.includes("@{") ||
44
+ normalized.endsWith(".") ||
45
+ normalized.endsWith(".lock")
46
+ ) {
47
+ return "";
48
+ }
49
+ if (/[~^:?*\[\]\s]/.test(normalized)) return "";
50
+ return normalized;
51
+ }
52
+
53
+ function normalizeBaseBranch(value: unknown): string {
54
+ return String(value ?? "")
55
+ .trim()
56
+ .replace(/^refs\/heads\//, "")
57
+ .replace(/\\/g, "/")
58
+ .replace(/\/+/g, "/")
59
+ .replace(/^\/+|\/+$/g, "");
60
+ }
61
+
62
+ function isMergeConflictOutput(text: string): boolean {
63
+ const normalized = String(text ?? "").toLowerCase();
64
+ return (
65
+ normalized.includes("could not apply") ||
66
+ normalized.includes("resolve all conflicts manually") ||
67
+ normalized.includes("merge conflict") ||
68
+ normalized.includes("fix conflicts and then run")
69
+ );
70
+ }
71
+
72
+ async function git(cwd: string, args: string[]): Promise<GitResult> {
73
+ const proc = Bun.spawn(["git", ...args], {
74
+ cwd,
75
+ stdout: "pipe",
76
+ stderr: "pipe",
77
+ });
78
+ const [stdout, stderr, exitCode] = await Promise.all([
79
+ new Response(proc.stdout).text(),
80
+ new Response(proc.stderr).text(),
81
+ proc.exited,
82
+ ]);
83
+ return {
84
+ ok: exitCode === 0,
85
+ stdout: stdout.trim(),
86
+ stderr: stderr.trim(),
87
+ };
88
+ }
89
+
90
+ async function mustGit(cwd: string, args: string[], label: string): Promise<string> {
91
+ const result = await git(cwd, args);
92
+ if (!result.ok) {
93
+ throw new Error(`${label} failed: git ${args.join(" ")}\n${result.stderr || result.stdout}`);
94
+ }
95
+ return result.stdout;
96
+ }
97
+
98
+ function dedupeStrings(values: string[], maxItems = 16): string[] {
99
+ const seen = new Set<string>();
100
+ const out: string[] = [];
101
+ for (const entry of values) {
102
+ const trimmed = String(entry ?? "").trim();
103
+ if (!trimmed || seen.has(trimmed)) continue;
104
+ seen.add(trimmed);
105
+ out.push(trimmed);
106
+ if (out.length >= maxItems) break;
107
+ }
108
+ return out;
109
+ }
110
+
111
+ function deriveLikelyDirs(paths: string[]): string[] {
112
+ return dedupeStrings(
113
+ paths
114
+ .map((entry) => dirname(entry).replace(/\\/g, "/"))
115
+ .filter((entry) => entry && entry !== "."),
116
+ 12,
117
+ );
118
+ }
119
+
120
+ function deriveRipgrepQueries(paths: string[]): string[] {
121
+ return dedupeStrings(paths.map((entry) => basename(entry)).filter(Boolean), 8);
122
+ }
123
+
124
+ function isTestPath(path: string): boolean {
125
+ return /(^tests\/|__tests__\/|\.test\.[cm]?[jt]sx?$|\.spec\.[cm]?[jt]sx?$)/i.test(path);
126
+ }
127
+
128
+ function deriveValidationSteps(existing: unknown, conflictPaths: string[]): string[] {
129
+ const preserved = Array.isArray(existing)
130
+ ? existing.map((entry) => String(entry ?? "").trim()).filter(Boolean)
131
+ : [];
132
+ const targeted = conflictPaths.filter(isTestPath).map((entry) => `bun test ${entry}`);
133
+ const merged = dedupeStrings([...targeted, ...preserved], 8);
134
+ return merged.length > 0 ? merged : ["bun test"];
135
+ }
136
+
137
+ function extractConflictPaths(stdout: string): string[] {
138
+ return dedupeStrings(
139
+ String(stdout ?? "")
140
+ .split(/\r?\n/)
141
+ .map((line) => line.trim())
142
+ .filter(Boolean),
143
+ 32,
144
+ );
145
+ }
146
+
147
+ function buildPlannerGuidance(
148
+ context: MergeConflictReviewContext,
149
+ conflictPaths: string[],
150
+ rebasedCleanly: boolean,
151
+ ): string {
152
+ const lines = [
153
+ "Merge-conflict sandbox state:",
154
+ `- You are on local branch ${context.publicBranch} inside an isolated container-local clone. This branch exists only inside the worker sandbox and does not switch the user's active checkout.`,
155
+ `- Remote push target: origin/${context.publicBranch}`,
156
+ `- Rebase target: origin/${context.baseBranch}`,
157
+ ];
158
+ if (context.expectedHeadSha) {
159
+ lines.push(
160
+ `- Expected remote lease SHA for push-back: ${context.expectedHeadSha}. If origin/${context.publicBranch} moved, stop and report the mismatch instead of overwriting newer work.`,
161
+ );
162
+ }
163
+ if (context.mergeError) {
164
+ lines.push(`- GitHub mergeability error: ${context.mergeError}`);
165
+ }
166
+ if (rebasedCleanly) {
167
+ lines.push(
168
+ `- The branch already rebased cleanly onto origin/${context.baseBranch} in this sandbox. Validate the rebased result and leave the repo clean for finalization.`,
169
+ );
170
+ } else {
171
+ lines.push(
172
+ `- The sandbox branch is already paused mid-rebase onto origin/${context.baseBranch}. Resolve the conflicts in the current repo state instead of re-discovering branch topology.`,
173
+ );
174
+ if (conflictPaths.length > 0) {
175
+ lines.push(`- Unresolved conflict files: ${conflictPaths.join(", ")}`);
176
+ }
177
+ lines.push(
178
+ "- After editing, run `git add <files>` and `git -c core.editor=true rebase --continue` until the rebase completes.",
179
+ );
180
+ }
181
+ lines.push("- Do not create a new PR or alternate branch. Update only the existing PR branch.");
182
+ return lines.join("\n");
183
+ }
184
+
185
+ export function extractMergeConflictReviewContext(
186
+ params: Record<string, unknown> | null | undefined,
187
+ ): MergeConflictReviewContext | null {
188
+ const reviewAgent = asRecord(params?.reviewAgent);
189
+ if (!reviewAgent) return null;
190
+ const resolutionType = String(reviewAgent.resolutionType ?? "").trim().toLowerCase();
191
+ if (resolutionType !== "merge_conflict") return null;
192
+
193
+ const publicBranch = normalizeBranchName(params?.completionBranch ?? reviewAgent.prHeadRef);
194
+ const baseBranch = normalizeBaseBranch(reviewAgent.prBaseRef);
195
+ if (!publicBranch || !baseBranch) return null;
196
+
197
+ return {
198
+ publicBranch,
199
+ baseBranch,
200
+ expectedHeadSha: String(reviewAgent.prHeadSha ?? "").trim(),
201
+ mergeError: String(reviewAgent.mergeError ?? "").trim(),
202
+ };
203
+ }
204
+
205
+ export function isMergeConflictResolutionParams(
206
+ params: Record<string, unknown> | null | undefined,
207
+ ): boolean {
208
+ return extractMergeConflictReviewContext(params) !== null;
209
+ }
210
+
211
+ export function applyMergeConflictExecutionHints(
212
+ params: Record<string, unknown>,
213
+ preparation: MergeConflictSandboxPreparation,
214
+ ): Record<string, unknown> {
215
+ const next: Record<string, unknown> = { ...params };
216
+ const planning = asRecord(next.planning) ? { ...(next.planning as Record<string, unknown>) } : {};
217
+ const existingGuidance = String(next.plannerWorkerInstruction ?? "").trim();
218
+ next.plannerWorkerInstruction = [existingGuidance, preparation.plannerGuidance]
219
+ .filter(Boolean)
220
+ .join("\n\n");
221
+
222
+ const hintedPaths = preparation.conflictPaths;
223
+ if (hintedPaths.length > 0) {
224
+ const currentTargetPaths = Array.isArray(planning.targetPaths)
225
+ ? planning.targetPaths.map((entry) => String(entry ?? "")).filter(Boolean)
226
+ : [];
227
+ planning.targetPaths = dedupeStrings([...hintedPaths, ...currentTargetPaths], 24);
228
+
229
+ const discovery = asRecord(planning.discovery)
230
+ ? { ...(planning.discovery as Record<string, unknown>) }
231
+ : {};
232
+ const likelyDirs = Array.isArray(discovery.likelyDirs)
233
+ ? discovery.likelyDirs.map((entry) => String(entry ?? "")).filter(Boolean)
234
+ : [];
235
+ const ripgrepQueries = Array.isArray(discovery.ripgrepQueries)
236
+ ? discovery.ripgrepQueries.map((entry) => String(entry ?? "")).filter(Boolean)
237
+ : [];
238
+ discovery.likelyDirs = dedupeStrings([...deriveLikelyDirs(hintedPaths), ...likelyDirs], 16);
239
+ discovery.ripgrepQueries = dedupeStrings(
240
+ [...deriveRipgrepQueries(hintedPaths), ...ripgrepQueries],
241
+ 16,
242
+ );
243
+ planning.discovery = discovery;
244
+ planning.validationSteps = deriveValidationSteps(planning.validationSteps, hintedPaths);
245
+ }
246
+
247
+ next.planning = planning;
248
+ const reviewAgent = asRecord(next.reviewAgent);
249
+ if (reviewAgent) {
250
+ next.reviewAgent = {
251
+ ...reviewAgent,
252
+ preparedWorkspaceMode: "isolated_container_clone",
253
+ preparedRebaseState: preparation.rebasedCleanly ? "clean" : "conflicted",
254
+ preparedConflictPaths: preparation.conflictPaths,
255
+ preparedHeadSha: preparation.currentHeadSha,
256
+ };
257
+ }
258
+ return next;
259
+ }
260
+
261
+ export async function prepareMergeConflictTaskRepo(
262
+ sourceRepo: string,
263
+ jobId: string,
264
+ params: Record<string, unknown>,
265
+ onLog?: LogFn,
266
+ ): Promise<MergeConflictSandboxPreparation> {
267
+ const context = extractMergeConflictReviewContext(params);
268
+ if (!context) {
269
+ return {
270
+ repoPath: sourceRepo,
271
+ cleanup: () => {},
272
+ conflictPaths: [],
273
+ plannerGuidance: "",
274
+ rebasedCleanly: false,
275
+ currentHeadSha: "",
276
+ };
277
+ }
278
+
279
+ const remoteUrl = await mustGit(sourceRepo, ["remote", "get-url", "origin"], "read origin URL");
280
+ const sourceUserName = (await git(sourceRepo, ["config", "--get", "user.name"])).stdout.trim();
281
+ const sourceUserEmail = (await git(sourceRepo, ["config", "--get", "user.email"])).stdout.trim();
282
+ const sandboxRoot = mkdtempSync(join(tmpdir(), "pushpals-merge-conflict-"));
283
+ const repoPath = join(sandboxRoot, "repo");
284
+ mkdirSync(repoPath, { recursive: true });
285
+
286
+ const cleanup = () => {
287
+ rmSync(sandboxRoot, { recursive: true, force: true });
288
+ };
289
+
290
+ try {
291
+ await mustGit(repoPath, ["init", "--quiet"], "init sandbox repo");
292
+ await mustGit(repoPath, ["remote", "add", "origin", remoteUrl], "add origin");
293
+ await mustGit(
294
+ repoPath,
295
+ ["config", "user.name", sourceUserName || "PushPals WorkerPal"],
296
+ "configure user.name",
297
+ );
298
+ await mustGit(
299
+ repoPath,
300
+ ["config", "user.email", sourceUserEmail || "pushpals-worker@local"],
301
+ "configure user.email",
302
+ );
303
+ await mustGit(repoPath, ["config", "rerere.enabled", "true"], "enable rerere");
304
+ await mustGit(repoPath, ["config", "rerere.autoupdate", "true"], "enable rerere autoupdate");
305
+
306
+ const fetchArgs = [
307
+ "fetch",
308
+ "--quiet",
309
+ "origin",
310
+ `+refs/heads/${context.publicBranch}:refs/remotes/origin/${context.publicBranch}`,
311
+ `+refs/heads/${context.baseBranch}:refs/remotes/origin/${context.baseBranch}`,
312
+ ];
313
+ await mustGit(repoPath, fetchArgs, "fetch merge-conflict refs");
314
+ await mustGit(
315
+ repoPath,
316
+ ["checkout", "-B", context.publicBranch, `refs/remotes/origin/${context.publicBranch}`],
317
+ "checkout PR branch in sandbox",
318
+ );
319
+ await mustGit(
320
+ repoPath,
321
+ ["branch", "--set-upstream-to", `origin/${context.publicBranch}`, context.publicBranch],
322
+ "set sandbox upstream",
323
+ );
324
+
325
+ const baseRemoteRef = `refs/remotes/origin/${context.baseBranch}`;
326
+ const rebase = await git(repoPath, ["-c", "core.editor=true", "rebase", baseRemoteRef]);
327
+ let conflictPaths: string[] = [];
328
+ let rebasedCleanly = false;
329
+ if (rebase.ok) {
330
+ rebasedCleanly = true;
331
+ const note = `[MergeConflictSandbox] ${jobId}: ${context.publicBranch} rebased cleanly onto origin/${context.baseBranch}.`;
332
+ onLog?.("stdout", note);
333
+ } else if (isMergeConflictOutput(`${rebase.stderr}\n${rebase.stdout}`)) {
334
+ const unresolved = await mustGit(
335
+ repoPath,
336
+ ["diff", "--name-only", "--diff-filter=U"],
337
+ "list unresolved conflict paths",
338
+ );
339
+ conflictPaths = extractConflictPaths(unresolved);
340
+ const note = `[MergeConflictSandbox] ${jobId}: prepared isolated sandbox for ${context.publicBranch} with ${conflictPaths.length} unresolved file(s).`;
341
+ onLog?.("stdout", note);
342
+ } else {
343
+ throw new Error(rebase.stderr || rebase.stdout || "unknown rebase failure");
344
+ }
345
+
346
+ const currentHeadSha = await mustGit(repoPath, ["rev-parse", "HEAD"], "resolve sandbox HEAD");
347
+ return {
348
+ repoPath: resolve(repoPath),
349
+ cleanup,
350
+ conflictPaths,
351
+ plannerGuidance: buildPlannerGuidance(context, conflictPaths, rebasedCleanly),
352
+ rebasedCleanly,
353
+ currentHeadSha,
354
+ };
355
+ } catch (error) {
356
+ cleanup();
357
+ throw error;
358
+ }
359
+ }
@@ -39,6 +39,7 @@ import {
39
39
  git,
40
40
  redactSensitiveText,
41
41
  resolveReviewNoChangeCompletionBranch,
42
+ shouldEnqueueNoChangeReviewCompletion,
42
43
  type JobResult,
43
44
  } from "./execute_job.js";
44
45
  import { DockerExecutionExhaustedError, DockerExecutor } from "./docker_executor.js";
@@ -852,6 +853,19 @@ async function resolveReReviewNoChangeCommit(
852
853
  return null;
853
854
  }
854
855
 
856
+ function failNoChangeReviewFixJob(jobId: string, result: WorkerJobResult): WorkerJobResult {
857
+ return {
858
+ ...result,
859
+ ok: false,
860
+ summary:
861
+ `Rejected review-fix job ${jobId} produced no code changes; refusing unchanged branch re-review.`,
862
+ stderr: [result.stderr, "Apply at least one concrete fix before requesting another review."]
863
+ .filter(Boolean)
864
+ .join("\n"),
865
+ exitCode: typeof result.exitCode === "number" ? result.exitCode : 4,
866
+ };
867
+ }
868
+
855
869
  async function enqueueCompletion(
856
870
  server: string,
857
871
  headers: Record<string, string>,
@@ -1289,6 +1303,11 @@ async function workerLoop(
1289
1303
  if (result.commit) {
1290
1304
  if (result.commit.sha !== "no-changes") {
1291
1305
  completionCommit = result.commit;
1306
+ } else if (!shouldEnqueueNoChangeReviewCompletion(parsedParams)) {
1307
+ console.warn(
1308
+ `[WorkerPals] Job ${job.id} produced no code changes for a rejected review-fix request; marking the job failed instead of enqueueing unchanged branch re-review.`,
1309
+ );
1310
+ result = failNoChangeReviewFixJob(job.id, result);
1292
1311
  } else {
1293
1312
  const reReviewCommit = await resolveReReviewNoChangeCommit(
1294
1313
  executionRepo,
@@ -1336,6 +1355,11 @@ async function workerLoop(
1336
1355
  branch: commitResult.branch,
1337
1356
  sha: commitResult.sha,
1338
1357
  };
1358
+ } else if (!shouldEnqueueNoChangeReviewCompletion(parsedParams)) {
1359
+ console.warn(
1360
+ `[WorkerPals] Job ${job.id} produced no staged review-fix changes; marking the job failed instead of enqueueing unchanged branch re-review.`,
1361
+ );
1362
+ result = failNoChangeReviewFixJob(job.id, result);
1339
1363
  }
1340
1364
  } else if (commitResult.error) {
1341
1365
  console.error(`[WorkerPals] Failed to create commit: ${commitResult.error}`);
@@ -6,6 +6,7 @@ Non-negotiable runtime invariants:
6
6
  - Do not modify tests or production code to bypass, stub, or remove Codex CLI usage due to assumed environment limitations.
7
7
  - Do not "adapt around" missing Codex access by rewriting coverage or behavior expectations.
8
8
  - If Codex CLI authentication/execution is unavailable, fail loudly with a clear error and stop.
9
+ - When worker guidance provides exact repo/branch/conflict state, treat that prepared sandbox state as authoritative and start from the current checkout instead of re-discovering topology.
9
10
 
10
11
  Execution rules:
11
12