@capagents/forgeqe 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,325 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { XMLParser } from "fast-xml-parser"
3
+
4
+ import { readIndex } from "../auth/store.ts"
5
+ import type { SourcesConfig } from "../config.ts"
6
+ import { basic, envSecret, htmlToText, http } from "./http.ts"
7
+ import { FAILURE_ACTION, FAILURE_LABEL } from "./failure.ts"
8
+ import { stepVerdictLines, type CaseResult, type CaseStep, type TestCase, type WriteBack } from "./types.ts"
9
+
10
+ const API = "api-version=7.1"
11
+ const FIELDS = [
12
+ "System.Title",
13
+ "System.Description",
14
+ "System.Tags",
15
+ "System.WorkItemType",
16
+ "System.AreaPath",
17
+ "System.TeamProject",
18
+ "Microsoft.VSTS.TCM.Steps",
19
+ "Microsoft.VSTS.TCM.LocalDataSource",
20
+ ]
21
+
22
+ type Ado = NonNullable<SourcesConfig["ado"]>
23
+ type WorkItem = { id: number; fields: Record<string, unknown> }
24
+ type Point = { id: number; testCaseReference: { id: number; name?: string }; configuration?: { name?: string } }
25
+
26
+ const scoped = (org: string, project: string) => `${org}/${encodeURIComponent(project)}`
27
+
28
+ export function adoSettings(cfg: SourcesConfig): { ado: Ado; auth: string; org: string; project?: string; base: string } {
29
+ if (!cfg.ado) {
30
+ throw new Error("Azure DevOps is not set up. Add sources.azure_devops.org_url to forgeqe.yaml, or set ADO_ORG_URL.")
31
+ }
32
+ // Without a token, the Azure CLI's sign-in, when the user chose it with forgeqe auth login azure_devops.
33
+ const auth = process.env[cfg.ado.tokenEnv] ? basic("", envSecret(cfg.ado.tokenEnv, "Azure DevOps")) : readIndex().systems.azure_devops?.via === "azcli" ? azCliAuth() : null
34
+ if (!auth) throw new Error(`Azure DevOps needs ${cfg.ado.tokenEnv} in the environment, or a sign-in: run forgeqe auth login azure_devops (or /auth azure_devops in the shell).`)
35
+ const org = cfg.ado.orgUrl.replace(/\/+$/, "")
36
+ const project = cfg.ado.project
37
+ return { ado: cfg.ado, auth, org, project, base: project ? scoped(org, project) : org }
38
+ }
39
+
40
+ /** Azure DevOps' resource id in Microsoft Entra ID: tokens for it are accepted by every Azure DevOps Services API. */
41
+ export const ADO_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798"
42
+
43
+ let azToken: { value: string; until: number } | null = null
44
+
45
+ /**
46
+ * A bearer token for Azure DevOps from the Azure CLI's sign-in (az login), cached until five minutes before it
47
+ * expires; null when az is missing or not signed in.
48
+ */
49
+ export function azCliAuth(run = spawnSync): string | null {
50
+ if (azToken && Date.now() < azToken.until) return `Bearer ${azToken.value}`
51
+ if (run === spawnSync && !Bun.which("az")) return null
52
+ const r = run("az", ["account", "get-access-token", "--resource", ADO_RESOURCE, "--output", "json", "--only-show-errors"], { encoding: "utf8", timeout: 30_000, shell: process.platform === "win32" })
53
+ if (r.status !== 0) return null
54
+ try {
55
+ const t = JSON.parse(String(r.stdout)) as { accessToken?: string; expires_on?: number; expiresOn?: string }
56
+ if (!t.accessToken) return null
57
+ const expires = t.expires_on ? t.expires_on * 1000 : t.expiresOn ? Date.parse(t.expiresOn) : Date.now() + 30 * 60_000
58
+ azToken = { value: t.accessToken, until: expires - 5 * 60_000 }
59
+ return `Bearer ${t.accessToken}`
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ /** Forget the cached Azure CLI token (after az login or logout). */
66
+ export function resetAzCliAuth() {
67
+ azToken = null
68
+ }
69
+
70
+ /** Every well-formed project in the organization, or the one project when sources.azure_devops.project is set. */
71
+ export async function adoProjectNames(cfg: SourcesConfig): Promise<string[]> {
72
+ const { auth, org, project } = adoSettings(cfg)
73
+ if (project) return [project]
74
+ const rows = await paged<{ name?: string }>(`${org}/_apis/projects?${API}&stateFilter=wellFormed`, auth)
75
+ const names = rows.map((p) => p.name).filter((n): n is string => Boolean(n))
76
+ if (!names.length) throw new Error("No projects in this Azure DevOps organization.")
77
+ return names
78
+ }
79
+
80
+ /**
81
+ * `ado:123,456` test case work items by id; `ado:plan=12` every suite in a plan; `ado:plan=12/suite=34` one suite.
82
+ * `ado:project=Shop/plan=12` names the project. With no project in the reference or in forgeqe.yaml, a plan is
83
+ * looked up across every project, and work item ids are read from the organization (ids are unique there).
84
+ * Cases from a plan carry their test point, so results can be recorded as a test run.
85
+ */
86
+ export async function adoCases(ref: string, cfg: SourcesConfig): Promise<TestCase[]> {
87
+ const { auth, org, project: configured } = adoSettings(cfg)
88
+ const named = ref.match(/(?:^|[/,\s])project=([^/\s,]+)/)?.[1]
89
+ const plan = ref.match(/plan=(\d+)/)?.[1]
90
+ const suite = ref.match(/suite=(\d+)/)?.[1]
91
+ if (plan) {
92
+ const project = named || configured || (await projectForPlan(org, plan, auth))
93
+ const base = scoped(org, project)
94
+ const suites = suite ? [Number(suite)] : (await paged<{ id: number }>(`${base}/_apis/testplan/Plans/${plan}/suites?${API}`, auth)).map((s) => s.id)
95
+ const points: Point[] = []
96
+ for (const s of suites) points.push(...(await paged<Point>(`${base}/_apis/testplan/Plans/${plan}/Suites/${s}/TestPoint?${API}`, auth)))
97
+ if (!points.length) throw new Error(`No test cases in Azure DevOps plan ${plan}${suite ? ` suite ${suite}` : ""}.`)
98
+ const items = await workItems([...new Set(points.map((p) => p.testCaseReference.id))], base, auth)
99
+ const perCase = new Map<number, number>()
100
+ for (const p of points) perCase.set(p.testCaseReference.id, (perCase.get(p.testCaseReference.id) || 0) + 1)
101
+ const cases: TestCase[] = []
102
+ for (const p of points) {
103
+ const item = items.get(p.testCaseReference.id)
104
+ if (!item) continue
105
+ const suffix = (perCase.get(p.testCaseReference.id) || 0) > 1 && p.configuration?.name ? ` [${p.configuration.name}]` : ""
106
+ for (const tc of await toCases(item, org, base, auth, project)) {
107
+ cases.push({ ...tc, title: tc.title + suffix, id: suffix ? `${tc.id}-${p.id}` : tc.id, ref: { ...tc.ref, project, plan: Number(plan), point: p.id } })
108
+ }
109
+ }
110
+ return cases
111
+ }
112
+ const ids = ref.split(/[,\s]+/).map((s) => Number(s.replace(/^ADO-/i, ""))).filter((n) => Number.isInteger(n) && n > 0)
113
+ if (!ids.length) throw new Error(`Azure DevOps reference "${ref}": use ado:123,456, ado:plan=12, ado:plan=12/suite=34, or ado:project=Shop/plan=12.`)
114
+ const project = named || configured
115
+ const base = project ? scoped(org, project) : org
116
+ const items = await workItems(ids, base, auth)
117
+ const missing = ids.filter((id) => !items.has(id))
118
+ if (missing.length) throw new Error(`Azure DevOps work items not found: ${missing.join(", ")}.`)
119
+ const cases: TestCase[] = []
120
+ for (const id of ids) cases.push(...(await toCases(items.get(id)!, org, base, auth, project)))
121
+ return cases
122
+ }
123
+
124
+ /** The project that owns a test plan, when forgeqe.yaml does not name one. */
125
+ async function projectForPlan(org: string, plan: string, auth: string): Promise<string> {
126
+ const rows = await paged<{ name?: string }>(`${org}/_apis/projects?${API}&stateFilter=wellFormed`, auth)
127
+ const names = rows.map((p) => p.name).filter((n): n is string => Boolean(n))
128
+ if (!names.length) throw new Error("No projects in this Azure DevOps organization.")
129
+ for (const name of names) {
130
+ try {
131
+ await http(`${scoped(org, name)}/_apis/testplan/Plans/${plan}?${API}`, { auth })
132
+ return name
133
+ } catch (err) {
134
+ if (!/failed: 404\b/.test((err as Error).message)) throw err
135
+ }
136
+ }
137
+ throw new Error(`Azure DevOps plan ${plan} was not found in any project.`)
138
+ }
139
+
140
+ async function paged<T>(url: string, auth: string): Promise<T[]> {
141
+ const out: T[] = []
142
+ let token: string | null = null
143
+ do {
144
+ const u: string = token ? `${url}&continuationToken=${encodeURIComponent(token)}` : url
145
+ const { data, headers } = await http<{ value?: T[] }>(u, { auth })
146
+ out.push(...(data?.value || []))
147
+ token = headers.get("x-ms-continuationtoken")
148
+ } while (token)
149
+ return out
150
+ }
151
+
152
+ async function workItems(ids: number[], base: string, auth: string): Promise<Map<number, WorkItem>> {
153
+ const map = new Map<number, WorkItem>()
154
+ for (let i = 0; i < ids.length; i += 200) {
155
+ const { data } = await http<{ value: WorkItem[] }>(`${base}/_apis/wit/workitemsbatch?${API}`, {
156
+ auth,
157
+ body: { ids: ids.slice(i, i + 200), fields: FIELDS, errorPolicy: "omit" },
158
+ })
159
+ for (const w of data?.value || []) if (w?.id) map.set(w.id, w)
160
+ }
161
+ return map
162
+ }
163
+
164
+ async function toCases(item: WorkItem, org: string, base: string, auth: string, project?: string): Promise<TestCase[]> {
165
+ const f = item.fields
166
+ const steps = await parseSteps(String(f["Microsoft.VSTS.TCM.Steps"] || ""), base, auth)
167
+ const rows = dataRows(String(f["Microsoft.VSTS.TCM.LocalDataSource"] || ""))
168
+ const tags = String(f["System.Tags"] || "").split(";").map((t) => t.trim()).filter(Boolean)
169
+ const team = project || (f["System.TeamProject"] ? String(f["System.TeamProject"]) : "")
170
+ const tc: TestCase = {
171
+ id: `ADO-${item.id}`,
172
+ title: String(f["System.Title"] || `Work item ${item.id}`),
173
+ source: "ado",
174
+ url: `${team ? scoped(org, team) : base}/_workitems/edit/${item.id}`,
175
+ description: htmlToText(String(f["System.Description"] || "")) || undefined,
176
+ steps,
177
+ tags: tags.length ? tags : undefined,
178
+ group: f["System.AreaPath"] ? String(f["System.AreaPath"]) : undefined,
179
+ ref: { workItem: item.id, ...(team ? { project: team } : {}) },
180
+ }
181
+ if (!rows.length) return [tc]
182
+ return rows.map((row, i) => ({
183
+ ...tc,
184
+ id: `${tc.id}.${i + 1}`,
185
+ title: `${tc.title} (data row ${i + 1})`,
186
+ steps: steps.map((s) => ({ action: fill(s.action, row), data: s.data && fill(s.data, row), expected: s.expected && fill(s.expected, row) })),
187
+ }))
188
+ }
189
+
190
+ const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", preserveOrder: true, parseTagValue: false, trimValues: false })
191
+
192
+ type Node = Record<string, unknown> & { ":@"?: Record<string, string> }
193
+
194
+ /** Microsoft.VSTS.TCM.Steps: `<step>` action/expected pairs in order, with shared steps (`<compref ref>`) inlined. */
195
+ async function parseSteps(source: string, base: string, auth: string, depth = 0): Promise<CaseStep[]> {
196
+ if (!source.trim()) return []
197
+ const steps: CaseStep[] = []
198
+ const walk = async (nodes: Node[]) => {
199
+ for (const node of nodes) {
200
+ const tag = Object.keys(node).find((k) => k !== ":@")
201
+ if (!tag) continue
202
+ const children = (node[tag] as Node[]) || []
203
+ if (tag === "steps") await walk(children)
204
+ else if (tag === "step") {
205
+ const strings = children.filter((c) => "parameterizedString" in c).map((c) => htmlToText(textOf(c.parameterizedString as Node[])))
206
+ if (strings[0]) steps.push({ action: strings[0], expected: strings[1] || undefined })
207
+ } else if (tag === "compref") {
208
+ const ref = Number(node[":@"]?.ref)
209
+ if (ref && depth < 3) {
210
+ const shared = (await workItems([ref], base, auth)).get(ref)
211
+ if (shared) steps.push(...(await parseSteps(String(shared.fields["Microsoft.VSTS.TCM.Steps"] || ""), base, auth, depth + 1)))
212
+ }
213
+ await walk(children)
214
+ }
215
+ }
216
+ }
217
+ await walk(xml.parse(source) as Node[])
218
+ return steps
219
+ }
220
+
221
+ function textOf(nodes: Node[] | undefined): string {
222
+ return (nodes || []).map((n) => (typeof n["#text"] === "string" ? (n["#text"] as string) : "")).join("")
223
+ }
224
+
225
+ /** Rows of a test case's local parameter table (`<NewDataSet><Table1><user>alice</user>…`). */
226
+ function dataRows(source: string): Record<string, string>[] {
227
+ if (!source.trim().startsWith("<")) return []
228
+ const parsed = new XMLParser({ ignoreAttributes: true, parseTagValue: false }).parse(source) as Record<string, any>
229
+ const set = parsed?.NewDataSet
230
+ if (!set) return []
231
+ const tables = Object.entries(set).filter(([k]) => !k.includes(":")).flatMap(([, v]) => (Array.isArray(v) ? v : [v]))
232
+ return tables
233
+ .filter((row) => row && typeof row === "object")
234
+ .map((row) => Object.fromEntries(Object.entries(row as Record<string, unknown>).map(([k, v]) => [k, String(v ?? "")])))
235
+ }
236
+
237
+ function fill(text: string, row: Record<string, string>): string {
238
+ return Object.entries(row).reduce((acc, [k, v]) => acc.replace(new RegExp(`@${k}\\b`, "g"), v), text)
239
+ }
240
+
241
+ const OUTCOME = { passed: "Passed", failed: "Failed", stopped: "Aborted", error: "Blocked" } as const
242
+ const RANK = { passed: 0, stopped: 1, error: 2, failed: 3 } as const
243
+
244
+ /**
245
+ * Cases from a plan: one test run per plan with every point's outcome. Cases by id: a comment on the work item.
246
+ * Data rows of one test case share its point; the point gets the worst row's outcome.
247
+ */
248
+ export async function adoWriteBack(results: CaseResult[], cfg: SourcesConfig, runName: string): Promise<WriteBack[]> {
249
+ const { auth, org, project, base: configuredBase } = adoSettings(cfg)
250
+ const resultBase = (r: CaseResult) => {
251
+ if (project) return configuredBase
252
+ const name = typeof r.case.ref?.project === "string" ? r.case.ref.project : ""
253
+ if (!name) throw new Error(`Azure DevOps result ${r.case.id} has no project, so it cannot be written back.`)
254
+ return scoped(org, name)
255
+ }
256
+ const out: WriteBack[] = []
257
+ const byPlan = new Map<string, CaseResult[]>()
258
+ for (const r of results) {
259
+ const plan = Number(r.case.ref?.plan)
260
+ if (plan && r.case.ref?.point) {
261
+ const key = `${project || r.case.ref.project || ""}:${plan}`
262
+ byPlan.set(key, [...(byPlan.get(key) || []), r])
263
+ } else if (r.case.ref?.workItem) {
264
+ try {
265
+ const base = resultBase(r)
266
+ await http(`${base}/_apis/wit/workItems/${r.case.ref.workItem}/comments?api-version=7.1-preview.4`, {
267
+ auth,
268
+ body: { text: commentHtml(r) },
269
+ })
270
+ out.push({ source: "ado", caseId: r.case.id, text: `${r.case.id}: result added as a comment`, url: r.case.url })
271
+ } catch (err) {
272
+ out.push({ source: "ado", caseId: r.case.id, text: `${r.case.id}: comment not added`, error: (err as Error).message })
273
+ }
274
+ }
275
+ }
276
+ for (const list of byPlan.values()) {
277
+ const plan = Number(list[0].case.ref?.plan)
278
+ try {
279
+ const base = resultBase(list[0])
280
+ const byPoint = new Map<number, CaseResult[]>()
281
+ for (const r of list) byPoint.set(Number(r.case.ref!.point), [...(byPoint.get(Number(r.case.ref!.point)) || []), r])
282
+ const { data: run } = await http<{ id: number; webAccessUrl?: string }>(`${base}/_apis/test/runs?${API}`, {
283
+ auth,
284
+ body: { name: runName, plan: { id: String(plan) }, pointIds: [...byPoint.keys()], automated: false },
285
+ })
286
+ const { data: created } = await http<{ value: { id: number; testPoint?: { id: string | number } }[] }>(
287
+ `${base}/_apis/test/Runs/${run.id}/results?${API}`,
288
+ { auth },
289
+ )
290
+ const updates = (created?.value || []).flatMap((res) => {
291
+ const rs = byPoint.get(Number(res.testPoint?.id))
292
+ if (!rs) return []
293
+ const worst = rs.reduce((a, b) => (RANK[b.status] > RANK[a.status] ? b : a))
294
+ return [{
295
+ id: res.id,
296
+ // A failure that is not the application's (the test case, the environment, the automation) blocks the point
297
+ // rather than failing it, so the plan's failures are product defects.
298
+ outcome: worst.failure && worst.failure.type !== "functional_defect" ? "Blocked" : OUTCOME[worst.status],
299
+ state: "Completed",
300
+ durationInMs: rs.reduce((n, r) => n + r.ms, 0),
301
+ completedDate: rs.map((r) => r.finishedAt).sort().at(-1),
302
+ comment: rs.map((r) => commentText(r)).join("\n\n").slice(0, 1000),
303
+ }]
304
+ })
305
+ if (updates.length) await http(`${base}/_apis/test/Runs/${run.id}/results?${API}`, { auth, method: "PATCH", body: updates })
306
+ await http(`${base}/_apis/test/runs/${run.id}?${API}`, { auth, method: "PATCH", body: { state: "Completed" } })
307
+ out.push({ source: "ado", text: `Azure DevOps test run ${run.id} (plan ${plan}, ${updates.length} results)`, url: run.webAccessUrl })
308
+ } catch (err) {
309
+ out.push({ source: "ado", text: `Azure DevOps plan ${plan}: results not recorded`, error: (err as Error).message })
310
+ }
311
+ }
312
+ return out
313
+ }
314
+
315
+ function commentText(r: CaseResult): string {
316
+ const kind = r.failure ? ` (${FAILURE_LABEL[r.failure.type]}: ${FAILURE_ACTION[r.failure.type]})` : ""
317
+ const lines = [`ForgeQE: ${r.case.id} ${r.status} in ${Math.round(r.ms / 1000)}s${kind}.`, r.summary.trim(), ...stepVerdictLines(r)]
318
+ if (r.code?.testFile) lines.push(`Test: ${r.code.testFile} (${r.code.status})${r.code.command ? `, run with: ${r.code.command}` : ""}`)
319
+ return lines.filter(Boolean).join("\n")
320
+ }
321
+
322
+ function commentHtml(r: CaseResult): string {
323
+ const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
324
+ return commentText(r).split("\n").map((l) => `<p>${esc(l)}</p>`).join("")
325
+ }
@@ -0,0 +1,117 @@
1
+ import type { SourcesConfig } from "../config.ts"
2
+ import { adoSettings } from "./ado.ts"
3
+ import { confluenceApi } from "./confluence.ts"
4
+ import { http } from "./http.ts"
5
+ import { jiraApi } from "./jira.ts"
6
+ import { xrayCloudToken, xrayGraphql } from "./xray.ts"
7
+
8
+ export type ApiQuery = {
9
+ system: "azure_devops" | "jira" | "xray" | "confluence"
10
+ path?: string
11
+ query?: string
12
+ variables?: Record<string, unknown>
13
+ /** azure_devops: a WIQL query (read-only by design); the project it runs in when it uses @project. */
14
+ wiql?: string
15
+ project?: string
16
+ }
17
+
18
+ const WIQL_TOP = 50
19
+ const WIQL_FIELDS = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.AssignedTo", "System.ChangedDate", "System.IterationPath"]
20
+
21
+ /**
22
+ * A WIQL search: Azure DevOps answers it with ids only (a POST, but WIQL has no statement that changes anything), so
23
+ * the first WIQL_TOP are read back with their main fields in one batch read.
24
+ */
25
+ async function adoWiql(cfg: SourcesConfig, wiql: string, project?: string): Promise<unknown> {
26
+ const { ado, auth } = adoSettings(cfg)
27
+ const org = ado.orgUrl.replace(/\/+$/, "")
28
+ const scope = project || ado.project
29
+ const base = scope ? `${org}/${encodeURIComponent(scope)}` : org
30
+ const { data } = await http<{ workItems?: { id: number }[]; workItemRelations?: { source?: { id: number } | null; target?: { id: number } }[] }>(
31
+ `${base}/_apis/wit/wiql?api-version=7.1&$top=${WIQL_TOP}`,
32
+ { auth, body: { query: wiql } },
33
+ )
34
+ const ids = [...new Set([...(data.workItems || []).map((w) => w.id), ...(data.workItemRelations || []).flatMap((r) => [r.source?.id, r.target?.id])])].filter((id): id is number => typeof id === "number").slice(0, WIQL_TOP)
35
+ if (!ids.length) return { count: 0, items: [] }
36
+ const batch = await http<{ value: { id: number; fields: Record<string, unknown> }[] }>(`${org}/_apis/wit/workitemsbatch?api-version=7.1`, { auth, body: { ids, fields: WIQL_FIELDS } })
37
+ const name = (v: unknown) => (v && typeof v === "object" ? String((v as { displayName?: string }).displayName || "") : String(v || ""))
38
+ return {
39
+ count: ids.length,
40
+ items: batch.data.value.map((w) => ({
41
+ id: w.id,
42
+ type: w.fields["System.WorkItemType"],
43
+ title: w.fields["System.Title"],
44
+ state: w.fields["System.State"],
45
+ assignedTo: name(w.fields["System.AssignedTo"]) || undefined,
46
+ changed: w.fields["System.ChangedDate"],
47
+ iteration: w.fields["System.IterationPath"],
48
+ })),
49
+ note: `Read each one in full with path <project>/_apis/wit/workitems/<id>?$expand=all.${ids.length === WIQL_TOP ? ` Only the first ${WIQL_TOP} are listed; narrow the query for the rest.` : ""}`,
50
+ }
51
+ }
52
+
53
+ const MAX_CHARS = 40_000
54
+
55
+ /** An absolute URL must stay on one of `bases`; a path is joined to the first. Keeps credentials on their own host. */
56
+ function resolve(path: string, bases: string[], system: string): string {
57
+ const p = path.trim()
58
+ if (/^https?:\/\//i.test(p)) {
59
+ if (bases.some((b) => p === b || p.startsWith(`${b}/`) || p.startsWith(`${b}?`))) return p
60
+ throw new Error(`${system} requests must stay on ${bases[0]}.`)
61
+ }
62
+ return `${bases[0]}/${p.replace(/^\/+/, "")}`
63
+ }
64
+
65
+ function adoBases(orgUrl: string): string[] {
66
+ const u = new URL(orgUrl)
67
+ if (u.hostname !== "dev.azure.com") return [orgUrl]
68
+ const org = u.pathname.split("/").filter(Boolean)[0]
69
+ return [orgUrl, ...["vsrm", "vssps", "almsearch", "feeds", "vsaex"].map((sub) => `https://${sub}.dev.azure.com/${org}`)]
70
+ }
71
+
72
+ /** True when a GraphQL document contains a mutation, ignoring string literals and comments. */
73
+ export function isMutation(query: string): boolean {
74
+ const code = query.replace(/"""[\s\S]*?"""|"(?:[^"\\]|\\.)*"/g, '""').replace(/#.*$/gm, "")
75
+ return /(^|[\s{}])mutation\b/.test(code)
76
+ }
77
+
78
+ function clipJson(data: unknown): unknown {
79
+ const text = typeof data === "string" ? data : JSON.stringify(data)
80
+ if (text.length <= MAX_CHARS) return data
81
+ return `${text.slice(0, MAX_CHARS)}… (cut at ${MAX_CHARS} characters; ask for fewer fields or a smaller page)`
82
+ }
83
+
84
+ /**
85
+ * Read-only requests with the credentials from forgeqe.yaml and the environment: GET and WIQL searches for Azure
86
+ * DevOps, GET for Jira (Xray Server/Data Center is under Jira's /rest/raven), GraphQL queries for Xray Cloud.
87
+ */
88
+ export async function queryApi(cfg: SourcesConfig, q: ApiQuery): Promise<unknown> {
89
+ switch (q.system) {
90
+ case "azure_devops": {
91
+ if (q.wiql?.trim()) return clipJson(await adoWiql(cfg, q.wiql, q.project))
92
+ if (!q.path) throw new Error("path or wiql is required for azure_devops")
93
+ const { ado, auth } = adoSettings(cfg)
94
+ let url = resolve(q.path, adoBases(ado.orgUrl), "Azure DevOps")
95
+ if (!/[?&]api-version=/.test(url)) url += `${url.includes("?") ? "&" : "?"}api-version=7.1`
96
+ return clipJson((await http(url, { auth })).data)
97
+ }
98
+ case "jira": {
99
+ if (!q.path) throw new Error("path is required for jira")
100
+ const j = jiraApi(cfg)
101
+ return clipJson((await http(resolve(q.path, [j.url], "Jira"), { auth: j.auth })).data)
102
+ }
103
+ case "confluence": {
104
+ if (!q.path) throw new Error("path is required for confluence")
105
+ const c = confluenceApi(cfg)
106
+ return clipJson((await http(resolve(q.path, [c.url], "Confluence"), { auth: c.auth })).data)
107
+ }
108
+ case "xray": {
109
+ if (cfg.jira && !cfg.jira.cloud) throw new Error("Xray Server/Data Center is part of Jira: use system jira with a /rest/raven/1.0/... path.")
110
+ if (!q.query) throw new Error("query (GraphQL) is required for xray")
111
+ if (isMutation(q.query)) throw new Error("query_api only reads; GraphQL mutations are not allowed.")
112
+ return clipJson(await xrayGraphql(cfg, await xrayCloudToken(cfg), q.query, q.variables || {}))
113
+ }
114
+ default:
115
+ throw new Error(`Unknown system "${String(q.system)}". Use azure_devops, jira, xray, or confluence.`)
116
+ }
117
+ }
@@ -0,0 +1,223 @@
1
+ import { createHash } from "node:crypto"
2
+ import { existsSync, readFileSync, statSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import type { BugsConfig, SourcesConfig } from "../config.ts"
6
+ import { browserErrors, problemLine } from "../report/data.ts"
7
+ import { ciInfo } from "../report/html.ts"
8
+ import { adoSettings } from "./ado.ts"
9
+ import { http, textToAdf } from "./http.ts"
10
+ import { jiraApi, type JiraApi } from "./jira.ts"
11
+ import { stepVerdictLines, type CaseResult, type WriteBack } from "./types.ts"
12
+
13
+ const MAX_ATTACHMENT = 8 * 1024 * 1024
14
+
15
+ /**
16
+ * The label (Jira) or tag (Azure DevOps) that ties a bug to one test case across runs. Cases from a test system keep
17
+ * their id; text and file cases are identified by their title, since their ids are only positions.
18
+ */
19
+ export function bugFingerprint(r: CaseResult): string {
20
+ const c = r.case
21
+ const identity = c.source === "text" || c.source === "file" ? `${c.source}:${c.title.trim().toLowerCase()}` : `${c.source}:${c.id}`
22
+ return `forgeqe-${createHash("sha1").update(identity).digest("hex").slice(0, 10)}`
23
+ }
24
+
25
+ export function bugTitle(r: CaseResult): string {
26
+ return `[ForgeQE] ${r.case.id}: ${r.case.title}`.slice(0, 250)
27
+ }
28
+
29
+ /** The bug's description: what failed, each step with the agent's verdict, where it ended, and where the evidence is. */
30
+ export function bugText(r: CaseResult, runName: string): string {
31
+ const c = r.case
32
+ const ci = ciInfo().ci
33
+ const lines = [
34
+ `ForgeQE found this failing in "${runName}".`,
35
+ "",
36
+ `Test case: ${c.id} ${c.title}${c.url ? ` (${c.url})` : ""}`,
37
+ `Result: ${r.status}. ${r.summary.trim()}`,
38
+ ]
39
+ const steps = stepVerdictLines(r)
40
+ if (steps.length) lines.push("", "Steps:", ...steps)
41
+ else if (c.steps.length) lines.push("", "Steps:", ...c.steps.map((s, i) => `${i + 1}. ${s.action}${s.expected ? ` (expected: ${s.expected})` : ""}`))
42
+ else if (c.description?.trim()) lines.push("", c.description.trim())
43
+ if (c.preconditions?.length) lines.push("", "Preconditions:", ...c.preconditions.map((p) => `- ${p}`))
44
+ const errors = browserErrors(r.runDir)
45
+ if (errors.length) lines.push("", "Browser errors:", ...errors.slice(0, 10).map((p) => `- ${problemLine(p)}`), ...(errors.length > 10 ? [`- and ${errors.length - 10} more`] : []))
46
+ lines.push("")
47
+ if (r.finalUrl) lines.push(`Ended on: ${r.finalUrl}`)
48
+ if (ci?.url) lines.push(`Build: ${ci.url}`)
49
+ if (r.runDir) lines.push(`Run folder: ${r.runDir}`)
50
+ return lines.join("\n").trim()
51
+ }
52
+
53
+ /**
54
+ * A bug is for a functional defect: a case whose failure was classified as something else (the test case needs
55
+ * updating, the environment was down, the automation could not finish) is not a product bug.
56
+ */
57
+ const failing = (r: CaseResult, b: BugsConfig) =>
58
+ r.failure ? r.failure.type === "functional_defect" : r.status === "failed" || (b.onError && r.status === "error")
59
+
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[]> {
62
+ const b = cfg.bugs
63
+ if (!b) return []
64
+ const relevant = results.filter((r) => failing(r, b) || r.status === "passed")
65
+ if (!relevant.length) return []
66
+ let tracker: Tracker
67
+ try {
68
+ tracker = b.system === "jira" ? jiraTracker(cfg, b) : adoTracker(cfg, b)
69
+ } catch (err) {
70
+ return [{ source: "bugs", text: "Bugs not filed", error: (err as Error).message }]
71
+ }
72
+ const out: WriteBack[] = []
73
+ for (const r of relevant) {
74
+ const fp = bugFingerprint(r)
75
+ 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
+ if (r.status === "passed") {
79
+ if (!open) continue
80
+ await tracker.comment(open.id, `ForgeQE: ${r.case.id} passed again in "${runName}".`)
81
+ 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 })
88
+ }
89
+ } catch (err) {
90
+ out.push({ source: "bugs", caseId: r.case.id, text: `${r.case.id}: bug not filed`, error: (err as Error).message })
91
+ }
92
+ }
93
+ return out
94
+ }
95
+
96
+ type BugRef = { id: string; label: string; url: string; note?: string }
97
+
98
+ type Tracker = {
99
+ findOpen(fingerprint: string): Promise<BugRef | null>
100
+ create(r: CaseResult, fingerprint: string, runName: string): Promise<BugRef>
101
+ comment(id: string, text: string): Promise<void>
102
+ }
103
+
104
+ function screenshot(r: CaseResult): { name: string; data: Buffer } | null {
105
+ if (!r.runDir) return null
106
+ const file = path.join(r.runDir, "failure.png")
107
+ if (!existsSync(file) || statSync(file).size > MAX_ATTACHMENT) return null
108
+ return { name: `${r.case.id.replace(/[^A-Za-z0-9._-]+/g, "-")}-failure.png`, data: readFileSync(file) }
109
+ }
110
+
111
+ function jiraTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
112
+ const j: JiraApi = jiraApi(cfg, "Filing bugs in Jira")
113
+ const body = (text: string) => (j.cloud ? textToAdf(text) : text)
114
+ const ref = (key: string): BugRef => ({ id: key, label: key, url: `${j.url}/browse/${key}` })
115
+ return {
116
+ async findOpen(fp) {
117
+ const jql = `project = "${b.project}" AND labels = "${fp}" AND statusCategory != Done ORDER BY created DESC`
118
+ const q = new URLSearchParams({ jql, fields: "summary", maxResults: "1" })
119
+ const { data } = await http<{ issues?: { key: string; id: string }[] }>(`${j.api}/${j.cloud ? "search/jql" : "search"}?${q}`, { auth: j.auth })
120
+ const hit = data?.issues?.[0]
121
+ return hit ? ref(hit.key) : null
122
+ },
123
+ async create(r, fp, runName) {
124
+ const { data } = await http<{ key: string; id: string }>(`${j.api}/issue`, {
125
+ auth: j.auth,
126
+ body: {
127
+ fields: {
128
+ project: { key: b.project },
129
+ issuetype: { name: b.type },
130
+ summary: bugTitle(r),
131
+ description: body(bugText(r, runName)),
132
+ labels: [...new Set([...b.labels, fp])],
133
+ },
134
+ },
135
+ })
136
+ const notes: string[] = []
137
+ const shot = screenshot(r)
138
+ if (shot) {
139
+ const form = new FormData()
140
+ form.append("file", new Blob([new Uint8Array(shot.data)], { type: "image/png" }), shot.name)
141
+ const res = await fetch(`${j.api}/issue/${encodeURIComponent(data.key)}/attachments`, {
142
+ method: "POST",
143
+ headers: { Authorization: j.auth, "X-Atlassian-Token": "no-check", Accept: "application/json" },
144
+ body: form,
145
+ })
146
+ notes.push(res.ok ? "screenshot attached" : `screenshot not attached: ${res.status}`)
147
+ }
148
+ const linked = r.case.source === "jira" || r.case.source === "xray" ? String(r.case.ref?.issue || r.case.ref?.test || r.case.id) : ""
149
+ if (linked) {
150
+ try {
151
+ await http(`${j.api}/issueLink`, { auth: j.auth, body: { type: { name: "Relates" }, inwardIssue: { key: data.key }, outwardIssue: { key: linked } } })
152
+ notes.push(`linked to ${linked}`)
153
+ } catch {
154
+ notes.push(`not linked to ${linked}`)
155
+ }
156
+ }
157
+ return { ...ref(data.key), note: notes.join(", ") || undefined }
158
+ },
159
+ async comment(id, text) {
160
+ await http(`${j.api}/issue/${encodeURIComponent(id)}/comment`, { auth: j.auth, body: { body: body(text) } })
161
+ },
162
+ }
163
+ }
164
+
165
+ const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
166
+ const html = (text: string) => text.split("\n").map((l) => (l ? `<div>${esc(l)}</div>` : "<br>")).join("")
167
+
168
+ function adoTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
169
+ const { ado, auth } = adoSettings(cfg)
170
+ const project = ado.project || b.project
171
+ if (!project) throw new Error("Azure DevOps bugs need a project. Set sources.bugs.project or sources.azure_devops.project.")
172
+ const base = `${ado.orgUrl.replace(/\/+$/, "")}/${encodeURIComponent(project)}`
173
+ const api = "api-version=7.1"
174
+ const ref = (id: number | string): BugRef => ({ id: String(id), label: `${b.type} ${id}`, url: `${base}/_workitems/edit/${id}` })
175
+ return {
176
+ async findOpen(fp) {
177
+ const query =
178
+ `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = '${b.type.replace(/'/g, "''")}' ` +
179
+ `AND [System.Tags] CONTAINS '${fp}' AND [System.State] NOT IN ('Closed', 'Done', 'Removed', 'Resolved') ORDER BY [System.CreatedDate] DESC`
180
+ const { data } = await http<{ workItems?: { id: number }[] }>(`${base}/_apis/wit/wiql?${api}&$top=1`, { auth, body: { query } })
181
+ const hit = data?.workItems?.[0]
182
+ return hit ? ref(hit.id) : null
183
+ },
184
+ async create(r, fp, runName) {
185
+ const notes: string[] = []
186
+ 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("; ") },
190
+ ]
191
+ if (b.areaPath) ops.push({ op: "add", path: "/fields/System.AreaPath", value: b.areaPath })
192
+ const testCase = r.case.source === "ado" ? Number(r.case.ref?.workItem) : 0
193
+ if (testCase) {
194
+ ops.push({ op: "add", path: "/relations/-", value: { rel: "Microsoft.VSTS.Common.TestedBy-Forward", url: `${ado.orgUrl}/_apis/wit/workItems/${testCase}` } })
195
+ notes.push(`linked to test case ${testCase}`)
196
+ }
197
+ const shot = screenshot(r)
198
+ if (shot) {
199
+ const res = await fetch(`${base}/_apis/wit/attachments?fileName=${encodeURIComponent(shot.name)}&${api}`, {
200
+ method: "POST",
201
+ headers: { Authorization: auth, "Content-Type": "application/octet-stream", Accept: "application/json" },
202
+ body: new Uint8Array(shot.data),
203
+ })
204
+ if (res.ok) {
205
+ const att = (await res.json()) as { url?: string }
206
+ if (att.url) {
207
+ ops.push({ op: "add", path: "/relations/-", value: { rel: "AttachedFile", url: att.url, attributes: { comment: "Last screenshot" } } })
208
+ notes.push("screenshot attached")
209
+ }
210
+ } else notes.push(`screenshot not attached: ${res.status}`)
211
+ }
212
+ const { data } = await http<{ id: number }>(`${base}/_apis/wit/workitems/$${encodeURIComponent(b.type)}?${api}`, {
213
+ auth,
214
+ body: ops,
215
+ contentType: "application/json-patch+json",
216
+ })
217
+ return { ...ref(data.id), note: notes.join(", ") || undefined }
218
+ },
219
+ async comment(id, text) {
220
+ await http(`${base}/_apis/wit/workItems/${id}/comments?api-version=7.1-preview.4`, { auth, body: { text: html(text) } })
221
+ },
222
+ }
223
+ }