skopix 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +65 -0
- package/.github/workflows/docker.yml +78 -0
- package/cli/commands/agent.js +378 -0
- package/cli/commands/config.js +67 -0
- package/cli/commands/dashboard.js +3524 -0
- package/cli/commands/init.js +190 -0
- package/cli/commands/report.js +41 -0
- package/cli/commands/run.js +350 -0
- package/cli/index.js +85 -0
- package/cli/ui.js +126 -0
- package/core/auth.js +148 -0
- package/core/browser.js +1049 -0
- package/core/credentials.js +47 -0
- package/core/db.js +503 -0
- package/core/llm.js +641 -0
- package/core/recorder.js +653 -0
- package/core/reporter.js +282 -0
- package/core/tracker.js +768 -0
- package/package.json +54 -0
- package/web/app/index.html +5937 -0
- package/web/index.html +644 -0
- package/web/invite.html +244 -0
- package/web/login.html +271 -0
- package/web/reset.html +222 -0
- package/web/setup.html +300 -0
package/core/tracker.js
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import fs from 'fs-extra';
|
|
6
|
+
|
|
7
|
+
dotenv.config({ path: path.resolve(process.cwd(), '.skopix.env') });
|
|
8
|
+
dotenv.config();
|
|
9
|
+
|
|
10
|
+
// Local issue store - shared across all tests in the project
|
|
11
|
+
const ISSUES_STORE_PATH = path.join(process.cwd(), 'skopix-reports', '.issues', 'issues.json');
|
|
12
|
+
|
|
13
|
+
async function loadIssueStore() {
|
|
14
|
+
try {
|
|
15
|
+
if (!await fs.pathExists(ISSUES_STORE_PATH)) return { issues: [] };
|
|
16
|
+
return await fs.readJson(ISSUES_STORE_PATH);
|
|
17
|
+
} catch {
|
|
18
|
+
return { issues: [] };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function saveIssueStore(store) {
|
|
23
|
+
await fs.ensureDir(path.dirname(ISSUES_STORE_PATH));
|
|
24
|
+
await fs.writeJson(ISSUES_STORE_PATH, store, { spaces: 2 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function findIssueInStore(fingerprint, tracker) {
|
|
28
|
+
const store = await loadIssueStore();
|
|
29
|
+
return store.issues.find(i => i.fingerprint === fingerprint && i.tracker === tracker);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function recordIssueInStore(record) {
|
|
33
|
+
const store = await loadIssueStore();
|
|
34
|
+
// Upsert by fingerprint + tracker
|
|
35
|
+
const idx = store.issues.findIndex(i => i.fingerprint === record.fingerprint && i.tracker === record.tracker);
|
|
36
|
+
if (idx >= 0) {
|
|
37
|
+
store.issues[idx] = { ...store.issues[idx], ...record };
|
|
38
|
+
} else {
|
|
39
|
+
store.issues.push(record);
|
|
40
|
+
}
|
|
41
|
+
await saveIssueStore(store);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function updateIssueInStore(fingerprint, tracker, updates) {
|
|
45
|
+
const store = await loadIssueStore();
|
|
46
|
+
const idx = store.issues.findIndex(i => i.fingerprint === fingerprint && i.tracker === tracker);
|
|
47
|
+
if (idx >= 0) {
|
|
48
|
+
store.issues[idx] = { ...store.issues[idx], ...updates };
|
|
49
|
+
await saveIssueStore(store);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { loadIssueStore, saveIssueStore };
|
|
54
|
+
|
|
55
|
+
export class IssueTracker {
|
|
56
|
+
constructor({ jira, linear, github }) {
|
|
57
|
+
this.useJira = jira;
|
|
58
|
+
this.useLinear = linear;
|
|
59
|
+
this.useGithub = github;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async pushIssues(issues, context) {
|
|
63
|
+
const created = [];
|
|
64
|
+
const { url, goal } = context;
|
|
65
|
+
|
|
66
|
+
for (const issue of issues) {
|
|
67
|
+
const safe = this._sanitise(issue, { url, goal });
|
|
68
|
+
const fingerprint = this._fingerprint(safe, context);
|
|
69
|
+
const body = this._formatBody(safe, context, fingerprint);
|
|
70
|
+
const title = this._formatTitle(safe, context);
|
|
71
|
+
|
|
72
|
+
if (this.useJira) {
|
|
73
|
+
try {
|
|
74
|
+
const result = await this._createOrUpdateJiraIssue(safe, title, body, fingerprint, context);
|
|
75
|
+
created.push({ tracker: 'jira', ...result });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error(`Jira error: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (this.useLinear) {
|
|
82
|
+
try {
|
|
83
|
+
const result = await this._createOrUpdateLinearIssue(safe, title, body, fingerprint, context);
|
|
84
|
+
created.push({ tracker: 'linear', ...result });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error(`Linear error: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (this.useGithub) {
|
|
91
|
+
try {
|
|
92
|
+
const result = await this._createOrUpdateGithubIssue(safe, title, body, fingerprint, context);
|
|
93
|
+
created.push({ tracker: 'github', ...result });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error(`GitHub error: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return created;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_sanitise(issue, { url, goal }) {
|
|
104
|
+
let title = issue.title && issue.title !== 'undefined' ? issue.title : null;
|
|
105
|
+
if (!title && issue.description && issue.description !== 'undefined') {
|
|
106
|
+
title = String(issue.description).split(/[.\n]/)[0].slice(0, 80).trim();
|
|
107
|
+
}
|
|
108
|
+
if (!title) {
|
|
109
|
+
const urlPath = url ? new URL(url).pathname : 'page';
|
|
110
|
+
title = `Issue detected on ${urlPath}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const description = issue.description && issue.description !== 'undefined'
|
|
114
|
+
? issue.description
|
|
115
|
+
: 'An issue was detected during automated testing. See session details for full context.';
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
title: title.replace(/^undefined\s*/i, '').trim() || 'Issue detected',
|
|
119
|
+
description,
|
|
120
|
+
severity: (issue.severity && issue.severity !== 'undefined') ? issue.severity : 'medium',
|
|
121
|
+
type: issue.type || 'bug',
|
|
122
|
+
step: issue.step || '?',
|
|
123
|
+
url: issue.url || url,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_fingerprint(issue, { testName, suiteName, url }) {
|
|
128
|
+
// Normalise the title - strip variable bits, lowercase, collapse whitespace
|
|
129
|
+
let normalisedTitle = (issue.title || '')
|
|
130
|
+
.toLowerCase()
|
|
131
|
+
// Strip session IDs, hashes, numbers in brackets
|
|
132
|
+
.replace(/[a-f0-9]{8,}/g, '')
|
|
133
|
+
.replace(/\[[\w\-:]+\]/g, '')
|
|
134
|
+
.replace(/\d+/g, 'N')
|
|
135
|
+
// Collapse whitespace and special chars
|
|
136
|
+
.replace(/[^\w\s]/g, ' ')
|
|
137
|
+
.replace(/\s+/g, ' ')
|
|
138
|
+
.trim();
|
|
139
|
+
|
|
140
|
+
// Strip common filler words and pluralisation to reduce minor variations
|
|
141
|
+
const fillerWords = new Set(['the', 'a', 'an', 'with', 'of', 'on', 'in', 'for', 'to', 'and', 'or', 'when', 'during', 'at', 'that', 'is', 'was', 'were', 'be', 'been', 'has', 'have', 'had', 'this', 'these', 'those', 'it']);
|
|
142
|
+
normalisedTitle = normalisedTitle
|
|
143
|
+
.split(' ')
|
|
144
|
+
.filter(w => w && !fillerWords.has(w))
|
|
145
|
+
// Crude singularisation - strip trailing s/es to handle plural variations
|
|
146
|
+
.map(w => {
|
|
147
|
+
// Crude singularisation - strip trailing s to handle plural variations
|
|
148
|
+
// Don't double-strip: "resources" → "resource" (strip s only), not "resourc"
|
|
149
|
+
if (w.length > 4 && w.endsWith('ies')) return w.slice(0, -3) + 'y'; // "categories" → "category"
|
|
150
|
+
if (w.length > 4 && w.endsWith('xes')) return w.slice(0, -2); // "boxes" → "box"
|
|
151
|
+
if (w.length > 4 && w.endsWith('ses') && !w.endsWith('sses')) return w.slice(0, -2); // "buses" → "bus"
|
|
152
|
+
if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') && !w.endsWith('us')) return w.slice(0, -1); // "resources" → "resource", "errors" → "error"
|
|
153
|
+
return w;
|
|
154
|
+
})
|
|
155
|
+
.slice(0, 8) // first 8 meaningful words only
|
|
156
|
+
.sort() // sort so word order doesn't affect hash
|
|
157
|
+
.join(' ');
|
|
158
|
+
|
|
159
|
+
// Use URL pathname only (ignore query strings, fragments)
|
|
160
|
+
let urlKey = '';
|
|
161
|
+
try {
|
|
162
|
+
const u = new URL(issue.url || url);
|
|
163
|
+
urlKey = u.hostname + u.pathname;
|
|
164
|
+
} catch {
|
|
165
|
+
urlKey = issue.url || url || '';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const parts = [
|
|
169
|
+
suiteName || '',
|
|
170
|
+
testName || '',
|
|
171
|
+
urlKey,
|
|
172
|
+
issue.type || 'bug',
|
|
173
|
+
normalisedTitle,
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
return crypto.createHash('sha256').update(parts.join('::')).digest('hex').slice(0, 16);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Filter issues to those that could plausibly be the "same" issue:
|
|
180
|
+
// - Same tracker
|
|
181
|
+
// - Same status (open)
|
|
182
|
+
// - Same URL hostname AND pathname (so /login on site A != /login on site B, and /login != /admin on same site)
|
|
183
|
+
_filterCandidates(allIssues, newIssue, tracker, contextUrl) {
|
|
184
|
+
const targetUrl = newIssue.url || contextUrl;
|
|
185
|
+
let targetHost = '';
|
|
186
|
+
let targetPath = '';
|
|
187
|
+
try {
|
|
188
|
+
const u = new URL(targetUrl);
|
|
189
|
+
targetHost = u.hostname.toLowerCase();
|
|
190
|
+
targetPath = u.pathname.toLowerCase().replace(/\/+$/, '') || '/';
|
|
191
|
+
} catch {
|
|
192
|
+
targetHost = (targetUrl || '').toLowerCase();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return allIssues.filter(stored => {
|
|
196
|
+
if (stored.tracker !== tracker) return false;
|
|
197
|
+
if (stored.status !== 'open') return false;
|
|
198
|
+
// URL match - same host AND same path
|
|
199
|
+
if (!stored.url) return false;
|
|
200
|
+
try {
|
|
201
|
+
const s = new URL(stored.url);
|
|
202
|
+
if (s.hostname.toLowerCase() !== targetHost) return false;
|
|
203
|
+
const sPath = s.pathname.toLowerCase().replace(/\/+$/, '') || '/';
|
|
204
|
+
if (sPath !== targetPath) return false;
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
// Same issue type (bug != performance != accessibility)
|
|
209
|
+
if (stored.type && newIssue.type && stored.type !== newIssue.type) return false;
|
|
210
|
+
return true;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Use the LLM to decide: is this new issue semantically the same as any of the candidates?
|
|
215
|
+
// Returns the matched stored issue, or null if it's genuinely new.
|
|
216
|
+
async _findSemanticMatch(newIssue, candidates) {
|
|
217
|
+
if (candidates.length === 0) return null;
|
|
218
|
+
|
|
219
|
+
// Fast path: if we have an exact fingerprint match in candidates, use it directly.
|
|
220
|
+
// Saves the LLM call when phrasing is identical.
|
|
221
|
+
// (Caller already passes through fingerprint-stored issues, but check anyway.)
|
|
222
|
+
const exactFp = this._fingerprintCore(newIssue.title || '');
|
|
223
|
+
for (const c of candidates) {
|
|
224
|
+
if (this._fingerprintCore(c.title || '') === exactFp) return c;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Slow path: ask the LLM via direct Gemini API call (no extra deps)
|
|
228
|
+
try {
|
|
229
|
+
const apiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
|
|
230
|
+
if (!apiKey) return null; // No LLM available - safer to fail open (create new)
|
|
231
|
+
|
|
232
|
+
const list = candidates.map((c, i) => `${i + 1}. "${c.title}" (description: "${(c.description || '').slice(0, 100)}", severity: ${c.severity}, type: ${c.type})`).join('\n');
|
|
233
|
+
const prompt = `You are deduplicating bug reports detected by an automated QA tool. A test detected this NEW issue on a website:
|
|
234
|
+
|
|
235
|
+
NEW ISSUE:
|
|
236
|
+
Title: "${newIssue.title}"
|
|
237
|
+
Description: "${(newIssue.description || '').slice(0, 200)}"
|
|
238
|
+
Severity: ${newIssue.severity}
|
|
239
|
+
Type: ${newIssue.type}
|
|
240
|
+
URL: ${newIssue.url}
|
|
241
|
+
|
|
242
|
+
Below are existing OPEN issues already raised for the SAME page. Decide if the NEW issue is reporting the SAME underlying bug as any of them.
|
|
243
|
+
|
|
244
|
+
EXISTING ISSUES (all on the same page):
|
|
245
|
+
${list}
|
|
246
|
+
|
|
247
|
+
Rules:
|
|
248
|
+
- Two issues are the SAME if they describe the same root cause, even with different wording. Examples of same: "Broken images" vs "Images failed to load" vs "404 errors on image resources". "Login failed" vs "Login Failed with Invalid Credentials" vs "Authentication failure".
|
|
249
|
+
- Two issues are DIFFERENT if they describe distinct technical problems. Examples of different: "Login button missing" vs "Slow page load". "Broken image" vs "Console error about Stripe widget".
|
|
250
|
+
- Be conservative: if unsure, say NEW. False merges hide bugs; false splits just create extra tickets.
|
|
251
|
+
|
|
252
|
+
Respond with ONLY one of these formats - nothing else:
|
|
253
|
+
- A single integer (1, 2, 3, etc.) referring to the matching existing issue number above.
|
|
254
|
+
- The literal word: NEW
|
|
255
|
+
|
|
256
|
+
Your answer:`;
|
|
257
|
+
|
|
258
|
+
const response = await axios.post(
|
|
259
|
+
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key=${apiKey}`,
|
|
260
|
+
{
|
|
261
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
262
|
+
generationConfig: { temperature: 0, maxOutputTokens: 10 },
|
|
263
|
+
},
|
|
264
|
+
{ headers: { 'Content-Type': 'application/json' }, timeout: 15000 }
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const text = response.data?.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
268
|
+
const trimmed = text.trim().toLowerCase().split(/\s+/)[0].replace(/[^a-z0-9]/g, '');
|
|
269
|
+
|
|
270
|
+
if (trimmed === 'new') return null;
|
|
271
|
+
const num = parseInt(trimmed, 10);
|
|
272
|
+
if (Number.isInteger(num) && num >= 1 && num <= candidates.length) {
|
|
273
|
+
return candidates[num - 1];
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
} catch (err) {
|
|
277
|
+
// LLM call failed - fail open (create new ticket). Better an extra ticket than a false merge.
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
_fingerprintCore(title) {
|
|
283
|
+
let s = (title || '')
|
|
284
|
+
.toLowerCase()
|
|
285
|
+
.replace(/[a-f0-9]{8,}/g, '')
|
|
286
|
+
.replace(/\d+/g, 'N')
|
|
287
|
+
.replace(/[^\w\s]/g, ' ')
|
|
288
|
+
.replace(/\s+/g, ' ')
|
|
289
|
+
.trim();
|
|
290
|
+
const fillers = new Set(['the', 'a', 'an', 'with', 'of', 'on', 'in', 'for', 'to', 'and', 'or', 'when', 'during', 'at', 'that', 'is', 'was', 'were', 'be', 'been', 'has', 'have', 'had', 'this', 'these', 'those', 'it']);
|
|
291
|
+
s = s.split(' ').filter(w => w && !fillers.has(w))
|
|
292
|
+
.map(w => {
|
|
293
|
+
if (w.length > 4 && w.endsWith('ies')) return w.slice(0, -3) + 'y';
|
|
294
|
+
if (w.length > 4 && w.endsWith('xes')) return w.slice(0, -2);
|
|
295
|
+
if (w.length > 4 && w.endsWith('ses') && !w.endsWith('sses')) return w.slice(0, -2);
|
|
296
|
+
if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') && !w.endsWith('us')) return w.slice(0, -1);
|
|
297
|
+
return w;
|
|
298
|
+
})
|
|
299
|
+
.slice(0, 8).sort().join(' ');
|
|
300
|
+
return s;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Find any existing issue (in local store) that matches the new one.
|
|
304
|
+
// Strategy:
|
|
305
|
+
// 1. Exact fingerprint match → use it (fastest, deterministic)
|
|
306
|
+
// 2. URL-scoped semantic comparison via LLM → use it (handles phrasing variation)
|
|
307
|
+
// 3. No match → return null (caller creates new)
|
|
308
|
+
async _findExistingMatch(issue, fingerprint, tracker, context) {
|
|
309
|
+
const store = await loadIssueStore();
|
|
310
|
+
const allIssues = store.issues || [];
|
|
311
|
+
|
|
312
|
+
// Step 1: exact fingerprint match wins fast
|
|
313
|
+
const exact = allIssues.find(i => i.fingerprint === fingerprint && i.tracker === tracker);
|
|
314
|
+
if (exact) {
|
|
315
|
+
exact._matchReason = 'fingerprint';
|
|
316
|
+
return exact;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Step 2: filter to plausible candidates (same URL host+path, same tracker, status open)
|
|
320
|
+
const candidates = this._filterCandidates(allIssues, issue, tracker, context.url);
|
|
321
|
+
if (candidates.length === 0) return null;
|
|
322
|
+
|
|
323
|
+
// Step 3: ask the LLM if any candidate is the same bug
|
|
324
|
+
const semantic = await this._findSemanticMatch(issue, candidates);
|
|
325
|
+
if (semantic) {
|
|
326
|
+
semantic._matchReason = 'semantic';
|
|
327
|
+
return semantic;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
_formatTitle(issue, { testName, suiteName }) {
|
|
334
|
+
let prefix = '[Skopix]';
|
|
335
|
+
if (suiteName) prefix = `[Skopix · ${suiteName}]`;
|
|
336
|
+
else if (testName) prefix = `[Skopix · ${testName}]`;
|
|
337
|
+
return `${prefix} ${issue.title}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
_formatBody(issue, { url, goal, sessionId, testName, suiteName, reportPath, dashboardUrl }, fingerprint) {
|
|
341
|
+
const lines = [];
|
|
342
|
+
|
|
343
|
+
lines.push(`<!-- skopix-fingerprint: ${fingerprint} -->`);
|
|
344
|
+
lines.push('');
|
|
345
|
+
lines.push(`## ${issue.title}`);
|
|
346
|
+
lines.push('');
|
|
347
|
+
lines.push(`**Severity:** ${issue.severity}`);
|
|
348
|
+
lines.push(`**Type:** ${issue.type}`);
|
|
349
|
+
lines.push(`**Detected at:** Step ${issue.step}`);
|
|
350
|
+
lines.push(`**URL:** ${issue.url}`);
|
|
351
|
+
lines.push('');
|
|
352
|
+
|
|
353
|
+
lines.push('### Description');
|
|
354
|
+
lines.push(issue.description);
|
|
355
|
+
lines.push('');
|
|
356
|
+
|
|
357
|
+
lines.push('### Test context');
|
|
358
|
+
if (suiteName) lines.push(`- **Suite:** ${suiteName}`);
|
|
359
|
+
if (testName) lines.push(`- **Test:** ${testName}`);
|
|
360
|
+
lines.push(`- **Goal:** "${goal}"`);
|
|
361
|
+
lines.push(`- **Target URL:** ${url}`);
|
|
362
|
+
lines.push(`- **Session ID:** \`${sessionId}\``);
|
|
363
|
+
if (reportPath) lines.push(`- **Local report:** \`${reportPath}\``);
|
|
364
|
+
if (dashboardUrl) lines.push(`- **Dashboard:** [Open in Skopix](${dashboardUrl})`);
|
|
365
|
+
lines.push('');
|
|
366
|
+
|
|
367
|
+
lines.push('---');
|
|
368
|
+
lines.push('*Detected automatically by [Skopix](https://skopix.dev) — AI-powered QA agent*');
|
|
369
|
+
|
|
370
|
+
return lines.join('\n');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
_formatRecurrenceComment({ sessionId, testName, suiteName, reportPath }) {
|
|
374
|
+
const when = new Date().toISOString().replace('T', ' ').slice(0, 16);
|
|
375
|
+
const lines = [`**Detected again** at ${when} UTC`];
|
|
376
|
+
if (suiteName) lines.push(`- Suite: ${suiteName}`);
|
|
377
|
+
if (testName) lines.push(`- Test: ${testName}`);
|
|
378
|
+
lines.push(`- Session: \`${sessionId}\``);
|
|
379
|
+
if (reportPath) lines.push(`- Report: \`${reportPath}\``);
|
|
380
|
+
return lines.join('\n');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── GITHUB ────────────────────────────────────────────────────────────────
|
|
384
|
+
async _findGithubIssueByFingerprint(fingerprint) {
|
|
385
|
+
const { GITHUB_TOKEN, GITHUB_REPO } = process.env;
|
|
386
|
+
try {
|
|
387
|
+
// GitHub search API - look for the fingerprint in open issues
|
|
388
|
+
const response = await axios.get(
|
|
389
|
+
`https://api.github.com/search/issues`,
|
|
390
|
+
{
|
|
391
|
+
params: {
|
|
392
|
+
q: `repo:${GITHUB_REPO} is:issue is:open "skopix-fingerprint: ${fingerprint}"`,
|
|
393
|
+
},
|
|
394
|
+
headers: {
|
|
395
|
+
Authorization: `token ${GITHUB_TOKEN}`,
|
|
396
|
+
Accept: 'application/vnd.github.v3+json',
|
|
397
|
+
},
|
|
398
|
+
}
|
|
399
|
+
);
|
|
400
|
+
const items = response.data.items || [];
|
|
401
|
+
return items.length > 0 ? items[0] : null;
|
|
402
|
+
} catch (err) {
|
|
403
|
+
// Search API can have eventual consistency, fail gracefully
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async _commentOnGithubIssue(issueNumber, comment) {
|
|
409
|
+
const { GITHUB_TOKEN, GITHUB_REPO } = process.env;
|
|
410
|
+
await axios.post(
|
|
411
|
+
`https://api.github.com/repos/${GITHUB_REPO}/issues/${issueNumber}/comments`,
|
|
412
|
+
{ body: comment },
|
|
413
|
+
{
|
|
414
|
+
headers: {
|
|
415
|
+
Authorization: `token ${GITHUB_TOKEN}`,
|
|
416
|
+
Accept: 'application/vnd.github.v3+json',
|
|
417
|
+
},
|
|
418
|
+
}
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async _createOrUpdateGithubIssue(issue, title, body, fingerprint, context) {
|
|
423
|
+
const { GITHUB_TOKEN, GITHUB_REPO } = process.env;
|
|
424
|
+
if (!GITHUB_TOKEN || !GITHUB_REPO) {
|
|
425
|
+
throw new Error('Missing GitHub env vars. Run `skopix init`.');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Find a matching existing issue using URL-scoped semantic comparison
|
|
429
|
+
const match = await this._findExistingMatch(issue, fingerprint, 'github', context);
|
|
430
|
+
|
|
431
|
+
if (match) {
|
|
432
|
+
// Verify it's still actually open on the tracker (auto-sync)
|
|
433
|
+
try {
|
|
434
|
+
const liveStatus = await this._getGithubIssueStatus(match.trackerRef);
|
|
435
|
+
if (liveStatus === 'closed') {
|
|
436
|
+
await updateIssueInStore(match.fingerprint, 'github', { status: 'resolved' });
|
|
437
|
+
// Fall through to create-new path
|
|
438
|
+
} else {
|
|
439
|
+
// Still open - comment, increment counter
|
|
440
|
+
const comment = this._formatRecurrenceComment(context);
|
|
441
|
+
await this._commentOnGithubIssue(match.trackerRef, comment);
|
|
442
|
+
await updateIssueInStore(match.fingerprint, 'github', {
|
|
443
|
+
lastSeen: new Date().toISOString(),
|
|
444
|
+
occurrences: (match.occurrences || 1) + 1,
|
|
445
|
+
sessions: [...(match.sessions || []), context.sessionId].slice(-20),
|
|
446
|
+
});
|
|
447
|
+
return {
|
|
448
|
+
action: 'commented',
|
|
449
|
+
number: match.trackerRef,
|
|
450
|
+
html_url: match.trackerUrl,
|
|
451
|
+
matchedBy: match._matchReason || 'fingerprint',
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
// Status check failed - safer to create new
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Create new (either no existing record OR previous one was resolved)
|
|
460
|
+
const labelMap = {
|
|
461
|
+
critical: ['bug', 'priority: critical'],
|
|
462
|
+
high: ['bug', 'priority: high'],
|
|
463
|
+
medium: ['bug'],
|
|
464
|
+
low: ['enhancement'],
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const response = await axios.post(
|
|
468
|
+
`https://api.github.com/repos/${GITHUB_REPO}/issues`,
|
|
469
|
+
{
|
|
470
|
+
title,
|
|
471
|
+
body,
|
|
472
|
+
labels: labelMap[issue.severity] || ['bug'],
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
headers: {
|
|
476
|
+
Authorization: `token ${GITHUB_TOKEN}`,
|
|
477
|
+
Accept: 'application/vnd.github.v3+json',
|
|
478
|
+
},
|
|
479
|
+
}
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
// Save to local store
|
|
483
|
+
await recordIssueInStore({
|
|
484
|
+
fingerprint,
|
|
485
|
+
tracker: 'github',
|
|
486
|
+
trackerRef: String(response.data.number),
|
|
487
|
+
trackerUrl: response.data.html_url,
|
|
488
|
+
title: issue.title,
|
|
489
|
+
severity: issue.severity,
|
|
490
|
+
type: issue.type,
|
|
491
|
+
status: 'open',
|
|
492
|
+
testName: context.testName || null,
|
|
493
|
+
suiteName: context.suiteName || null,
|
|
494
|
+
url: issue.url,
|
|
495
|
+
createdAt: new Date().toISOString(),
|
|
496
|
+
lastSeen: new Date().toISOString(),
|
|
497
|
+
occurrences: 1,
|
|
498
|
+
sessions: [context.sessionId],
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
action: 'created',
|
|
503
|
+
number: response.data.number,
|
|
504
|
+
html_url: response.data.html_url,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async _getGithubIssueStatus(issueNumber) {
|
|
509
|
+
const { GITHUB_TOKEN, GITHUB_REPO } = process.env;
|
|
510
|
+
const response = await axios.get(
|
|
511
|
+
`https://api.github.com/repos/${GITHUB_REPO}/issues/${issueNumber}`,
|
|
512
|
+
{
|
|
513
|
+
headers: {
|
|
514
|
+
Authorization: `token ${GITHUB_TOKEN}`,
|
|
515
|
+
Accept: 'application/vnd.github.v3+json',
|
|
516
|
+
},
|
|
517
|
+
}
|
|
518
|
+
);
|
|
519
|
+
return response.data.state; // 'open' or 'closed'
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ─── JIRA ──────────────────────────────────────────────────────────────────
|
|
523
|
+
async _findJiraIssueByFingerprint(fingerprint) {
|
|
524
|
+
const { JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN, JIRA_PROJECT_KEY } = process.env;
|
|
525
|
+
try {
|
|
526
|
+
const jql = `project = "${JIRA_PROJECT_KEY}" AND statusCategory != Done AND text ~ "skopix-fingerprint ${fingerprint}"`;
|
|
527
|
+
const response = await axios.get(
|
|
528
|
+
`${JIRA_BASE_URL}/rest/api/3/search`,
|
|
529
|
+
{
|
|
530
|
+
params: { jql, fields: 'summary,status', maxResults: 1 },
|
|
531
|
+
auth: { username: JIRA_EMAIL, password: JIRA_API_TOKEN },
|
|
532
|
+
}
|
|
533
|
+
);
|
|
534
|
+
const issues = response.data.issues || [];
|
|
535
|
+
return issues.length > 0 ? issues[0] : null;
|
|
536
|
+
} catch (err) {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async _commentOnJiraIssue(issueKey, comment) {
|
|
542
|
+
const { JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN } = process.env;
|
|
543
|
+
await axios.post(
|
|
544
|
+
`${JIRA_BASE_URL}/rest/api/3/issue/${issueKey}/comment`,
|
|
545
|
+
{
|
|
546
|
+
body: {
|
|
547
|
+
type: 'doc', version: 1,
|
|
548
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: comment }] }],
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
auth: { username: JIRA_EMAIL, password: JIRA_API_TOKEN },
|
|
553
|
+
headers: { 'Content-Type': 'application/json' },
|
|
554
|
+
}
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async _createOrUpdateJiraIssue(issue, title, body, fingerprint, context) {
|
|
559
|
+
const { JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN, JIRA_PROJECT_KEY } = process.env;
|
|
560
|
+
if (!JIRA_BASE_URL || !JIRA_EMAIL || !JIRA_API_TOKEN || !JIRA_PROJECT_KEY) {
|
|
561
|
+
throw new Error('Missing Jira env vars. Run `skopix init`.');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const match = await this._findExistingMatch(issue, fingerprint, 'jira', context);
|
|
565
|
+
if (match) {
|
|
566
|
+
try {
|
|
567
|
+
const liveStatus = await this._getJiraIssueStatus(match.trackerRef);
|
|
568
|
+
if (liveStatus === 'closed') {
|
|
569
|
+
await updateIssueInStore(match.fingerprint, 'jira', { status: 'resolved' });
|
|
570
|
+
} else {
|
|
571
|
+
const comment = this._formatRecurrenceComment(context);
|
|
572
|
+
await this._commentOnJiraIssue(match.trackerRef, comment);
|
|
573
|
+
await updateIssueInStore(match.fingerprint, 'jira', {
|
|
574
|
+
lastSeen: new Date().toISOString(),
|
|
575
|
+
occurrences: (match.occurrences || 1) + 1,
|
|
576
|
+
sessions: [...(match.sessions || []), context.sessionId].slice(-20),
|
|
577
|
+
});
|
|
578
|
+
return {
|
|
579
|
+
action: 'commented',
|
|
580
|
+
key: match.trackerRef,
|
|
581
|
+
url: match.trackerUrl,
|
|
582
|
+
matchedBy: match._matchReason || 'fingerprint',
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
} catch {}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const priorityMap = { critical: 'Highest', high: 'High', medium: 'Medium', low: 'Low' };
|
|
589
|
+
|
|
590
|
+
const response = await axios.post(
|
|
591
|
+
`${JIRA_BASE_URL}/rest/api/3/issue`,
|
|
592
|
+
{
|
|
593
|
+
fields: {
|
|
594
|
+
project: { key: JIRA_PROJECT_KEY },
|
|
595
|
+
summary: title,
|
|
596
|
+
description: {
|
|
597
|
+
type: 'doc', version: 1,
|
|
598
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: body }] }],
|
|
599
|
+
},
|
|
600
|
+
issuetype: { name: 'Bug' },
|
|
601
|
+
priority: { name: priorityMap[issue.severity] || 'Medium' },
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
auth: { username: JIRA_EMAIL, password: JIRA_API_TOKEN },
|
|
606
|
+
headers: { 'Content-Type': 'application/json' },
|
|
607
|
+
}
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
await recordIssueInStore({
|
|
611
|
+
fingerprint, tracker: 'jira',
|
|
612
|
+
trackerRef: response.data.key,
|
|
613
|
+
trackerUrl: `${JIRA_BASE_URL}/browse/${response.data.key}`,
|
|
614
|
+
title: issue.title, severity: issue.severity, type: issue.type,
|
|
615
|
+
status: 'open',
|
|
616
|
+
testName: context.testName || null, suiteName: context.suiteName || null,
|
|
617
|
+
url: issue.url,
|
|
618
|
+
createdAt: new Date().toISOString(),
|
|
619
|
+
lastSeen: new Date().toISOString(),
|
|
620
|
+
occurrences: 1, sessions: [context.sessionId],
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
action: 'created',
|
|
625
|
+
key: response.data.key,
|
|
626
|
+
url: `${JIRA_BASE_URL}/browse/${response.data.key}`,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async _getJiraIssueStatus(issueKey) {
|
|
631
|
+
const { JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN } = process.env;
|
|
632
|
+
const response = await axios.get(
|
|
633
|
+
`${JIRA_BASE_URL}/rest/api/3/issue/${issueKey}?fields=status`,
|
|
634
|
+
{ auth: { username: JIRA_EMAIL, password: JIRA_API_TOKEN } }
|
|
635
|
+
);
|
|
636
|
+
const cat = response.data.fields?.status?.statusCategory?.key;
|
|
637
|
+
return cat === 'done' ? 'closed' : 'open';
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ─── LINEAR ────────────────────────────────────────────────────────────────
|
|
641
|
+
async _findLinearIssueByFingerprint(fingerprint) {
|
|
642
|
+
const { LINEAR_API_KEY, LINEAR_TEAM_ID } = process.env;
|
|
643
|
+
try {
|
|
644
|
+
const query = `
|
|
645
|
+
query SearchIssues($filter: IssueFilter!) {
|
|
646
|
+
issues(filter: $filter, first: 5) {
|
|
647
|
+
nodes { id identifier url description state { type } }
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
`;
|
|
651
|
+
const response = await axios.post(
|
|
652
|
+
'https://api.linear.app/graphql',
|
|
653
|
+
{
|
|
654
|
+
query,
|
|
655
|
+
variables: {
|
|
656
|
+
filter: {
|
|
657
|
+
team: { id: { eq: LINEAR_TEAM_ID } },
|
|
658
|
+
state: { type: { neq: 'completed' } },
|
|
659
|
+
description: { contains: `skopix-fingerprint: ${fingerprint}` },
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
{ headers: { Authorization: LINEAR_API_KEY } }
|
|
664
|
+
);
|
|
665
|
+
const nodes = response.data.data?.issues?.nodes || [];
|
|
666
|
+
return nodes.length > 0 ? nodes[0] : null;
|
|
667
|
+
} catch {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async _commentOnLinearIssue(issueId, comment) {
|
|
673
|
+
const { LINEAR_API_KEY } = process.env;
|
|
674
|
+
const query = `
|
|
675
|
+
mutation CreateComment($input: CommentCreateInput!) {
|
|
676
|
+
commentCreate(input: $input) { success }
|
|
677
|
+
}
|
|
678
|
+
`;
|
|
679
|
+
await axios.post(
|
|
680
|
+
'https://api.linear.app/graphql',
|
|
681
|
+
{ query, variables: { input: { issueId, body: comment } } },
|
|
682
|
+
{ headers: { Authorization: LINEAR_API_KEY } }
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async _createOrUpdateLinearIssue(issue, title, body, fingerprint, context) {
|
|
687
|
+
const { LINEAR_API_KEY, LINEAR_TEAM_ID } = process.env;
|
|
688
|
+
if (!LINEAR_API_KEY || !LINEAR_TEAM_ID) {
|
|
689
|
+
throw new Error('Missing Linear env vars. Run `skopix init`.');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const match = await this._findExistingMatch(issue, fingerprint, 'linear', context);
|
|
693
|
+
if (match) {
|
|
694
|
+
try {
|
|
695
|
+
const liveStatus = await this._getLinearIssueStatus(match.trackerRef);
|
|
696
|
+
if (liveStatus === 'closed') {
|
|
697
|
+
await updateIssueInStore(match.fingerprint, 'linear', { status: 'resolved' });
|
|
698
|
+
} else {
|
|
699
|
+
const comment = this._formatRecurrenceComment(context);
|
|
700
|
+
await this._commentOnLinearIssue(match.trackerRef, comment);
|
|
701
|
+
await updateIssueInStore(match.fingerprint, 'linear', {
|
|
702
|
+
lastSeen: new Date().toISOString(),
|
|
703
|
+
occurrences: (match.occurrences || 1) + 1,
|
|
704
|
+
sessions: [...(match.sessions || []), context.sessionId].slice(-20),
|
|
705
|
+
});
|
|
706
|
+
return {
|
|
707
|
+
action: 'commented',
|
|
708
|
+
id: match.trackerRef,
|
|
709
|
+
url: match.trackerUrl,
|
|
710
|
+
matchedBy: match._matchReason || 'fingerprint',
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
} catch {}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const priorityMap = { critical: 1, high: 2, medium: 3, low: 4 };
|
|
717
|
+
const query = `
|
|
718
|
+
mutation CreateIssue($input: IssueCreateInput!) {
|
|
719
|
+
issueCreate(input: $input) {
|
|
720
|
+
success issue { id url }
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
`;
|
|
724
|
+
const response = await axios.post(
|
|
725
|
+
'https://api.linear.app/graphql',
|
|
726
|
+
{
|
|
727
|
+
query,
|
|
728
|
+
variables: {
|
|
729
|
+
input: {
|
|
730
|
+
title, description: body,
|
|
731
|
+
teamId: LINEAR_TEAM_ID,
|
|
732
|
+
priority: priorityMap[issue.severity] || 3,
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
{ headers: { Authorization: LINEAR_API_KEY } }
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
const created = response.data.data?.issueCreate?.issue;
|
|
740
|
+
if (created) {
|
|
741
|
+
await recordIssueInStore({
|
|
742
|
+
fingerprint, tracker: 'linear',
|
|
743
|
+
trackerRef: created.id,
|
|
744
|
+
trackerUrl: created.url,
|
|
745
|
+
title: issue.title, severity: issue.severity, type: issue.type,
|
|
746
|
+
status: 'open',
|
|
747
|
+
testName: context.testName || null, suiteName: context.suiteName || null,
|
|
748
|
+
url: issue.url,
|
|
749
|
+
createdAt: new Date().toISOString(),
|
|
750
|
+
lastSeen: new Date().toISOString(),
|
|
751
|
+
occurrences: 1, sessions: [context.sessionId],
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
return { action: 'created', id: created?.id, url: created?.url };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async _getLinearIssueStatus(issueId) {
|
|
758
|
+
const { LINEAR_API_KEY } = process.env;
|
|
759
|
+
const query = `query GetIssue($id: String!) { issue(id: $id) { state { type } } }`;
|
|
760
|
+
const response = await axios.post(
|
|
761
|
+
'https://api.linear.app/graphql',
|
|
762
|
+
{ query, variables: { id: issueId } },
|
|
763
|
+
{ headers: { Authorization: LINEAR_API_KEY } }
|
|
764
|
+
);
|
|
765
|
+
const stateType = response.data.data?.issue?.state?.type;
|
|
766
|
+
return stateType === 'completed' || stateType === 'canceled' ? 'closed' : 'open';
|
|
767
|
+
}
|
|
768
|
+
}
|