sela-core 1.0.5 → 1.0.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/README.md +1 -1
- package/dist/config/ConfigLoader.d.ts +1 -0
- package/dist/config/ConfigLoader.d.ts.map +1 -1
- package/dist/config/ConfigLoader.js +10 -0
- package/dist/config/DryRunGuard.d.ts +14 -0
- package/dist/config/DryRunGuard.d.ts.map +1 -0
- package/dist/config/DryRunGuard.js +79 -0
- package/dist/config/SelaConfig.d.ts +15 -1
- package/dist/config/SelaConfig.d.ts.map +1 -1
- package/dist/config/SelaConfig.js +27 -1
- package/dist/engine/SelaEngine.d.ts +26 -6
- package/dist/engine/SelaEngine.d.ts.map +1 -1
- package/dist/engine/SelaEngine.js +329 -77
- package/dist/errors/SelaError.d.ts +133 -0
- package/dist/errors/SelaError.d.ts.map +1 -0
- package/dist/errors/SelaError.js +155 -0
- package/dist/fixtures/expectProxy.d.ts.map +1 -1
- package/dist/fixtures/expectProxy.js +7 -0
- package/dist/fixtures/index.d.ts +7 -1
- package/dist/fixtures/index.d.ts.map +1 -1
- package/dist/fixtures/index.js +15 -0
- package/dist/fixtures/proxyTag.d.ts +12 -0
- package/dist/fixtures/proxyTag.d.ts.map +1 -0
- package/dist/fixtures/proxyTag.js +45 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/reporter/SelaReporter.d.ts +110 -0
- package/dist/reporter/SelaReporter.d.ts.map +1 -0
- package/dist/reporter/SelaReporter.js +328 -0
- package/dist/services/ASTSourceUpdater.d.ts +19 -0
- package/dist/services/ASTSourceUpdater.d.ts.map +1 -1
- package/dist/services/ASTSourceUpdater.js +173 -29
- package/dist/services/HealReportService.d.ts +62 -3
- package/dist/services/HealReportService.d.ts.map +1 -1
- package/dist/services/HealReportService.js +184 -14
- package/dist/services/HealingCacheService.d.ts +135 -0
- package/dist/services/HealingCacheService.d.ts.map +1 -0
- package/dist/services/HealingCacheService.js +460 -0
- package/dist/services/InitializerUpdater.d.ts.map +1 -1
- package/dist/services/InitializerUpdater.js +20 -1
- package/dist/services/IntentAuditor.d.ts +18 -1
- package/dist/services/IntentAuditor.d.ts.map +1 -1
- package/dist/services/IntentAuditor.js +19 -6
- package/dist/services/InteractiveReview.d.ts +24 -0
- package/dist/services/InteractiveReview.d.ts.map +1 -0
- package/dist/services/InteractiveReview.js +218 -0
- package/dist/services/LLMService.d.ts +23 -2
- package/dist/services/LLMService.d.ts.map +1 -1
- package/dist/services/LLMService.js +48 -12
- package/dist/services/PRAutomationService.d.ts +23 -3
- package/dist/services/PRAutomationService.d.ts.map +1 -1
- package/dist/services/PRAutomationService.js +325 -79
- package/dist/services/PendingPromptLedger.d.ts +44 -0
- package/dist/services/PendingPromptLedger.d.ts.map +1 -0
- package/dist/services/PendingPromptLedger.js +180 -0
- package/dist/services/ReportGenerator.d.ts +70 -0
- package/dist/services/ReportGenerator.d.ts.map +1 -0
- package/dist/services/ReportGenerator.js +191 -0
- package/dist/services/SafetyGuard.d.ts +34 -2
- package/dist/services/SafetyGuard.d.ts.map +1 -1
- package/dist/services/SafetyGuard.js +65 -13
- package/dist/services/SourceUpdater.d.ts.map +1 -1
- package/dist/services/SourceUpdater.js +44 -13
- package/dist/services/WorkspaceSnapshotService.d.ts +71 -0
- package/dist/services/WorkspaceSnapshotService.d.ts.map +1 -0
- package/dist/services/WorkspaceSnapshotService.js +202 -0
- package/dist/utils/IsolatedDiff.d.ts +45 -0
- package/dist/utils/IsolatedDiff.d.ts.map +1 -0
- package/dist/utils/IsolatedDiff.js +379 -0
- package/package.json +71 -67
|
@@ -48,11 +48,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
48
48
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
49
|
exports.detectBranch = detectBranch;
|
|
50
50
|
exports.decideEffectiveStrategy = decideEffectiveStrategy;
|
|
51
|
+
exports.fmtPct = fmtPct;
|
|
51
52
|
exports.execute = execute;
|
|
52
53
|
exports.handleBugDetected = handleBugDetected;
|
|
53
54
|
const fs = __importStar(require("fs"));
|
|
54
55
|
const path = __importStar(require("path"));
|
|
55
56
|
const child_process_1 = require("child_process");
|
|
57
|
+
const WorkspaceSnapshotService_1 = require("./WorkspaceSnapshotService");
|
|
58
|
+
const IsolatedDiff_1 = require("../utils/IsolatedDiff");
|
|
56
59
|
// ═══════════════════════════════════════════════════════════════════
|
|
57
60
|
// SHELL HELPERS
|
|
58
61
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -186,12 +189,19 @@ function detectBranch(cwd = process.cwd()) {
|
|
|
186
189
|
// STRATEGY DECISION
|
|
187
190
|
// ═══════════════════════════════════════════════════════════════════
|
|
188
191
|
function decideEffectiveStrategy(cfg, branch, heals) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
// Compute MIN across only the heals that reported a finite numeric
|
|
193
|
+
// confidence. When *no* heal carried one, the result is `undefined` —
|
|
194
|
+
// surfaced as "n/a" in reports rather than synthesised as 100% (which
|
|
195
|
+
// would silently mask the absence of evidence) or 0% (which would
|
|
196
|
+
// incorrectly force a downgrade for every clean run on Sonnet).
|
|
197
|
+
const aiVals = heals
|
|
198
|
+
.map((h) => h.aiConfidence)
|
|
199
|
+
.filter((v) => typeof v === "number" && Number.isFinite(v));
|
|
200
|
+
const audVals = heals
|
|
201
|
+
.map((h) => h.auditor?.confidence)
|
|
202
|
+
.filter((v) => typeof v === "number" && Number.isFinite(v));
|
|
203
|
+
const minAi = aiVals.length > 0 ? Math.min(...aiVals) : undefined;
|
|
204
|
+
const minAud = audVals.length > 0 ? Math.min(...audVals) : undefined;
|
|
195
205
|
const baseDecision = {
|
|
196
206
|
effective: cfg.strategy,
|
|
197
207
|
configured: cfg.strategy,
|
|
@@ -209,8 +219,11 @@ function decideEffectiveStrategy(cfg, branch, heals) {
|
|
|
209
219
|
downgradeReason: "PROTECTED_BRANCH",
|
|
210
220
|
};
|
|
211
221
|
}
|
|
212
|
-
// Rule 2: low AI confidence
|
|
222
|
+
// Rule 2: low AI confidence — only triggers when the measured min is
|
|
223
|
+
// actually below the threshold. Missing confidence is "unknown", not
|
|
224
|
+
// "zero", so we do NOT downgrade purely on absence.
|
|
213
225
|
if (cfg.reviewThresholds.minConfidenceForDirectCommit > 0 &&
|
|
226
|
+
typeof minAi === "number" &&
|
|
214
227
|
minAi < cfg.reviewThresholds.minConfidenceForDirectCommit) {
|
|
215
228
|
return {
|
|
216
229
|
...baseDecision,
|
|
@@ -218,8 +231,9 @@ function decideEffectiveStrategy(cfg, branch, heals) {
|
|
|
218
231
|
downgradeReason: "LOW_CONFIDENCE",
|
|
219
232
|
};
|
|
220
233
|
}
|
|
221
|
-
// Rule 3: low auditor score
|
|
234
|
+
// Rule 3: low auditor score — same "absence ≠ zero" rule as Rule 2.
|
|
222
235
|
if (cfg.reviewThresholds.minAuditorScoreForDirectCommit > 0 &&
|
|
236
|
+
typeof minAud === "number" &&
|
|
223
237
|
minAud < cfg.reviewThresholds.minAuditorScoreForDirectCommit) {
|
|
224
238
|
return {
|
|
225
239
|
...baseDecision,
|
|
@@ -235,6 +249,40 @@ function decideEffectiveStrategy(cfg, branch, heals) {
|
|
|
235
249
|
function fmtPct(v) {
|
|
236
250
|
return typeof v === "number" && Number.isFinite(v) ? `${v}%` : "n/a";
|
|
237
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Strip the file-level preamble from a unified diff so the GitHub Markdown
|
|
254
|
+
* renderer doesn't show `--- a/file` / `+++ b/file` as red/green lines (which
|
|
255
|
+
* read as fake deletions/additions in a ```diff fence). Keeps the `@@` hunk
|
|
256
|
+
* header and every -/+/space content row intact, so the rendered block is
|
|
257
|
+
* still syntactically a valid unified diff that GitHub highlights correctly.
|
|
258
|
+
*/
|
|
259
|
+
function stripDiffPreamble(raw) {
|
|
260
|
+
return raw
|
|
261
|
+
.split(/\r?\n/)
|
|
262
|
+
.filter((ln) => {
|
|
263
|
+
if (ln.startsWith("diff "))
|
|
264
|
+
return false;
|
|
265
|
+
if (ln.startsWith("index "))
|
|
266
|
+
return false;
|
|
267
|
+
if (ln.startsWith("--- "))
|
|
268
|
+
return false;
|
|
269
|
+
if (ln.startsWith("+++ "))
|
|
270
|
+
return false;
|
|
271
|
+
if (ln.startsWith("new file mode"))
|
|
272
|
+
return false;
|
|
273
|
+
if (ln.startsWith("deleted file mode"))
|
|
274
|
+
return false;
|
|
275
|
+
if (ln.startsWith("similarity "))
|
|
276
|
+
return false;
|
|
277
|
+
if (ln.startsWith("rename "))
|
|
278
|
+
return false;
|
|
279
|
+
if (ln.startsWith("Binary files"))
|
|
280
|
+
return false;
|
|
281
|
+
return true;
|
|
282
|
+
})
|
|
283
|
+
.join("\n")
|
|
284
|
+
.replace(/\n+$/, "");
|
|
285
|
+
}
|
|
238
286
|
function buildDefaultBody(heals, branchInfo, reportLink, decision) {
|
|
239
287
|
const lines = [];
|
|
240
288
|
lines.push(`# 🤖 Sela Insights — Automation Suite Healed!`);
|
|
@@ -256,8 +304,17 @@ function buildDefaultBody(heals, branchInfo, reportLink, decision) {
|
|
|
256
304
|
lines.push(`### \`${h.sourceFile}:${lineRef}\` — ${h.testTitle ?? "(no test title)"}`);
|
|
257
305
|
lines.push("");
|
|
258
306
|
lines.push("```diff");
|
|
259
|
-
|
|
260
|
-
|
|
307
|
+
if (h.gitUnifiedDiff && h.gitUnifiedDiff.trim().length > 0) {
|
|
308
|
+
// Emit the real git diff verbatim. This natively handles cross-file
|
|
309
|
+
// healing, multi-line edits, and template-literal changes — none of
|
|
310
|
+
// which the legacy single-line -/+ pair could represent.
|
|
311
|
+
lines.push(stripDiffPreamble(h.gitUnifiedDiff));
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
// Fallback when git was unavailable / the file was untracked.
|
|
315
|
+
lines.push(`- ${(h.oldCodeLine ?? "").trim()}`);
|
|
316
|
+
lines.push(`+ ${(h.newCodeLine ?? "").trim()}`);
|
|
317
|
+
}
|
|
261
318
|
lines.push("```");
|
|
262
319
|
if (h.aiExplanation) {
|
|
263
320
|
lines.push(`> 🧠 ${h.aiExplanation}`);
|
|
@@ -272,7 +329,19 @@ function buildDefaultBody(heals, branchInfo, reportLink, decision) {
|
|
|
272
329
|
lines.push(`Generated automatically by \`sela-core\`.`);
|
|
273
330
|
return lines.join("\n");
|
|
274
331
|
}
|
|
275
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Clean-Room execution lifecycle.
|
|
334
|
+
*
|
|
335
|
+
* The legacy flow committed the developer's whole dirty working tree onto
|
|
336
|
+
* the PR branch — polluting reviewers with `test.only`, commented debug
|
|
337
|
+
* blocks, etc. This refactor isolates Sela's specific AST changes onto an
|
|
338
|
+
* ephemeral branch, leaves the developer's local workspace byte-identical
|
|
339
|
+
* to its pre-Sela state, and is wrapped in a try/finally that restores
|
|
340
|
+
* the workspace even on mid-flight crashes.
|
|
341
|
+
*/
|
|
342
|
+
async function execute(cfg, decision, heals, branchInfo, ctx,
|
|
343
|
+
/** Injectable for tests — defaults to the module singleton. */
|
|
344
|
+
workspace = WorkspaceSnapshotService_1.sharedWorkspaceSnapshot) {
|
|
276
345
|
const result = {
|
|
277
346
|
effective: decision.effective,
|
|
278
347
|
prUrl: null,
|
|
@@ -300,14 +369,13 @@ async function execute(cfg, decision, heals, branchInfo, ctx) {
|
|
|
300
369
|
console.warn(`[Sela PR] ⚠️ Detached HEAD — skipping git operations.`);
|
|
301
370
|
return result;
|
|
302
371
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
console.warn(`[Sela PR] ⚠️ No file paths in HEALED events — skipping.`);
|
|
372
|
+
const mutatedSnaps = workspace.getMutatedSnapshots();
|
|
373
|
+
if (mutatedSnaps.length === 0) {
|
|
374
|
+
result.errors.push("no mutated files tracked");
|
|
375
|
+
console.warn(`[Sela PR] ⚠️ WorkspaceSnapshotService reports no mutated files — skipping.`);
|
|
308
376
|
return result;
|
|
309
377
|
}
|
|
310
|
-
// Render title —
|
|
378
|
+
// Render title / body up front — needed by all branches below.
|
|
311
379
|
const first = heals[0];
|
|
312
380
|
const titleVars = {
|
|
313
381
|
testName: first.testTitle ?? "automation",
|
|
@@ -326,90 +394,268 @@ async function execute(cfg, decision, heals, branchInfo, ctx) {
|
|
|
326
394
|
reasoning: heals.map((h) => h.aiExplanation).join("\n\n"),
|
|
327
395
|
})
|
|
328
396
|
: buildDefaultBody(heals, branchInfo, reportLink, decision);
|
|
329
|
-
// ── directCommit ────────────────────────────────────────────────
|
|
330
397
|
if (decision.effective === "directCommit") {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
console.log(`[Sela PR] ✅ Direct commit on '${branchInfo.branch}': ${title}`);
|
|
343
|
-
const push = sh(`git push`, ctx.cwd);
|
|
344
|
-
if (!push.ok) {
|
|
345
|
-
result.errors.push(`git push: ${push.stderr}`);
|
|
346
|
-
console.warn(`[Sela PR] ⚠️ git push failed: ${push.stderr}`);
|
|
347
|
-
return result;
|
|
348
|
-
}
|
|
349
|
-
console.log(`[Sela PR] 🚀 Pushed to '${branchInfo.branch}'`);
|
|
350
|
-
return result;
|
|
398
|
+
return runCleanRoom({
|
|
399
|
+
mode: "directCommit",
|
|
400
|
+
cfg,
|
|
401
|
+
ctx,
|
|
402
|
+
mutatedSnaps,
|
|
403
|
+
title,
|
|
404
|
+
body,
|
|
405
|
+
branchInfo,
|
|
406
|
+
decision,
|
|
407
|
+
result,
|
|
408
|
+
});
|
|
351
409
|
}
|
|
352
|
-
// ── pr / draftPR ────────────────────────────────────────────────
|
|
353
410
|
if (decision.effective === "pr" || decision.effective === "draftPR") {
|
|
354
411
|
if (!ghAvailable(ctx.cwd)) {
|
|
355
412
|
result.errors.push("gh CLI not available");
|
|
356
413
|
console.warn(`[Sela PR] ⚠️ gh CLI not installed — cannot open PR. Run \`gh auth login\` after install.`);
|
|
357
414
|
return result;
|
|
358
415
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
416
|
+
return runCleanRoom({
|
|
417
|
+
mode: decision.effective,
|
|
418
|
+
cfg,
|
|
419
|
+
ctx,
|
|
420
|
+
mutatedSnaps,
|
|
421
|
+
title,
|
|
422
|
+
body,
|
|
423
|
+
branchInfo,
|
|
424
|
+
decision,
|
|
425
|
+
result,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
async function runCleanRoom(args) {
|
|
431
|
+
const { mode, cfg, ctx, mutatedSnaps, title, body, branchInfo, decision, result } = args;
|
|
432
|
+
const id = String(Date.now());
|
|
433
|
+
const stashLabel = `sela:clean-room-${id}`;
|
|
434
|
+
const originalBranch = currentBranch(ctx.cwd);
|
|
435
|
+
const targetBranch = mode === "directCommit"
|
|
436
|
+
? originalBranch
|
|
437
|
+
: render(cfg.branchNameTemplate, { id, timestamp: id });
|
|
438
|
+
// Lifecycle state — used by the finally{} restoration block.
|
|
439
|
+
let didRestoreOriginal = false;
|
|
440
|
+
let didStash = false;
|
|
441
|
+
let didSwitchToTarget = false;
|
|
442
|
+
let didCommit = false;
|
|
443
|
+
try {
|
|
444
|
+
// 1. State Checkpoint — rewind developer's mutated files to pre-Sela
|
|
445
|
+
// state on disk. Other dirty files (unrelated to Sela) stay where
|
|
446
|
+
// they are; the subsequent stash will sweep them up.
|
|
447
|
+
workspaceRestoreOriginal(mutatedSnaps);
|
|
448
|
+
didRestoreOriginal = true;
|
|
449
|
+
// 2. Branch Isolation — stash everything (tracked + untracked) so the
|
|
450
|
+
// worktree mirrors HEAD when we switch / branch off.
|
|
451
|
+
const stash = sh(`git stash push -u -m ${shellQuote(stashLabel)}`, ctx.cwd);
|
|
452
|
+
if (stash.ok) {
|
|
453
|
+
didStash = !/No local changes to save/i.test(stash.stdout);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
// Stash failures are non-fatal when the worktree was already clean.
|
|
457
|
+
console.debug?.(`[Sela PR] stash skipped: ${stash.stderr}`);
|
|
458
|
+
}
|
|
459
|
+
if (mode !== "directCommit") {
|
|
460
|
+
const checkout = sh(`git checkout -b ${targetBranch}`, ctx.cwd);
|
|
461
|
+
if (!checkout.ok) {
|
|
462
|
+
throw new Error(`git checkout -b ${targetBranch}: ${checkout.stderr}`);
|
|
463
|
+
}
|
|
464
|
+
didSwitchToTarget = true;
|
|
366
465
|
}
|
|
367
|
-
|
|
368
|
-
|
|
466
|
+
// 3. Targeted Hunk Patching — apply ONLY Sela's blocks to each file's
|
|
467
|
+
// clean HEAD content. Refuses to write if dirty edits collide with
|
|
468
|
+
// Sela's mutation site (block not found / not unique).
|
|
469
|
+
for (const snap of mutatedSnaps) {
|
|
470
|
+
const applied = applyMutationToClean(snap);
|
|
471
|
+
if (!applied.ok) {
|
|
472
|
+
throw new Error(`cannot apply Sela mutation to clean base of ${snap.relativePath}: ${applied.reason}`);
|
|
473
|
+
}
|
|
474
|
+
fs.writeFileSync(snap.absolutePath, applied.result, "utf8");
|
|
475
|
+
}
|
|
476
|
+
// 4. Stage, commit, push.
|
|
477
|
+
for (const snap of mutatedSnaps) {
|
|
478
|
+
const add = sh(`git add ${shellQuote(snap.relativePath)}`, ctx.cwd);
|
|
369
479
|
if (!add.ok)
|
|
370
|
-
result.errors.push(`git add ${
|
|
480
|
+
result.errors.push(`git add ${snap.relativePath}: ${add.stderr}`);
|
|
371
481
|
}
|
|
482
|
+
configureGitIdentityForCI(ctx.cwd);
|
|
372
483
|
const commit = sh(`git commit -m ${shellQuote(title)}`, ctx.cwd);
|
|
373
484
|
if (!commit.ok) {
|
|
374
|
-
|
|
375
|
-
console.warn(`[Sela PR] ⚠️ git commit failed: ${commit.stderr}`);
|
|
376
|
-
return result;
|
|
485
|
+
throw new Error(`git commit: ${commit.stderr}`);
|
|
377
486
|
}
|
|
378
|
-
|
|
487
|
+
didCommit = true;
|
|
488
|
+
console.log(`[Sela PR] ✅ Clean commit on '${targetBranch ?? "(unknown)"}': ${title}`);
|
|
489
|
+
const pushCmd = mode === "directCommit"
|
|
490
|
+
? `git push`
|
|
491
|
+
: `git push -u origin ${shellQuote(targetBranch)}`;
|
|
492
|
+
const push = sh(pushCmd, ctx.cwd);
|
|
379
493
|
if (!push.ok) {
|
|
380
|
-
|
|
381
|
-
console.warn(`[Sela PR] ⚠️ git push failed: ${push.stderr}`);
|
|
382
|
-
return result;
|
|
494
|
+
throw new Error(`git push: ${push.stderr}`);
|
|
383
495
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
496
|
+
if (mode !== "directCommit") {
|
|
497
|
+
result.branchPushed = targetBranch;
|
|
498
|
+
}
|
|
499
|
+
// 5. (PR modes only) open the PR.
|
|
500
|
+
if (mode === "pr" || mode === "draftPR") {
|
|
501
|
+
const prUrl = openPullRequest({
|
|
502
|
+
cfg,
|
|
503
|
+
ctx,
|
|
504
|
+
mode,
|
|
505
|
+
title,
|
|
506
|
+
body,
|
|
507
|
+
branchInfo,
|
|
508
|
+
id,
|
|
509
|
+
result,
|
|
510
|
+
});
|
|
511
|
+
if (prUrl)
|
|
512
|
+
result.prUrl = prUrl;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
result.errors.push(err?.message ?? String(err));
|
|
517
|
+
console.warn(`[Sela PR] ⚠️ Clean-room flow failed: ${err?.message ?? err}`);
|
|
518
|
+
}
|
|
519
|
+
finally {
|
|
520
|
+
// 6. Workspace Restoration — ALWAYS attempt, even on mid-flight crash.
|
|
521
|
+
// Order matters:
|
|
522
|
+
// a) Switch back to the developer's original branch (if we left it).
|
|
523
|
+
// b) Pop the stash to restore the dirty pre-Sela working tree.
|
|
524
|
+
// c) Defensively re-write contentBefore for tracked files in case
|
|
525
|
+
// the stash was lost.
|
|
398
526
|
try {
|
|
399
|
-
|
|
527
|
+
if (didSwitchToTarget && originalBranch) {
|
|
528
|
+
const back = sh(`git checkout ${shellQuote(originalBranch)}`, ctx.cwd);
|
|
529
|
+
if (!back.ok) {
|
|
530
|
+
console.warn(`[Sela PR] ⚠️ Could not switch back to '${originalBranch}': ${back.stderr}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (didStash) {
|
|
534
|
+
const pop = sh(`git stash pop`, ctx.cwd);
|
|
535
|
+
if (!pop.ok) {
|
|
536
|
+
console.warn(`[Sela PR] ⚠️ git stash pop failed: ${pop.stderr}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (didRestoreOriginal) {
|
|
540
|
+
// Belt-and-braces — every tracked snapshot back to its dirty
|
|
541
|
+
// pre-Sela state. No-op if stash pop already did the job.
|
|
542
|
+
workspaceRestoreOriginal(mutatedSnaps);
|
|
543
|
+
}
|
|
400
544
|
}
|
|
401
|
-
catch {
|
|
402
|
-
|
|
403
|
-
result.errors.push(`gh pr create: ${pr.stderr}`);
|
|
404
|
-
console.warn(`[Sela PR] ⚠️ gh pr create failed: ${pr.stderr}`);
|
|
405
|
-
return result;
|
|
545
|
+
catch (restoreErr) {
|
|
546
|
+
console.warn(`[Sela PR] ⚠️ Workspace restoration error: ${restoreErr?.message ?? restoreErr}`);
|
|
406
547
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
548
|
+
}
|
|
549
|
+
if (!didCommit && result.errors.length === 0) {
|
|
550
|
+
result.errors.push("clean-room flow did not produce a commit");
|
|
410
551
|
}
|
|
411
552
|
return result;
|
|
412
553
|
}
|
|
554
|
+
function openPullRequest(args) {
|
|
555
|
+
const { cfg, ctx, mode, title, body, branchInfo, id, result } = args;
|
|
556
|
+
const bodyFile = path.join(ctx.cwd, `.sela-pr-body-${id}.md`);
|
|
557
|
+
try {
|
|
558
|
+
fs.writeFileSync(bodyFile, body, "utf8");
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
result.errors.push(`pr body write: ${err?.message ?? err}`);
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
const labelFlag = cfg.labels.length > 0
|
|
565
|
+
? cfg.labels.map((l) => `--label ${shellQuote(l)}`).join(" ")
|
|
566
|
+
: "";
|
|
567
|
+
const draftFlag = mode === "draftPR" ? "--draft" : "";
|
|
568
|
+
const baseFlag = branchInfo.branch ? `--base ${shellQuote(branchInfo.branch)}` : "";
|
|
569
|
+
const prCmd = `gh pr create ${draftFlag} ${baseFlag} ` +
|
|
570
|
+
`--title ${shellQuote(title)} ` +
|
|
571
|
+
`--body-file ${shellQuote(bodyFile)} ` +
|
|
572
|
+
`${labelFlag}`;
|
|
573
|
+
const pr = sh(prCmd, ctx.cwd);
|
|
574
|
+
try {
|
|
575
|
+
fs.unlinkSync(bodyFile);
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
/* ignore */
|
|
579
|
+
}
|
|
580
|
+
if (!pr.ok) {
|
|
581
|
+
result.errors.push(`gh pr create: ${pr.stderr}`);
|
|
582
|
+
console.warn(`[Sela PR] ⚠️ gh pr create failed: ${pr.stderr}`);
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
const url = pr.stdout.trim();
|
|
586
|
+
console.log(`[Sela PR] ✅ ${mode === "draftPR" ? "Draft PR" : "PR"} opened: ${url}`);
|
|
587
|
+
return url;
|
|
588
|
+
}
|
|
589
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
590
|
+
// CLEAN-ROOM HELPERS
|
|
591
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
592
|
+
function currentBranch(cwd) {
|
|
593
|
+
const r = sh("git rev-parse --abbrev-ref HEAD", cwd);
|
|
594
|
+
if (!r.ok)
|
|
595
|
+
return null;
|
|
596
|
+
const b = r.stdout.trim();
|
|
597
|
+
return b && b !== "HEAD" ? b : null;
|
|
598
|
+
}
|
|
599
|
+
function workspaceRestoreOriginal(snaps) {
|
|
600
|
+
for (const snap of snaps) {
|
|
601
|
+
try {
|
|
602
|
+
if (snap.createdBySela) {
|
|
603
|
+
if (fs.existsSync(snap.absolutePath))
|
|
604
|
+
fs.unlinkSync(snap.absolutePath);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
fs.writeFileSync(snap.absolutePath, snap.contentBefore, "utf8");
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
console.warn(`[Sela PR] ⚠️ Could not restore ${snap.relativePath}: ${err?.message ?? err}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function applyMutationToClean(snap) {
|
|
615
|
+
const after = snap.contentAfter ?? "";
|
|
616
|
+
// File didn't exist on HEAD (created by Sela or untracked). Write the
|
|
617
|
+
// post-mutation content directly — there's no clean base to merge into.
|
|
618
|
+
if (snap.headContent === null) {
|
|
619
|
+
return { ok: true, result: after };
|
|
620
|
+
}
|
|
621
|
+
// No dirty edits — `headContent === contentBefore`, so the mutation
|
|
622
|
+
// applies cleanly as a 1:1 string equivalence.
|
|
623
|
+
if (snap.headContent === snap.contentBefore) {
|
|
624
|
+
return { ok: true, result: after };
|
|
625
|
+
}
|
|
626
|
+
// Dirty edits present — extract Sela's specific change blocks and
|
|
627
|
+
// re-apply them against the clean HEAD content via unique substring
|
|
628
|
+
// replacement. Fails loudly when the dirty edits overlap Sela's site.
|
|
629
|
+
const blocks = (0, IsolatedDiff_1.extractChangeBlocks)(snap.contentBefore, after);
|
|
630
|
+
if (blocks.length === 0) {
|
|
631
|
+
return { ok: true, result: snap.headContent };
|
|
632
|
+
}
|
|
633
|
+
return (0, IsolatedDiff_1.applyChangeBlocks)(snap.headContent, blocks);
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* CI adapters (GitHub Actions, GitLab, Jenkins) often run with no global
|
|
637
|
+
* git identity. `git commit` then fails with `Please tell me who you are`,
|
|
638
|
+
* tanking the whole heal pipeline. Plant a sela bot identity for THIS
|
|
639
|
+
* commit only when no `user.email` / `user.name` is configured — never
|
|
640
|
+
* overwrite an existing identity.
|
|
641
|
+
*/
|
|
642
|
+
function configureGitIdentityForCI(cwd) {
|
|
643
|
+
const isCI = process.env.CI === "true" ||
|
|
644
|
+
process.env.CI === "1" ||
|
|
645
|
+
!!process.env.GITHUB_ACTIONS ||
|
|
646
|
+
!!process.env.GITLAB_CI ||
|
|
647
|
+
!!process.env.JENKINS_URL;
|
|
648
|
+
if (!isCI)
|
|
649
|
+
return;
|
|
650
|
+
const email = sh("git config user.email", cwd);
|
|
651
|
+
if (!email.ok || email.stdout.trim().length === 0) {
|
|
652
|
+
sh(`git config user.email "sela-bot@anthropic.local"`, cwd);
|
|
653
|
+
}
|
|
654
|
+
const name = sh("git config user.name", cwd);
|
|
655
|
+
if (!name.ok || name.stdout.trim().length === 0) {
|
|
656
|
+
sh(`git config user.name "Sela Bot"`, cwd);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
413
659
|
// ═══════════════════════════════════════════════════════════════════
|
|
414
660
|
// BUG-DETECTED HANDLING
|
|
415
661
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { WorkspaceSnapshotService } from "./WorkspaceSnapshotService";
|
|
2
|
+
import type { HealedEvent } from "./HealReportService";
|
|
3
|
+
export interface PendingPromptEntry {
|
|
4
|
+
absolutePath: string;
|
|
5
|
+
relativePath: string;
|
|
6
|
+
contentBefore: string;
|
|
7
|
+
contentAfter: string;
|
|
8
|
+
createdBySela: boolean;
|
|
9
|
+
/** 1-based line number for the vscode:// deeplink, or null when unknown. */
|
|
10
|
+
lineNumber: number | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the ledger directory for a given cwd. The directory is created
|
|
14
|
+
* lazily on the first write so we never clutter repos that aren't running
|
|
15
|
+
* Sela.
|
|
16
|
+
*/
|
|
17
|
+
export declare function ledgerDir(cwd: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Persist this worker's mutated snapshots to a fresh JSON file under
|
|
20
|
+
* `<cwd>/.sela/pending-prompts/`. Idempotent within a single worker —
|
|
21
|
+
* subsequent calls in the same process append a NEW file (rare in
|
|
22
|
+
* practice but keeps the contract simple).
|
|
23
|
+
*
|
|
24
|
+
* Returns the absolute path of the written file, or `null` when there is
|
|
25
|
+
* nothing to persist (so callers can skip the prompt entirely in CI /
|
|
26
|
+
* empty-mutation runs).
|
|
27
|
+
*/
|
|
28
|
+
export declare function writePendingPromptsLedger(opts: {
|
|
29
|
+
cwd: string;
|
|
30
|
+
workspace: WorkspaceSnapshotService;
|
|
31
|
+
healedEvents: HealedEvent[];
|
|
32
|
+
}): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Drain every per-worker JSON file under the ledger directory and merge
|
|
35
|
+
* their entries into a single de-duplicated list (keyed by absolutePath).
|
|
36
|
+
* The most-recent contentAfter wins on collision — that's the disk truth.
|
|
37
|
+
*
|
|
38
|
+
* The directory is NOT deleted here so the reporter can inspect entries
|
|
39
|
+
* before calling `clearPendingPromptsLedger()`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function readPendingPromptsLedger(cwd: string): PendingPromptEntry[];
|
|
42
|
+
/** Remove the ledger directory and every file beneath it. Idempotent. */
|
|
43
|
+
export declare function clearPendingPromptsLedger(cwd: string): void;
|
|
44
|
+
//# sourceMappingURL=PendingPromptLedger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PendingPromptLedger.d.ts","sourceRoot":"","sources":["../../src/services/PendingPromptLedger.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAEV,wBAAwB,EACzB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAID;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,wBAAwB,CAAC;IACpC,YAAY,EAAE,WAAW,EAAE,CAAC;CAC7B,GAAG,MAAM,GAAG,IAAI,CAehB;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,kBAAkB,EAAE,CAyB1E;AAED,yEAAyE;AACzE,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAI3D"}
|