@capagents/forgeqe 1.1.4 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/HOW_TO_USE.md +46 -4
  2. package/README.md +2 -1
  3. package/browserstack.example.yaml +14 -2
  4. package/docs/user-guide.html +62 -10
  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 +7 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +12 -2
  11. package/prompts/test-reviewer.md +5 -2
  12. package/prompts/test-rules.md +8 -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 +511 -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/config.ts +142 -18
  41. package/src/mcp/attachtools.ts +223 -0
  42. package/src/mcp/commands.ts +1 -1
  43. package/src/mcp/defecttools.ts +106 -0
  44. package/src/mcp/devicetools.ts +125 -0
  45. package/src/mcp/external.ts +42 -1
  46. package/src/mcp/tools.ts +92 -9
  47. package/src/monitor/run.ts +1 -1
  48. package/src/report/data.ts +14 -0
  49. package/src/report/html.ts +15 -3
  50. package/src/report/template.html +44 -43
  51. package/src/run.ts +4 -2
  52. package/src/suite/report.ts +44 -11
  53. package/src/suite/run.ts +163 -16
  54. package/src/tui/App.tsx +9 -3
  55. package/src/usage.ts +1 -1
  56. package/testmu.example.yaml +11 -0
@@ -24,22 +24,25 @@ A test is approved only when all these conditions are true:
24
24
 
25
25
  ## Problem types
26
26
  - correctness: the test does not do what the goal says, from start to end, or not reliably. Examples are a missing step or check, a weaker assertion, live values in assertions, and ForgeQE files that the test reads.
27
- - standards: the test passes and does what the goal says, but not in the way of the repository. Examples are test data in a page object, a method that is not generic, a hard wait, and scratch files.
27
+ - standards: the test passes and does what the goal says, but not in the way of the repository. Examples are test data in a page object, a method that is not generic, a hard wait, an XPath or positional locator where a semantic one finds the element, a deprecated API of the installed framework version, and scratch files.
28
28
 
29
29
  ## Inputs
30
30
  - The message from ForgeQE: the goal, the checks that the browser agent made, and the files that this job changed, with their contents.
