sela-core 1.0.3 → 1.0.5
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/cli/commands/showReport.d.ts +3 -0
- package/dist/cli/commands/showReport.d.ts.map +1 -0
- package/dist/cli/commands/showReport.js +80 -0
- package/dist/cli/index.js +3 -0
- package/dist/config/SelaConfig.d.ts +57 -11
- package/dist/config/SelaConfig.d.ts.map +1 -1
- package/dist/config/SelaConfig.js +37 -7
- package/dist/engine/SelaEngine.d.ts +37 -0
- package/dist/engine/SelaEngine.d.ts.map +1 -0
- package/dist/engine/SelaEngine.js +726 -0
- package/dist/engine/singleton.d.ts +2 -2
- package/dist/engine/singleton.d.ts.map +1 -1
- package/dist/engine/singleton.js +2 -2
- package/dist/fixtures/expectProxy.d.ts +2 -2
- package/dist/fixtures/expectProxy.d.ts.map +1 -1
- package/dist/fixtures/expectProxy.js +2 -2
- package/dist/fixtures/index.d.ts.map +1 -1
- package/dist/fixtures/index.js +21 -15
- package/dist/services/HealReportService.d.ts +110 -0
- package/dist/services/HealReportService.d.ts.map +1 -0
- package/dist/services/HealReportService.js +1195 -0
- package/dist/services/PRAutomationService.d.ts +30 -0
- package/dist/services/PRAutomationService.d.ts.map +1 -0
- package/dist/services/PRAutomationService.js +509 -0
- package/dist/services/SnapshotService.d.ts.map +1 -1
- package/dist/services/SnapshotService.js +7 -8
- package/dist/services/SourceUpdater.js +1 -1
- package/dist/storage/SnapshotManager.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ResolvedPRAutomation, PRStrategy, BugAction } from "../config/SelaConfig";
|
|
2
|
+
import type { HealedEvent, ProtectedEvent } from "./HealReportService";
|
|
3
|
+
export interface BranchInfo {
|
|
4
|
+
branch: string | null;
|
|
5
|
+
isPR: boolean;
|
|
6
|
+
prNumber: number | null;
|
|
7
|
+
source: "env" | "git" | "none";
|
|
8
|
+
}
|
|
9
|
+
export interface StrategyDecision {
|
|
10
|
+
effective: PRStrategy;
|
|
11
|
+
configured: PRStrategy;
|
|
12
|
+
downgradeReason: "PROTECTED_BRANCH" | "LOW_CONFIDENCE" | "LOW_AUDITOR" | null;
|
|
13
|
+
minAiConfidence: number;
|
|
14
|
+
minAuditorConfidence: number;
|
|
15
|
+
}
|
|
16
|
+
export interface ExecuteContext {
|
|
17
|
+
cwd: string;
|
|
18
|
+
reportHtmlPath: string | null;
|
|
19
|
+
}
|
|
20
|
+
export declare function detectBranch(cwd?: string): BranchInfo;
|
|
21
|
+
export declare function decideEffectiveStrategy(cfg: ResolvedPRAutomation, branch: string | null, heals: HealedEvent[]): StrategyDecision;
|
|
22
|
+
export interface ExecutionResult {
|
|
23
|
+
effective: PRStrategy;
|
|
24
|
+
prUrl: string | null;
|
|
25
|
+
branchPushed: string | null;
|
|
26
|
+
errors: string[];
|
|
27
|
+
}
|
|
28
|
+
export declare function execute(cfg: ResolvedPRAutomation, decision: StrategyDecision, heals: HealedEvent[], branchInfo: BranchInfo, ctx: ExecuteContext): Promise<ExecutionResult>;
|
|
29
|
+
export declare function handleBugDetected(action: BugAction, events: ProtectedEvent[], branchInfo: BranchInfo, ctx: ExecuteContext): Promise<void>;
|
|
30
|
+
//# sourceMappingURL=PRAutomationService.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PRAutomationService.d.ts","sourceRoot":"","sources":["../../src/services/PRAutomationService.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EACV,oBAAoB,EACpB,UAAU,EACV,SAAS,EACV,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EACV,WAAW,EACX,cAAc,EACf,MAAM,qBAAqB,CAAC;AAM7B,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,KAAK,GAAG,KAAK,GAAG,MAAM,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,UAAU,CAAC;IACtB,UAAU,EAAE,UAAU,CAAC;IACvB,eAAe,EAAE,kBAAkB,GAAG,gBAAgB,GAAG,aAAa,GAAG,IAAI,CAAC;IAC9E,eAAe,EAAE,MAAM,CAAC;IACxB,oBAAoB,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AA2ED,wBAAgB,YAAY,CAAC,GAAG,GAAE,MAAsB,GAAG,UAAU,CA8EpE;AAMD,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,oBAAoB,EACzB,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,KAAK,EAAE,WAAW,EAAE,GACnB,gBAAgB,CAuDlB;AA6DD,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,UAAU,CAAC;IACtB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,wBAAsB,OAAO,CAC3B,GAAG,EAAE,oBAAoB,EACzB,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,WAAW,EAAE,EACpB,UAAU,EAAE,UAAU,EACtB,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,eAAe,CAAC,CA4J1B;AAMD,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,SAAS,EACjB,MAAM,EAAE,cAAc,EAAE,EACxB,UAAU,EAAE,UAAU,EACtB,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,IAAI,CAAC,CA0Ff"}
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/services/PRAutomationService.ts
|
|
3
|
+
//
|
|
4
|
+
// Git-flow automation layer. Runs ONCE per session at SelaEngine.commitUpdates().
|
|
5
|
+
// Responsibilities:
|
|
6
|
+
// 1. Detect the active branch (CI env vars first, git CLI fallback).
|
|
7
|
+
// 2. Resolve the EFFECTIVE strategy via trust-aware downgrade.
|
|
8
|
+
// 3. Execute the strategy: directCommit | pr | draftPR | readOnly.
|
|
9
|
+
// 4. Handle PROTECTED events per onBugDetected policy.
|
|
10
|
+
//
|
|
11
|
+
// Hard requirements:
|
|
12
|
+
// - GitHub-only in v1 (uses `gh` CLI).
|
|
13
|
+
// - Never throws into the caller. Failures degrade to warnings + readOnly.
|
|
14
|
+
// - Detached HEAD or missing `gh` → skip git ops, emit clear log.
|
|
15
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
18
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
19
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
20
|
+
}
|
|
21
|
+
Object.defineProperty(o, k2, desc);
|
|
22
|
+
}) : (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
o[k2] = m[k];
|
|
25
|
+
}));
|
|
26
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
27
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
28
|
+
}) : function(o, v) {
|
|
29
|
+
o["default"] = v;
|
|
30
|
+
});
|
|
31
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
32
|
+
var ownKeys = function(o) {
|
|
33
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
34
|
+
var ar = [];
|
|
35
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
36
|
+
return ar;
|
|
37
|
+
};
|
|
38
|
+
return ownKeys(o);
|
|
39
|
+
};
|
|
40
|
+
return function (mod) {
|
|
41
|
+
if (mod && mod.__esModule) return mod;
|
|
42
|
+
var result = {};
|
|
43
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
44
|
+
__setModuleDefault(result, mod);
|
|
45
|
+
return result;
|
|
46
|
+
};
|
|
47
|
+
})();
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.detectBranch = detectBranch;
|
|
50
|
+
exports.decideEffectiveStrategy = decideEffectiveStrategy;
|
|
51
|
+
exports.execute = execute;
|
|
52
|
+
exports.handleBugDetected = handleBugDetected;
|
|
53
|
+
const fs = __importStar(require("fs"));
|
|
54
|
+
const path = __importStar(require("path"));
|
|
55
|
+
const child_process_1 = require("child_process");
|
|
56
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
57
|
+
// SHELL HELPERS
|
|
58
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
59
|
+
function sh(cmd, cwd) {
|
|
60
|
+
try {
|
|
61
|
+
const stdout = (0, child_process_1.execSync)(cmd, {
|
|
62
|
+
cwd,
|
|
63
|
+
encoding: "utf-8",
|
|
64
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
65
|
+
});
|
|
66
|
+
return { ok: true, stdout: stdout.toString(), stderr: "" };
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
stdout: err?.stdout?.toString() ?? "",
|
|
72
|
+
stderr: err?.stderr?.toString() ?? err?.message ?? "unknown error",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function ghAvailable(cwd) {
|
|
77
|
+
return sh("gh --version", cwd).ok;
|
|
78
|
+
}
|
|
79
|
+
function gitAvailable(cwd) {
|
|
80
|
+
return sh("git --version", cwd).ok;
|
|
81
|
+
}
|
|
82
|
+
function isDetachedHead(cwd) {
|
|
83
|
+
return !sh("git symbolic-ref HEAD", cwd).ok;
|
|
84
|
+
}
|
|
85
|
+
const SELA_BRANCH_PREFIX = "sela/heal-";
|
|
86
|
+
function isSelaHealBranch(branch) {
|
|
87
|
+
return !!branch && branch.startsWith(SELA_BRANCH_PREFIX);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the repo's default integration branch.
|
|
91
|
+
* 1. origin/HEAD symbolic-ref (set by `git clone`).
|
|
92
|
+
* 2. First existing local ref among: main, master, develop, dev.
|
|
93
|
+
* Returns null if none found — caller must handle that.
|
|
94
|
+
*/
|
|
95
|
+
function findDefaultBranch(cwd) {
|
|
96
|
+
const headRef = sh("git symbolic-ref refs/remotes/origin/HEAD", cwd);
|
|
97
|
+
if (headRef.ok) {
|
|
98
|
+
const m = headRef.stdout.trim().match(/refs\/remotes\/origin\/(.+)$/);
|
|
99
|
+
if (m)
|
|
100
|
+
return m[1];
|
|
101
|
+
}
|
|
102
|
+
for (const candidate of ["main", "master", "develop", "dev"]) {
|
|
103
|
+
if (sh(`git rev-parse --verify --quiet ${candidate}`, cwd).ok) {
|
|
104
|
+
return candidate;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
110
|
+
// TEMPLATING
|
|
111
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
112
|
+
function render(template, vars) {
|
|
113
|
+
return template.replace(/\$\{(\w+)\}/g, (_m, k) => k in vars ? String(vars[k]) : `\${${k}}`);
|
|
114
|
+
}
|
|
115
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
116
|
+
// BRANCH DETECTION
|
|
117
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
118
|
+
function detectBranch(cwd = process.cwd()) {
|
|
119
|
+
// GitHub Actions exposes both REF_NAME (push) and HEAD_REF (PR).
|
|
120
|
+
const ghHeadRef = process.env.GITHUB_HEAD_REF; // PR source branch
|
|
121
|
+
const ghRefName = process.env.GITHUB_REF_NAME; // current branch on push, or "<n>/merge" on PR
|
|
122
|
+
const ghEventName = process.env.GITHUB_EVENT_NAME;
|
|
123
|
+
const ghRef = process.env.GITHUB_REF; // "refs/pull/123/merge"
|
|
124
|
+
// Prefer HEAD_REF on PRs — REF_NAME is the synthetic merge ref.
|
|
125
|
+
if (ghHeadRef && ghHeadRef.length > 0 && !isSelaHealBranch(ghHeadRef)) {
|
|
126
|
+
const prNumber = (() => {
|
|
127
|
+
const m = ghRef?.match(/refs\/pull\/(\d+)\//);
|
|
128
|
+
return m ? Number(m[1]) : null;
|
|
129
|
+
})();
|
|
130
|
+
return { branch: ghHeadRef, isPR: true, prNumber, source: "env" };
|
|
131
|
+
}
|
|
132
|
+
if (ghRefName &&
|
|
133
|
+
ghRefName.length > 0 &&
|
|
134
|
+
!ghRefName.endsWith("/merge") &&
|
|
135
|
+
!isSelaHealBranch(ghRefName)) {
|
|
136
|
+
return {
|
|
137
|
+
branch: ghRefName,
|
|
138
|
+
isPR: ghEventName === "pull_request",
|
|
139
|
+
prNumber: null,
|
|
140
|
+
source: "env",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// GitLab CI fallback
|
|
144
|
+
const gitlabRef = process.env.CI_COMMIT_REF_NAME;
|
|
145
|
+
if (gitlabRef && gitlabRef.length > 0 && !isSelaHealBranch(gitlabRef)) {
|
|
146
|
+
return { branch: gitlabRef, isPR: false, prNumber: null, source: "env" };
|
|
147
|
+
}
|
|
148
|
+
// Generic Jenkins / local fallback
|
|
149
|
+
const jenkinsBranch = process.env.GIT_BRANCH;
|
|
150
|
+
if (jenkinsBranch && jenkinsBranch.length > 0) {
|
|
151
|
+
const cleaned = jenkinsBranch.replace(/^origin\//, "");
|
|
152
|
+
if (!isSelaHealBranch(cleaned)) {
|
|
153
|
+
return { branch: cleaned, isPR: false, prNumber: null, source: "env" };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Local: git CLI
|
|
157
|
+
if (!gitAvailable(cwd)) {
|
|
158
|
+
return { branch: null, isPR: false, prNumber: null, source: "none" };
|
|
159
|
+
}
|
|
160
|
+
const r = sh("git rev-parse --abbrev-ref HEAD", cwd);
|
|
161
|
+
if (!r.ok)
|
|
162
|
+
return { branch: null, isPR: false, prNumber: null, source: "none" };
|
|
163
|
+
const localBranch = r.stdout.trim();
|
|
164
|
+
if (!localBranch || localBranch === "HEAD") {
|
|
165
|
+
return { branch: null, isPR: false, prNumber: null, source: "none" };
|
|
166
|
+
}
|
|
167
|
+
// ── Sela self-branch guard ─────────────────────────────────────────
|
|
168
|
+
// If we're sitting on a previously-created `sela/heal-*` branch (typical
|
|
169
|
+
// local-rerun scenario), do NOT use it as the PR base. Fall back to the
|
|
170
|
+
// repo's default integration branch (main / master / develop) so the new
|
|
171
|
+
// PR targets a real branch, not another sela patch branch.
|
|
172
|
+
if (isSelaHealBranch(localBranch)) {
|
|
173
|
+
const fallback = findDefaultBranch(cwd);
|
|
174
|
+
if (fallback) {
|
|
175
|
+
console.warn(`[Sela PR] ⚠️ HEAD is on '${localBranch}' (sela patch branch) — ` +
|
|
176
|
+
`using '${fallback}' as PR base instead.`);
|
|
177
|
+
return { branch: fallback, isPR: false, prNumber: null, source: "git" };
|
|
178
|
+
}
|
|
179
|
+
console.warn(`[Sela PR] ⚠️ HEAD is on '${localBranch}' and no default branch found — ` +
|
|
180
|
+
`skipping PR automation to avoid self-targeting.`);
|
|
181
|
+
return { branch: null, isPR: false, prNumber: null, source: "none" };
|
|
182
|
+
}
|
|
183
|
+
return { branch: localBranch, isPR: false, prNumber: null, source: "git" };
|
|
184
|
+
}
|
|
185
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
186
|
+
// STRATEGY DECISION
|
|
187
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
188
|
+
function decideEffectiveStrategy(cfg, branch, heals) {
|
|
189
|
+
const minAi = heals.length === 0
|
|
190
|
+
? 100
|
|
191
|
+
: Math.min(...heals.map((h) => h.aiConfidence ?? 100));
|
|
192
|
+
const minAud = heals.length === 0
|
|
193
|
+
? 100
|
|
194
|
+
: Math.min(...heals.map((h) => h.auditor?.confidence ?? 100));
|
|
195
|
+
const baseDecision = {
|
|
196
|
+
effective: cfg.strategy,
|
|
197
|
+
configured: cfg.strategy,
|
|
198
|
+
downgradeReason: null,
|
|
199
|
+
minAiConfidence: minAi,
|
|
200
|
+
minAuditorConfidence: minAud,
|
|
201
|
+
};
|
|
202
|
+
if (cfg.strategy !== "directCommit")
|
|
203
|
+
return baseDecision;
|
|
204
|
+
// Rule 1: protected branch → forced downgrade
|
|
205
|
+
if (branch && cfg.protectedBranches.includes(branch)) {
|
|
206
|
+
return {
|
|
207
|
+
...baseDecision,
|
|
208
|
+
effective: cfg.reviewThresholds.downgradeTo,
|
|
209
|
+
downgradeReason: "PROTECTED_BRANCH",
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// Rule 2: low AI confidence
|
|
213
|
+
if (cfg.reviewThresholds.minConfidenceForDirectCommit > 0 &&
|
|
214
|
+
minAi < cfg.reviewThresholds.minConfidenceForDirectCommit) {
|
|
215
|
+
return {
|
|
216
|
+
...baseDecision,
|
|
217
|
+
effective: cfg.reviewThresholds.downgradeTo,
|
|
218
|
+
downgradeReason: "LOW_CONFIDENCE",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// Rule 3: low auditor score
|
|
222
|
+
if (cfg.reviewThresholds.minAuditorScoreForDirectCommit > 0 &&
|
|
223
|
+
minAud < cfg.reviewThresholds.minAuditorScoreForDirectCommit) {
|
|
224
|
+
return {
|
|
225
|
+
...baseDecision,
|
|
226
|
+
effective: cfg.reviewThresholds.downgradeTo,
|
|
227
|
+
downgradeReason: "LOW_AUDITOR",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return baseDecision;
|
|
231
|
+
}
|
|
232
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
233
|
+
// PR BODY RENDERING
|
|
234
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
235
|
+
function fmtPct(v) {
|
|
236
|
+
return typeof v === "number" && Number.isFinite(v) ? `${v}%` : "n/a";
|
|
237
|
+
}
|
|
238
|
+
function buildDefaultBody(heals, branchInfo, reportLink, decision) {
|
|
239
|
+
const lines = [];
|
|
240
|
+
lines.push(`# 🤖 Sela Insights — Automation Suite Healed!`);
|
|
241
|
+
lines.push("");
|
|
242
|
+
lines.push(`Sela kept your pipeline running by healing ${heals.length} broken locator(s).`);
|
|
243
|
+
lines.push("");
|
|
244
|
+
lines.push(`## 📊 Executive Summary`);
|
|
245
|
+
lines.push(`* **Heals:** ${heals.length}`);
|
|
246
|
+
lines.push(`* **Min AI Confidence:** ${fmtPct(decision.minAiConfidence)}`);
|
|
247
|
+
lines.push(`* **Min Auditor Confidence:** ${fmtPct(decision.minAuditorConfidence)}`);
|
|
248
|
+
lines.push(`* **Branch:** \`${branchInfo.branch ?? "unknown"}\``);
|
|
249
|
+
if (decision.downgradeReason) {
|
|
250
|
+
lines.push(`* **⚠️ Strategy Downgrade:** \`${decision.configured}\` → \`${decision.effective}\` (${decision.downgradeReason})`);
|
|
251
|
+
}
|
|
252
|
+
lines.push("");
|
|
253
|
+
lines.push(`## 💻 Per-Heal Diff`);
|
|
254
|
+
for (const h of heals) {
|
|
255
|
+
const lineRef = h.newLineNumber ?? h.sourceLine;
|
|
256
|
+
lines.push(`### \`${h.sourceFile}:${lineRef}\` — ${h.testTitle ?? "(no test title)"}`);
|
|
257
|
+
lines.push("");
|
|
258
|
+
lines.push("```diff");
|
|
259
|
+
lines.push(`- ${(h.oldCodeLine ?? "").trim()}`);
|
|
260
|
+
lines.push(`+ ${(h.newCodeLine ?? "").trim()}`);
|
|
261
|
+
lines.push("```");
|
|
262
|
+
if (h.aiExplanation) {
|
|
263
|
+
lines.push(`> 🧠 ${h.aiExplanation}`);
|
|
264
|
+
}
|
|
265
|
+
lines.push(`> AI Confidence: **${fmtPct(h.aiConfidence)}** · Auditor: **${fmtPct(h.auditor?.confidence)}**`);
|
|
266
|
+
lines.push("");
|
|
267
|
+
}
|
|
268
|
+
lines.push(`## 🔍 Visual Evidence`);
|
|
269
|
+
lines.push(`[📄 View Full Interactive Healing Report](${reportLink})`);
|
|
270
|
+
lines.push("");
|
|
271
|
+
lines.push(`---`);
|
|
272
|
+
lines.push(`Generated automatically by \`sela-core\`.`);
|
|
273
|
+
return lines.join("\n");
|
|
274
|
+
}
|
|
275
|
+
async function execute(cfg, decision, heals, branchInfo, ctx) {
|
|
276
|
+
const result = {
|
|
277
|
+
effective: decision.effective,
|
|
278
|
+
prUrl: null,
|
|
279
|
+
branchPushed: null,
|
|
280
|
+
errors: [],
|
|
281
|
+
};
|
|
282
|
+
if (decision.effective === "readOnly") {
|
|
283
|
+
console.log(`[Sela PR] 🛡️ readOnly — no git operations performed.`);
|
|
284
|
+
if (ctx.reportHtmlPath) {
|
|
285
|
+
console.log(`[Sela PR] Report artifact: ${ctx.reportHtmlPath}`);
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
if (heals.length === 0) {
|
|
290
|
+
console.log(`[Sela PR] No HEALED events — skipping git operations.`);
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
if (!gitAvailable(ctx.cwd)) {
|
|
294
|
+
result.errors.push("git CLI not available");
|
|
295
|
+
console.warn(`[Sela PR] ⚠️ git CLI not available — skipping.`);
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
if (isDetachedHead(ctx.cwd)) {
|
|
299
|
+
result.errors.push("detached HEAD");
|
|
300
|
+
console.warn(`[Sela PR] ⚠️ Detached HEAD — skipping git operations.`);
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
// ── Stage all healed files (relative paths from event records) ────
|
|
304
|
+
const filesToStage = Array.from(new Set(heals.map((h) => h.sourceFile).filter((f) => f && f.length > 0)));
|
|
305
|
+
if (filesToStage.length === 0) {
|
|
306
|
+
result.errors.push("no files to stage");
|
|
307
|
+
console.warn(`[Sela PR] ⚠️ No file paths in HEALED events — skipping.`);
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
// Render title — use first heal as representative
|
|
311
|
+
const first = heals[0];
|
|
312
|
+
const titleVars = {
|
|
313
|
+
testName: first.testTitle ?? "automation",
|
|
314
|
+
fileName: path.basename(first.sourceFile),
|
|
315
|
+
count: heals.length,
|
|
316
|
+
};
|
|
317
|
+
const title = render(cfg.titleTemplate, titleVars);
|
|
318
|
+
const reportLink = ctx.reportHtmlPath
|
|
319
|
+
? path.relative(ctx.cwd, ctx.reportHtmlPath)
|
|
320
|
+
: cfg.reportArtifactPath;
|
|
321
|
+
const body = cfg.bodyTemplate
|
|
322
|
+
? render(cfg.bodyTemplate, {
|
|
323
|
+
...titleVars,
|
|
324
|
+
summary: `${heals.length} heal(s)`,
|
|
325
|
+
reportLink,
|
|
326
|
+
reasoning: heals.map((h) => h.aiExplanation).join("\n\n"),
|
|
327
|
+
})
|
|
328
|
+
: buildDefaultBody(heals, branchInfo, reportLink, decision);
|
|
329
|
+
// ── directCommit ────────────────────────────────────────────────
|
|
330
|
+
if (decision.effective === "directCommit") {
|
|
331
|
+
for (const f of filesToStage) {
|
|
332
|
+
const add = sh(`git add "${f}"`, ctx.cwd);
|
|
333
|
+
if (!add.ok)
|
|
334
|
+
result.errors.push(`git add ${f}: ${add.stderr}`);
|
|
335
|
+
}
|
|
336
|
+
const commit = sh(`git commit -m ${shellQuote(title)}`, ctx.cwd);
|
|
337
|
+
if (!commit.ok) {
|
|
338
|
+
result.errors.push(`git commit: ${commit.stderr}`);
|
|
339
|
+
console.warn(`[Sela PR] ⚠️ git commit failed: ${commit.stderr}`);
|
|
340
|
+
return result;
|
|
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;
|
|
351
|
+
}
|
|
352
|
+
// ── pr / draftPR ────────────────────────────────────────────────
|
|
353
|
+
if (decision.effective === "pr" || decision.effective === "draftPR") {
|
|
354
|
+
if (!ghAvailable(ctx.cwd)) {
|
|
355
|
+
result.errors.push("gh CLI not available");
|
|
356
|
+
console.warn(`[Sela PR] ⚠️ gh CLI not installed — cannot open PR. Run \`gh auth login\` after install.`);
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
359
|
+
const id = String(Date.now());
|
|
360
|
+
const branchName = render(cfg.branchNameTemplate, { id, timestamp: id });
|
|
361
|
+
const checkout = sh(`git checkout -b ${branchName}`, ctx.cwd);
|
|
362
|
+
if (!checkout.ok) {
|
|
363
|
+
result.errors.push(`git checkout: ${checkout.stderr}`);
|
|
364
|
+
console.warn(`[Sela PR] ⚠️ Could not create branch '${branchName}': ${checkout.stderr}`);
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
for (const f of filesToStage) {
|
|
368
|
+
const add = sh(`git add "${f}"`, ctx.cwd);
|
|
369
|
+
if (!add.ok)
|
|
370
|
+
result.errors.push(`git add ${f}: ${add.stderr}`);
|
|
371
|
+
}
|
|
372
|
+
const commit = sh(`git commit -m ${shellQuote(title)}`, ctx.cwd);
|
|
373
|
+
if (!commit.ok) {
|
|
374
|
+
result.errors.push(`git commit: ${commit.stderr}`);
|
|
375
|
+
console.warn(`[Sela PR] ⚠️ git commit failed: ${commit.stderr}`);
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
const push = sh(`git push -u origin ${branchName}`, ctx.cwd);
|
|
379
|
+
if (!push.ok) {
|
|
380
|
+
result.errors.push(`git push: ${push.stderr}`);
|
|
381
|
+
console.warn(`[Sela PR] ⚠️ git push failed: ${push.stderr}`);
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
result.branchPushed = branchName;
|
|
385
|
+
// Write body to a temp file to avoid shell-escaping headaches
|
|
386
|
+
const bodyFile = path.join(ctx.cwd, `.sela-pr-body-${id}.md`);
|
|
387
|
+
fs.writeFileSync(bodyFile, body, "utf8");
|
|
388
|
+
const labelFlag = cfg.labels.length > 0
|
|
389
|
+
? cfg.labels.map((l) => `--label ${shellQuote(l)}`).join(" ")
|
|
390
|
+
: "";
|
|
391
|
+
const draftFlag = decision.effective === "draftPR" ? "--draft" : "";
|
|
392
|
+
const baseFlag = branchInfo.branch ? `--base ${shellQuote(branchInfo.branch)}` : "";
|
|
393
|
+
const prCmd = `gh pr create ${draftFlag} ${baseFlag} ` +
|
|
394
|
+
`--title ${shellQuote(title)} ` +
|
|
395
|
+
`--body-file ${shellQuote(bodyFile)} ` +
|
|
396
|
+
`${labelFlag}`;
|
|
397
|
+
const pr = sh(prCmd, ctx.cwd);
|
|
398
|
+
try {
|
|
399
|
+
fs.unlinkSync(bodyFile);
|
|
400
|
+
}
|
|
401
|
+
catch { /* ignore */ }
|
|
402
|
+
if (!pr.ok) {
|
|
403
|
+
result.errors.push(`gh pr create: ${pr.stderr}`);
|
|
404
|
+
console.warn(`[Sela PR] ⚠️ gh pr create failed: ${pr.stderr}`);
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
result.prUrl = pr.stdout.trim();
|
|
408
|
+
console.log(`[Sela PR] ✅ ${decision.effective === "draftPR" ? "Draft PR" : "PR"} opened: ${result.prUrl}`);
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
return result;
|
|
412
|
+
}
|
|
413
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
414
|
+
// BUG-DETECTED HANDLING
|
|
415
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
416
|
+
async function handleBugDetected(action, events, branchInfo, ctx) {
|
|
417
|
+
if (events.length === 0)
|
|
418
|
+
return;
|
|
419
|
+
console.warn(`\n[Sela Protection] ⚠️ ${events.length} potential regression(s) detected — auto-fix blocked.`);
|
|
420
|
+
for (const e of events) {
|
|
421
|
+
console.warn(` • ${e.sourceFile}:${e.sourceLine} — ${e.reason} (auditor: ${e.auditor?.verdict ?? "n/a"})`);
|
|
422
|
+
}
|
|
423
|
+
if (action === "failCI") {
|
|
424
|
+
console.warn(`[Sela Protection] 🔴 onBugDetected='failCI' — setting process.exitCode = 1`);
|
|
425
|
+
process.exitCode = 1;
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (action === "createIssue") {
|
|
429
|
+
if (!ghAvailable(ctx.cwd)) {
|
|
430
|
+
console.warn(`[Sela Protection] ⚠️ gh CLI unavailable — cannot create issue.`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
for (const e of events) {
|
|
434
|
+
const id = String(Date.now());
|
|
435
|
+
const title = `[Sela Alert] Potential Bug Detected: ${e.testTitle ?? path.basename(e.sourceFile)}`;
|
|
436
|
+
const body = [
|
|
437
|
+
`## 🛑 Sela detected a potential regression`,
|
|
438
|
+
``,
|
|
439
|
+
`* **File:** \`${e.sourceFile}:${e.sourceLine}\``,
|
|
440
|
+
`* **Test:** ${e.testTitle ?? "(unknown)"}`,
|
|
441
|
+
`* **Reason:** ${e.reason}`,
|
|
442
|
+
`* **Safety Level:** ${e.safetyLevel}`,
|
|
443
|
+
`* **Auditor:** ${e.auditor?.verdict ?? "n/a"} (${e.auditor?.confidence ?? "n/a"}%)`,
|
|
444
|
+
``,
|
|
445
|
+
`### Old selector`,
|
|
446
|
+
`\`${e.oldSelector}\``,
|
|
447
|
+
``,
|
|
448
|
+
`### AI candidate (BLOCKED)`,
|
|
449
|
+
`\`${e.candidateNewSelector ?? "(none)"}\``,
|
|
450
|
+
``,
|
|
451
|
+
`### AI explanation`,
|
|
452
|
+
`> ${e.aiExplanation || "(none)"}`,
|
|
453
|
+
].join("\n");
|
|
454
|
+
const bodyFile = path.join(ctx.cwd, `.sela-issue-body-${id}.md`);
|
|
455
|
+
fs.writeFileSync(bodyFile, body, "utf8");
|
|
456
|
+
const cmd = `gh issue create --title ${shellQuote(title)} ` +
|
|
457
|
+
`--body-file ${shellQuote(bodyFile)} ` +
|
|
458
|
+
`--label sela-bug-detected`;
|
|
459
|
+
const r = sh(cmd, ctx.cwd);
|
|
460
|
+
try {
|
|
461
|
+
fs.unlinkSync(bodyFile);
|
|
462
|
+
}
|
|
463
|
+
catch { /* ignore */ }
|
|
464
|
+
if (!r.ok) {
|
|
465
|
+
console.warn(`[Sela Protection] ⚠️ gh issue create failed: ${r.stderr}`);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
console.log(`[Sela Protection] 📝 Issue created: ${r.stdout.trim()}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (action === "labelOnly") {
|
|
474
|
+
if (!ghAvailable(ctx.cwd)) {
|
|
475
|
+
console.warn(`[Sela Protection] ⚠️ gh CLI unavailable — cannot label PR.`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!branchInfo.isPR || !branchInfo.prNumber) {
|
|
479
|
+
console.warn(`[Sela Protection] ⚠️ Not running on a PR — labelOnly skipped.`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const labelCmd = `gh pr edit ${branchInfo.prNumber} --add-label sela-bug-detected`;
|
|
483
|
+
const labelRes = sh(labelCmd, ctx.cwd);
|
|
484
|
+
if (!labelRes.ok) {
|
|
485
|
+
console.warn(`[Sela Protection] ⚠️ gh pr edit failed: ${labelRes.stderr}`);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const comment = `⚠️ Sela detected ${events.length} potential regression(s). Review before merging.`;
|
|
489
|
+
const commentRes = sh(`gh pr comment ${branchInfo.prNumber} --body ${shellQuote(comment)}`, ctx.cwd);
|
|
490
|
+
if (!commentRes.ok) {
|
|
491
|
+
console.warn(`[Sela Protection] ⚠️ gh pr comment failed: ${commentRes.stderr}`);
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
console.log(`[Sela Protection] 🏷️ PR #${branchInfo.prNumber} labeled + commented.`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
499
|
+
// SHELL QUOTING — cross-platform (POSIX single-quote escape;
|
|
500
|
+
// on Windows execSync uses cmd.exe which handles double-quotes natively).
|
|
501
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
502
|
+
function shellQuote(s) {
|
|
503
|
+
if (process.platform === "win32") {
|
|
504
|
+
// cmd.exe: escape inner double-quotes by doubling them.
|
|
505
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
506
|
+
}
|
|
507
|
+
// POSIX: wrap in single quotes; close-and-reopen for embedded single quotes.
|
|
508
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
509
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SnapshotService.d.ts","sourceRoot":"","sources":["../../src/services/SnapshotService.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,
|
|
1
|
+
{"version":3,"file":"SnapshotService.d.ts","sourceRoot":"","sources":["../../src/services/SnapshotService.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAkB,MAAM,UAAU,CAAC;AAE9E,qBAAa,eAAe;IAC1B,OAAO,CAAC,OAAO,CAAS;;IASlB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAYvD,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IAsBpD,cAAc,CAClB,IAAI,EAAE,IAAI,EACV,QAAQ,EAAE,MAAM,EAChB,SAAS,GAAE,MAAM,EAAO,GACvB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IAoRpC,OAAO,CAAC,WAAW;CAsBpB"}
|
|
@@ -50,10 +50,10 @@ class SnapshotService {
|
|
|
50
50
|
const filePath = path.join(this.baseDir, `${key}.json`);
|
|
51
51
|
try {
|
|
52
52
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
|
|
53
|
-
console.log(`[
|
|
53
|
+
console.log(`[Sela] 📸 Snapshot saved: ${key}.json`);
|
|
54
54
|
}
|
|
55
55
|
catch (err) {
|
|
56
|
-
console.error(`[
|
|
56
|
+
console.error(`[Sela] ❌ Failed to save snapshot:`, err);
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
async load(key) {
|
|
@@ -163,8 +163,8 @@ class SnapshotService {
|
|
|
163
163
|
}
|
|
164
164
|
else if (cs.pointerEvents === "none") {
|
|
165
165
|
// Child exception: ghost only if no immediate child re-enables pointer events
|
|
166
|
-
const hasInteractableChild = Array.from(el.children).some((child) => window.getComputedStyle(child)
|
|
167
|
-
"none");
|
|
166
|
+
const hasInteractableChild = Array.from(el.children).some((child) => window.getComputedStyle(child)
|
|
167
|
+
.pointerEvents !== "none");
|
|
168
168
|
if (!hasInteractableChild) {
|
|
169
169
|
isGhost = true;
|
|
170
170
|
ghostReason = "G4_POINTER_EVENTS_NONE";
|
|
@@ -237,8 +237,7 @@ class SnapshotService {
|
|
|
237
237
|
let sib = el.previousElementSibling;
|
|
238
238
|
while (sib) {
|
|
239
239
|
const sibCs = window.getComputedStyle(sib);
|
|
240
|
-
if (sibCs.display !== "none" &&
|
|
241
|
-
sibCs.visibility !== "hidden") {
|
|
240
|
+
if (sibCs.display !== "none" && sibCs.visibility !== "hidden") {
|
|
242
241
|
const t = (sib.textContent || "").trim();
|
|
243
242
|
if (t && t.length < 80) {
|
|
244
243
|
closestLabel = t;
|
|
@@ -314,7 +313,7 @@ class SnapshotService {
|
|
|
314
313
|
};
|
|
315
314
|
}
|
|
316
315
|
catch (err) {
|
|
317
|
-
console.debug(`[
|
|
316
|
+
console.debug(`[Sela] 🧬 captureElement skipped for "${selector}": ${err.message}`);
|
|
318
317
|
return null;
|
|
319
318
|
}
|
|
320
319
|
}
|
|
@@ -326,7 +325,7 @@ class SnapshotService {
|
|
|
326
325
|
// the engine decides when to flush.
|
|
327
326
|
// ─────────────────────────────────────────────────────────────
|
|
328
327
|
migrateToV2(v1) {
|
|
329
|
-
console.log(`[
|
|
328
|
+
console.log(`[Sela] 🔄 Migrating v1 snapshot to v2 schema (key: "${v1.tagName}/${v1.id ?? "no-id"}")`);
|
|
330
329
|
return {
|
|
331
330
|
...v1,
|
|
332
331
|
schemaVersion: 2,
|
|
@@ -471,7 +471,7 @@ class SourceUpdater {
|
|
|
471
471
|
if (!contentChange)
|
|
472
472
|
return;
|
|
473
473
|
console.log(`[SourceUpdater] 🔍 Checking for assertion mismatches...`);
|
|
474
|
-
console.warn(`\n[
|
|
474
|
+
console.warn(`\n[Sela] 💡 To fix the assertion at line ${caller.line}, ` +
|
|
475
475
|
`change to: .toHaveText("${contentChange.newText}")\n`);
|
|
476
476
|
};
|
|
477
477
|
// ── Strategy 0: Layered Frame Healing ──────────────────────
|
|
@@ -15,7 +15,7 @@ class SnapshotManager {
|
|
|
15
15
|
await promises_1.default.writeFile(filePath, JSON.stringify(data, null, 2));
|
|
16
16
|
}
|
|
17
17
|
catch (error) {
|
|
18
|
-
console.error(`[
|
|
18
|
+
console.error(`[Sela] Failed to save snapshot: ${error}`);
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
static async load(filePath) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sela-core",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "AI self-healing Playwright wrapper — drop-in replacement for @playwright/test",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"ts-morph": "^27.0.2"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@playwright/test": "^1.
|
|
58
|
+
"@playwright/test": "^1.60.0",
|
|
59
59
|
"@types/jest": "^30.0.0",
|
|
60
60
|
"@types/node": "^25.6.2",
|
|
61
61
|
"jest": "^30.3.0",
|