@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.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { createInterface } from "node:readline"
|
|
4
|
+
|
|
5
|
+
import { cardText } from "../card.ts"
|
|
6
|
+
import type { TestCase } from "../cases/index.ts"
|
|
7
|
+
import type { Detail } from "../detail.ts"
|
|
8
|
+
import { formatEvent } from "../print.ts"
|
|
9
|
+
import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
|
|
10
|
+
import { buildSummary, formatSummary } from "../summary.ts"
|
|
11
|
+
import type { ToolContext, ToolEvent } from "../mcp/tools.ts"
|
|
12
|
+
import { formatSuite, markdownSummary } from "../suite/report.ts"
|
|
13
|
+
import type { SuiteEvent, SuiteResult } from "../suite/run.ts"
|
|
14
|
+
|
|
15
|
+
/** Plain-terminal output and prompts: tool and suite events as lines, Enter to continue, numbered choices. */
|
|
16
|
+
|
|
17
|
+
export function suiteEventLine(e: SuiteEvent, parallel: number, color: boolean, detail: Detail = "brief"): string | null {
|
|
18
|
+
const prefix = (tc: TestCase) => (parallel > 1 ? `[${tc.id}] ` : "")
|
|
19
|
+
if (e.type === "case-start") return `\n━━ ${e.index + 1}/${e.total} ${e.case.id} ${e.case.title}`
|
|
20
|
+
if (e.type === "code-start") return `\n${prefix(e.case)}▸ Writing a test for ${e.case.id}`
|
|
21
|
+
if (e.type === "code-done") {
|
|
22
|
+
const c = e.result.code
|
|
23
|
+
if (!c) return null
|
|
24
|
+
const extra = c.existing === "reused" ? " (the existing test passes: no coding job)" : c.knownDefect ? " (marked as an expected failure until the defect is fixed)" : c.attention ? ` (moved to ${c.attention}/)` : c.existing === "updated" ? " (the existing test, updated)" : ""
|
|
25
|
+
return `${prefix(e.case)}${c.status === "passed" ? "✓" : "✗"} Test for ${e.case.id} ${c.status}${c.testFile ? `: ${c.testFile}` : c.error ? `: ${c.error}` : ""}${extra}`
|
|
26
|
+
}
|
|
27
|
+
if (e.type === "case-event" && e.event.type !== "done") {
|
|
28
|
+
const line = formatEvent(e.event, color, detail)
|
|
29
|
+
return line ? prefix(e.case) + line : null
|
|
30
|
+
}
|
|
31
|
+
if (e.type === "case-done") {
|
|
32
|
+
const mark = { passed: "✓", failed: "✗", error: "!", stopped: "■" }[e.result.status]
|
|
33
|
+
const f = e.result.failure
|
|
34
|
+
const why = f ? ` · ${FAILURE_LABEL[f.type]}: ${FAILURE_ACTION[f.type]}` : ""
|
|
35
|
+
return `${prefix(e.result.case)}${mark} ${e.result.case.id} ${e.result.status} in ${Math.round(e.result.ms / 1000)}s${why}`
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function writeJobSummary(suite: SuiteResult) {
|
|
41
|
+
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdownSummary(suite))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Prints what ForgeQE's tools do (browser runs, suites, test commands) as plain lines. */
|
|
45
|
+
export function toolPrinter(color: boolean, write: (line: string) => void, detail: Detail = "brief"): (e: ToolEvent) => void {
|
|
46
|
+
let steps = 0
|
|
47
|
+
let parallel = 1
|
|
48
|
+
const out = (line: string | null) => {
|
|
49
|
+
if (line) write(line)
|
|
50
|
+
}
|
|
51
|
+
return (e) => {
|
|
52
|
+
switch (e.type) {
|
|
53
|
+
case "run-start":
|
|
54
|
+
steps = 0
|
|
55
|
+
return out(`\n▸ Browser: ${e.goal}${e.url ? ` (${e.url})` : ""}`)
|
|
56
|
+
case "run-event":
|
|
57
|
+
if (e.event.type === "step") steps = Math.max(steps, e.event.step)
|
|
58
|
+
return e.event.type === "done" ? undefined : out(formatEvent(e.event, color, detail))
|
|
59
|
+
case "run-done":
|
|
60
|
+
if (!e.result) return out(`✗ Browser run failed: ${e.error}`)
|
|
61
|
+
return out(
|
|
62
|
+
`\n${formatSummary(
|
|
63
|
+
buildSummary({
|
|
64
|
+
status: e.result.status,
|
|
65
|
+
text: e.result.summary,
|
|
66
|
+
finalUrl: e.result.finalUrl,
|
|
67
|
+
ms: e.ms,
|
|
68
|
+
tokens: e.result.usage.input + e.result.usage.output,
|
|
69
|
+
steps,
|
|
70
|
+
files: e.files,
|
|
71
|
+
memory: e.result.memory,
|
|
72
|
+
usage: e.result.usage,
|
|
73
|
+
cost: e.result.cost,
|
|
74
|
+
outcome: e.result.outcome,
|
|
75
|
+
failedChecks: e.result.failedChecks,
|
|
76
|
+
quality: e.result.quality,
|
|
77
|
+
budgetExceeded: e.result.budgetExceeded,
|
|
78
|
+
}),
|
|
79
|
+
)}\n`,
|
|
80
|
+
)
|
|
81
|
+
case "suite-start":
|
|
82
|
+
parallel = e.parallel
|
|
83
|
+
return out(`\n▸ ${e.cases.length} test case${e.cases.length === 1 ? "" : "s"}${e.parallel > 1 ? `, ${e.parallel} at a time` : ""}`)
|
|
84
|
+
case "suite-event":
|
|
85
|
+
// A suite started by a tool reports its coding through code-start, agent, and code-done events instead.
|
|
86
|
+
if (e.event.type === "code-start" || e.event.type === "code-done") return
|
|
87
|
+
return out(suiteEventLine(e.event, parallel, color, detail))
|
|
88
|
+
case "suite-done":
|
|
89
|
+
writeJobSummary(e.suite)
|
|
90
|
+
return out(`\n${formatSuite(e.suite)}\n`)
|
|
91
|
+
case "info":
|
|
92
|
+
return out(`${e.ok === undefined ? "▸" : e.ok ? "✓" : "✗"} ${e.text}`)
|
|
93
|
+
case "agent":
|
|
94
|
+
return e.event.kind === "usage" ? undefined : out(formatEvent(e.event, color, detail))
|
|
95
|
+
case "code-start":
|
|
96
|
+
return out(`\n⇢ Handing over to the coding agent: ${path.relative(process.cwd(), e.runDir) || e.runDir} → a test in ${e.repo}`)
|
|
97
|
+
case "code-done":
|
|
98
|
+
return e.error ? out(`✗ Coding agent: ${e.error}`) : undefined
|
|
99
|
+
case "card":
|
|
100
|
+
return out(`\n${cardText(e.card)}\n`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Enter on the terminal continues, Ctrl+C (the signal) cancels; none when stdin is not a terminal. */
|
|
106
|
+
export function terminalWait(signal: AbortSignal): ToolContext["waitForUser"] {
|
|
107
|
+
if (!process.stdin.isTTY) return undefined
|
|
108
|
+
return (prompt) =>
|
|
109
|
+
new Promise((resolve) => {
|
|
110
|
+
const keys = createInterface({ input: process.stdin })
|
|
111
|
+
const done = (ok: boolean) => {
|
|
112
|
+
keys.close()
|
|
113
|
+
resolve(ok)
|
|
114
|
+
}
|
|
115
|
+
console.log(`\n${prompt} Press Enter to continue (Ctrl+C cancels).`)
|
|
116
|
+
keys.once("line", () => done(true))
|
|
117
|
+
signal.addEventListener("abort", () => done(false), { once: true })
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Numbered options on the terminal; Enter picks the first, Ctrl+C (the signal) cancels. None when stdin is not a terminal. */
|
|
122
|
+
export function terminalAsk(signal: AbortSignal): ToolContext["ask"] {
|
|
123
|
+
if (!process.stdin.isTTY) return undefined
|
|
124
|
+
return (q) =>
|
|
125
|
+
new Promise((resolve) => {
|
|
126
|
+
const keys = createInterface({ input: process.stdin, output: process.stdout })
|
|
127
|
+
const done = (id: string | null) => {
|
|
128
|
+
keys.close()
|
|
129
|
+
resolve(id)
|
|
130
|
+
}
|
|
131
|
+
console.log(["", q.title, ...(q.detail ? [q.detail] : []), ...(q.lines || []), ...q.options.map((o, i) => ` ${i + 1}. ${o.label}`)].join("\n"))
|
|
132
|
+
const askOnce = () =>
|
|
133
|
+
keys.question(`Choose 1-${q.options.length} (Enter: 1): `, (line) => {
|
|
134
|
+
const t = line.trim()
|
|
135
|
+
const n = t ? Number(t) : 1
|
|
136
|
+
if (Number.isInteger(n) && n >= 1 && n <= q.options.length) return done(q.options[n - 1].id)
|
|
137
|
+
askOnce()
|
|
138
|
+
})
|
|
139
|
+
askOnce()
|
|
140
|
+
signal.addEventListener("abort", () => done(null), { once: true })
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* One line from the terminal; a secret is read with echo off, so a pasted token never shows. Enter on an empty line
|
|
146
|
+
* takes `initial`; Ctrl+C or the signal cancels (null). Null straight away when stdin is not a terminal.
|
|
147
|
+
*/
|
|
148
|
+
export function terminalInput(signal?: AbortSignal): (q: { label: string; secret?: boolean; initial?: string }) => Promise<string | null> {
|
|
149
|
+
return (q) =>
|
|
150
|
+
new Promise((resolve) => {
|
|
151
|
+
if (!process.stdin.isTTY) return resolve(null)
|
|
152
|
+
const label = `${q.label}${q.initial ? ` [${q.initial}]` : ""}: `
|
|
153
|
+
if (!q.secret) {
|
|
154
|
+
const keys = createInterface({ input: process.stdin, output: process.stdout })
|
|
155
|
+
const done = (v: string | null) => {
|
|
156
|
+
keys.close()
|
|
157
|
+
resolve(v)
|
|
158
|
+
}
|
|
159
|
+
keys.question(label, (line) => done(line.trim() || q.initial || ""))
|
|
160
|
+
keys.on("SIGINT", () => done(null))
|
|
161
|
+
signal?.addEventListener("abort", () => done(null), { once: true })
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const stdin = process.stdin
|
|
165
|
+
let value = ""
|
|
166
|
+
const done = (v: string | null) => {
|
|
167
|
+
stdin.off("data", onData)
|
|
168
|
+
stdin.setRawMode(false)
|
|
169
|
+
stdin.pause()
|
|
170
|
+
process.stdout.write("\n")
|
|
171
|
+
resolve(v)
|
|
172
|
+
}
|
|
173
|
+
const onData = (chunk: Buffer | string) => {
|
|
174
|
+
for (const ch of chunk.toString()) {
|
|
175
|
+
if (ch === "\r" || ch === "\n") return done(value.trim() || q.initial || "")
|
|
176
|
+
if (ch === "\u0003") return done(null)
|
|
177
|
+
if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1)
|
|
178
|
+
else if (ch >= " ") value += ch
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
process.stdout.write(`${label}(hidden) `)
|
|
182
|
+
stdin.setRawMode(true)
|
|
183
|
+
stdin.resume()
|
|
184
|
+
stdin.on("data", onData)
|
|
185
|
+
signal?.addEventListener("abort", () => done(null), { once: true })
|
|
186
|
+
})
|
|
187
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import type { AppConfig } from "../config.ts"
|
|
6
|
+
import { adoRequirements, type Requirement } from "../cases/context.ts"
|
|
7
|
+
import { adoSettings } from "../cases/ado.ts"
|
|
8
|
+
import { http } from "../cases/http.ts"
|
|
9
|
+
import { readPages, type MemoryPage } from "../memory.ts"
|
|
10
|
+
import { importsOf, isTestFile } from "./impact.ts"
|
|
11
|
+
import { adoBase, fetchPr, fetchPrBranches, gh, gitRemotes, parsePrRef, type PrInfo, type PrRef } from "./prreview.ts"
|
|
12
|
+
import { readMappings } from "./traceability.ts"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Which parts of the product a pull request touches, when the product's code and the automated tests may live in
|
|
16
|
+
* different repositories. The pull request is read from a local clone of the product repository when there is one
|
|
17
|
+
* (its diff, and its imports to follow a shared component to the pages that use it), otherwise through the GitHub or
|
|
18
|
+
* Azure DevOps API (the changed files and their contents). Its changes are then tied to the product by:
|
|
19
|
+
*
|
|
20
|
+
* - the work items it is linked to, up through feature and epic, with their Area Paths;
|
|
21
|
+
* - the test ids, ids, labels, and texts its changed UI code carries, matched against page memory (every element
|
|
22
|
+
* ForgeQE met on each page of the application), and the routes it changes, matched against the pages' paths;
|
|
23
|
+
* - in the automation repository (the working directory): the tests that reach those pages, the test cases they
|
|
24
|
+
* automate, and the requirements those test cases test.
|
|
25
|
+
*
|
|
26
|
+
* ForgeQE's agent turns the evidence into the impact report: the areas, the risk, what to test, and the gaps.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type ChangedSource = { path: string; added: string }
|
|
30
|
+
|
|
31
|
+
export type ImpactedPage = { page: string; title?: string; evidence: string[]; files: string[] }
|
|
32
|
+
export type ImpactedTest = { file: string; reasons: string[]; caseId?: string }
|
|
33
|
+
export type ManualCase = { id: string; title?: string; url?: string; requirement: string }
|
|
34
|
+
|
|
35
|
+
export type AreaImpact = {
|
|
36
|
+
pr: PrInfo
|
|
37
|
+
/** Where the changes were read: a clone's diff, or the API. */
|
|
38
|
+
via: "clone" | "api"
|
|
39
|
+
files: string[]
|
|
40
|
+
requirements: Requirement[]
|
|
41
|
+
pages: ImpactedPage[]
|
|
42
|
+
routes: string[]
|
|
43
|
+
tests: ImpactedTest[]
|
|
44
|
+
manual: ManualCase[]
|
|
45
|
+
/** Changed UI files nothing above ties to a page. */
|
|
46
|
+
unmapped: string[]
|
|
47
|
+
notes: string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const UI = /\.(tsx|jsx|vue|svelte|html?|cshtml|razor|component\.ts|aspx|erb|hbs|ejs|jinja2?|twig|xaml)$/i
|
|
51
|
+
const CODE = /\.([cm]?[jt]sx?|vue|svelte|py|cs|cshtml|razor|java|kt|go|rb|php|html?)$/i
|
|
52
|
+
const MAX_FILES = 60
|
|
53
|
+
const MAX_BYTES = 200_000
|
|
54
|
+
|
|
55
|
+
const git = (repo: string, args: string[]) => spawnSync("git", ["-c", "core.quotepath=false", ...args], { cwd: repo, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 })
|
|
56
|
+
|
|
57
|
+
/** Added lines per file from a unified diff. */
|
|
58
|
+
export function addedByFile(diff: string): ChangedSource[] {
|
|
59
|
+
const out = new Map<string, string[]>()
|
|
60
|
+
let file = ""
|
|
61
|
+
for (const line of diff.split("\n")) {
|
|
62
|
+
if (line.startsWith("+++ ")) {
|
|
63
|
+
file = line === "+++ /dev/null" ? "" : line.slice(4).replace(/^b\//, "")
|
|
64
|
+
if (file && !out.has(file)) out.set(file, [])
|
|
65
|
+
} else if (file && line.startsWith("+") && !line.startsWith("+++")) out.get(file)!.push(line.slice(1))
|
|
66
|
+
}
|
|
67
|
+
return [...out].map(([p, lines]) => ({ path: p, added: lines.join("\n") }))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- What the changed code names ----
|
|
71
|
+
|
|
72
|
+
const ATTR = /\b(data-testid|data-test-id|data-test|data-qa|data-cy|id|name|aria-label|placeholder|title|alt|formControlName|testID)\s*=\s*\{?\s*["'`]([^"'`{}$\n]{2,60})["'`]/g
|
|
73
|
+
const TEST_ID_CALL = /\bgetBy(?:TestId|Label|Placeholder|Text|Title|AltText)\(\s*["'`]([^"'`\n]{2,60})["'`]/g
|
|
74
|
+
const JSX_TEXT = />\s*([A-Z][^<>{}\n]{2,40}?)\s*</g
|
|
75
|
+
const ROUTE_PATTERNS = [
|
|
76
|
+
/\b(?:path|route|to|href|url)\s*[:=]\s*["'`](\/[\w\-/:.{}[\]*]*)["'`]/g,
|
|
77
|
+
/<Route[^>]*\bpath=\{?["'`](\/?[\w\-/:.{}[\]*]*)["'`]/g,
|
|
78
|
+
/@(?:app|router|bp|blueprint)\.(?:route|get|post|put|delete)\(\s*["'`](\/[^"'`]*)["'`]/g,
|
|
79
|
+
/\b(?:router|app)\.(?:get|post|put|patch|delete|use)\(\s*["'`](\/[^"'`]*)["'`]/g,
|
|
80
|
+
/\[(?:Http(?:Get|Post|Put|Delete))?Route\(\s*"([^"]+)"\s*\)\]/g,
|
|
81
|
+
/@(?:Get|Post|Put|Delete|Request)Mapping\(\s*(?:value\s*=\s*)?"([^"]+)"/g,
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
export function namesInCode(text: string): { selectors: string[]; texts: string[]; routes: string[] } {
|
|
85
|
+
const selectors = new Set<string>()
|
|
86
|
+
const texts = new Set<string>()
|
|
87
|
+
const routes = new Set<string>()
|
|
88
|
+
for (const m of text.matchAll(ATTR)) selectors.add(m[2].trim())
|
|
89
|
+
for (const m of text.matchAll(TEST_ID_CALL)) selectors.add(m[1].trim())
|
|
90
|
+
for (const m of text.matchAll(JSX_TEXT)) {
|
|
91
|
+
const t = m[1].trim()
|
|
92
|
+
if (t.split(/\s+/).length <= 6 && !/[=;()]/.test(t)) texts.add(t)
|
|
93
|
+
}
|
|
94
|
+
for (const r of ROUTE_PATTERNS) for (const m of text.matchAll(r)) if (m[1] && m[1] !== "/") routes.add(m[1].startsWith("/") ? m[1] : `/${m[1]}`)
|
|
95
|
+
return { selectors: [...selectors], texts: [...texts], routes: [...routes] }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The route a file stands for under file-based routing (Next.js pages/ and app/, SvelteKit, Nuxt). */
|
|
99
|
+
export function fileRoute(file: string): string | null {
|
|
100
|
+
const m = /(?:^|\/)(?:src\/)?(pages|app|routes)\/(.+?)\.(tsx|jsx|ts|js|vue|svelte)$/.exec(file)
|
|
101
|
+
if (!m) return null
|
|
102
|
+
let route = m[2].replace(/\/(page|index|\+page|_index)$/, "").replace(/^(page|index|\+page)$/, "")
|
|
103
|
+
if (m[1] === "app" && !/(^|\/)page$/.test(m[2])) return null
|
|
104
|
+
route = route.replace(/\([^)]*\)\/?/g, "").replace(/\[\.\.\.(\w+)\]/g, "*").replace(/\[(\w+)\]/g, ":$1")
|
|
105
|
+
if (/^_|\/_/.test(route) || /^api(\/|$)/.test(route)) return null
|
|
106
|
+
return `/${route}`.replace(/\/+$/, "") || "/"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- Matching against page memory ----
|
|
110
|
+
|
|
111
|
+
const lower = (s: string | null | undefined) => (s || "").trim().toLowerCase()
|
|
112
|
+
|
|
113
|
+
/** The pages whose elements carry a changed selector or text, or whose path is a changed route. */
|
|
114
|
+
export function pagesFor(pages: MemoryPage[], found: { file: string; selectors: string[]; texts: string[]; routes: string[] }[]): ImpactedPage[] {
|
|
115
|
+
const out = new Map<string, ImpactedPage>()
|
|
116
|
+
const hit = (p: MemoryPage, why: string, file: string) => {
|
|
117
|
+
const key = `${p.host}${p.path}`
|
|
118
|
+
const e = out.get(key) || { page: key, ...(p.title ? { title: p.title } : {}), evidence: [], files: [] }
|
|
119
|
+
if (!e.evidence.includes(why)) e.evidence.push(why)
|
|
120
|
+
if (!e.files.includes(file)) e.files.push(file)
|
|
121
|
+
out.set(key, e)
|
|
122
|
+
}
|
|
123
|
+
const routeRe = (r: string) => new RegExp(`^${r.replace(/[.+?^${}()|\\]/g, "\\$&").replace(/:\w+|\\\{\w+\\\}|\[\w+\]/g, "[^/]+").replace(/\*/g, ".*")}/?$`, "i")
|
|
124
|
+
for (const f of found) {
|
|
125
|
+
for (const p of pages) {
|
|
126
|
+
const elements = Object.values(p.elements || {})
|
|
127
|
+
for (const sel of f.selectors) {
|
|
128
|
+
const s = lower(sel)
|
|
129
|
+
const el = elements.find((e) => Object.values(e.attributes || {}).some((v) => lower(v) === s))
|
|
130
|
+
if (el) hit(p, `element ${JSON.stringify(sel)} (${el.tag}${el.name ? ` "${el.name}"` : ""})`, f.file)
|
|
131
|
+
}
|
|
132
|
+
for (const t of f.texts) {
|
|
133
|
+
const s = lower(t)
|
|
134
|
+
if (s.length >= 3 && elements.some((e) => lower(e.name) === s || lower(e.text) === s)) hit(p, `text ${JSON.stringify(t)}`, f.file)
|
|
135
|
+
}
|
|
136
|
+
for (const r of f.routes) if (routeRe(r).test(p.path) || p.path.toLowerCase().endsWith(r.toLowerCase())) hit(p, `route ${r}`, f.file)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return [...out.values()].sort((a, b) => b.evidence.length - a.evidence.length)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- Reading the pull request ----
|
|
143
|
+
|
|
144
|
+
async function adoChanges(cfg: AppConfig, ref: PrRef, pr: PrInfo): Promise<ChangedSource[]> {
|
|
145
|
+
const { auth } = adoSettings(cfg.sources)
|
|
146
|
+
const base = adoBase(ref)
|
|
147
|
+
const { data: its } = await http<{ value?: { id: number }[] }>(`${base}/pullRequests/${ref.number}/iterations?api-version=7.1`, { auth })
|
|
148
|
+
const last = its?.value?.at(-1)?.id
|
|
149
|
+
if (!last) return []
|
|
150
|
+
const { data: ch } = await http<{ changeEntries?: { item?: { path?: string; isFolder?: boolean }; changeType?: string }[] }>(`${base}/pullRequests/${ref.number}/iterations/${last}/changes?api-version=7.1&$top=500`, { auth })
|
|
151
|
+
const paths = (ch?.changeEntries || []).filter((c) => c.item?.path && !c.item.isFolder && !/delete/i.test(c.changeType || "")).map((c) => c.item!.path!.replace(/^\//, ""))
|
|
152
|
+
const out: ChangedSource[] = []
|
|
153
|
+
for (const p of paths) {
|
|
154
|
+
if (!CODE.test(p) || out.length >= MAX_FILES) {
|
|
155
|
+
out.push({ path: p, added: "" })
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const q = new URLSearchParams({ path: `/${p}`, "versionDescriptor.version": pr.headSha, "versionDescriptor.versionType": "commit", includeContent: "true", "api-version": "7.1" })
|
|
160
|
+
const { data } = await http<{ content?: string } | string>(`${base}/items?${q}`, { auth, headers: { Accept: "application/json" } })
|
|
161
|
+
const content = typeof data === "string" ? data : data?.content || ""
|
|
162
|
+
out.push({ path: p, added: content.slice(0, MAX_BYTES) })
|
|
163
|
+
} catch {
|
|
164
|
+
out.push({ path: p, added: "" })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The pull request and its changed files with their added code: from a matching clone, else the API. */
|
|
171
|
+
export async function prSources(cfg: AppConfig, input: string, opts: { productRepo?: string; cwd: string }): Promise<{ pr: PrInfo; files: ChangedSource[]; via: "clone" | "api"; clone?: string; notes: string[] }> {
|
|
172
|
+
const notes: string[] = []
|
|
173
|
+
const candidates = [opts.productRepo, opts.cwd].filter((d): d is string => Boolean(d)).map((d) => path.resolve(opts.cwd, d))
|
|
174
|
+
let ref: PrRef | null = null
|
|
175
|
+
for (const dir of candidates) {
|
|
176
|
+
const top = git(dir, ["rev-parse", "--show-toplevel"])
|
|
177
|
+
if (top.status !== 0) continue
|
|
178
|
+
const root = top.stdout.trim()
|
|
179
|
+
const remotes = await gitRemotes(root)
|
|
180
|
+
try {
|
|
181
|
+
ref = parsePrRef(input, remotes)
|
|
182
|
+
} catch {
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
const remote = remotes.find((r) => r.ref.platform === ref!.platform && (r.ref.repo || "").toLowerCase() === (ref!.repo || "").toLowerCase())
|
|
186
|
+
if (!remote) continue
|
|
187
|
+
const pr = await fetchPr(ref, cfg, root)
|
|
188
|
+
const { base, dropRefs } = await fetchPrBranches(root, remote.name, pr)
|
|
189
|
+
try {
|
|
190
|
+
const diff = git(root, ["diff", "--no-color", "--no-ext-diff", "-U0", "-M", "--src-prefix=a/", "--dst-prefix=b/", base, pr.headSha]).stdout
|
|
191
|
+
return { pr, files: addedByFile(diff), via: "clone", clone: root, notes }
|
|
192
|
+
} finally {
|
|
193
|
+
await dropRefs()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!ref) ref = parsePrRef(input, [])
|
|
197
|
+
const pr = await fetchPr(ref, cfg, opts.cwd)
|
|
198
|
+
notes.push("No local clone of the product repository: its changes were read through the API, without its imports, so a shared component is tied to pages only by what it names.")
|
|
199
|
+
if (ref.platform === "github") return { pr, files: addedByFile(await gh(["pr", "diff", String(ref.number), "--repo", ref.repo!], opts.cwd)), via: "api", notes }
|
|
200
|
+
return { pr, files: await adoChanges(cfg, ref, pr), via: "api", notes }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- Following a shared component to its pages (with a clone) ----
|
|
204
|
+
|
|
205
|
+
const PAGE_FILE = /(^|\/)(pages|app|routes|views|screens|containers|features)\//i
|
|
206
|
+
|
|
207
|
+
function pagesThatImport(clone: string, changed: string[]): Map<string, string> {
|
|
208
|
+
const files = git(clone, ["ls-files"]).stdout.split("\n").filter((f) => f && /\.[cm]?[jt]sx?$|\.vue$|\.svelte$/.test(f) && !/node_modules\//.test(f)).slice(0, 8000)
|
|
209
|
+
const set = new Set(files)
|
|
210
|
+
const importers = new Map<string, string[]>()
|
|
211
|
+
for (const f of files) {
|
|
212
|
+
let text = ""
|
|
213
|
+
try {
|
|
214
|
+
const full = path.join(clone, f)
|
|
215
|
+
if (statSync(full).size <= MAX_BYTES) text = readFileSync(full, "utf8")
|
|
216
|
+
} catch {}
|
|
217
|
+
for (const dep of importsOf(f, text, set)) importers.set(dep, [...(importers.get(dep) || []), f])
|
|
218
|
+
}
|
|
219
|
+
// A page or view file that (through other modules) imports a changed file: page file → the changed file it reaches.
|
|
220
|
+
const reached = new Map<string, string>()
|
|
221
|
+
for (const start of changed) {
|
|
222
|
+
const queue = [start]
|
|
223
|
+
const seen = new Set([start])
|
|
224
|
+
while (queue.length && seen.size < 400) {
|
|
225
|
+
const f = queue.shift()!
|
|
226
|
+
for (const imp of importers.get(f) || []) {
|
|
227
|
+
if (seen.has(imp)) continue
|
|
228
|
+
seen.add(imp)
|
|
229
|
+
if (PAGE_FILE.test(imp) && !isTestFile(imp)) reached.set(imp, start)
|
|
230
|
+
queue.push(imp)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return reached
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- The whole analysis ----
|
|
238
|
+
|
|
239
|
+
/** Tests in the automation repository that visit a page: by its path, its title, or a changed selector they use. */
|
|
240
|
+
function testsForPages(repo: string, pages: ImpactedPage[], selectors: string[]): ImpactedTest[] {
|
|
241
|
+
const out = new Map<string, ImpactedTest>()
|
|
242
|
+
const files = git(repo, ["ls-files", "--cached", "--others", "--exclude-standard"]).stdout.split("\n").filter((f) => f && /\.([cm]?[jt]sx?|py|cs|java|kt)$/.test(f) && !/(^|\/)(node_modules|needs-attention)\//.test(f)).slice(0, 6000)
|
|
243
|
+
const texts = new Map<string, string>()
|
|
244
|
+
for (const f of files) {
|
|
245
|
+
try {
|
|
246
|
+
const full = path.join(repo, f)
|
|
247
|
+
if (statSync(full).size <= MAX_BYTES) texts.set(f, readFileSync(full, "utf8"))
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
const add = (file: string, why: string) => {
|
|
251
|
+
const t = out.get(file) || { file, reasons: [] }
|
|
252
|
+
if (!t.reasons.includes(why)) t.reasons.push(why)
|
|
253
|
+
out.set(file, t)
|
|
254
|
+
}
|
|
255
|
+
// Page objects and helpers that mention the page, then the tests that use them (by name).
|
|
256
|
+
const mentions = new Map<string, string>()
|
|
257
|
+
for (const p of pages) {
|
|
258
|
+
const pathPart = p.page.slice(p.page.indexOf("/"))
|
|
259
|
+
for (const [f, text] of texts) {
|
|
260
|
+
if ((pathPart.length > 1 && text.includes(pathPart)) || (p.title && p.title.length > 4 && text.includes(p.title))) mentions.set(f, p.page)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const s of selectors) for (const [f, text] of texts) if (s.length > 3 && (text.includes(`'${s}'`) || text.includes(`"${s}"`))) mentions.set(f, mentions.get(f) || `the element ${s}`)
|
|
264
|
+
for (const [f, what] of mentions) {
|
|
265
|
+
if (isTestFile(f)) add(f, `visits ${what}`)
|
|
266
|
+
else {
|
|
267
|
+
const stem = path.basename(f).replace(/\.[^.]+$/, "")
|
|
268
|
+
for (const [t, text] of texts) if (isTestFile(t) && stem.length >= 4 && text.includes(stem)) add(t, `uses ${stem}, which covers ${what}`)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const map = readMappings(repo).cases
|
|
272
|
+
for (const [id, m] of Object.entries(map)) if (m.testFile && out.has(m.testFile)) out.get(m.testFile)!.caseId = id
|
|
273
|
+
return [...out.values()].sort((a, b) => a.file.localeCompare(b.file))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function analyzeAreas(cfg: AppConfig, input: string, opts: { productRepo?: string; cwd: string }): Promise<AreaImpact> {
|
|
277
|
+
const src = await prSources(cfg, input, opts)
|
|
278
|
+
const notes = [...src.notes]
|
|
279
|
+
const found = src.files.map((f) => {
|
|
280
|
+
const names = namesInCode(f.added)
|
|
281
|
+
const route = fileRoute(f.path)
|
|
282
|
+
return { file: f.path, ...names, routes: [...names.routes, ...(route ? [route] : [])] }
|
|
283
|
+
})
|
|
284
|
+
// With a clone: a changed shared component counts for each page that imports it.
|
|
285
|
+
if (src.clone) {
|
|
286
|
+
const changedCode = src.files.map((f) => f.path).filter((p) => /\.[cm]?[jt]sx?$|\.vue$|\.svelte$/.test(p))
|
|
287
|
+
for (const [pageFile, via] of pagesThatImport(src.clone, changedCode)) {
|
|
288
|
+
let text = ""
|
|
289
|
+
try {
|
|
290
|
+
text = readFileSync(path.join(src.clone, pageFile), "utf8").slice(0, MAX_BYTES)
|
|
291
|
+
} catch {}
|
|
292
|
+
const names = namesInCode(text)
|
|
293
|
+
const route = fileRoute(pageFile)
|
|
294
|
+
found.push({ file: `${via} (through ${pageFile})`, selectors: names.selectors, texts: [], routes: [...names.routes, ...(route ? [route] : [])] })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const memory = cfg.memory?.dir && existsSync(cfg.memory.dir) ? readPages(cfg.memory.dir) : []
|
|
298
|
+
if (!memory.length) notes.push("Page memory is empty: run the application's flows with ForgeQE first, so changes can be tied to its pages.")
|
|
299
|
+
const pages = pagesFor(memory, found)
|
|
300
|
+
const routes = [...new Set(found.flatMap((f) => f.routes))]
|
|
301
|
+
const selectors = [...new Set(found.flatMap((f) => f.selectors))]
|
|
302
|
+
const tests = testsForPages(opts.cwd, pages, selectors)
|
|
303
|
+
|
|
304
|
+
// Requirements: the work items the pull request is linked to, and those the impacted tests' test cases test.
|
|
305
|
+
const ctx = { requirements: new Map<string, Requirement>() }
|
|
306
|
+
if (cfg.sources.ado) {
|
|
307
|
+
try {
|
|
308
|
+
if (src.pr.workItemIds?.length) await adoRequirements(cfg, src.pr.workItemIds, ctx)
|
|
309
|
+
const caseIds = tests.map((t) => t.caseId).filter((c): c is string => Boolean(c?.startsWith("ADO-"))).map((c) => Number(c.slice(4)))
|
|
310
|
+
for (const id of caseIds.slice(0, 15)) {
|
|
311
|
+
const { data } = await http<{ relations?: { rel: string; url: string }[] }>(`${adoSettings(cfg.sources).org}/_apis/wit/workitems/${id}?$expand=relations&api-version=7.1`, { auth: adoSettings(cfg.sources).auth })
|
|
312
|
+
const tested = (data?.relations || []).filter((r) => r.rel === "Microsoft.VSTS.Common.TestedBy-Reverse").map((r) => Number(r.url.split("/").pop()))
|
|
313
|
+
if (tested.length) await adoRequirements(cfg, tested, ctx)
|
|
314
|
+
}
|
|
315
|
+
} catch (err) {
|
|
316
|
+
notes.push(`Some work items could not be read: ${(err as Error).message.slice(0, 200)}`)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const requirements = [...ctx.requirements.values()]
|
|
320
|
+
// Manual test cases: test cases of the impacted requirements that no automated test covers yet.
|
|
321
|
+
const automated = new Set(Object.entries(readMappings(opts.cwd).cases).filter(([, m]) => m.status !== "needs_attention").map(([id]) => id))
|
|
322
|
+
const manual: ManualCase[] = []
|
|
323
|
+
for (const r of requirements) for (const tc of r.testedBy || []) if (!automated.has(tc) && !manual.some((m) => m.id === tc)) manual.push({ id: tc, requirement: r.id })
|
|
324
|
+
if (manual.length && cfg.sources.ado) {
|
|
325
|
+
try {
|
|
326
|
+
const { org, auth } = adoSettings(cfg.sources)
|
|
327
|
+
const { data } = await http<{ value?: { id: number; fields: Record<string, string> }[] }>(`${org}/_apis/wit/workitemsbatch?api-version=7.1`, { auth, body: { ids: manual.slice(0, 200).map((m) => Number(m.id.slice(4))), fields: ["System.Title", "System.TeamProject"], errorPolicy: "omit" } })
|
|
328
|
+
for (const w of data?.value || []) {
|
|
329
|
+
const m = manual.find((x) => x.id === `ADO-${w.id}`)
|
|
330
|
+
if (m) Object.assign(m, { title: w.fields["System.Title"], url: `${org}/${encodeURIComponent(w.fields["System.TeamProject"] || "")}/_workitems/edit/${w.id}` })
|
|
331
|
+
}
|
|
332
|
+
} catch {}
|
|
333
|
+
}
|
|
334
|
+
const tied = new Set(pages.flatMap((p) => p.files.map((f) => f.split(" (through")[0])))
|
|
335
|
+
const unmapped = src.files.map((f) => f.path).filter((p) => UI.test(p) && !tied.has(p))
|
|
336
|
+
return { pr: src.pr, via: src.via, files: src.files.map((f) => f.path), requirements, pages, routes, tests, manual, unmapped, notes }
|
|
337
|
+
}
|