@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.
Files changed (65) hide show
  1. package/HOW_TO_USE.md +64 -17
  2. package/README.md +2 -1
  3. package/browserstack.example.yaml +14 -2
  4. package/docs/user-guide.html +78 -23
  5. package/forgeqe.example.yaml +18 -5
  6. package/package.json +2 -1
  7. package/prompts/README.md +1 -0
  8. package/prompts/brain.md +8 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +13 -2
  11. package/prompts/test-reviewer.md +8 -3
  12. package/prompts/test-rules.md +10 -4
  13. package/py/forgeqe_engine/browser.py +1 -1
  14. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  15. package/skills/playwright-typescript/SKILL.md +2 -1
  16. package/src/brain/index.ts +1 -1
  17. package/src/capabilities/registry.ts +34 -6
  18. package/src/cases/bugs.ts +209 -22
  19. package/src/cases/defectHistory.ts +193 -0
  20. package/src/cases/defects.ts +255 -0
  21. package/src/cases/github.ts +162 -0
  22. package/src/cases/goal.ts +1 -1
  23. package/src/cases/index.ts +23 -4
  24. package/src/cases/predict.ts +372 -0
  25. package/src/cases/rca.ts +258 -0
  26. package/src/cases/readiness.ts +38 -1
  27. package/src/cases/repro.ts +202 -0
  28. package/src/cases/types.ts +8 -1
  29. package/src/cli/help.ts +8 -2
  30. package/src/cli/index.ts +36 -7
  31. package/src/code/graph/graph.ts +518 -0
  32. package/src/code/graph/locators.ts +398 -0
  33. package/src/code/graph/parse.ts +439 -0
  34. package/src/code/graph/tools.ts +113 -0
  35. package/src/code/handoff.ts +93 -18
  36. package/src/code/loop.ts +30 -5
  37. package/src/code/profile.ts +14 -3
  38. package/src/code/replay.ts +1 -1
  39. package/src/code/review.ts +22 -0
  40. package/src/code/runtime.ts +8 -1
  41. package/src/config.ts +142 -18
  42. package/src/mcp/attachtools.ts +223 -0
  43. package/src/mcp/commands.ts +1 -1
  44. package/src/mcp/defecttools.ts +106 -0
  45. package/src/mcp/devicetools.ts +125 -0
  46. package/src/mcp/external.ts +42 -1
  47. package/src/mcp/server.ts +3 -1
  48. package/src/mcp/tools.ts +95 -10
  49. package/src/monitor/run.ts +1 -1
  50. package/src/report/data.ts +14 -0
  51. package/src/report/html.ts +15 -3
  52. package/src/report/template.html +44 -43
  53. package/src/run.ts +4 -2
  54. package/src/suite/report.ts +44 -11
  55. package/src/suite/run.ts +163 -16
  56. package/src/tui/App.tsx +153 -94
  57. package/src/tui/components.tsx +27 -23
  58. package/src/tui/inspector.tsx +140 -83
  59. package/src/tui/landing.ts +3 -4
  60. package/src/tui/refine-panel.tsx +102 -52
  61. package/src/tui/shortcuts.tsx +2 -0
  62. package/src/tui/transcript.tsx +32 -2
  63. package/src/types.ts +3 -1
  64. package/src/usage.ts +1 -1
  65. 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
- /** File a bug for each failing case, or comment on the open one filed before; note on the open bug when a case passes again. */
61
- export async function fileBugs(results: CaseResult[], cfg: SourcesConfig, runName: string): Promise<WriteBack[]> {
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
- } else if (open) {
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
- type BugRef = { id: string; label: string; url: string; note?: string }
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 Tracker = {
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
- create(r: CaseResult, fingerprint: string, runName: string): Promise<BugRef>
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 create(r, fp, runName) {
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: bugTitle(r),
131
- description: body(bugText(r, runName)),
132
- labels: [...new Set([...b.labels, fp])],
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 create(r, fp, runName) {
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: bugTitle(r) },
188
- { op: "add", path: "/fields/Microsoft.VSTS.TCM.ReproSteps", value: html(bugText(r, runName)) },
189
- { op: "add", path: "/fields/System.Tags", value: [...new Set([...b.labels, fp])].join("; ") },
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
+ }