31
+ - When the code graph is on: outlines of the classes that the changed code uses, the browser settings of the repository, and the code_* tools (code_outline Class.member gives one member's source).
31
32
  - ForgeQE gets those files from a comparison of the working tree with its state before the job.
32
33
  - The handoff, and other files of the repository, when a point depends on them.
33
34
 
34
35
  ## Rules
35
36
  Scope:
36
37
  - You MUST review from the changed files. You SHOULD read the handoff or other files only when a point depends on them.
38
+ - You MUST NOT read a file to check a signature, a locator, or a browser setting that the outlines or the settings in the message give. To check a member's body, use code_outline Class.member.
37
39
  - You MUST NOT report files that the user changed or added before the job.
38
40
  - You MUST NOT report the files of ForgeQE: .forgeqe/, forgeqe.yaml, and llms.json.
39
41
  - In a file that the user changed before the job, you MUST review only the edits of this job.
40
42
 
41
43
  Locators and values:
42
44
  - You MUST NOT ask to replace locators and texts that the handoff took from its replay script and checks. They matched the live site.
45
+ - When the message lists what the code graph of ForgeQE found, you MUST check each point in the changed files. A new member that locates an element an existing member already locates is a standards problem: the correction is to reuse the existing member. A brittle locator is a standards problem when the handoff gives a ✓ semantic locator for the same element.
43
46
  - You MUST accept a value that the test case names, such as a user name. Credentials MUST still come from the environment.
44
47
 
45
48
  Known defects (the handoff has a "Known defect" section):
@@ -52,7 +55,7 @@ Problems:
52
55
  - You MUST give all the problems in your first review. The coding agent then corrects them in one round.
53
56
  - For each problem, you MUST give the file, what is wrong, the correction, and its type: correctness or standards.
54
57
  - You MUST NOT ask for checks, data handling, or behaviour that the test case and the handoff do not ask for.
55
- - You MUST NOT report style points that the linters and conventions of the repository accept.
58
+ - You MUST NOT report style points that the linters and conventions of the repository accept. Locators, waits, assertions, and deprecated APIs are not style points: judge them by the test rules, also when older tests of the repository do otherwise.
56
59
  - You MUST set approved to true when there is no correctness problem, also when there are standards problems. ForgeQE then asks for those corrections in one round.
57
60
  - When there is nothing to correct, you MUST approve.
58
61
 
@@ -33,7 +33,11 @@ When the page of the test differs from what ForgeQE saw:
33
33
  - With blocked, you MUST give what you compared and what you saw, with the path of a screenshot or a trace.
34
34
 
35
35
  ## Code standards
36
- - You MUST follow the conventions of the repository: .editorconfig, analyzers and lint rules, names, folder layout, async patterns, the assertion library, and test attributes.
36
+ - You MUST follow the conventions of the repository for its structure: .editorconfig, analyzers and lint rules, names, folder layout, base classes, fixtures, the assertion library, and test attributes.
37
+ - You MUST write the code itself with the current best practice of the installed framework version, also when older tests of the repository do not. The test stack in your prompt gives the versions.
38
+ - Locators, in this order of preference: role with accessible name, label, placeholder, text, test id, then CSS. You MUST NOT use XPath, or positions (nth, :nth-child, index) to pick an element, when one of the others finds it. In a framework without role locators, use its nearest semantic one (Testing Library queries, aria/ selectors) or a stable attribute.
39
+ - You MUST assert with the retrying assertions of the framework, and wait for outcomes (an assertion, a URL, a response), never for time. You MUST NOT use sleeps, fixed timeouts, or deprecated APIs of the installed version.
40
+ - You MUST NOT rewrite existing tests to modern style. When the code you reuse is in an older style, say so in notes.
37
41
  - The build MUST NOT get more warnings.
38
42
  - Page objects, components, and helpers MUST be generic. Each method takes the items that it acts on as parameters, and gives back what the test checks.
39
43
  - You MUST NOT put test data, expected values, or URLs with query strings in page objects. You MUST make locators from the parameters.
@@ -49,11 +53,11 @@ When the page of the test differs from what ForgeQE saw:
49
53
  ## Overlays and page versions
50
54
  - The notes of the browser agent tell when a cookie banner, sign-in prompt, promotion, or other overlay came into view, and what the agent did.
51
55
  - An overlay can come into view at a different time, or not at all. You MUST handle it where it comes into view.
52
- - When the handoff lists overlays that the run closed, you MUST write one helper that closes each visible overlay, in the sequence of the handoff, top one first.
56
+ - When the handoff lists one overlay that the run closed, and the framework has an overlay handler (Playwright 1.42 and later: page.addLocatorHandler, Page.AddLocatorHandlerAsync), you MUST register the handler once, in the fixture or the base class setup, with the close control of the handoff.
57
+ - When the handoff lists two or more overlays, or the framework has no overlay handler, you MUST write one helper that closes each visible overlay, in the sequence of the handoff, top one first.
53
58
  - For each overlay, the helper MUST check its close control with a plain visibility check, click it, and wait until the overlay is hidden.
54
59
  - You MUST call the helper after each navigation, and immediately before each step that opens a thing, such as a date picker or a menu.
55
- - You MUST NOT use the locator handlers of the framework (page.addLocatorHandler, Page.AddLocatorHandlerAsync) or a combined locator (locator.or) for overlays.
56
- - Two overlays at the same time block each other, and strict mode then fails.
60
+ - You MUST NOT use overlay handlers or a combined locator (locator.or) for two or more overlays. They can show at the same time: the click of one handler is blocked by the other overlay, handlers do not run inside a handler, and strict mode fails on a combined locator.
57
61
  - You MUST do the flow again, not the tries of the agent.
58
62
  - A site can give different versions of a page to different visits (A/B tests). When you see a different version, you MUST write steps that work on each version.
59
63
  - You SHOULD find elements by what the versions have in common (role and accessible name). You MUST say in notes which versions you saw.
@@ -156,7 +156,7 @@ def _browserstack_caps(remote: Dict[str, Any]) -> Dict[str, Any]:
156
156
  if remote.get("name"):
157
157
  caps["name"] = str(remote["name"])
158
158
  if remote.get("local") is True:
159
- caps["browserstack.local"] = True
159
+ caps["browserstack.local"] = "true"
160
160
  extra = remote.get("extra")
161
161
  if isinstance(extra, dict):
162
162
  caps.update(extra)
@@ -26,7 +26,8 @@ agents: [scribe]
26
26
  - Locators: `Page.GetByRole(AriaRole.Button, new() { Name = "Save" })`, `GetByLabel`, `GetByPlaceholder`, `GetByText`, and `GetByTestId`, then CSS. Avoid XPath and position-based selectors.
27
27
  - Assert page state with retrying assertions: `await Expect(locator).ToBeVisibleAsync()`, `ToHaveTextAsync`, `ToHaveValueAsync`, and `await Expect(Page).ToHaveURLAsync(...)`. Use NUnit's `Assert.That` for plain values only, never on `await locator.IsVisibleAsync()`.
28
28
  - Wait for outcomes with `Page.WaitForURLAsync`, `Page.RunAndWaitForResponseAsync`, or an assertion.
29
- - Overlays that appear at random (cookie banners, surveys): register `Page.AddLocatorHandlerAsync(...)` in `[SetUp]` of the base class rather than clicking them inside a test.
29
+ - One overlay that appears at random (a cookie banner, a survey): register `Page.AddLocatorHandlerAsync(...)` in `[SetUp]` of the base class (Playwright 1.42 and later) rather than clicking it inside a test.
30
+ - Two or more that can show at once: write one helper that closes each visible one, top first, as the test rules describe. A handler's click is blocked while another overlay covers it.
30
31
 
31
32
  ## Data, secrets, attributes
32
33
  - Data: `[TestCase(...)]` or `[TestCaseSource(nameof(Cases))]`, or the repository's data files and builders. Compute dates when the test runs.
@@ -27,7 +27,8 @@ agents: [scribe]
27
27
  - Wait for outcomes, never time: no `page.waitForTimeout`. Use an assertion, `page.waitForURL`, or a `page.waitForResponse(...)` promise created before the click that triggers the request.
28
28
 
29
29
  ## Overlays that come and go
30
- - Cookie banners, chat prompts, and surveys that appear at random: register `page.addLocatorHandler(page.getByRole('button', { name: 'Accept all' }), async (button) => { await button.click() })` in a fixture or `beforeEach`, instead of clicking them inside the test.
30
+ - One cookie banner, chat prompt, or survey that appears at random: register `page.addLocatorHandler(page.getByRole('button', { name: 'Accept all' }), async (button) => { await button.click() })` in a fixture or `beforeEach` (Playwright 1.42 and later), instead of clicking it inside the test.
31
+ - Two or more that can show at once: write one helper that closes each visible one, top first, as the test rules describe. A handler's click is blocked while another overlay covers it.
31
32
 
32
33
  ## Data and secrets
33
34
  - Read secrets with `process.env.NAME`, and fail with a clear message when one is missing. If the config loads `dotenv`, add new names to `.env.example` without values.
@@ -120,7 +120,7 @@ export async function openBrain(opts: BrainOptions): Promise<Brain> {
120
120
  const cwd = realpathOr(opts.cwd)
121
121
  const context = (): ToolContext => ({ ...opts.context(), config, cwd })
122
122
  const tools = localToolServer({ tools: TOOLS, context, instructions: () => environmentNotes(context()), timeoutMs: TOOL_TIMEOUT_MS })
123
- const external = opts.external || externalMcp(config)
123
+ const external = opts.external || externalMcp(config, Bun.which, process.env, { cwd })
124
124
  const notes = [environmentNotes(context()), ...externalNotes(external), ...(opts.notes || [])].filter(Boolean).join("\n")
125
125
  const readDirs = [...new Set([config.export.outDir, config.memory.dir, cwd].flatMap((d) => [path.resolve(d), realpathOr(d)]))]
126
126
  const onEvent = (e: CodeEvent) => {
@@ -62,8 +62,12 @@ export const CAPABILITIES: Capability[] = [
62
62
  title: "Run any goal in a real browser",
63
63
  summary:
64
64
  "Carries out a plain-language goal in a real browser one step at a time and verifies the outcome, with screenshots, a report, and the steps it took. The browser comes from forgeqe.yaml: local Chrome or Chromium by default, or BrowserStack or TestMu AI/LambdaTest when configured. Page memory replays steps that already worked on a site, so repeat runs are faster and cheaper.",
65
- tools: ["browser_run", "run_details", "page_memory"],
66
- examples: ["Go to booking.com and search hotels in Mumbai for next weekend", "What does ForgeQE know about the checkout page?"],
65
+ tools: ["browser_run", "run_details", "page_memory", "browser_devices"],
66
+ examples: [
67
+ "Go to booking.com and search hotels in Mumbai for next weekend",
68
+ "What does ForgeQE know about the checkout page?",
69
+ "Add Chrome on Windows 11 and Chrome 128 on Windows 10 as TestMu devices, then run SHOP-12 on both",
70
+ ],
67
71
  requires: [{ kind: "live_model" }],
68
72
  limits: ["Web applications only: local Chrome or Chromium, or configured remote web grids such as BrowserStack and TestMu AI/LambdaTest; not native mobile or desktop applications."],
69
73
  docs: [
@@ -258,6 +262,24 @@ export const CAPABILITIES: Capability[] = [
258
262
  requires: [{ kind: "input", what: "a browser run (from browser_run or run_test_cases); new runs need a live model" }],
259
263
  docs: [{ doc: "HOW_TO_USE.md", heading: "Visual comparison" }],
260
264
  },
265
+ {
266
+ id: "defect-intelligence",
267
+ area: "Quality",
268
+ title: "Defect intelligence: trends and prediction",
269
+ summary:
270
+ "Groups failures into defects, keeps their history across suites (new, known, gone, back again), and gives defect trends and where a change is likely to cause defects, with the cases to run first.",
271
+ tools: ["defect_trends", "predict_defects"],
272
+ examples: ["Which defects keep coming back?", "What can the changes since main break?"],
273
+ requires: [
274
+ { kind: "input", what: "suites run into one folder (suite.out_dir)" },
275
+ { kind: "command", command: "git", optional: true, why: "for predictions from the product's repository" },
276
+ ],
277
+ limits: ["Prediction is a heuristic from the saved suites and the change, not a trained model; its accuracy shows once suites have scored it."],
278
+ docs: [
279
+ { doc: "HOW_TO_USE.md", heading: "Bugs for failures" },
280
+ { doc: "HOW_TO_USE.md", heading: "Defect trends and prediction" },
281
+ ],
282
+ },
261
283
  {
262
284
  id: "release-readiness",
263
285
  area: "Release",
@@ -318,10 +340,16 @@ export const CAPABILITIES: Capability[] = [
318
340
  {
319
341
  id: "systems",
320
342
  area: "Systems",
321
- title: "Work with Azure DevOps, Jira, and Confluence",
322
- summary: "Reads anything in the configured systems (work items, issues, queries, boards, pages), makes changes the user asks for, and builds Azure DevOps dashboards and wiki pages.",
323
- tools: ["query_api", "read_confluence", "create_ado_dashboard", "write_ado_wiki_page"],
324
- examples: ["Show the open bugs in this sprint", "Read the checkout requirements from Confluence", "Make a dashboard of this sprint's test results"],
343
+ title: "Work with Azure DevOps, Jira, GitHub, and Confluence",
344
+ summary: "Reads anything in the configured systems (work items, issues, queries, boards, pages), makes changes the user asks for, puts run evidence (screenshots, reports) on work items and issues as attachments or comments, and builds Azure DevOps dashboards and wiki pages.",
345
+ tools: ["query_api", "read_confluence", "create_ado_dashboard", "write_ado_wiki_page", "attach_to_work_item"],
346
+ examples: [
347
+ "Show the open bugs in this sprint",
348
+ "Read the checkout requirements from Confluence",
349
+ "Make a dashboard of this sprint's test results",
350
+ "Add the last screenshot of that run to ADO 256 as a comment",
351
+ "Run the GitHub issues labelled login as test cases",
352
+ ],
325
353
  requires: [{ kind: "system", anyOf: ["azure_devops", "jira", "xray", "confluence"] }],
326
354
  docs: [{ doc: "README.md", heading: "Azure DevOps, Jira, and other systems" }],
327
355
  },
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
  }