@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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
import { pageKey } from "../quality/history.ts"
|
|
4
|
+
import { problemLine, readTimeline, type BrowserProblem } from "../report/data.ts"
|
|
5
|
+
import { FAILURE_LABEL, type FailureType } from "./failure.ts"
|
|
6
|
+
import type { DefectHistoryNote, PastDefect } from "./defectHistory.ts"
|
|
7
|
+
import type { ReportEnv } from "../report/html.ts"
|
|
8
|
+
import type { RootCause } from "./rca.ts"
|
|
9
|
+
import type { ReproPack } from "./repro.ts"
|
|
10
|
+
import type { CaseResult, WriteBack } from "./types.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Defect intelligence, part one: failures with the same cause are one defect. Each failing case gets a signature from
|
|
14
|
+
* the strongest evidence its run recorded, ids and numbers taken out so the same fault matches across cases, devices
|
|
15
|
+
* and runs:
|
|
16
|
+
*
|
|
17
|
+
* - request: a request that failed (a 5xx before a 4xx), by its route and status
|
|
18
|
+
* - exception: an uncaught error in the page, by its message
|
|
19
|
+
* - console: a console error, by its text
|
|
20
|
+
* - page: no browser evidence; the page and what the agent saw. Two such cases are one defect only on the same page
|
|
21
|
+
* with mostly the same words, and only within a run, since the agent's words change from run to run.
|
|
22
|
+
*
|
|
23
|
+
* Errors that also appear in the suite's passing runs are noise (an analytics call, a missing favicon), not evidence.
|
|
24
|
+
* Evidence is the cause whatever the case's failure was classified as: a 500 that fails one case and stops another
|
|
25
|
+
* from running is one defect.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type SignatureKind = "request" | "exception" | "console" | "page"
|
|
29
|
+
|
|
30
|
+
/** The defect a failing case belongs to; kept on the case result, so suite.json carries it. */
|
|
31
|
+
export type CaseDefect = {
|
|
32
|
+
/** forgeqe-d-<hash> for evidence that holds across runs (also the bug's label or tag); forgeqe-p-<hash> for page. */
|
|
33
|
+
id: string
|
|
34
|
+
kind: SignatureKind
|
|
35
|
+
title: string
|
|
36
|
+
evidence: string[]
|
|
37
|
+
page?: string
|
|
38
|
+
/** Words a person filing this bug by hand would likely use (a route, an error message), to find their bugs. */
|
|
39
|
+
term?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A second run of a defect's first case: whether it failed the same way. */
|
|
43
|
+
export type Reproduced = { runs: number; failed: number; runDir?: string }
|
|
44
|
+
|
|
45
|
+
/** One defect and every case it breaks, for the report and the suite summary. */
|
|
46
|
+
export type DefectCluster = CaseDefect & {
|
|
47
|
+
failure: FailureType | "unclassified"
|
|
48
|
+
failureLabel: string
|
|
49
|
+
cases: { id: string; title: string; status: CaseResult["status"]; device?: string }[]
|
|
50
|
+
pages: string[]
|
|
51
|
+
bug?: { label: string; url?: string }
|
|
52
|
+
/** What earlier runs and the bug tracker know about it (src/cases/defectHistory.ts). */
|
|
53
|
+
history?: DefectHistoryNote
|
|
54
|
+
/** Open bugs not filed for this defect that mention its route or error: possibly the same. */
|
|
55
|
+
related?: { label: string; url?: string; title?: string }[]
|
|
56
|
+
/** How to make it happen again (src/cases/repro.ts). */
|
|
57
|
+
repro?: ReproPack
|
|
58
|
+
/** Why it may happen (src/cases/rca.ts): a hypothesis, never a verdict. */
|
|
59
|
+
rca?: RootCause
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi
|
|
63
|
+
const ID_SEGMENT = /^(\d+|[0-9a-f-]{8,}|(?=.*\d)[A-Za-z0-9_-]{16,})$/i
|
|
64
|
+
|
|
65
|
+
/** A URL as its route: host and path, ids in the path as :id, no query or hash. */
|
|
66
|
+
export function routeOf(url: string): string {
|
|
67
|
+
try {
|
|
68
|
+
const u = new URL(url)
|
|
69
|
+
const segs = u.pathname.split("/").map((s) => (s && /\d/.test(s) && ID_SEGMENT.test(s) ? ":id" : s))
|
|
70
|
+
return `${u.host}${segs.join("/").replace(/\/+$/, "") || "/"}`
|
|
71
|
+
} catch {
|
|
72
|
+
return url.split(/[?#]/)[0]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Text with what changes between runs taken out: URLs as routes, then ids, times and numbers. */
|
|
77
|
+
export function normalize(text: string): string {
|
|
78
|
+
return text
|
|
79
|
+
.replace(/https?:\/\/[^\s"')]+/g, (u) => routeOf(u))
|
|
80
|
+
.replace(UUID, "<id>")
|
|
81
|
+
.replace(/\b[0-9a-f]{12,}\b/gi, "<id>")
|
|
82
|
+
.replace(/\b\d{4}-\d\d-\d\d[T ][\d:.]+Z?/g, "<time>")
|
|
83
|
+
.replace(/\d+(\.\d+)?/g, "#")
|
|
84
|
+
.replace(/\s+/g, " ")
|
|
85
|
+
.trim()
|
|
86
|
+
.slice(0, 200)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Requests the browser cancels itself (a navigation, a blocker) say nothing about the application. */
|
|
90
|
+
const IGNORED = /ERR_ABORTED|ERR_BLOCKED_BY_CLIENT|favicon\.ico/i
|
|
91
|
+
|
|
92
|
+
function problemKey(p: BrowserProblem): string {
|
|
93
|
+
if (p.kind === "network") return `request:${p.status || "failed"} ${routeOf(p.url || p.text)}`
|
|
94
|
+
if (p.kind === "exception") return `exception:${normalize(p.text.split("\n")[0])}`
|
|
95
|
+
return `console:${normalize(p.text)}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Stronger evidence first: a server error, an uncaught exception, any other failed request, a console error. */
|
|
99
|
+
function rank(p: BrowserProblem): number {
|
|
100
|
+
if (p.kind === "network" && (p.status || 0) >= 500) return 0
|
|
101
|
+
if (p.kind === "exception") return 1
|
|
102
|
+
if (p.kind === "network") return 2
|
|
103
|
+
return 3
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function errorsOf(r: CaseResult): { p: BrowserProblem; step: number }[] {
|
|
107
|
+
return readTimeline(r.runDir).flatMap((t, i) => (t.problems || []).filter((p) => p.level === "error" && p.kind !== "slow" && !IGNORED.test(`${p.url || ""} ${p.text}`)).map((p) => ({ p, step: i })))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const clip = (s: string, n: number) => {
|
|
111
|
+
const t = s.replace(/\s+/g, " ").trim()
|
|
112
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The route's path without its ids (/api/cart), or the error's message without its type (price is undefined). */
|
|
116
|
+
function problemTerm(p: BrowserProblem): string | undefined {
|
|
117
|
+
if (p.kind === "network") {
|
|
118
|
+
const route = routeOf(p.url || p.text)
|
|
119
|
+
const segs = route.slice(route.indexOf("/")).split("/").filter((s) => s && s !== ":id")
|
|
120
|
+
return segs.length ? `/${segs.join("/")}` : undefined
|
|
121
|
+
}
|
|
122
|
+
if (p.kind === "exception") {
|
|
123
|
+
const msg = p.text.split("\n")[0].replace(/^[\w.$]*(Error|Exception):\s*/, "").trim()
|
|
124
|
+
return msg.length >= 8 ? clip(msg, 60) : undefined
|
|
125
|
+
}
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function problemTitle(p: BrowserProblem): string {
|
|
130
|
+
if (p.kind === "network") return `${routeOf(p.url || p.text)} ${p.status ? `returns ${p.status}` : "fails"}`
|
|
131
|
+
if (p.kind === "exception") return `Uncaught error: ${clip(p.text.split("\n")[0], 120)}`
|
|
132
|
+
return `Console error: ${clip(p.text, 120)}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What the agent saw go wrong, without the step number the failure reason starts with. */
|
|
136
|
+
function reasonOf(r: CaseResult): string {
|
|
137
|
+
return (r.failure?.reason || r.summary.split("\n")[0] || "").replace(/^Step \d+:\s*/, "").trim()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const STOP = new Set(["the", "and", "but", "not", "was", "were", "with", "that", "this", "from", "for", "page", "shows", "instead", "expected", "step"])
|
|
141
|
+
const words = (s: string) => new Set(normalize(s).toLowerCase().split(/[^a-z#<>]+/).filter((w) => w.length > 2 && !STOP.has(w)))
|
|
142
|
+
|
|
143
|
+
function overlap(a: Set<string>, b: Set<string>): number {
|
|
144
|
+
if (!a.size || !b.size) return 0
|
|
145
|
+
let both = 0
|
|
146
|
+
for (const w of a) if (b.has(w)) both++
|
|
147
|
+
return both / (a.size + b.size - both)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const hash = (s: string) => createHash("sha1").update(s).digest("hex").slice(0, 10)
|
|
151
|
+
|
|
152
|
+
const failureOf = (r: CaseResult): DefectCluster["failure"] => r.failure?.type || "unclassified"
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The defects of one suite: the noise its passing runs show, the defects found so far, and the bug each has in this
|
|
156
|
+
* run. Bug filing runs through `serial`, so two cases of one defect finishing at once file one bug, not two.
|
|
157
|
+
*/
|
|
158
|
+
export class DefectBook {
|
|
159
|
+
private noise = new Set<string>()
|
|
160
|
+
private pages: { id: string; failure: string; page: string; words: Set<string> }[] = []
|
|
161
|
+
private queue: Promise<unknown> = Promise.resolve()
|
|
162
|
+
/** The bug a defect has in this run, and the case that filed or found it. */
|
|
163
|
+
readonly bugs = new Map<string, { id: string; label: string; url: string; caseId: string }>()
|
|
164
|
+
/** The closed bug a defect matched in the tracker: it came back. */
|
|
165
|
+
readonly closed = new Map<string, { label: string; url?: string; closedAt?: string }>()
|
|
166
|
+
/** Open bugs not filed by ForgeQE that mention a defect's route or error. */
|
|
167
|
+
readonly related = new Map<string, { label: string; url?: string; title?: string }[]>()
|
|
168
|
+
/** What earlier runs know about each defect (defect-history.json); empty outside a suite. */
|
|
169
|
+
past: Record<string, PastDefect> = {}
|
|
170
|
+
/** Defects whose first case was run a second time in this suite, to see whether it repeats. */
|
|
171
|
+
readonly reproduced = new Set<string>()
|
|
172
|
+
/** Where the suite runs: the browser, the build, the commit; for the repro steps in a bug. */
|
|
173
|
+
env?: ReportEnv
|
|
174
|
+
/** How many suites in a row before this one a case failed in: its earlier failures count as repeats. */
|
|
175
|
+
earlier?: (caseId: string) => number
|
|
176
|
+
|
|
177
|
+
/** A passing run's errors are noise for every failure after it. */
|
|
178
|
+
notePassed(r: CaseResult): void {
|
|
179
|
+
for (const { p } of errorsOf(r)) this.noise.add(problemKey(p))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The defect a failing case belongs to; undefined for a case that passed or did not run. */
|
|
183
|
+
assign(r: CaseResult): CaseDefect | undefined {
|
|
184
|
+
return this.signature(r, true)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The defect a failing run would belong to, without recording it: to compare a second run with the first. */
|
|
188
|
+
peek(r: CaseResult): CaseDefect | undefined {
|
|
189
|
+
return this.signature(r, false)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private signature(r: CaseResult, record: boolean): CaseDefect | undefined {
|
|
193
|
+
if (r.status !== "failed" && r.status !== "error") return undefined
|
|
194
|
+
const failure = failureOf(r)
|
|
195
|
+
const evidence = errorsOf(r)
|
|
196
|
+
.filter(({ p }) => !this.noise.has(problemKey(p)))
|
|
197
|
+
// The strongest kind; of those, the one nearest the failure (the latest step).
|
|
198
|
+
.sort((a, b) => rank(a.p) - rank(b.p) || b.step - a.step)[0]?.p
|
|
199
|
+
const page = r.finalUrl ? pageKey(r.finalUrl) : undefined
|
|
200
|
+
if (evidence) {
|
|
201
|
+
const key = problemKey(evidence)
|
|
202
|
+
const term = problemTerm(evidence)
|
|
203
|
+
return { id: `forgeqe-d-${hash(key)}`, kind: key.split(":")[0] as SignatureKind, title: problemTitle(evidence), evidence: [problemLine(evidence)], ...(page ? { page } : {}), ...(term ? { term } : {}) }
|
|
204
|
+
}
|
|
205
|
+
const reason = reasonOf(r)
|
|
206
|
+
const own = words(reason)
|
|
207
|
+
const same = this.pages.find((c) => c.failure === failure && c.page === (page || "") && overlap(c.words, own) >= 0.5)
|
|
208
|
+
const id = same?.id || `forgeqe-p-${hash(`${failure}|${page || ""}|${normalize(reason).toLowerCase()}`)}`
|
|
209
|
+
if (!same && record) this.pages.push({ id, failure, page: page || "", words: own })
|
|
210
|
+
const where = page ? routeOf(page) : ""
|
|
211
|
+
return { id, kind: "page", title: clip(`${where ? `${where}: ` : ""}${reason || FAILURE_LABEL[failure as FailureType] || "failed"}`, 160), evidence: reason ? [clip(reason, 300)] : [], ...(page ? { page } : {}) }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Run `fn` after every earlier call has finished. */
|
|
215
|
+
serial<T>(fn: () => Promise<T>): Promise<T> {
|
|
216
|
+
const next = this.queue.then(fn, fn)
|
|
217
|
+
this.queue = next.catch(() => {})
|
|
218
|
+
return next
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The suite's defects, the ones breaking most cases first. Results without a defect (passed, not run) are left out.
|
|
224
|
+
* A defect's bug is the one set on a case, else one posted for it (bugs filed at the end of the suite, or for a case
|
|
225
|
+
* run on several devices, whose results carry the device in their id).
|
|
226
|
+
*/
|
|
227
|
+
export function defectClusters(results: CaseResult[], posted: WriteBack[] = []): DefectCluster[] {
|
|
228
|
+
const postedBug = (id: string) => {
|
|
229
|
+
const w = posted.find((p) => p.source === "bugs" && p.url && !p.error && p.caseId && (p.caseId === id || id.startsWith(`${p.caseId}@`)))
|
|
230
|
+
return w ? { label: w.text.split(":")[0], url: w.url } : undefined
|
|
231
|
+
}
|
|
232
|
+
const byId = new Map<string, DefectCluster>()
|
|
233
|
+
for (const r of results) {
|
|
234
|
+
const d = r.defect
|
|
235
|
+
if (!d || (r.status !== "failed" && r.status !== "error")) continue
|
|
236
|
+
let c = byId.get(d.id)
|
|
237
|
+
if (!c) {
|
|
238
|
+
const failure = failureOf(r)
|
|
239
|
+
c = { ...d, evidence: [...d.evidence], failure, failureLabel: failure === "unclassified" ? "not classified" : FAILURE_LABEL[failure], cases: [], pages: [] }
|
|
240
|
+
byId.set(d.id, c)
|
|
241
|
+
}
|
|
242
|
+
c.cases.push({ id: r.case.id, title: r.case.title, status: r.status, ...(r.device ? { device: r.device } : {}) })
|
|
243
|
+
if (d.page && !c.pages.includes(d.page)) c.pages.push(d.page)
|
|
244
|
+
for (const e of d.evidence) if (c.evidence.length < 5 && !c.evidence.includes(e)) c.evidence.push(e)
|
|
245
|
+
if (!c.bug) c.bug = r.bug || postedBug(r.case.id)
|
|
246
|
+
}
|
|
247
|
+
return [...byId.values()].sort((a, b) => b.cases.length - a.cases.length)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** "7 failures from 3 defects", or "" when nothing failed. */
|
|
251
|
+
export function defectLine(clusters: DefectCluster[]): string {
|
|
252
|
+
const failures = clusters.reduce((n, c) => n + c.cases.length, 0)
|
|
253
|
+
if (!failures) return ""
|
|
254
|
+
return `${failures} failure${failures === 1 ? "" : "s"} from ${clusters.length} defect${clusters.length === 1 ? "" : "s"}`
|
|
255
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
import type { BugsConfig, SourcesConfig } from "../config.ts"
|
|
4
|
+
import type { BugRef, Tracker } from "./bugs.ts"
|
|
5
|
+
import { http } from "./http.ts"
|
|
6
|
+
import { resultText } from "./jira.ts"
|
|
7
|
+
import type { CaseResult, TestCase, WriteBack } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GitHub issues as test cases (github:owner/repo#12, github:#12,#13, github:label=login, github:q=<search>), results
|
|
11
|
+
* posted back as issue comments, and GitHub as a bug tracker. Signs in with GITHUB_TOKEN (sources.github.token_env),
|
|
12
|
+
* else the GitHub CLI's sign-in (gh auth token), so `forgeqe auth login github` is all it needs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type GithubApi = { api: string; web: string; auth: string; repo?: string }
|
|
16
|
+
|
|
17
|
+
let ghCached: string | null | undefined
|
|
18
|
+
|
|
19
|
+
/** The GitHub CLI's token, once per process; never on test machines' saved sign-ins (FORGEQE_SAVED_AUTH=off). */
|
|
20
|
+
export function ghAuthToken(): string | null {
|
|
21
|
+
if (process.env.FORGEQE_SAVED_AUTH === "off") return null
|
|
22
|
+
if (ghCached !== undefined) return ghCached
|
|
23
|
+
if (!Bun.which("gh")) return (ghCached = null)
|
|
24
|
+
const res = spawnSync("gh", ["auth", "token"], { encoding: "utf8", timeout: 10_000 })
|
|
25
|
+
ghCached = res.status === 0 ? res.stdout.trim() || null : null
|
|
26
|
+
return ghCached
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The token GitHub calls use, or null when there is none (no GITHUB_TOKEN and no gh sign-in). */
|
|
30
|
+
export function githubToken(cfg: SourcesConfig): string | null {
|
|
31
|
+
const env = cfg.github?.tokenEnv || "GITHUB_TOKEN"
|
|
32
|
+
return process.env[env]?.trim() || ghAuthToken()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function githubApi(cfg: SourcesConfig, what = "GitHub"): GithubApi {
|
|
36
|
+
const token = githubToken(cfg)
|
|
37
|
+
const env = cfg.github?.tokenEnv || "GITHUB_TOKEN"
|
|
38
|
+
if (!token) throw new Error(`${what} needs ${env} in the environment, or a GitHub CLI sign-in: run forgeqe auth login github.`)
|
|
39
|
+
const api = (cfg.github?.apiUrl || "https://api.github.com").replace(/\/+$/, "")
|
|
40
|
+
const web = api === "https://api.github.com" ? "https://github.com" : api.replace(/\/api\/v3$/, "")
|
|
41
|
+
return { api, web, auth: `Bearer ${token}`, ...(cfg.github?.repo ? { repo: cfg.github.repo } : {}) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
|
|
45
|
+
|
|
46
|
+
type Issue = { number: number; title: string; body?: string | null; html_url: string; labels?: ({ name?: string } | string)[]; milestone?: { title?: string } | null; pull_request?: unknown }
|
|
47
|
+
|
|
48
|
+
/** owner/repo#12,#13 · #12 · 12,13 · label=login,smoke · q=<GitHub search> (each optionally after owner/repo). */
|
|
49
|
+
export function parseGithubRef(ref: string, defaultRepo?: string): { repo: string; numbers: number[]; labels: string[]; query?: string } {
|
|
50
|
+
let rest = ref.trim()
|
|
51
|
+
let repo = defaultRepo || ""
|
|
52
|
+
const lead = rest.match(/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?=#|\s|\?|$|:)/)
|
|
53
|
+
if (lead) {
|
|
54
|
+
repo = lead[1]
|
|
55
|
+
rest = rest.slice(lead[0].length).replace(/^[?:\s]+/, "")
|
|
56
|
+
}
|
|
57
|
+
if (!REPO.test(repo)) throw new Error(`GitHub reference "${ref}": name the repository as owner/repo (or set sources.github.repo).`)
|
|
58
|
+
const q = rest.match(/^q=(.+)$/s)?.[1]
|
|
59
|
+
if (q) return { repo, numbers: [], labels: [], query: q.trim() }
|
|
60
|
+
const labels = rest.match(/^labels?=(.+)$/s)?.[1]
|
|
61
|
+
if (labels) return { repo, numbers: [], labels: labels.split(",").map((l) => l.trim()).filter(Boolean) }
|
|
62
|
+
const numbers = rest.split(/[,\s]+/).map((n) => n.replace(/^#/, "").trim()).filter(Boolean)
|
|
63
|
+
if (!numbers.length || numbers.some((n) => !/^\d+$/.test(n))) throw new Error(`GitHub reference "${ref}": use owner/repo#12,#13, label=<labels>, or q=<search>.`)
|
|
64
|
+
return { repo, numbers: numbers.map(Number), labels: [] }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function issueCase(repo: string, i: Issue): TestCase {
|
|
68
|
+
const labels = (i.labels || []).map((l) => (typeof l === "string" ? l : l.name || "")).filter(Boolean)
|
|
69
|
+
return {
|
|
70
|
+
id: `${repo}#${i.number}`,
|
|
71
|
+
title: i.title || `${repo}#${i.number}`,
|
|
72
|
+
source: "github",
|
|
73
|
+
url: i.html_url,
|
|
74
|
+
description: i.body?.trim() || undefined,
|
|
75
|
+
steps: [],
|
|
76
|
+
...(labels.length ? { tags: labels } : {}),
|
|
77
|
+
...(i.milestone?.title ? { group: i.milestone.title } : {}),
|
|
78
|
+
ref: { repo, issue: i.number },
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function githubCases(ref: string, cfg: SourcesConfig): Promise<TestCase[]> {
|
|
83
|
+
const g = githubApi(cfg)
|
|
84
|
+
const { repo, numbers, labels, query } = parseGithubRef(ref, g.repo)
|
|
85
|
+
if (numbers.length) {
|
|
86
|
+
const out: TestCase[] = []
|
|
87
|
+
for (const n of numbers) {
|
|
88
|
+
const { data } = await http<Issue>(`${g.api}/repos/${repo}/issues/${n}`, { auth: g.auth })
|
|
89
|
+
if (data.pull_request) throw new Error(`${repo}#${n} is a pull request, not an issue: review_pull_request or select_pr_tests cover pull requests.`)
|
|
90
|
+
out.push(issueCase(repo, data))
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
// Pull requests are issues to this API: only issues are test cases.
|
|
95
|
+
const issues: Issue[] = []
|
|
96
|
+
if (query) {
|
|
97
|
+
const q = new URLSearchParams({ q: `repo:${repo} is:issue ${query}`, per_page: "100" })
|
|
98
|
+
issues.push(...((await http<{ items: Issue[] }>(`${g.api}/search/issues?${q}`, { auth: g.auth })).data.items || []))
|
|
99
|
+
} else {
|
|
100
|
+
const q = new URLSearchParams({ labels: labels.join(","), state: "open", per_page: "100" })
|
|
101
|
+
issues.push(...(await http<Issue[]>(`${g.api}/repos/${repo}/issues?${q}`, { auth: g.auth })).data)
|
|
102
|
+
}
|
|
103
|
+
return issues.filter((i) => !i.pull_request).map((i) => issueCase(repo, i))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Each result as a comment on its issue. */
|
|
107
|
+
export async function githubWriteBack(results: CaseResult[], cfg: SourcesConfig): Promise<WriteBack[]> {
|
|
108
|
+
const g = githubApi(cfg)
|
|
109
|
+
const out: WriteBack[] = []
|
|
110
|
+
for (const r of results) {
|
|
111
|
+
const repo = String(r.case.ref?.repo || "")
|
|
112
|
+
const n = Number(r.case.ref?.issue)
|
|
113
|
+
const label = `${repo}#${n}`
|
|
114
|
+
try {
|
|
115
|
+
await http(`${g.api}/repos/${repo}/issues/${n}/comments`, { auth: g.auth, body: { body: resultText(r) } })
|
|
116
|
+
out.push({ source: "github", caseId: r.case.id, text: `${label}: result added as a comment`, url: r.case.url })
|
|
117
|
+
} catch (err) {
|
|
118
|
+
out.push({ source: "github", caseId: r.case.id, text: `${label}: comment not added`, error: (err as Error).message })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** GitHub as sources.bugs: an issue per defect or failing case, found again by its fingerprint labels. */
|
|
125
|
+
export function githubBugTracker(cfg: SourcesConfig, b: BugsConfig): Tracker {
|
|
126
|
+
const g = githubApi(cfg, "Filing bugs in GitHub")
|
|
127
|
+
const repo = b.project || g.repo
|
|
128
|
+
if (!repo || !REPO.test(repo)) throw new Error("Filing bugs in GitHub needs the repository: sources.bugs.project (owner/repo) or sources.github.repo.")
|
|
129
|
+
const ref = (n: number, url?: string): BugRef => ({ id: String(n), label: `${repo}#${n}`, url: url || `${g.web}/${repo}/issues/${n}` })
|
|
130
|
+
return {
|
|
131
|
+
async findOpen(fp: string): Promise<BugRef | null> {
|
|
132
|
+
const q = new URLSearchParams({ labels: fp, state: "open", per_page: "1" })
|
|
133
|
+
const { data } = await http<Issue[]>(`${g.api}/repos/${repo}/issues?${q}`, { auth: g.auth })
|
|
134
|
+
const hit = data.find((i) => !i.pull_request)
|
|
135
|
+
return hit ? ref(hit.number, hit.html_url) : null
|
|
136
|
+
},
|
|
137
|
+
async findClosed(fp) {
|
|
138
|
+
const q = new URLSearchParams({ labels: fp, state: "closed", sort: "updated", per_page: "5" })
|
|
139
|
+
const { data } = await http<(Issue & { closed_at?: string | null; state_reason?: string | null })[]>(`${g.api}/repos/${repo}/issues?${q}`, { auth: g.auth })
|
|
140
|
+
// Closed as not planned is not a fix: it cannot come back.
|
|
141
|
+
const hit = data.find((i) => !i.pull_request && i.state_reason !== "not_planned")
|
|
142
|
+
return hit ? { ...ref(hit.number, hit.html_url), ...(hit.closed_at ? { closedAt: hit.closed_at } : {}) } : null
|
|
143
|
+
},
|
|
144
|
+
async related(term, limit) {
|
|
145
|
+
const q = new URLSearchParams({ q: `repo:${repo} is:issue is:open "${term.replace(/"/g, " ")}"`, per_page: String(limit), sort: "updated" })
|
|
146
|
+
const { data } = await http<{ items?: Issue[] }>(`${g.api}/search/issues?${q}`, { auth: g.auth })
|
|
147
|
+
return (data?.items || []).filter((i) => !i.pull_request).map((i) => ({ ...ref(i.number, i.html_url), title: i.title }))
|
|
148
|
+
},
|
|
149
|
+
async create(_r, bug) {
|
|
150
|
+
const regression = bug.regressionOf ? `\n\nRegression of #${bug.regressionOf.id}.` : ""
|
|
151
|
+
const body = `${bug.body}${regression}\n\n_GitHub has no API to upload a screenshot to an issue: it is in the run folder above._`
|
|
152
|
+
const { data } = await http<Issue>(`${g.api}/repos/${repo}/issues`, { auth: g.auth, body: { title: bug.title, body, labels: bug.labels } })
|
|
153
|
+
return ref(data.number, data.html_url)
|
|
154
|
+
},
|
|
155
|
+
async comment(id, body) {
|
|
156
|
+
await http(`${g.api}/repos/${repo}/issues/${id}/comments`, { auth: g.auth, body: { body } })
|
|
157
|
+
},
|
|
158
|
+
async tag(id, label) {
|
|
159
|
+
await http(`${g.api}/repos/${repo}/issues/${id}/labels`, { auth: g.auth, body: { labels: [label] } })
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/cases/goal.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TestCase } from "./types.ts"
|
|
2
2
|
|
|
3
|
-
const SOURCE_NAME = { text: "text", file: "file", ado: "Azure DevOps", jira: "Jira", xray: "Xray", testrail: "TestRail", zephyr: "Zephyr Scale", qtest: "qTest" } as const
|
|
3
|
+
const SOURCE_NAME = { text: "text", file: "file", ado: "Azure DevOps", jira: "Jira", xray: "Xray", testrail: "TestRail", zephyr: "Zephyr Scale", qtest: "qTest", github: "GitHub" } as const
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The browser goal for a test case: every step with its data and expected result, and an instruction to check each
|
package/src/cases/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path"
|
|
|
4
4
|
import type { SourcesConfig } from "../config.ts"
|
|
5
5
|
import { adoCases, adoWriteBack } from "./ado.ts"
|
|
6
6
|
import { fileCases, textCase } from "./files.ts"
|
|
7
|
+
import { githubCases, githubWriteBack } from "./github.ts"
|
|
7
8
|
import { jiraCases, jiraWriteBack } from "./jira.ts"
|
|
8
9
|
import { qtestCases, qtestWriteBack } from "./qtest.ts"
|
|
9
10
|
import { testrailCases, testrailWriteBack } from "./testrail.ts"
|
|
@@ -18,10 +19,11 @@ export type { CaseResult, CaseStatus, CaseStep, TestCase, WriteBack } from "./ty
|
|
|
18
19
|
* Where a reference points: `ado:…`, `jira:…`, `xray:…`, `testrail:…`, `zephyr:…`, `qtest:…`, an existing file, or
|
|
19
20
|
* otherwise plain text that is itself the test case.
|
|
20
21
|
*/
|
|
21
|
-
export function refKind(ref: string): "ado" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "file" | "text" {
|
|
22
|
-
const scheme = ref.match(/^(ado|azure|jira|xray|testrail|zephyr|qtest):/i)?.[1]?.toLowerCase()
|
|
22
|
+
export function refKind(ref: string): "ado" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "github" | "file" | "text" {
|
|
23
|
+
const scheme = ref.match(/^(ado|azure|jira|xray|testrail|zephyr|qtest|github|gh):/i)?.[1]?.toLowerCase()
|
|
23
24
|
if (scheme === "ado" || scheme === "azure") return "ado"
|
|
24
|
-
if (scheme
|
|
25
|
+
if (scheme === "gh") return "github"
|
|
26
|
+
if (scheme) return scheme as "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "github"
|
|
25
27
|
const file = path.resolve(ref)
|
|
26
28
|
if (ref.length < 1024 && existsSync(file) && statSync(file).isFile()) return "file"
|
|
27
29
|
return "text"
|
|
@@ -39,6 +41,7 @@ export async function loadCases(refs: string[], cfg: SourcesConfig): Promise<Tes
|
|
|
39
41
|
else if (kind === "testrail") cases.push(...(await testrailCases(rest, cfg)))
|
|
40
42
|
else if (kind === "zephyr") cases.push(...(await zephyrCases(rest, cfg)))
|
|
41
43
|
else if (kind === "qtest") cases.push(...(await qtestCases(rest, cfg)))
|
|
44
|
+
else if (kind === "github") cases.push(...(await githubCases(rest, cfg)))
|
|
42
45
|
else if (kind === "file") cases.push(...fileCases(path.resolve(ref)))
|
|
43
46
|
else cases.push(textCase(ref, ++texts))
|
|
44
47
|
}
|
|
@@ -47,8 +50,14 @@ export async function loadCases(refs: string[], cfg: SourcesConfig): Promise<Tes
|
|
|
47
50
|
|
|
48
51
|
/** Where the agent wants test cases from, as it decided from the user's request. */
|
|
49
52
|
export type CaseSelector = {
|
|
50
|
-
source: "azure_devops" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "file" | "text"
|
|
53
|
+
source: "azure_devops" | "jira" | "xray" | "testrail" | "zephyr" | "qtest" | "github" | "file" | "text"
|
|
51
54
|
ids?: string[]
|
|
55
|
+
/** github: owner/repo (default sources.github.repo). */
|
|
56
|
+
repo?: string
|
|
57
|
+
/** github: issue labels, all of which a case has. */
|
|
58
|
+
labels?: string[]
|
|
59
|
+
/** github: GitHub issue search, e.g. "login in:title is:open". */
|
|
60
|
+
query?: string
|
|
52
61
|
plan?: string
|
|
53
62
|
suite?: string
|
|
54
63
|
run?: string
|
|
@@ -82,6 +91,15 @@ export async function casesFrom(selectors: CaseSelector[], cfg: SourcesConfig, c
|
|
|
82
91
|
cases.push(...(await zephyrCases(s.cycle ? `cycle=${s.cycle}` : s.folder ? `folder=${s.folder}` : ids(s, "Zephyr Scale"), cfg)))
|
|
83
92
|
} else if (s.source === "qtest") {
|
|
84
93
|
cases.push(...(await qtestCases(s.cycle ? `cycle=${s.cycle}` : s.suite ? `suite=${s.suite}` : ids(s, "qTest"), cfg)))
|
|
94
|
+
} else if (s.source === "github") {
|
|
95
|
+
const repo = s.repo ? `${s.repo} ` : ""
|
|
96
|
+
const nums = (s.ids || []).map((i) => String(i).trim()).filter(Boolean)
|
|
97
|
+
const sel = s.query ? `q=${s.query}` : s.labels?.length ? `label=${s.labels.join(",")}` : nums.length ? nums.map((n) => (n.includes("#") ? n : `#${n}`)).join(",") : ""
|
|
98
|
+
if (!sel) throw new Error("GitHub: give issue numbers, labels, or a query.")
|
|
99
|
+
// owner/repo#12 ids name their own repository; the others take repo.
|
|
100
|
+
if (nums.some((n) => /^[^#\s]+\/[^#\s]+#\d+$/.test(n)) && !s.query && !s.labels?.length) {
|
|
101
|
+
for (const n of nums) cases.push(...(await githubCases(n, cfg)))
|
|
102
|
+
} else cases.push(...(await githubCases(`${repo}${sel}`.trim(), cfg)))
|
|
85
103
|
} else if (s.source === "file") {
|
|
86
104
|
if (!s.paths?.length) throw new Error("File source: give the file paths.")
|
|
87
105
|
for (const p of s.paths) {
|
|
@@ -117,6 +135,7 @@ export async function writeBack(results: CaseResult[], cfg: SourcesConfig, runNa
|
|
|
117
135
|
["testrail", (rs) => testrailWriteBack(rs, cfg, runName)],
|
|
118
136
|
["zephyr", (rs) => zephyrWriteBack(rs, cfg, runName)],
|
|
119
137
|
["qtest", (rs) => qtestWriteBack(rs, cfg, runName)],
|
|
138
|
+
["github", (rs) => githubWriteBack(rs, cfg)],
|
|
120
139
|
]
|
|
121
140
|
for (const [source, post] of tasks) {
|
|
122
141
|
const rs = of(source)
|