@capagents/forgeqe 1.1.4 → 1.1.6
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/HOW_TO_USE.md +64 -17
- package/README.md +2 -1
- package/browserstack.example.yaml +14 -2
- package/docs/user-guide.html +78 -23
- package/forgeqe.example.yaml +18 -5
- package/package.json +2 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +8 -1
- package/prompts/defect-rca.md +43 -0
- package/prompts/test-agent.md +13 -2
- package/prompts/test-reviewer.md +8 -3
- package/prompts/test-rules.md +10 -4
- package/py/forgeqe_engine/browser.py +1 -1
- package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
- package/skills/playwright-typescript/SKILL.md +2 -1
- package/src/brain/index.ts +1 -1
- package/src/capabilities/registry.ts +34 -6
- package/src/cases/bugs.ts +209 -22
- package/src/cases/defectHistory.ts +193 -0
- package/src/cases/defects.ts +255 -0
- package/src/cases/github.ts +162 -0
- package/src/cases/goal.ts +1 -1
- package/src/cases/index.ts +23 -4
- package/src/cases/predict.ts +372 -0
- package/src/cases/rca.ts +258 -0
- package/src/cases/readiness.ts +38 -1
- package/src/cases/repro.ts +202 -0
- package/src/cases/types.ts +8 -1
- package/src/cli/help.ts +8 -2
- package/src/cli/index.ts +36 -7
- package/src/code/graph/graph.ts +518 -0
- package/src/code/graph/locators.ts +398 -0
- package/src/code/graph/parse.ts +439 -0
- package/src/code/graph/tools.ts +113 -0
- package/src/code/handoff.ts +93 -18
- package/src/code/loop.ts +30 -5
- package/src/code/profile.ts +14 -3
- package/src/code/replay.ts +1 -1
- package/src/code/review.ts +22 -0
- package/src/code/runtime.ts +8 -1
- package/src/config.ts +142 -18
- package/src/mcp/attachtools.ts +223 -0
- package/src/mcp/commands.ts +1 -1
- package/src/mcp/defecttools.ts +106 -0
- package/src/mcp/devicetools.ts +125 -0
- package/src/mcp/external.ts +42 -1
- package/src/mcp/server.ts +3 -1
- package/src/mcp/tools.ts +95 -10
- package/src/monitor/run.ts +1 -1
- package/src/report/data.ts +14 -0
- package/src/report/html.ts +15 -3
- package/src/report/template.html +44 -43
- package/src/run.ts +4 -2
- package/src/suite/report.ts +44 -11
- package/src/suite/run.ts +163 -16
- package/src/tui/App.tsx +153 -94
- package/src/tui/components.tsx +27 -23
- package/src/tui/inspector.tsx +140 -83
- package/src/tui/landing.ts +3 -4
- package/src/tui/refine-panel.tsx +102 -52
- package/src/tui/shortcuts.tsx +2 -0
- package/src/tui/transcript.tsx +32 -2
- package/src/types.ts +3 -1
- package/src/usage.ts +1 -1
- package/testmu.example.yaml +11 -0
package/src/cases/bugs.ts
CHANGED
|
@@ -6,6 +6,9 @@ import type { BugsConfig, SourcesConfig } from "../config.ts"
|
|
|
6
6
|
import { browserErrors, problemLine } from "../report/data.ts"
|
|
7
7
|
import { ciInfo } from "../report/html.ts"
|
|
8
8
|
import { adoSettings } from "./ado.ts"
|
|
9
|
+
import { DefectBook, type CaseDefect, type DefectCluster } from "./defects.ts"
|
|
10
|
+
import { rcaLines, reproPack, reproText } from "./repro.ts"
|
|
11
|
+
import { githubBugTracker } from "./github.ts"
|
|
9
12
|
import { http, textToAdf } from "./http.ts"
|
|
10
13
|
import { jiraApi, type JiraApi } from "./jira.ts"
|
|
11
14
|
import { stepVerdictLines, type CaseResult, type WriteBack } from "./types.ts"
|
|
@@ -57,35 +60,82 @@ export function bugText(r: CaseResult, runName: string): string {
|
|
|
57
60
|
const failing = (r: CaseResult, b: BugsConfig) =>
|
|
58
61
|
r.failure ? r.failure.type === "functional_defect" : r.status === "failed" || (b.onError && r.status === "error")
|
|
59
62
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
63
|
+
/** A defect from browser evidence holds across runs and cases; a "page" defect (the agent's words) only within a run. */
|
|
64
|
+
const lasting = (d?: CaseDefect): d is CaseDefect => !!d && d.kind !== "page"
|
|
65
|
+
|
|
66
|
+
/** The bug's title: the defect, when cases share it by evidence; else the case. */
|
|
67
|
+
export function defectBugTitle(r: CaseResult, d?: CaseDefect): string {
|
|
68
|
+
return lasting(d) ? `[ForgeQE] ${d.title}`.slice(0, 250) : bugTitle(r)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The bug's description: the defect's evidence first when it has some, then the case that found it, then how to make
|
|
73
|
+
* it happen again (src/cases/repro.ts) when the defect is known.
|
|
74
|
+
*/
|
|
75
|
+
export function defectBugText(r: CaseResult, runName: string, d?: CaseDefect, book?: DefectBook): string {
|
|
76
|
+
const repro = d ? ["", reproText(reproPack(r, d, { env: book?.env, seen: book?.past[d.id]?.seen ?? book?.earlier?.(r.case.id) }))] : []
|
|
77
|
+
if (!lasting(d)) return [bugText(r, runName), ...repro].join("\n")
|
|
78
|
+
return [`Defect: ${d.title}`, ...d.evidence.map((e) => `- ${e}`), "", "Every test case that fails this way is added to this bug as a comment.", "", "First found by:", bugText(r, runName), ...repro].join("\n")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The root-cause analysis of each defect added to its bug as a comment, once the suite has it. Only bugs filed or
|
|
83
|
+
* found in this run, and only analyses with a hypothesis.
|
|
84
|
+
*/
|
|
85
|
+
export async function commentAnalyses(cfg: SourcesConfig, book: DefectBook, clusters: DefectCluster[]): Promise<WriteBack[]> {
|
|
86
|
+
const b = cfg.bugs
|
|
87
|
+
const todo = clusters.filter((c) => c.rca?.hypothesis && book.bugs.has(c.id))
|
|
88
|
+
if (!b || !todo.length) return []
|
|
89
|
+
let tracker: Tracker
|
|
90
|
+
try {
|
|
91
|
+
tracker = b.system === "jira" ? jiraTracker(cfg, b) : b.system === "github" ? githubBugTracker(cfg, b) : adoTracker(cfg, b)
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return [{ source: "bugs", text: "Analyses not added to bugs", error: (err as Error).message }]
|
|
94
|
+
}
|
|
95
|
+
const out: WriteBack[] = []
|
|
96
|
+
for (const c of todo) {
|
|
97
|
+
const bug = book.bugs.get(c.id)!
|
|
98
|
+
try {
|
|
99
|
+
await tracker.comment(bug.id, ["Root-cause analysis by ForgeQE: a hypothesis to check, not a verdict.", "", ...rcaLines(c.rca!)].join("\n"))
|
|
100
|
+
out.push({ source: "bugs", caseId: bug.caseId, text: `${bug.label}: root-cause analysis added`, url: bug.url })
|
|
101
|
+
} catch (err) {
|
|
102
|
+
out.push({ source: "bugs", caseId: bug.caseId, text: `${bug.label}: analysis not added`, error: (err as Error).message })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* File a bug for each defect (cases failing the same way share one: src/cases/defects.ts), or for each failing case
|
|
110
|
+
* when bugs.group_by is case. A case joining a defect's bug is added as a comment and tagged with its own fingerprint,
|
|
111
|
+
* so the note when it passes again finds that bug. Notes on the open bug when a case passes again.
|
|
112
|
+
*/
|
|
113
|
+
export async function fileBugs(results: CaseResult[], cfg: SourcesConfig, runName: string, book = new DefectBook()): Promise<WriteBack[]> {
|
|
62
114
|
const b = cfg.bugs
|
|
63
115
|
if (!b) return []
|
|
64
116
|
const relevant = results.filter((r) => failing(r, b) || r.status === "passed")
|
|
65
117
|
if (!relevant.length) return []
|
|
66
118
|
let tracker: Tracker
|
|
67
119
|
try {
|
|
68
|
-
tracker = b.system === "jira" ? jiraTracker(cfg, b) : adoTracker(cfg, b)
|
|
120
|
+
tracker = b.system === "jira" ? jiraTracker(cfg, b) : b.system === "github" ? githubBugTracker(cfg, b) : adoTracker(cfg, b)
|
|
69
121
|
} catch (err) {
|
|
70
122
|
return [{ source: "bugs", text: "Bugs not filed", error: (err as Error).message }]
|
|
71
123
|
}
|
|
72
124
|
const out: WriteBack[] = []
|
|
73
125
|
for (const r of relevant) {
|
|
74
126
|
const fp = bugFingerprint(r)
|
|
127
|
+
// A bug filed before ForgeQE was renamed from WebPilot carries the same hash under the old prefix.
|
|
128
|
+
const byCase = async () => (await tracker.findOpen(fp)) ?? (await tracker.findOpen(fp.replace(/^forgeqe-/, "webpilot-")))
|
|
75
129
|
try {
|
|
76
|
-
// A bug filed before ForgeQE was renamed from WebPilot carries the same hash under the old prefix.
|
|
77
|
-
const open = (await tracker.findOpen(fp)) ?? (await tracker.findOpen(fp.replace(/^forgeqe-/, "webpilot-")))
|
|
78
130
|
if (r.status === "passed") {
|
|
131
|
+
const open = await byCase()
|
|
79
132
|
if (!open) continue
|
|
80
133
|
await tracker.comment(open.id, `ForgeQE: ${r.case.id} passed again in "${runName}".`)
|
|
81
134
|
out.push({ source: "bugs", caseId: r.case.id, text: `${open.label}: noted that ${r.case.id} passes again`, url: open.url })
|
|
82
|
-
|
|
83
|
-
await tracker.comment(open.id, `Still failing.\n\n${bugText(r, runName)}`)
|
|
84
|
-
out.push({ source: "bugs", caseId: r.case.id, text: `${open.label}: still failing, comment added`, url: open.url })
|
|
85
|
-
} else {
|
|
86
|
-
const bug = await tracker.create(r, fp, runName)
|
|
87
|
-
out.push({ source: "bugs", caseId: r.case.id, text: `${bug.label}: bug filed for ${r.case.id}${bug.note ? ` (${bug.note})` : ""}`, url: bug.url })
|
|
135
|
+
continue
|
|
88
136
|
}
|
|
137
|
+
const d = b.groupBy === "case" ? undefined : (r.defect ?? book.assign(r))
|
|
138
|
+
out.push(await book.serial(() => fileOne(tracker, b, r, fp, d, runName, book, byCase)))
|
|
89
139
|
} catch (err) {
|
|
90
140
|
out.push({ source: "bugs", caseId: r.case.id, text: `${r.case.id}: bug not filed`, error: (err as Error).message })
|
|
91
141
|
}
|
|
@@ -93,12 +143,91 @@ export async function fileBugs(results: CaseResult[], cfg: SourcesConfig, runNam
|
|
|
93
143
|
return out
|
|
94
144
|
}
|
|
95
145
|
|
|
96
|
-
|
|
146
|
+
async function fileOne(tracker: Tracker, b: BugsConfig, r: CaseResult, fp: string, d: CaseDefect | undefined, runName: string, book: DefectBook, byCase: () => Promise<BugRef | null>): Promise<WriteBack> {
|
|
147
|
+
const id = r.case.id
|
|
148
|
+
// Another case of this defect filed or found its bug earlier in the run: this case joins it.
|
|
149
|
+
const shared = d && book.bugs.get(d.id)
|
|
150
|
+
if (shared) {
|
|
151
|
+
await tracker.comment(shared.id, `Also fails: ${id}.\n\n${bugText(r, runName)}`)
|
|
152
|
+
const tagged = await tag(tracker, shared.id, fp)
|
|
153
|
+
return { source: "bugs", caseId: id, text: `${shared.label}: same defect as ${shared.caseId}, ${id} added${tagged}`, url: shared.url }
|
|
154
|
+
}
|
|
155
|
+
const byDefect = lasting(d) ? await tracker.findOpen(d.id) : null
|
|
156
|
+
const open = byDefect ?? (await byCase())
|
|
157
|
+
const gone = d && book.past[d.id]?.goneSince
|
|
158
|
+
if (open) {
|
|
159
|
+
const head = gone ? `Failing again after it went away on ${gone.slice(0, 10)}` : "Still failing"
|
|
160
|
+
await tracker.comment(open.id, `${head}${byDefect ? `: ${id}` : ""}.\n\n${bugText(r, runName)}`)
|
|
161
|
+
// Found by one fingerprint, it gets the other, so the next run finds it either way.
|
|
162
|
+
const tagged = await tag(tracker, open.id, byDefect ? fp : lasting(d) ? d.id : "")
|
|
163
|
+
if (d) book.bugs.set(d.id, { ...open, caseId: id })
|
|
164
|
+
return { source: "bugs", caseId: id, text: `${open.label}: still failing, comment added${tagged}`, url: open.url }
|
|
165
|
+
}
|
|
166
|
+
// No open bug: a closed one with either fingerprint means it was fixed and came back.
|
|
167
|
+
const closed = (lasting(d) ? await tracker.findClosed(d.id) : null) ?? (await tracker.findClosed(fp))
|
|
168
|
+
const labels = [...new Set([...b.labels, ...(lasting(d) ? [d.id] : []), fp])]
|
|
169
|
+
if (closed) {
|
|
170
|
+
const when = closed.closedAt ? ` on ${closed.closedAt.slice(0, 10)}` : ""
|
|
171
|
+
const body = [`Regression: this came back. ${closed.label} (${closed.url}) was closed${when}.`, "", defectBugText(r, runName, d, book)].join("\n")
|
|
172
|
+
const bug = await tracker.create(r, { title: `[ForgeQE] Regression: ${defectBugTitle(r, d).replace(/^\[ForgeQE\] /, "")}`.slice(0, 250), body, labels, regressionOf: closed }, runName)
|
|
173
|
+
if (d) {
|
|
174
|
+
book.bugs.set(d.id, { ...bug, caseId: id })
|
|
175
|
+
book.closed.set(d.id, { label: closed.label, url: closed.url, ...(closed.closedAt ? { closedAt: closed.closedAt } : {}) })
|
|
176
|
+
}
|
|
177
|
+
return { source: "bugs", caseId: id, text: `${bug.label}: regression of ${closed.label}, bug filed for ${id}${bug.note ? ` (${bug.note})` : ""}`, url: bug.url }
|
|
178
|
+
}
|
|
179
|
+
// Bugs people filed by hand about the same route or error: named on the new bug, never merged.
|
|
180
|
+
const related = lasting(d) && d.term ? await relatedBugs(tracker, d.term) : []
|
|
181
|
+
const body = defectBugText(r, runName, d, book) + (related.length ? `\n\nPossibly related open bugs (they mention ${d!.term}):\n${related.map((x) => `- ${x.label}: ${x.title} (${x.url})`).join("\n")}` : "")
|
|
182
|
+
const bug = await tracker.create(r, { title: defectBugTitle(r, d), body, labels }, runName)
|
|
183
|
+
if (d) {
|
|
184
|
+
book.bugs.set(d.id, { ...bug, caseId: id })
|
|
185
|
+
if (related.length) book.related.set(d.id, related.map((x) => ({ label: x.label, url: x.url, title: x.title })))
|
|
186
|
+
}
|
|
187
|
+
const also = related.length ? `, ${related.length} possibly related` : ""
|
|
188
|
+
return { source: "bugs", caseId: id, text: `${bug.label}: bug filed for ${id}${bug.note || also ? ` (${[bug.note, also.slice(2)].filter(Boolean).join(", ")})` : ""}`, url: bug.url }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The search for related bugs is a hint: a tracker that cannot run it files the bug without one. */
|
|
192
|
+
async function relatedBugs(tracker: Tracker, term: string): Promise<FoundBug[]> {
|
|
193
|
+
try {
|
|
194
|
+
return (await tracker.related(term, 3)).filter((x) => !/^\[(ForgeQE|WebPilot)\]/.test(x.title))
|
|
195
|
+
} catch {
|
|
196
|
+
return []
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Add a label or tag; a tracker that refuses only loses the note, not the comment. */
|
|
201
|
+
async function tag(tracker: Tracker, id: string, label: string): Promise<string> {
|
|
202
|
+
if (!label) return ""
|
|
203
|
+
try {
|
|
204
|
+
await tracker.tag(id, label)
|
|
205
|
+
return ""
|
|
206
|
+
} catch {
|
|
207
|
+
return ` (not tagged ${label})`
|
|
208
|
+
}
|
|
209
|
+
}
|
|
97
210
|
|
|
98
|
-
type
|
|
211
|
+
export type BugRef = { id: string; label: string; url: string; note?: string }
|
|
212
|
+
|
|
213
|
+
/** A closed bug, and when it was closed when the tracker says. */
|
|
214
|
+
export type ClosedBug = BugRef & { closedAt?: string }
|
|
215
|
+
|
|
216
|
+
/** An open bug found by its text, with its title. */
|
|
217
|
+
export type FoundBug = BugRef & { title: string }
|
|
218
|
+
|
|
219
|
+
/** The bug's title, description, and labels (or tags): its fingerprints among them. `regressionOf`: the closed bug it came back from, linked. */
|
|
220
|
+
export type NewBug = { title: string; body: string; labels: string[]; regressionOf?: ClosedBug }
|
|
221
|
+
|
|
222
|
+
export type Tracker = {
|
|
99
223
|
findOpen(fingerprint: string): Promise<BugRef | null>
|
|
100
|
-
|
|
224
|
+
/** The most recently closed bug with this label or tag. */
|
|
225
|
+
findClosed(fingerprint: string): Promise<ClosedBug | null>
|
|
226
|
+
/** Open bugs whose text mentions `term`, newest first. */
|
|
227
|
+
related(term: string, limit: number): Promise<FoundBug[]>
|
|
228
|
+
create(r: CaseResult, bug: NewBug, runName: string): Promise<BugRef>
|
|
101
229
|
comment(id: string, text: string): Promise<void>
|
|
230
|
+
tag(id: string, label: string): Promise<void>
|
|
102
231
|
}
|
|
103
232
|
|
|
104
233
|
function screenshot(r: CaseResult): { name: string; data: Buffer } | null {
|
|
@@ -120,16 +249,29 @@ function jiraTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
120
249
|
const hit = data?.issues?.[0]
|
|
121
250
|
return hit ? ref(hit.key) : null
|
|
122
251
|
},
|
|
123
|
-
async
|
|
252
|
+
async findClosed(fp) {
|
|
253
|
+
const jql = `project = "${b.project}" AND labels = "${fp}" AND statusCategory = Done ORDER BY updated DESC`
|
|
254
|
+
const q = new URLSearchParams({ jql, fields: "summary,resolutiondate", maxResults: "1" })
|
|
255
|
+
const { data } = await http<{ issues?: { key: string; fields?: { resolutiondate?: string | null } }[] }>(`${j.api}/${j.cloud ? "search/jql" : "search"}?${q}`, { auth: j.auth })
|
|
256
|
+
const hit = data?.issues?.[0]
|
|
257
|
+
return hit ? { ...ref(hit.key), ...(hit.fields?.resolutiondate ? { closedAt: hit.fields.resolutiondate } : {}) } : null
|
|
258
|
+
},
|
|
259
|
+
async related(term, limit) {
|
|
260
|
+
const jql = `project = "${b.project}" AND statusCategory != Done AND text ~ "\\"${term.replace(/["\\]/g, " ")}\\"" ORDER BY updated DESC`
|
|
261
|
+
const q = new URLSearchParams({ jql, fields: "summary", maxResults: String(limit) })
|
|
262
|
+
const { data } = await http<{ issues?: { key: string; fields?: { summary?: string } }[] }>(`${j.api}/${j.cloud ? "search/jql" : "search"}?${q}`, { auth: j.auth })
|
|
263
|
+
return (data?.issues || []).map((i) => ({ ...ref(i.key), title: i.fields?.summary || "" }))
|
|
264
|
+
},
|
|
265
|
+
async create(r, bug) {
|
|
124
266
|
const { data } = await http<{ key: string; id: string }>(`${j.api}/issue`, {
|
|
125
267
|
auth: j.auth,
|
|
126
268
|
body: {
|
|
127
269
|
fields: {
|
|
128
270
|
project: { key: b.project },
|
|
129
271
|
issuetype: { name: b.type },
|
|
130
|
-
summary:
|
|
131
|
-
description: body(
|
|
132
|
-
labels:
|
|
272
|
+
summary: bug.title,
|
|
273
|
+
description: body(bug.body),
|
|
274
|
+
labels: bug.labels,
|
|
133
275
|
},
|
|
134
276
|
},
|
|
135
277
|
})
|
|
@@ -145,6 +287,14 @@ function jiraTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
145
287
|
})
|
|
146
288
|
notes.push(res.ok ? "screenshot attached" : `screenshot not attached: ${res.status}`)
|
|
147
289
|
}
|
|
290
|
+
if (bug.regressionOf) {
|
|
291
|
+
try {
|
|
292
|
+
await http(`${j.api}/issueLink`, { auth: j.auth, body: { type: { name: "Relates" }, inwardIssue: { key: data.key }, outwardIssue: { key: bug.regressionOf.id } } })
|
|
293
|
+
notes.push(`linked to ${bug.regressionOf.label}`)
|
|
294
|
+
} catch {
|
|
295
|
+
notes.push(`not linked to ${bug.regressionOf.label}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
148
298
|
const linked = r.case.source === "jira" || r.case.source === "xray" ? String(r.case.ref?.issue || r.case.ref?.test || r.case.id) : ""
|
|
149
299
|
if (linked) {
|
|
150
300
|
try {
|
|
@@ -159,6 +309,9 @@ function jiraTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
159
309
|
async comment(id, text) {
|
|
160
310
|
await http(`${j.api}/issue/${encodeURIComponent(id)}/comment`, { auth: j.auth, body: { body: body(text) } })
|
|
161
311
|
},
|
|
312
|
+
async tag(id, label) {
|
|
313
|
+
await http(`${j.api}/issue/${encodeURIComponent(id)}`, { method: "PUT", auth: j.auth, body: { update: { labels: [{ add: label }] } } })
|
|
314
|
+
},
|
|
162
315
|
}
|
|
163
316
|
}
|
|
164
317
|
|
|
@@ -181,12 +334,35 @@ function adoTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
181
334
|
const hit = data?.workItems?.[0]
|
|
182
335
|
return hit ? ref(hit.id) : null
|
|
183
336
|
},
|
|
184
|
-
async
|
|
337
|
+
async findClosed(fp) {
|
|
338
|
+
const query =
|
|
339
|
+
`SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = '${b.type.replace(/'/g, "''")}' ` +
|
|
340
|
+
`AND [System.Tags] CONTAINS '${fp}' AND [System.State] IN ('Closed', 'Done', 'Resolved') ORDER BY [System.ChangedDate] DESC`
|
|
341
|
+
const { data } = await http<{ workItems?: { id: number }[] }>(`${base}/_apis/wit/wiql?${api}&$top=1`, { auth, body: { query } })
|
|
342
|
+
const hit = data?.workItems?.[0]
|
|
343
|
+
if (!hit) return null
|
|
344
|
+
// Items created closed (or moved there by a rule) have no ClosedDate: when their state last changed is when they closed.
|
|
345
|
+
const dates = ["Microsoft.VSTS.Common.ClosedDate", "Microsoft.VSTS.Common.ResolvedDate", "Microsoft.VSTS.Common.StateChangeDate"]
|
|
346
|
+
const { data: item } = await http<{ fields?: Record<string, string> }>(`${base}/_apis/wit/workitems/${hit.id}?fields=${dates.join(",")}&${api}`, { auth })
|
|
347
|
+
const at = dates.map((f) => item?.fields?.[f]).find(Boolean)
|
|
348
|
+
return { ...ref(hit.id), ...(at ? { closedAt: at } : {}) }
|
|
349
|
+
},
|
|
350
|
+
async related(term, limit) {
|
|
351
|
+
const query =
|
|
352
|
+
`SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = '${b.type.replace(/'/g, "''")}' ` +
|
|
353
|
+
`AND [System.State] NOT IN ('Closed', 'Done', 'Removed', 'Resolved') AND ([System.Title] CONTAINS '${term.replace(/'/g, "''")}' OR [Microsoft.VSTS.TCM.ReproSteps] CONTAINS WORDS '${term.replace(/'/g, "''")}') ORDER BY [System.ChangedDate] DESC`
|
|
354
|
+
const { data } = await http<{ workItems?: { id: number }[] }>(`${base}/_apis/wit/wiql?${api}&$top=${limit}`, { auth, body: { query } })
|
|
355
|
+
const ids = (data?.workItems || []).slice(0, limit).map((w) => w.id)
|
|
356
|
+
if (!ids.length) return []
|
|
357
|
+
const { data: items } = await http<{ value?: { id: number; fields?: Record<string, string> }[] }>(`${base}/_apis/wit/workitems?ids=${ids.join(",")}&fields=System.Title&${api}`, { auth })
|
|
358
|
+
return (items?.value || []).map((w) => ({ ...ref(w.id), title: w.fields?.["System.Title"] || "" }))
|
|
359
|
+
},
|
|
360
|
+
async create(r, bug) {
|
|
185
361
|
const notes: string[] = []
|
|
186
362
|
const ops: Record<string, unknown>[] = [
|
|
187
|
-
{ op: "add", path: "/fields/System.Title", value:
|
|
188
|
-
{ op: "add", path: "/fields/Microsoft.VSTS.TCM.ReproSteps", value: html(
|
|
189
|
-
{ op: "add", path: "/fields/System.Tags", value:
|
|
363
|
+
{ op: "add", path: "/fields/System.Title", value: bug.title },
|
|
364
|
+
{ op: "add", path: "/fields/Microsoft.VSTS.TCM.ReproSteps", value: html(bug.body) },
|
|
365
|
+
{ op: "add", path: "/fields/System.Tags", value: bug.labels.join("; ") },
|
|
190
366
|
]
|
|
191
367
|
if (b.areaPath) ops.push({ op: "add", path: "/fields/System.AreaPath", value: b.areaPath })
|
|
192
368
|
const testCase = r.case.source === "ado" ? Number(r.case.ref?.workItem) : 0
|
|
@@ -194,6 +370,10 @@ function adoTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
194
370
|
ops.push({ op: "add", path: "/relations/-", value: { rel: "Microsoft.VSTS.Common.TestedBy-Forward", url: `${ado.orgUrl}/_apis/wit/workItems/${testCase}` } })
|
|
195
371
|
notes.push(`linked to test case ${testCase}`)
|
|
196
372
|
}
|
|
373
|
+
if (bug.regressionOf) {
|
|
374
|
+
ops.push({ op: "add", path: "/relations/-", value: { rel: "System.LinkTypes.Related", url: `${ado.orgUrl}/_apis/wit/workItems/${bug.regressionOf.id}`, attributes: { comment: "Regression of" } } })
|
|
375
|
+
notes.push(`linked to ${bug.regressionOf.label}`)
|
|
376
|
+
}
|
|
197
377
|
const shot = screenshot(r)
|
|
198
378
|
if (shot) {
|
|
199
379
|
const res = await fetch(`${base}/_apis/wit/attachments?fileName=${encodeURIComponent(shot.name)}&${api}`, {
|
|
@@ -219,5 +399,12 @@ function adoTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
|
219
399
|
async comment(id, text) {
|
|
220
400
|
await http(`${base}/_apis/wit/workItems/${id}/comments?api-version=7.1-preview.4`, { auth, body: { text: html(text) } })
|
|
221
401
|
},
|
|
402
|
+
async tag(id, label) {
|
|
403
|
+
// Setting System.Tags replaces them all: read the ones it has first.
|
|
404
|
+
const { data } = await http<{ fields?: Record<string, string> }>(`${base}/_apis/wit/workitems/${id}?fields=System.Tags&${api}`, { auth })
|
|
405
|
+
const tags = (data?.fields?.["System.Tags"] || "").split(";").map((t) => t.trim()).filter(Boolean)
|
|
406
|
+
if (tags.includes(label)) return
|
|
407
|
+
await http(`${base}/_apis/wit/workitems/${id}?${api}`, { method: "PATCH", auth, contentType: "application/json-patch+json", body: [{ op: "add", path: "/fields/System.Tags", value: [...tags, label].join("; ") }] })
|
|
408
|
+
},
|
|
222
409
|
}
|
|
223
410
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { DefectCluster } from "./defects.ts"
|
|
5
|
+
import type { CaseResult, CaseStatus } from "./types.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Defect intelligence, part two: what earlier runs know about each defect. Kept in defect-history.json next to the
|
|
9
|
+
* suite folders (the folder the report reads earlier runs from; keep it between CI runs as a cache to track a
|
|
10
|
+
* pipeline's defects). Only defects found by browser evidence are kept: a defect matched by the agent's words gets
|
|
11
|
+
* new words, and so a new id, every run.
|
|
12
|
+
*
|
|
13
|
+
* - new: never seen before
|
|
14
|
+
* - known: seen in earlier runs, and still there
|
|
15
|
+
* - regression: it went away (a case it broke passed, and it was not seen) and came back; or its bug was closed
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const DEFECT_HISTORY = "defect-history.json"
|
|
19
|
+
const KEEP_SIGHTINGS = 30
|
|
20
|
+
const KEEP_CASES = 50
|
|
21
|
+
/** Earlier suites read to tell an intermittent failure from a steady one. */
|
|
22
|
+
const WINDOW = 20
|
|
23
|
+
|
|
24
|
+
export type Sighting = { at: string; suite: string; cases: string[] }
|
|
25
|
+
|
|
26
|
+
export type PastDefect = {
|
|
27
|
+
title: string
|
|
28
|
+
kind: string
|
|
29
|
+
/** The page it was seen on last, for the area it belongs to. */
|
|
30
|
+
page?: string
|
|
31
|
+
firstSeen: string
|
|
32
|
+
lastSeen: string
|
|
33
|
+
/** Runs it was seen in. */
|
|
34
|
+
seen: number
|
|
35
|
+
sightings: Sighting[]
|
|
36
|
+
/** The cases it has broken, newest last. */
|
|
37
|
+
cases: string[]
|
|
38
|
+
bug?: { label: string; url?: string }
|
|
39
|
+
/** It went away: not seen in a run where a case it broke passed. */
|
|
40
|
+
goneSince?: string
|
|
41
|
+
/** When it came back after going away. */
|
|
42
|
+
returns?: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type DefectHistoryFile = { version: 1; defects: Record<string, PastDefect> }
|
|
46
|
+
|
|
47
|
+
export type DefectHistoryNote = {
|
|
48
|
+
status: "new" | "known" | "regression"
|
|
49
|
+
/** Earlier runs it was seen in. */
|
|
50
|
+
seen: number
|
|
51
|
+
firstSeen?: string
|
|
52
|
+
/** For a regression: since when it was gone, or the closed bug it matches. */
|
|
53
|
+
goneSince?: string
|
|
54
|
+
previousBug?: { label: string; url?: string; closedAt?: string }
|
|
55
|
+
/** Its cases kept changing between passing and failing: failed in `failed` of the last `runs` runs. */
|
|
56
|
+
intermittent?: { failed: number; runs: number }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const defectHistoryFile = (suiteDir: string) => path.join(path.dirname(suiteDir), DEFECT_HISTORY)
|
|
60
|
+
|
|
61
|
+
export function readDefectHistory(file: string): DefectHistoryFile {
|
|
62
|
+
try {
|
|
63
|
+
const d = JSON.parse(readFileSync(file, "utf8")) as DefectHistoryFile
|
|
64
|
+
return { version: 1, defects: d?.defects && typeof d.defects === "object" ? d.defects : {} }
|
|
65
|
+
} catch {
|
|
66
|
+
return { version: 1, defects: {} }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function writeDefectHistory(file: string, h: DefectHistoryFile): void {
|
|
71
|
+
writeFileSync(file, `${JSON.stringify(h, null, 2)}\n`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type SavedSuite = { at: string; results: { case: { id: string }; status: CaseStatus }[] }
|
|
75
|
+
|
|
76
|
+
/** The suites saved next to this one (this one left out), oldest first. */
|
|
77
|
+
function savedSuites(suiteDir: string): SavedSuite[] {
|
|
78
|
+
const parent = path.dirname(suiteDir)
|
|
79
|
+
const self = path.basename(suiteDir)
|
|
80
|
+
const suites: SavedSuite[] = []
|
|
81
|
+
let names: string[] = []
|
|
82
|
+
try {
|
|
83
|
+
names = readdirSync(parent).filter((n) => n !== self && existsSync(path.join(parent, n, "suite.json")))
|
|
84
|
+
} catch {}
|
|
85
|
+
for (const n of names) {
|
|
86
|
+
try {
|
|
87
|
+
const s = JSON.parse(readFileSync(path.join(parent, n, "suite.json"), "utf8"))
|
|
88
|
+
if (s?.startedAt && Array.isArray(s.results)) suites.push({ at: s.startedAt, results: s.results })
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
return suites.sort((a, b) => a.at.localeCompare(b.at))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Each case's results in the suites saved next to this one, oldest first (this suite left out). */
|
|
95
|
+
export function caseOutcomes(suiteDir: string, window = WINDOW): Map<string, CaseStatus[]> {
|
|
96
|
+
const out = new Map<string, CaseStatus[]>()
|
|
97
|
+
for (const s of savedSuites(suiteDir).slice(-window)) {
|
|
98
|
+
for (const r of s.results) {
|
|
99
|
+
if (r.status === "stopped") continue
|
|
100
|
+
const list = out.get(r.case.id) || []
|
|
101
|
+
list.push(r.status)
|
|
102
|
+
out.set(r.case.id, list)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** When the newest saved suite in which one of these cases passed started; undefined when none did. */
|
|
109
|
+
export function lastPassed(suiteDir: string, caseIds: string[]): string | undefined {
|
|
110
|
+
const ids = new Set(caseIds)
|
|
111
|
+
return [...savedSuites(suiteDir)].reverse().find((s) => s.results.some((r) => ids.has(r.case.id) && r.status === "passed"))?.at
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** How many of the latest runs in a row failed: 0 when the latest passed or there is none. */
|
|
115
|
+
export function failedInARow(statuses: CaseStatus[] = []): number {
|
|
116
|
+
let n = 0
|
|
117
|
+
for (let i = statuses.length - 1; i >= 0 && statuses[i] !== "passed"; i--) n++
|
|
118
|
+
return n
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Failed in some runs and passed in others, changing more than once: pass, fail, pass. */
|
|
122
|
+
function intermittent(statuses: CaseStatus[]): { failed: number; runs: number } | undefined {
|
|
123
|
+
const bad = statuses.map((s) => s !== "passed")
|
|
124
|
+
const flips = bad.slice(1).filter((b, i) => b !== bad[i]).length
|
|
125
|
+
return flips >= 2 ? { failed: bad.filter(Boolean).length, runs: bad.length } : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What is known about each of this run's defects: from the history, the closed bug a regression matched in the tracker,
|
|
130
|
+
* and the earlier results of its cases (with this run's result counted).
|
|
131
|
+
*/
|
|
132
|
+
export function noteHistory(clusters: DefectCluster[], h: DefectHistoryFile, outcomes: Map<string, CaseStatus[]>, closed: Map<string, { label: string; url?: string; closedAt?: string }> = new Map()): void {
|
|
133
|
+
for (const c of clusters) {
|
|
134
|
+
const past = c.kind === "page" ? undefined : h.defects[c.id]
|
|
135
|
+
const previousBug = closed.get(c.id)
|
|
136
|
+
// A defect matched by the agent's words has no history of its own: its cases failing in the runs just before
|
|
137
|
+
// make it known, so it is not run again and analysed again every suite.
|
|
138
|
+
const streak = c.kind === "page" ? Math.min(...c.cases.map((x) => failedInARow(outcomes.get(x.id)))) : 0
|
|
139
|
+
const status = past?.goneSince || previousBug ? "regression" : past || streak > 0 ? "known" : "new"
|
|
140
|
+
// A regression's pass in between was the fix, not chance.
|
|
141
|
+
const flaky = status === "regression" ? undefined : c.cases.map((x) => intermittent([...(outcomes.get(x.id) || []), x.status])).find(Boolean)
|
|
142
|
+
c.history = {
|
|
143
|
+
status,
|
|
144
|
+
seen: past?.seen || streak,
|
|
145
|
+
...(past ? { firstSeen: past.firstSeen } : {}),
|
|
146
|
+
...(past?.goneSince ? { goneSince: past.goneSince } : {}),
|
|
147
|
+
...(previousBug || (past?.goneSince && past.bug) ? { previousBug: previousBug || past!.bug } : {}),
|
|
148
|
+
...(flaky ? { intermittent: flaky } : {}),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Add this run: its defects are seen now; an earlier defect not seen while a case it broke passed has gone away. */
|
|
154
|
+
export function recordRun(h: DefectHistoryFile, clusters: DefectCluster[], results: CaseResult[], suite: string, at: string): void {
|
|
155
|
+
const seen = new Set<string>()
|
|
156
|
+
for (const c of clusters) {
|
|
157
|
+
if (c.kind === "page") continue
|
|
158
|
+
seen.add(c.id)
|
|
159
|
+
const cases = c.cases.map((x) => x.id)
|
|
160
|
+
const p: PastDefect = h.defects[c.id] || { title: c.title, kind: c.kind, firstSeen: at, lastSeen: at, seen: 0, sightings: [], cases: [] }
|
|
161
|
+
if (p.goneSince) {
|
|
162
|
+
p.returns = [...(p.returns || []), at].slice(-KEEP_SIGHTINGS)
|
|
163
|
+
delete p.goneSince
|
|
164
|
+
}
|
|
165
|
+
p.title = c.title
|
|
166
|
+
if (c.page || c.pages[0]) p.page = c.page || c.pages[0]
|
|
167
|
+
p.lastSeen = at
|
|
168
|
+
p.seen += 1
|
|
169
|
+
p.sightings = [...p.sightings, { at, suite, cases }].slice(-KEEP_SIGHTINGS)
|
|
170
|
+
p.cases = [...p.cases.filter((id) => !cases.includes(id)), ...cases].slice(-KEEP_CASES)
|
|
171
|
+
if (c.bug) p.bug = c.bug
|
|
172
|
+
h.defects[c.id] = p
|
|
173
|
+
}
|
|
174
|
+
const passed = new Set(results.filter((r) => r.status === "passed").map((r) => r.case.id))
|
|
175
|
+
for (const [id, p] of Object.entries(h.defects)) {
|
|
176
|
+
if (!seen.has(id) && !p.goneSince && p.cases.some((c) => passed.has(c))) p.goneSince = at
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** "new", "seen in 3 earlier runs since 2026-10-02", "regression of SHOP-12 (closed 2026-10-01)", "intermittent: …". */
|
|
181
|
+
export function historyText(n?: DefectHistoryNote): string {
|
|
182
|
+
if (!n) return ""
|
|
183
|
+
const day = (at?: string) => (at ? at.slice(0, 10) : "")
|
|
184
|
+
const base =
|
|
185
|
+
n.status === "regression"
|
|
186
|
+
? n.previousBug
|
|
187
|
+
? `regression of ${n.previousBug.label}${n.previousBug.closedAt ? ` (closed ${day(n.previousBug.closedAt)})` : n.goneSince ? ` (gone since ${day(n.goneSince)})` : ""}`
|
|
188
|
+
: `regression (gone since ${day(n.goneSince)})`
|
|
189
|
+
: n.status === "known"
|
|
190
|
+
? `seen in ${n.seen} earlier run${n.seen === 1 ? "" : "s"}${n.firstSeen ? ` since ${day(n.firstSeen)}` : ""}`
|
|
191
|
+
: "new"
|
|
192
|
+
return n.intermittent ? `${base}; intermittent: failed ${n.intermittent.failed} of the last ${n.intermittent.runs} runs` : base
|
|
193
|
+
}
|