@capagents/forgeqe 1.1.4 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +64 -17
- package/README.md +2 -1
- package/browserstack.example.yaml +14 -2
- package/docs/user-guide.html +78 -23
- package/forgeqe.example.yaml +18 -5
- package/package.json +2 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +8 -1
- package/prompts/defect-rca.md +43 -0
- package/prompts/test-agent.md +13 -2
- package/prompts/test-reviewer.md +8 -3
- package/prompts/test-rules.md +10 -4
- package/py/forgeqe_engine/browser.py +1 -1
- package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
- package/skills/playwright-typescript/SKILL.md +2 -1
- package/src/brain/index.ts +1 -1
- package/src/capabilities/registry.ts +34 -6
- package/src/cases/bugs.ts +209 -22
- package/src/cases/defectHistory.ts +193 -0
- package/src/cases/defects.ts +255 -0
- package/src/cases/github.ts +162 -0
- package/src/cases/goal.ts +1 -1
- package/src/cases/index.ts +23 -4
- package/src/cases/predict.ts +372 -0
- package/src/cases/rca.ts +258 -0
- package/src/cases/readiness.ts +38 -1
- package/src/cases/repro.ts +202 -0
- package/src/cases/types.ts +8 -1
- package/src/cli/help.ts +8 -2
- package/src/cli/index.ts +36 -7
- package/src/code/graph/graph.ts +518 -0
- package/src/code/graph/locators.ts +398 -0
- package/src/code/graph/parse.ts +439 -0
- package/src/code/graph/tools.ts +113 -0
- package/src/code/handoff.ts +93 -18
- package/src/code/loop.ts +30 -5
- package/src/code/profile.ts +14 -3
- package/src/code/replay.ts +1 -1
- package/src/code/review.ts +22 -0
- package/src/code/runtime.ts +8 -1
- package/src/config.ts +142 -18
- package/src/mcp/attachtools.ts +223 -0
- package/src/mcp/commands.ts +1 -1
- package/src/mcp/defecttools.ts +106 -0
- package/src/mcp/devicetools.ts +125 -0
- package/src/mcp/external.ts +42 -1
- package/src/mcp/server.ts +3 -1
- package/src/mcp/tools.ts +95 -10
- package/src/monitor/run.ts +1 -1
- package/src/report/data.ts +14 -0
- package/src/report/html.ts +15 -3
- package/src/report/template.html +44 -43
- package/src/run.ts +4 -2
- package/src/suite/report.ts +44 -11
- package/src/suite/run.ts +163 -16
- package/src/tui/App.tsx +153 -94
- package/src/tui/components.tsx +27 -23
- package/src/tui/inspector.tsx +140 -83
- package/src/tui/landing.ts +3 -4
- package/src/tui/refine-panel.tsx +102 -52
- package/src/tui/shortcuts.tsx +2 -0
- package/src/tui/transcript.tsx +32 -2
- package/src/types.ts +3 -1
- package/src/usage.ts +1 -1
- package/testmu.example.yaml +11 -0
package/src/config.ts
CHANGED
|
@@ -38,6 +38,13 @@ export type BrowserRemoteConfig = {
|
|
|
38
38
|
extra?: Record<string, unknown>
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** A named remote browser (devices: in a provider file): the provider's shared remote settings with its own on top. */
|
|
42
|
+
export type BrowserDevice = {
|
|
43
|
+
name: string
|
|
44
|
+
provider: RemoteBrowserProvider
|
|
45
|
+
remote: BrowserRemoteConfig
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
export type BrowserConfig = {
|
|
42
49
|
provider: BrowserProvider
|
|
43
50
|
headless: boolean
|
|
@@ -51,6 +58,8 @@ export type BrowserConfig = {
|
|
|
51
58
|
remote?: BrowserRemoteConfig
|
|
52
59
|
remotes: BrowserRemoteConfigs
|
|
53
60
|
providerFiles: BrowserProviderFiles
|
|
61
|
+
/** Named devices from the provider files, by name; names are unique across providers. */
|
|
62
|
+
devices: Record<string, BrowserDevice>
|
|
54
63
|
/** ForgeQE overlay in a visible browser: edge glow, a bar with the current step, the person's input locked. */
|
|
55
64
|
overlay: boolean
|
|
56
65
|
/** A second model call checks the finished run against the goal, and its verdict can fail a run the agent completed. */
|
|
@@ -78,6 +87,8 @@ export type CodeConfig = {
|
|
|
78
87
|
stallAfter?: number
|
|
79
88
|
/** Give Scribe a Playwright-driven browser, set up like the run's, switched on once a step fails. */
|
|
80
89
|
liveBrowser?: boolean
|
|
90
|
+
/** Give Scribe the code graph of the repository (code_find, code_outline, code_element, …) and link the handoff's elements to the page objects that locate them (code.graph, default true). */
|
|
91
|
+
graph?: boolean
|
|
81
92
|
/** Before Scribe starts, replay the run's steps as a Playwright script and hand over what worked and where it failed. */
|
|
82
93
|
replay?: boolean
|
|
83
94
|
/** Model for ForgeQE's agent and Scribe as provider/model, instead of the run's llms.json profile. */
|
|
@@ -122,11 +133,16 @@ export type SourcesConfig = {
|
|
|
122
133
|
qtest?: { url: string; tokenEnv: string; projectId: number; statuses: { passed: string; failed: string; error: string } }
|
|
123
134
|
/** File a bug for each failed case, or comment on the open one ForgeQE filed before. */
|
|
124
135
|
bugs?: BugsConfig
|
|
136
|
+
/**
|
|
137
|
+
* GitHub: issues as test cases and bugs, and GitHub's MCP server for ForgeQE's agent. `repo` (owner/name) is the default
|
|
138
|
+
* for issue references; the token is `tokenEnv`, else the GitHub CLI's sign-in. `toolsets`: the MCP server's tool groups.
|
|
139
|
+
*/
|
|
140
|
+
github?: { repo?: string; apiUrl: string; tokenEnv: string; mcp: boolean; toolsets: string[] }
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
export type BugsConfig = {
|
|
128
|
-
system: "jira" | "azure_devops"
|
|
129
|
-
/** Jira project key. Azure DevOps uses this, or sources.azure_devops.project. */
|
|
144
|
+
system: "jira" | "azure_devops" | "github"
|
|
145
|
+
/** Jira project key; GitHub owner/repo (default sources.github.repo). Azure DevOps uses this, or sources.azure_devops.project. */
|
|
130
146
|
project?: string
|
|
131
147
|
/** Jira issue type or Azure DevOps work item type. */
|
|
132
148
|
type: string
|
|
@@ -135,6 +151,11 @@ export type BugsConfig = {
|
|
|
135
151
|
areaPath?: string
|
|
136
152
|
/** Also file bugs for cases that could not run (status error). */
|
|
137
153
|
onError: boolean
|
|
154
|
+
/**
|
|
155
|
+
* "defect" (the default): one bug per defect, so cases failing the same way (the same failed request, the same
|
|
156
|
+
* uncaught error) share a bug, each added to it. "case": one bug per failing case.
|
|
157
|
+
*/
|
|
158
|
+
groupBy?: "defect" | "case"
|
|
138
159
|
}
|
|
139
160
|
|
|
140
161
|
/** A site the browser agent can sign in to. Values come from the environment; the agent only sees placeholders. */
|
|
@@ -307,6 +328,9 @@ function notifyConfig(raw: unknown): NotifyConfig | undefined {
|
|
|
307
328
|
if (unknown.length) throw new Error(`${where}.on: ${unknown.join(", ")} is not an event. Events: ${NOTIFY_EVENTS.join(", ")}.`)
|
|
308
329
|
return { name, ...alerts, on: on as NotifyEvent[] }
|
|
309
330
|
})
|
|
331
|
+
// No channels set up, but a Teams webhook in the environment: a "teams" channel for messages the user asks for. It
|
|
332
|
+
// takes no events (on: []), so runs nobody watches never start posting to it unasked.
|
|
333
|
+
if (!channels.length && process.env.TEAMS_WEBHOOK_URL?.trim()) channels.push({ name: "teams", teams: { webhookEnv: "TEAMS_WEBHOOK_URL" }, on: [] })
|
|
310
334
|
if (!channels.length) return undefined
|
|
311
335
|
return { channels, ...(n.report_url ? { reportUrl: trimUrl(n.report_url) } : {}) }
|
|
312
336
|
}
|
|
@@ -345,6 +369,8 @@ export type AppConfig = {
|
|
|
345
369
|
prompts: { stepInstruction: string; guiderails: string; dirs?: { dir: string; source: string }[] }
|
|
346
370
|
/** Product knowledge (.forgeqe/knowledge): see src/knowledge.ts. */
|
|
347
371
|
knowledge?: { dir: string; browserMaxChars: number }
|
|
372
|
+
/** Defect intelligence (defects: in forgeqe.yaml): see src/cases/defects.ts, repro.ts, rca.ts. */
|
|
373
|
+
defects: DefectsConfig
|
|
348
374
|
export: { outDir: string; format: string }
|
|
349
375
|
memory: MemoryConfig
|
|
350
376
|
code: CodeConfig
|
|
@@ -517,9 +543,16 @@ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProv
|
|
|
517
543
|
if (!/chrome|edge|chromium/i.test(browser) || /^(playwright|pw)-/i.test(browser)) {
|
|
518
544
|
throw new Error(`${where}.remote.browser is "${browser}": ForgeQE drives remote browsers over the Chrome DevTools Protocol, so use chrome or edge.`)
|
|
519
545
|
}
|
|
546
|
+
// Checked live: TestMu AI's CDP endpoint opens Chrome sessions only; an Edge session never opens and times out.
|
|
547
|
+
if (provider === "testmu" && !/chrome/i.test(browser)) {
|
|
548
|
+
throw new Error(`${where}.remote.browser is "${browser}": TestMu AI's CDP endpoint only serves Chrome, so use Chrome there (Edge works on BrowserStack).`)
|
|
549
|
+
}
|
|
520
550
|
const browserVersion = String(remote.browser_version || remote.browserVersion || "latest").trim() || "latest"
|
|
521
|
-
const
|
|
522
|
-
const
|
|
551
|
+
const rawOs = String(remote.os || "").trim() || undefined
|
|
552
|
+
const rawOsVersion = String(remote.os_version || remote.osVersion || "").trim() || undefined
|
|
553
|
+
// BrowserStack documents "Windows" and "OS X" with versions like 11 or Sequoia: what people write maps onto those.
|
|
554
|
+
const os = provider === "browserstack" && rawOs ? browserstackOs(rawOs) : rawOs
|
|
555
|
+
const osVersion = provider === "browserstack" && rawOsVersion && os === "OS X" ? titleCase(rawOsVersion) : rawOsVersion
|
|
523
556
|
const platform = String(remote.platform || "").trim() || undefined
|
|
524
557
|
if (provider === "browserstack" && (!os || !osVersion)) {
|
|
525
558
|
throw new Error(`${where}.remote.os and ${where}.remote.os_version are required when browser.provider is browserstack.`)
|
|
@@ -550,28 +583,68 @@ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProv
|
|
|
550
583
|
}
|
|
551
584
|
}
|
|
552
585
|
|
|
553
|
-
function
|
|
586
|
+
function browserstackOs(os: string): string {
|
|
587
|
+
if (/^(os ?x|macos|mac( ?os)?)$/i.test(os)) return "OS X"
|
|
588
|
+
if (/^win(dows)?$/i.test(os)) return "Windows"
|
|
589
|
+
return os
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const titleCase = (s: string) => s.toLowerCase().replace(/\b[a-z]/g, (c) => c.toUpperCase())
|
|
593
|
+
|
|
594
|
+
/** A device name: what the user and the agent call it ("win11-chrome"), also used in folder names. */
|
|
595
|
+
export const DEVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
596
|
+
|
|
597
|
+
function browserRemoteConfigs(
|
|
598
|
+
browser: Record<string, unknown>,
|
|
599
|
+
provider: BrowserProvider,
|
|
600
|
+
base: string,
|
|
601
|
+
): { remotes: BrowserRemoteConfigs; providerFiles: BrowserProviderFiles; devices: Record<string, BrowserDevice> } {
|
|
554
602
|
const providerFiles = browserProviderFiles(browser, base)
|
|
555
603
|
const remotes: BrowserRemoteConfigs = {}
|
|
604
|
+
const devices: Record<string, BrowserDevice> = {}
|
|
556
605
|
for (const remoteProvider of REMOTE_BROWSER_PROVIDERS) {
|
|
557
606
|
const fromFile = providerFiles[remoteProvider] ? browserProviderFileSection(providerFiles[remoteProvider]!, remoteProvider) : undefined
|
|
558
607
|
const inline = provider === remoteProvider ? browser : undefined
|
|
559
608
|
if (!fromFile && !inline) continue
|
|
560
609
|
const where = fromFile ? `browser.providers.${remoteProvider}` : "browser"
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
}
|
|
567
|
-
remoteProvider,
|
|
568
|
-
|
|
569
|
-
|
|
610
|
+
const shared = { ...asRecord(fromFile?.remote), ...asRecord(inline?.remote) }
|
|
611
|
+
const named = objectMap(fromFile?.devices, `${where}.devices`) || {}
|
|
612
|
+
for (const [name, raw] of Object.entries(named)) {
|
|
613
|
+
if (!DEVICE_NAME.test(name)) throw new Error(`${where}.devices.${name}: a device name uses letters, digits, ".", "_" and "-".`)
|
|
614
|
+
const other = devices[name]
|
|
615
|
+
if (other) throw new Error(`Device ${name} is in both ${other.provider} and ${remoteProvider}; device names must be unique.`)
|
|
616
|
+
const remote = browserRemoteConfig({ remote: { ...shared, ...asRecord(raw) } }, remoteProvider, `${where}.devices.${name}`)!
|
|
617
|
+
devices[name] = { name, provider: remoteProvider, remote }
|
|
618
|
+
}
|
|
619
|
+
// A provider file that only lists devices: its first device is the provider's default browser.
|
|
620
|
+
const first = Object.values(devices).find((d) => d.provider === remoteProvider)
|
|
621
|
+
remotes[remoteProvider] =
|
|
622
|
+
!String(shared.browser || "").trim() && first
|
|
623
|
+
? first.remote
|
|
624
|
+
: browserRemoteConfig({ ...(fromFile || {}), ...(inline || {}), remote: shared }, remoteProvider, where)
|
|
570
625
|
}
|
|
571
|
-
return { remotes, providerFiles }
|
|
626
|
+
return { remotes, providerFiles, devices }
|
|
572
627
|
}
|
|
573
628
|
|
|
574
|
-
|
|
629
|
+
/**
|
|
630
|
+
* The browser a run uses: a named device (which also picks its provider), else the provider's default remote,
|
|
631
|
+
* else the local browser. A device on another provider than the one asked for is an error, not a silent switch.
|
|
632
|
+
*/
|
|
633
|
+
export function browserConfigFor(
|
|
634
|
+
config: Pick<AppConfig, "browser">,
|
|
635
|
+
provider?: BrowserProvider,
|
|
636
|
+
device?: string,
|
|
637
|
+
): Pick<BrowserConfig, "provider" | "remote"> & { device?: string } {
|
|
638
|
+
if (device) {
|
|
639
|
+
const d = config.browser.devices?.[device]
|
|
640
|
+
if (!d) {
|
|
641
|
+
const known = Object.keys(config.browser.devices || {})
|
|
642
|
+
throw new Error(`No browser device named ${device}. ${known.length ? `Configured: ${known.join(", ")}.` : "None is configured yet."} Add one with browser_devices, or under devices: in the provider's YAML file.`)
|
|
643
|
+
}
|
|
644
|
+
if (provider && provider !== d.provider) throw new Error(`Device ${device} is on ${d.provider}, not ${provider}.`)
|
|
645
|
+
// The session name shows the device in the provider's dashboard.
|
|
646
|
+
return { provider: d.provider, remote: { ...d.remote, name: [d.remote.name, device].filter(Boolean).join(" · ") }, device }
|
|
647
|
+
}
|
|
575
648
|
const chosen = provider || config.browser.provider || "local"
|
|
576
649
|
if (chosen === "local") return { provider: chosen }
|
|
577
650
|
const remote = config.browser.remotes?.[chosen] || ((config.browser.provider === chosen || !config.browser.provider) ? config.browser.remote : undefined)
|
|
@@ -751,7 +824,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
|
|
|
751
824
|
|
|
752
825
|
const sessionsDir = configPath ? path.join(projectDir(base), "sessions") : path.join(forgeqeHome(), "sessions")
|
|
753
826
|
const provider = browserProvider(browser.provider)
|
|
754
|
-
const { remotes, providerFiles } = browserRemoteConfigs(browser, provider, base)
|
|
827
|
+
const { remotes, providerFiles, devices } = browserRemoteConfigs(browser, provider, base)
|
|
755
828
|
const config: AppConfig = {
|
|
756
829
|
configPath,
|
|
757
830
|
mcp: extraMcpServers(yaml.mcp, base),
|
|
@@ -771,6 +844,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
|
|
|
771
844
|
remote: provider === "local" ? undefined : remotes[provider],
|
|
772
845
|
remotes,
|
|
773
846
|
providerFiles,
|
|
847
|
+
devices,
|
|
774
848
|
overlay: browser.overlay !== false,
|
|
775
849
|
judge: browser.judge === true,
|
|
776
850
|
},
|
|
@@ -780,6 +854,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
|
|
|
780
854
|
dirs: promptDirsFor(prompts, base, configPath),
|
|
781
855
|
},
|
|
782
856
|
knowledge: knowledgeConfig(asRecord(yaml.knowledge), base, configPath),
|
|
857
|
+
defects: defectsConfig(asRecord(yaml.defects), base),
|
|
783
858
|
export: {
|
|
784
859
|
outDir,
|
|
785
860
|
format: String(exp.format || "playwright"),
|
|
@@ -799,6 +874,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
|
|
|
799
874
|
testTimeoutSeconds: Number(code.test_timeout_seconds || 600),
|
|
800
875
|
stallAfter: Math.max(2, Number(code.stall_after || 5)),
|
|
801
876
|
liveBrowser: code.live_browser !== false,
|
|
877
|
+
graph: code.graph !== false,
|
|
802
878
|
replay: code.replay !== false,
|
|
803
879
|
model: code.model ? String(code.model) : undefined,
|
|
804
880
|
...(code.review_profile ? { reviewProfile: String(code.review_profile) } : {}),
|
|
@@ -1001,6 +1077,26 @@ function sourcesConfig(raw: Record<string, unknown>): SourcesConfig {
|
|
|
1001
1077
|
...optional("zephyr", zephyrConfig(raw.zephyr)),
|
|
1002
1078
|
...optional("qtest", qtestConfig(asRecord(raw.qtest))),
|
|
1003
1079
|
...optional("bugs", bugsConfig(raw.bugs)),
|
|
1080
|
+
...optional("github", githubConfig(raw.github)),
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** The GitHub MCP server's tool groups ForgeQE loads unless sources.github.toolsets says otherwise. */
|
|
1085
|
+
export const GITHUB_TOOLSETS = ["repos", "issues", "pull_requests", "actions"]
|
|
1086
|
+
|
|
1087
|
+
function githubConfig(raw: unknown): SourcesConfig["github"] {
|
|
1088
|
+
if (raw === undefined || raw === null) return undefined
|
|
1089
|
+
if (raw === false) return { apiUrl: "https://api.github.com", tokenEnv: "GITHUB_TOKEN", mcp: false, toolsets: [] }
|
|
1090
|
+
const g = asRecord(raw)
|
|
1091
|
+
const repo = String(g.repo || "").trim()
|
|
1092
|
+
if (repo && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new Error(`sources.github.repo is owner/name; got "${repo}".`)
|
|
1093
|
+
const toolsets = Array.isArray(g.toolsets) ? g.toolsets.map(String).filter(Boolean) : GITHUB_TOOLSETS
|
|
1094
|
+
return {
|
|
1095
|
+
...(repo ? { repo } : {}),
|
|
1096
|
+
apiUrl: String(g.api_url || "https://api.github.com").replace(/\/+$/, ""),
|
|
1097
|
+
tokenEnv: String(g.token_env || "GITHUB_TOKEN"),
|
|
1098
|
+
mcp: g.mcp !== false,
|
|
1099
|
+
toolsets,
|
|
1004
1100
|
}
|
|
1005
1101
|
}
|
|
1006
1102
|
|
|
@@ -1024,6 +1120,27 @@ function promptDirsFor(p: Record<string, unknown>, base: string, configPath: str
|
|
|
1024
1120
|
return dirs.filter((d, i) => dirs.findIndex((x) => path.resolve(x.dir) === path.resolve(d.dir)) === i)
|
|
1025
1121
|
}
|
|
1026
1122
|
|
|
1123
|
+
export type DefectsConfig = {
|
|
1124
|
+
/** The application's own repository: suspect commits, and the code the root-cause analysis reads. */
|
|
1125
|
+
productRepo?: string
|
|
1126
|
+
/** Run the first case of each new defect once more, to see whether it repeats. */
|
|
1127
|
+
reproduce: boolean
|
|
1128
|
+
/** A model writes a root-cause hypothesis for new defects and regressions. */
|
|
1129
|
+
analyze: boolean
|
|
1130
|
+
/** Most defects analysed by the model in one suite. */
|
|
1131
|
+
maxAnalyses: number
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
export function defectsConfig(d: Record<string, unknown>, base: string): DefectsConfig {
|
|
1135
|
+
const max = Number(d.max_analyses)
|
|
1136
|
+
return {
|
|
1137
|
+
...(d.product_repo ? { productRepo: path.resolve(base, String(d.product_repo)) } : {}),
|
|
1138
|
+
reproduce: d.reproduce !== false,
|
|
1139
|
+
analyze: d.analyze !== false,
|
|
1140
|
+
maxAnalyses: Number.isInteger(max) && max >= 0 && d.max_analyses !== undefined ? max : 3,
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1027
1144
|
function knowledgeConfig(k: Record<string, unknown>, base: string, configPath: string | undefined): NonNullable<AppConfig["knowledge"]> {
|
|
1028
1145
|
const dir = k.dir ? path.resolve(base, String(k.dir)) : configPath ? path.join(projectDir(base), "knowledge") : path.join(forgeqeHome(), "knowledge")
|
|
1029
1146
|
const max = Number(k.browser_max_chars)
|
|
@@ -1163,7 +1280,7 @@ function bugsConfig(raw: unknown): BugsConfig | undefined {
|
|
|
1163
1280
|
if (raw === undefined || raw === null || raw === false) return undefined
|
|
1164
1281
|
const b = asRecord(raw)
|
|
1165
1282
|
const system = String(b.system || "").toLowerCase().replace(/[\s-]/g, "_")
|
|
1166
|
-
if (system !== "jira" && system !== "azure_devops") throw new Error(`sources.bugs.system must be jira or
|
|
1283
|
+
if (system !== "jira" && system !== "azure_devops" && system !== "github") throw new Error(`sources.bugs.system must be jira, azure_devops, or github; got "${String(b.system ?? "")}".`)
|
|
1167
1284
|
if (system === "jira" && !b.project) throw new Error("sources.bugs.project is required for Jira: the project key bugs are filed in.")
|
|
1168
1285
|
return {
|
|
1169
1286
|
system,
|
|
@@ -1172,9 +1289,16 @@ function bugsConfig(raw: unknown): BugsConfig | undefined {
|
|
|
1172
1289
|
labels: Array.isArray(b.labels) ? b.labels.map(String).filter(Boolean) : ["forgeqe"],
|
|
1173
1290
|
...(b.area_path ? { areaPath: String(b.area_path) } : {}),
|
|
1174
1291
|
onError: b.on_error === true,
|
|
1292
|
+
...(groupBy(b.group_by) === "case" ? { groupBy: "case" as const } : {}),
|
|
1175
1293
|
}
|
|
1176
1294
|
}
|
|
1177
1295
|
|
|
1296
|
+
function groupBy(raw: unknown): "defect" | "case" {
|
|
1297
|
+
if (raw === undefined || raw === null || raw === "defect") return "defect"
|
|
1298
|
+
if (raw === "case") return "case"
|
|
1299
|
+
throw new Error(`sources.bugs.group_by must be defect or case; got "${String(raw)}".`)
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1178
1302
|
function jiraMcp(raw: unknown): JiraMcp | false {
|
|
1179
1303
|
if (raw === false || raw === "false" || raw === "off") return false
|
|
1180
1304
|
if (raw === undefined || raw === null || raw === true || raw === "mcp-atlassian") return "mcp-atlassian"
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { adoSettings } from "../cases/ado.ts"
|
|
5
|
+
import { http, textToAdf } from "../cases/http.ts"
|
|
6
|
+
import { jiraApi } from "../cases/jira.ts"
|
|
7
|
+
import type { AppConfig } from "../config.ts"
|
|
8
|
+
import type { McpTool } from "./tools.ts"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Evidence onto an existing work item or issue: files from a run (screenshots, the report, JUnit, the trace) uploaded
|
|
12
|
+
* as attachments, posted in a comment (images inline, text as text), or both. Only files inside the working folder or
|
|
13
|
+
* the reports folders can go, so a request can never send anything else from the machine.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const MAX_FILE = 25 * 1024 * 1024
|
|
17
|
+
/** Text read into a comment; longer text is attached instead. */
|
|
18
|
+
const MAX_COMMENT_TEXT = 20_000
|
|
19
|
+
const IMAGE = /\.(png|jpe?g|gif|webp)$/i
|
|
20
|
+
const TEXT = /\.(md|txt|log|json|xml|csv|ya?ml)$/i
|
|
21
|
+
const MIME: Record<string, string> = {
|
|
22
|
+
".png": "image/png",
|
|
23
|
+
".jpg": "image/jpeg",
|
|
24
|
+
".jpeg": "image/jpeg",
|
|
25
|
+
".gif": "image/gif",
|
|
26
|
+
".webp": "image/webp",
|
|
27
|
+
".html": "text/html",
|
|
28
|
+
".json": "application/json",
|
|
29
|
+
".xml": "application/xml",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Pick = "last_screenshot" | "failure_screenshot" | "report" | "junit" | "trace"
|
|
33
|
+
const PICKS: Pick[] = ["last_screenshot", "failure_screenshot", "report", "junit", "trace"]
|
|
34
|
+
|
|
35
|
+
type File = { path: string; name: string; data: Buffer; type: string }
|
|
36
|
+
|
|
37
|
+
/** Files a run folder holds, by what they are. A suite folder's last screenshot is its last case's. */
|
|
38
|
+
export function pickFiles(runDir: string, picks: Pick[]): string[] {
|
|
39
|
+
const out: string[] = []
|
|
40
|
+
const lastShot = (dir: string): string | undefined => {
|
|
41
|
+
const steps = path.join(dir, "steps")
|
|
42
|
+
const shots = existsSync(steps) ? readdirSync(steps).filter((f) => IMAGE.test(f)).sort() : []
|
|
43
|
+
if (shots.length) return path.join(steps, shots[shots.length - 1])
|
|
44
|
+
if (existsSync(path.join(dir, "failure.png"))) return path.join(dir, "failure.png")
|
|
45
|
+
// A suite folder: its case folders, last one first.
|
|
46
|
+
const cases = readdirSync(dir).filter((d) => statSync(path.join(dir, d)).isDirectory() && d !== "steps").sort().reverse()
|
|
47
|
+
for (const c of cases) {
|
|
48
|
+
const shot = lastShot(path.join(dir, c))
|
|
49
|
+
if (shot) return shot
|
|
50
|
+
}
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
for (const pick of picks) {
|
|
54
|
+
const file =
|
|
55
|
+
pick === "last_screenshot"
|
|
56
|
+
? lastShot(runDir)
|
|
57
|
+
: pick === "failure_screenshot"
|
|
58
|
+
? [path.join(runDir, "failure.png")].find(existsSync)
|
|
59
|
+
: pick === "report"
|
|
60
|
+
? path.join(runDir, "report.html")
|
|
61
|
+
: pick === "junit"
|
|
62
|
+
? path.join(runDir, "junit.xml")
|
|
63
|
+
: path.join(runDir, "trace.md")
|
|
64
|
+
if (!file || !existsSync(file)) throw new Error(`${runDir} has no ${pick.replace("_", " ")}.`)
|
|
65
|
+
out.push(file)
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A file to send: inside the working folder or a reports folder, and not too big. */
|
|
71
|
+
function readAllowed(file: string, config: AppConfig, cwd: string): File {
|
|
72
|
+
if (!existsSync(file)) throw new Error(`No such file: ${file}`)
|
|
73
|
+
const real = realpathSync(file)
|
|
74
|
+
const roots = [cwd, config.export?.outDir, config.suite?.outDir, config.monitor?.outDir].filter(Boolean).map((r) => (existsSync(r!) ? realpathSync(r!) : path.resolve(r!)))
|
|
75
|
+
if (!roots.some((r) => real === r || real.startsWith(`${r}${path.sep}`))) {
|
|
76
|
+
throw new Error(`${file} is outside the working folder and the reports folders, so it is not sent.`)
|
|
77
|
+
}
|
|
78
|
+
const size = statSync(real).size
|
|
79
|
+
if (size > MAX_FILE) throw new Error(`${file} is ${(size / 1024 / 1024).toFixed(1)} MB; the limit is ${MAX_FILE / 1024 / 1024} MB.`)
|
|
80
|
+
return { path: real, name: path.basename(real), data: readFileSync(real), type: MIME[path.extname(real).toLowerCase()] || "application/octet-stream" }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
84
|
+
|
|
85
|
+
type Outcome = { item: string; url?: string; attached: string[]; commented: boolean; inline: string[] }
|
|
86
|
+
|
|
87
|
+
async function toAdo(config: AppConfig, id: string, files: File[], mode: string, comment: string): Promise<Outcome> {
|
|
88
|
+
const { auth, org } = adoSettings(config.sources)
|
|
89
|
+
if (!/^\d+$/.test(id)) throw new Error(`An Azure DevOps work item id is a number; got "${id}".`)
|
|
90
|
+
// Comments and attachments live in the work item's own project.
|
|
91
|
+
const { data: wi } = await http<{ fields?: Record<string, unknown>; _links?: { html?: { href?: string } } }>(`${org}/_apis/wit/workitems/${id}?fields=System.TeamProject&api-version=7.1`, { auth })
|
|
92
|
+
const project = encodeURIComponent(String(wi.fields?.["System.TeamProject"] || ""))
|
|
93
|
+
const scoped = `${org}/${project}`
|
|
94
|
+
const upload = async (f: File) => {
|
|
95
|
+
const res = await fetch(`${scoped}/_apis/wit/attachments?fileName=${encodeURIComponent(f.name)}&api-version=7.1`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { Authorization: auth, "Content-Type": "application/octet-stream", Accept: "application/json" },
|
|
98
|
+
body: new Uint8Array(f.data),
|
|
99
|
+
})
|
|
100
|
+
if (!res.ok) throw new Error(`Uploading ${f.name} to work item ${id} failed: ${res.status} ${res.statusText}.`)
|
|
101
|
+
return ((await res.json()) as { url: string }).url
|
|
102
|
+
}
|
|
103
|
+
const out: Outcome = { item: `#${id}`, url: wi._links?.html?.href, attached: [], commented: false, inline: [] }
|
|
104
|
+
const urls = new Map<string, string>()
|
|
105
|
+
if (mode === "attach" || mode === "both") {
|
|
106
|
+
const ops = []
|
|
107
|
+
for (const f of files) {
|
|
108
|
+
const url = await upload(f)
|
|
109
|
+
urls.set(f.path, url)
|
|
110
|
+
ops.push({ op: "add", path: "/relations/-", value: { rel: "AttachedFile", url, attributes: { comment: comment ? comment.slice(0, 250) : "Added by ForgeQE" } } })
|
|
111
|
+
out.attached.push(f.name)
|
|
112
|
+
}
|
|
113
|
+
await http(`${org}/_apis/wit/workitems/${id}?api-version=7.1`, { method: "PATCH", auth, body: ops, contentType: "application/json-patch+json" })
|
|
114
|
+
}
|
|
115
|
+
if (mode === "comment" || mode === "both") {
|
|
116
|
+
const parts = comment ? [`<p>${esc(comment).replace(/\n/g, "<br>")}</p>`] : []
|
|
117
|
+
for (const f of files) {
|
|
118
|
+
if (IMAGE.test(f.name)) {
|
|
119
|
+
const url = urls.get(f.path) || (await upload(f))
|
|
120
|
+
parts.push(`<p><b>${esc(f.name)}</b><br><img src="${url}" alt="${esc(f.name)}"></p>`)
|
|
121
|
+
out.inline.push(f.name)
|
|
122
|
+
} else if (TEXT.test(f.name) && f.data.length <= MAX_COMMENT_TEXT) {
|
|
123
|
+
parts.push(`<p><b>${esc(f.name)}</b></p><pre>${esc(f.data.toString("utf8"))}</pre>`)
|
|
124
|
+
out.inline.push(f.name)
|
|
125
|
+
} else {
|
|
126
|
+
// An HTML report or a big file cannot go in a comment: it is attached, and the comment names it.
|
|
127
|
+
const url = urls.get(f.path) || (await upload(f))
|
|
128
|
+
if (!urls.has(f.path)) {
|
|
129
|
+
await http(`${org}/_apis/wit/workitems/${id}?api-version=7.1`, {
|
|
130
|
+
method: "PATCH",
|
|
131
|
+
auth,
|
|
132
|
+
body: [{ op: "add", path: "/relations/-", value: { rel: "AttachedFile", url, attributes: { comment: "Added by ForgeQE" } } }],
|
|
133
|
+
contentType: "application/json-patch+json",
|
|
134
|
+
})
|
|
135
|
+
out.attached.push(f.name)
|
|
136
|
+
}
|
|
137
|
+
parts.push(`<p>Attached: <a href="${url}">${esc(f.name)}</a></p>`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
await http(`${scoped}/_apis/wit/workItems/${id}/comments?api-version=7.1-preview.4`, { auth, body: { text: parts.join("\n") } })
|
|
141
|
+
out.commented = true
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function toJira(config: AppConfig, key: string, files: File[], mode: string, comment: string): Promise<Outcome> {
|
|
147
|
+
const j = jiraApi(config.sources, "Attaching to Jira")
|
|
148
|
+
const issue = key.trim().toUpperCase()
|
|
149
|
+
if (!/^[A-Z][A-Z0-9_]*-\d+$/.test(issue)) throw new Error(`A Jira issue key looks like SHOP-12; got "${key}".`)
|
|
150
|
+
const out: Outcome = { item: issue, url: `${j.url}/browse/${issue}`, attached: [], commented: false, inline: [] }
|
|
151
|
+
// Jira shows images from the issue's attachments, so files go up as attachments in either mode.
|
|
152
|
+
const send = files.filter((f) => mode !== "comment" || !(TEXT.test(f.name) && f.data.length <= MAX_COMMENT_TEXT))
|
|
153
|
+
for (const f of send) {
|
|
154
|
+
const form = new FormData()
|
|
155
|
+
form.append("file", new Blob([new Uint8Array(f.data)], { type: f.type }), f.name)
|
|
156
|
+
const res = await fetch(`${j.api}/issue/${encodeURIComponent(issue)}/attachments`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { Authorization: j.auth, "X-Atlassian-Token": "no-check", Accept: "application/json" },
|
|
159
|
+
body: form,
|
|
160
|
+
})
|
|
161
|
+
if (!res.ok) throw new Error(`Attaching ${f.name} to ${issue} failed: ${res.status} ${res.statusText}.`)
|
|
162
|
+
out.attached.push(f.name)
|
|
163
|
+
}
|
|
164
|
+
if (mode === "comment" || mode === "both") {
|
|
165
|
+
const lines = comment ? [comment, ""] : []
|
|
166
|
+
for (const f of files) {
|
|
167
|
+
if (TEXT.test(f.name) && f.data.length <= MAX_COMMENT_TEXT) {
|
|
168
|
+
lines.push(`${f.name}:`, f.data.toString("utf8"), "")
|
|
169
|
+
out.inline.push(f.name)
|
|
170
|
+
} else if (IMAGE.test(f.name) && !j.cloud) {
|
|
171
|
+
// Jira Server/Data Center wiki markup shows an attached image in the comment.
|
|
172
|
+
lines.push(`!${f.name}|thumbnail!`)
|
|
173
|
+
out.inline.push(f.name)
|
|
174
|
+
} else lines.push(`Attached: ${f.name}`)
|
|
175
|
+
}
|
|
176
|
+
const text = lines.join("\n").trim()
|
|
177
|
+
await http(`${j.api}/issue/${encodeURIComponent(issue)}/comment`, { auth: j.auth, body: { body: j.cloud ? textToAdf(text) : text } })
|
|
178
|
+
out.commented = true
|
|
179
|
+
}
|
|
180
|
+
return out
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const ATTACH_TOOLS: McpTool[] = [
|
|
184
|
+
{
|
|
185
|
+
name: "attach_to_work_item",
|
|
186
|
+
description:
|
|
187
|
+
"Put evidence on an existing Azure DevOps work item or Jira issue, when the user asks (\"add the screenshot to ADO 256\", \"attach the report to SHOP-12\", \"comment the results on the bug\"). mode attach uploads the files as attachments; comment posts a comment with images shown inline and text files as text (an HTML report cannot be shown in a comment, so it is attached and named); both does both. Files come from a run folder by what they are (pick), or by path; only files in the working folder or the reports folders can be sent. Returns the item's link and what went where.",
|
|
188
|
+
inputSchema: {
|
|
189
|
+
type: "object",
|
|
190
|
+
properties: {
|
|
191
|
+
system: { type: "string", enum: ["azure_devops", "jira"] },
|
|
192
|
+
item: { type: "string", description: "Work item id (azure_devops), e.g. 256, or issue key (jira), e.g. SHOP-12" },
|
|
193
|
+
mode: { type: "string", enum: ["attach", "comment", "both"], description: "attach: upload as attachments. comment: one comment with the files in it. both: attachments and a comment (default when the user did not say)" },
|
|
194
|
+
run_dir: { type: "string", description: "A run or suite folder from browser_run or run_test_cases, to pick files from" },
|
|
195
|
+
pick: { type: "array", items: { type: "string", enum: PICKS }, description: "What to take from run_dir: last_screenshot, failure_screenshot, report, junit, trace" },
|
|
196
|
+
files: { type: "array", items: { type: "string" }, description: "Other files by path, relative to the working folder" },
|
|
197
|
+
comment: { type: "string", description: "Text for the comment (comment/both), and the attachment note: what ran, where, the result" },
|
|
198
|
+
},
|
|
199
|
+
required: ["system", "item"],
|
|
200
|
+
},
|
|
201
|
+
async run(args, ctx) {
|
|
202
|
+
const mode = String(args.mode || "both")
|
|
203
|
+
if (!["attach", "comment", "both"].includes(mode)) throw new Error(`mode is attach, comment, or both; got "${mode}".`)
|
|
204
|
+
const picks = (Array.isArray(args.pick) ? args.pick.map(String) : []) as Pick[]
|
|
205
|
+
const bad = picks.filter((p) => !PICKS.includes(p))
|
|
206
|
+
if (bad.length) throw new Error(`pick takes ${PICKS.join(", ")}; got ${bad.join(", ")}.`)
|
|
207
|
+
if (picks.length && !args.run_dir) throw new Error("pick needs run_dir: the run folder to take the files from.")
|
|
208
|
+
const runDir = args.run_dir ? path.resolve(ctx.cwd, String(args.run_dir)) : ""
|
|
209
|
+
const paths = [...(runDir ? pickFiles(runDir, picks.length ? picks : ["last_screenshot"]) : []), ...(Array.isArray(args.files) ? args.files.map((f) => path.resolve(ctx.cwd, String(f))) : [])]
|
|
210
|
+
const comment = String(args.comment || "").trim()
|
|
211
|
+
if (!paths.length && !comment) throw new Error("Nothing to add: give run_dir (and pick), files, or a comment.")
|
|
212
|
+
if (!paths.length && mode === "attach") throw new Error("Nothing to attach: give run_dir (and pick) or files.")
|
|
213
|
+
const files = [...new Set(paths)].map((p) => readAllowed(p, ctx.config, ctx.cwd))
|
|
214
|
+
const out = args.system === "jira" ? await toJira(ctx.config, String(args.item), files, mode, comment) : await toAdo(ctx.config, String(args.item), files, mode, comment)
|
|
215
|
+
return {
|
|
216
|
+
item: out.item,
|
|
217
|
+
...(out.url ? { link: out.url } : {}),
|
|
218
|
+
...(out.attached.length ? { attached: out.attached } : {}),
|
|
219
|
+
...(out.commented ? { comment: out.inline.length ? `posted, with ${out.inline.join(", ")} in it` : "posted" } : {}),
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
]
|
package/src/mcp/commands.ts
CHANGED
|
@@ -361,7 +361,7 @@ export const COMMAND_TOOLS: McpTool[] = [
|
|
|
361
361
|
note: "The user switches profiles with Tab or /profile in the shell, or --profile.",
|
|
362
362
|
}
|
|
363
363
|
case "mcp_servers": {
|
|
364
|
-
const { servers, skipped } = externalMcp(cfg)
|
|
364
|
+
const { servers, skipped } = externalMcp(cfg, Bun.which, process.env, { cwd: ctx.cwd })
|
|
365
365
|
return {
|
|
366
366
|
servers: servers.map((s) => {
|
|
367
367
|
const c = s.config as { type: string; command?: string[]; url?: string }
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { areaFacts, changeOf, defectTrends, predict, predictionLines, savePrediction, trendsMarkdown, type Prediction } from "../cases/predict.ts"
|
|
6
|
+
import type { McpTool } from "./tools.ts"
|
|
7
|
+
|
|
8
|
+
/** The branch a change is compared with when none is named: the remote's default branch, else main, else master. */
|
|
9
|
+
export function defaultBase(repo: string): string {
|
|
10
|
+
const ok = (ref: string) => spawnSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", ref], { encoding: "utf8" }).status === 0
|
|
11
|
+
const head = spawnSync("git", ["-C", repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], { encoding: "utf8" })
|
|
12
|
+
if (head.status === 0 && head.stdout.trim()) return head.stdout.trim()
|
|
13
|
+
for (const ref of ["origin/main", "main", "origin/master", "master"]) if (ok(ref)) return ref
|
|
14
|
+
throw new Error("No base branch found (origin/HEAD, main, or master): name one with base.")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const pct = (a: number, b: number) => (b ? `${Math.round((a / b) * 100)}%` : "—")
|
|
18
|
+
|
|
19
|
+
export const DEFECT_TOOLS: McpTool[] = [
|
|
20
|
+
{
|
|
21
|
+
name: "defect_trends",
|
|
22
|
+
description:
|
|
23
|
+
"How the defects of the saved suite runs went over time, from defect-history.json next to the suite folders: how many are still there, gone, or came back after a fix, the time they took to go away, the most seen ones with their bugs, defects by area (page or API route), defects per suite, and how often defect predictions were right. Writes defect-trends.md next to the suites. Shows a card. Nothing runs.",
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
folder: { type: "string", description: "Folder of saved suites (default: suite.out_dir in forgeqe.yaml)" },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
async run(args, ctx) {
|
|
31
|
+
const folder = args.folder ? path.resolve(ctx.cwd, String(args.folder)) : ctx.config.suite.outDir
|
|
32
|
+
const t = defectTrends(folder)
|
|
33
|
+
const md = trendsMarkdown(t)
|
|
34
|
+
mkdirSync(folder, { recursive: true })
|
|
35
|
+
const file = path.join(folder, "defect-trends.md")
|
|
36
|
+
writeFileSync(file, md)
|
|
37
|
+
const p = t.predictions
|
|
38
|
+
ctx.emit?.({
|
|
39
|
+
type: "card",
|
|
40
|
+
card: {
|
|
41
|
+
status: t.defects ? (t.open ? "fail" : "ok") : "info",
|
|
42
|
+
title: t.defects ? `${t.defects} defect${t.defects === 1 ? "" : "s"}: ${t.open} still there, ${t.gone} gone, ${t.regressions} came back` : "No defect history yet",
|
|
43
|
+
stats: `${t.suites} suite run${t.suites === 1 ? "" : "s"}`,
|
|
44
|
+
body: t.defects
|
|
45
|
+
? [
|
|
46
|
+
...(t.daysToFix ? [`Time to go away: median ${t.daysToFix.median} days over ${t.daysToFix.count}`] : []),
|
|
47
|
+
...(p ? [`Predictions (${p.scored} scored): ${p.inPredicted} of ${p.defects} defects (${pct(p.inPredicted, p.defects)}) were in a predicted area; ${p.highWithDefect} of ${p.high} high-risk areas (${pct(p.highWithDefect, p.high)}) had one`] : []),
|
|
48
|
+
"",
|
|
49
|
+
"Most seen:",
|
|
50
|
+
...t.recurring.slice(0, 5).map((d) => ` ${d.seen}× ${d.title}${d.open ? "" : " (gone)"}${d.returns ? `, came back ${d.returns}×` : ""}${d.bug ? ` ${d.bug.label}` : ""}`),
|
|
51
|
+
...(t.areas.length ? ["", "By area:", ...t.areas.slice(0, 5).map((a) => ` ${a.area}: ${a.defects} defect${a.defects === 1 ? "" : "s"}${a.runs ? `, ${a.failed} of ${a.runs} case runs failed` : ""}`)] : []),
|
|
52
|
+
"",
|
|
53
|
+
`Report: ${path.relative(ctx.cwd, file) || file}`,
|
|
54
|
+
]
|
|
55
|
+
: ["Run suites into this folder first: each one adds its defects to defect-history.json."],
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
return { ...t, report: file }
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "predict_defects",
|
|
63
|
+
description:
|
|
64
|
+
"Where a change is likely to cause defects. Compares a git range of the product's repository (base...head; default: the default branch to HEAD, in defects.product_repo or the working directory) with what the saved suites know about each area (page or API route): the change touches an area when it changes a file named after it or its diff mentions the route; the area's earlier defects, defects that came back, defects still open, and case failures raise its risk. Gives each area high, medium, or low with every reason, the cases to run first, and changed code no case reaches. Saves the prediction; the next suite scores it, and defect_trends says how often predictions were right. A heuristic, not a model. Shows a card. Nothing runs.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
repo: { type: "string", description: "The product's repository (default: defects.product_repo, else the working directory)" },
|
|
69
|
+
base: { type: "string", description: "Base ref of the change (default: origin/HEAD, main, or master)" },
|
|
70
|
+
head: { type: "string", description: "Head ref of the change (default: HEAD)" },
|
|
71
|
+
folder: { type: "string", description: "Folder of saved suites (default: suite.out_dir in forgeqe.yaml)" },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
async run(args, ctx) {
|
|
75
|
+
const repo = args.repo ? path.resolve(ctx.cwd, String(args.repo)) : ctx.config.defects?.productRepo || ctx.cwd
|
|
76
|
+
const folder = args.folder ? path.resolve(ctx.cwd, String(args.folder)) : ctx.config.suite.outDir
|
|
77
|
+
const base = args.base ? String(args.base) : defaultBase(repo)
|
|
78
|
+
const head = args.head ? String(args.head) : "HEAD"
|
|
79
|
+
const change = changeOf(repo, base, head)
|
|
80
|
+
const facts = areaFacts(folder)
|
|
81
|
+
const at = new Date().toISOString()
|
|
82
|
+
const p: Prediction = { id: at.replace(/[:.]/g, "-"), at, repo, base, head, changed: change.files, ...predict(facts, change) }
|
|
83
|
+
mkdirSync(folder, { recursive: true })
|
|
84
|
+
savePrediction(folder, p)
|
|
85
|
+
const high = p.areas.filter((a) => a.level === "high").length
|
|
86
|
+
ctx.emit?.({
|
|
87
|
+
type: "card",
|
|
88
|
+
card: {
|
|
89
|
+
status: high ? "fail" : p.areas.length ? "info" : "ok",
|
|
90
|
+
title: p.areas.length ? `${high} high-risk area${high === 1 ? "" : "s"} in ${base}...${head}` : `No known area touched by ${base}...${head}`,
|
|
91
|
+
stats: `${p.changed.length} changed file${p.changed.length === 1 ? "" : "s"} · ${facts.size} known area${facts.size === 1 ? "" : "s"}`,
|
|
92
|
+
body: [...predictionLines(p), ...(facts.size ? [] : ["", "ForgeQE knows no areas yet: run suites into this folder first."])],
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
return {
|
|
96
|
+
base,
|
|
97
|
+
head,
|
|
98
|
+
changed: p.changed,
|
|
99
|
+
areas: p.areas,
|
|
100
|
+
run_first: p.runFirst,
|
|
101
|
+
untested: p.untested,
|
|
102
|
+
next_step: p.runFirst.length ? "Offer to run the run_first cases before the others (run_test_cases)." : undefined,
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
]
|