@capagents/forgeqe 1.1.7 → 1.1.8
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 +16 -6
- package/docs/user-guide.html +17 -8
- package/package.json +1 -1
- package/prompts/brain.md +4 -2
- package/src/brain/index.ts +4 -0
- package/src/capabilities/registry.ts +6 -2
- package/src/cases/api.ts +75 -22
- package/src/code/runtime.ts +3 -0
- package/src/mcp/reporttools.ts +205 -14
- package/src/mcp/server.ts +8 -3
- package/src/mcp/tools.ts +4 -1
- package/src/report/evidence.ts +313 -0
- package/src/report/html.ts +7 -3
- package/src/report/template.html +46 -45
package/HOW_TO_USE.md
CHANGED
|
@@ -67,7 +67,7 @@ Everything ForgeQE can do, by area. ForgeQE's agent answers "can you…?" questi
|
|
|
67
67
|
| Release | **Requirement coverage and release readiness**: Gives requirement coverage and a GO / GO WITH RISKS / NO-GO verdict for a sprint, area, epic, or stories, from test cases, results, bugs, and flaky tests; can publish it to the wiki. | `release_readiness` | [docs](#requirement-coverage-and-release-readiness) |
|
|
68
68
|
| Operations | **Scheduled monitoring with alerts**: Runs checks on a schedule and alerts Teams or email when one keeps failing, and again when it recovers. | `monitor` | [docs](#9-monitoring) |
|
|
69
69
|
| Operations | **Teams and email notifications**: Tells the team in Microsoft Teams channels or by email when a suite fails or passes, when a release readiness verdict is in, and when the domain evaluation falls below its gate, with the failing cases, their bugs, and links to the report and the CI build. Each channel picks its events; the agent also posts a summary to a channel when asked. | `notify` | [docs](#17-notifications) |
|
|
70
|
-
| Release | **Reports you can share**: Writes up what ForgeQE's agent found, such as
|
|
70
|
+
| Release | **Reports you can share**: Writes up what ForgeQE's agent found, such as open defects across projects, release status, or pipeline health, as a page with filters (project, state, priority, assignee, …), charts, and a sortable table, plus Markdown and CSV. Every table is built from evidence: the results of the tool calls the agent made, or a read-only query ForgeQE runs itself, listed on the page's Sources with each raw result kept beside the report. | `write_report` | [docs](#reports-from-the-agent) |
|
|
71
71
|
| Operations | **CI pipelines**: Writes a GitHub Actions or Azure Pipelines workflow that runs ForgeQE, stores the secrets it needs, pushes a branch, and makes the pipeline green. | `push_branch`, `set_ci_secret` | [docs](#8-pipelines) |
|
|
72
72
|
| Systems | **Work with Azure DevOps, Jira, GitHub, and Confluence**: Reads anything in the configured systems (work items, issues, queries, boards, pages), makes changes the user asks for, puts run evidence (screenshots, reports) on work items and issues as attachments or comments, and builds Azure DevOps dashboards and wiki pages. | `query_api`, `read_confluence`, `create_ado_dashboard`, `write_ado_wiki_page`, `attach_to_work_item` | [docs](README.md#azure-devops-jira-and-other-systems) |
|
|
73
73
|
| Setup | **Setup, skills, and documentation**: Shows the model profiles and MCP servers, installs or repairs the browser engine, creates a forgeqe.yaml, manages skills, and answers questions about ForgeQE from its documentation and this list. | `forgeqe_setup`, `capabilities` | [docs](#2-scaffold-config) |
|
|
@@ -390,15 +390,25 @@ The text is the agent's own result. Failed runs show `✗ Task failed` with the
|
|
|
390
390
|
|
|
391
391
|
### Reports from the agent
|
|
392
392
|
|
|
393
|
-
Ask for a report and ForgeQE's agent writes one you can share: "
|
|
393
|
+
Ask for a report and ForgeQE's agent writes one you can share: "generate a defect report for all open defects", "create a release report for all projects", "summarise this sprint's test results as a report". Every table in it is built from **evidence**, not typed by the model:
|
|
394
|
+
|
|
395
|
+
- ForgeQE keeps the result of every tool call in the session (its own tools, and the Azure DevOps and Jira MCP servers). A dataset of the report names the calls it comes from, and ForgeQE takes the rows from their results.
|
|
396
|
+
- Or the dataset is a read-only query that ForgeQE runs itself when it writes the report: a WIQL query reads up to 1000 work items with their project, type, state, priority, severity, assignee, area, iteration, dates, and a link. The agent uses this for "all" of something.
|
|
397
|
+
- The agent writes only the summary paragraph.
|
|
398
|
+
|
|
399
|
+
The report goes to the reports folder (`export.out_dir`, `.forgeqe/reports/` by default):
|
|
394
400
|
|
|
395
401
|
```
|
|
396
|
-
.forgeqe/reports/
|
|
397
|
-
report.
|
|
398
|
-
report.
|
|
402
|
+
.forgeqe/reports/open-defects-all-projects-20261013-101512/
|
|
403
|
+
report.html # one file: summary, filters, charts, sortable table, Sources
|
|
404
|
+
report.md # the same, to edit, paste into a wiki, or attach
|
|
405
|
+
open-defects.csv # each dataset's rows
|
|
406
|
+
evidence/E1.json # the raw result of each tool call the report used
|
|
399
407
|
```
|
|
400
408
|
|
|
401
|
-
The
|
|
409
|
+
The page adds filters for the columns that are in the data (Project, State, Type, Priority, Severity, Assigned To, Area, Iteration, …, and other columns with a short list of values). The counts, the charts (click a bar to filter by it), and the table follow the filters, and **Export CSV** saves the rows you filtered. **Sources** lists each tool call, when it ran, and how many rows it gave, with a link to its raw result.
|
|
410
|
+
|
|
411
|
+
The chat gets a short summary and a card with the paths as links, and **Open the report** appears as a next step. To post it, ask for it ("post the report to the release channel") once `notify.channels` is set up; the reports folder is linked when it is served (`notify.report_url`).
|
|
402
412
|
|
|
403
413
|
### The screen
|
|
404
414
|
|
package/docs/user-guide.html
CHANGED
|
@@ -1804,22 +1804,31 @@ forgeqe visual approve --page /home # make this run's /home the baseline (asks
|
|
|
1804
1804
|
</article>
|
|
1805
1805
|
<article class="spec feat" id="cap-reports">
|
|
1806
1806
|
<div class="hd"><span class="mono">Release</span><h3>Reports you can share</h3></div>
|
|
1807
|
-
<p class="ds">Writes up what ForgeQE's agent found, such as
|
|
1807
|
+
<p class="ds">Writes up what ForgeQE's agent found, such as open defects across projects, release status, or pipeline health, as a page with filters (project, state, priority, assignee, …), charts, and a sortable table, plus Markdown and CSV. Every table is built from evidence: the results of the tool calls the agent made, or a read-only query ForgeQE runs itself, listed on the page's Sources with each raw result kept beside the report.</p>
|
|
1808
1808
|
<div class="facts">
|
|
1809
1809
|
<div class="fact"><span class="mono">Ask it</span><ul class="asks"><li>Create a release report for all projects</li><li>Write up the open defects as a report I can share</li></ul></div>
|
|
1810
1810
|
<div class="fact"><span class="mono">What it needs</span><ul class="needs"><li><span class="need req">Required</span><span>A live model profile in llms.json (an API key or Microsoft Entra ID sign-in)</span></li></ul></div>
|
|
1811
|
-
<div class="fact"><span class="mono">Limits</span><ul class="plainlist"><li>
|
|
1811
|
+
<div class="fact"><span class="mono">Limits</span><ul class="plainlist"><li>Rows come only from tool results in the session or a query ForgeQE runs; the agent writes only the summary, which is not checked figure by figure.</li><li>A WIQL query reads up to 1000 work items; the page says when a query matched more.</li><li>The page is not published anywhere unless the reports folder is served (notify.report_url).</li></ul></div>
|
|
1812
1812
|
<div class="fact"><span class="mono">Tools</span><p class="tools"><code>write_report</code></p></div>
|
|
1813
1813
|
</div>
|
|
1814
1814
|
<div class="detail"><span class="mono">In detail</span>
|
|
1815
1815
|
<section class="doc" id="doc-how_to_use-reports-from-the-agent">
|
|
1816
1816
|
<h4>Reports from the agent</h4>
|
|
1817
|
-
<div class="prose"><p>Ask for a report and ForgeQE's agent writes one you can share: "
|
|
1818
|
-
<
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1817
|
+
<div class="prose"><p>Ask for a report and ForgeQE's agent writes one you can share: "generate a defect report for all open defects", "create a release report for all projects", "summarise this sprint's test results as a report". Every table in it is built from <strong>evidence</strong>, not typed by the model:</p>
|
|
1818
|
+
<ul>
|
|
1819
|
+
<li>ForgeQE keeps the result of every tool call in the session (its own tools, and the Azure DevOps and Jira MCP servers). A dataset of the report names the calls it comes from, and ForgeQE takes the rows from their results.</li>
|
|
1820
|
+
<li>Or the dataset is a read-only query that ForgeQE runs itself when it writes the report: a WIQL query reads up to 1000 work items with their project, type, state, priority, severity, assignee, area, iteration, dates, and a link. The agent uses this for "all" of something.</li>
|
|
1821
|
+
<li>The agent writes only the summary paragraph.</li>
|
|
1822
|
+
</ul>
|
|
1823
|
+
<p>The report goes to the reports folder (<code>export.out_dir</code>, <code>.forgeqe/reports/</code> by default):</p>
|
|
1824
|
+
<pre><code>.forgeqe/reports/open-defects-all-projects-20261013-101512/
|
|
1825
|
+
report.html # one file: summary, filters, charts, sortable table, Sources
|
|
1826
|
+
report.md # the same, to edit, paste into a wiki, or attach
|
|
1827
|
+
open-defects.csv # each dataset's rows
|
|
1828
|
+
evidence/E1.json # the raw result of each tool call the report used
|
|
1829
|
+
</code></pre>
|
|
1830
|
+
<p>The page adds filters for the columns that are in the data (Project, State, Type, Priority, Severity, Assigned To, Area, Iteration, …, and other columns with a short list of values). The counts, the charts (click a bar to filter by it), and the table follow the filters, and <strong>Export CSV</strong> saves the rows you filtered. <strong>Sources</strong> lists each tool call, when it ran, and how many rows it gave, with a link to its raw result.</p>
|
|
1831
|
+
<p>The chat gets a short summary and a card with the paths as links, and <strong>Open the report</strong> appears as a next step. To post it, ask for it ("post the report to the release channel") once <code>notify.channels</code> is set up; the reports folder is linked when it is served (<code>notify.report_url</code>).</p>
|
|
1823
1832
|
</div>
|
|
1824
1833
|
</section>
|
|
1825
1834
|
</div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capagents/forgeqe",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/prompts/brain.md
CHANGED
|
@@ -63,7 +63,7 @@ A message can also be a task specification with the parts Role, Objective, Conte
|
|
|
63
63
|
- logins: manages saved logins. It lists them. It lets the user sign in by hand in a browser (sign_in). It removes a session.
|
|
64
64
|
- page_memory: gives what ForgeQE knows about the pages, locators, and recorded flows of a site. Clear it only when the user asks.
|
|
65
65
|
- notify: posts a message to the Teams or email channels of the team. Use it only when the user asks. Suites, release verdicts, and failed evaluations go to their channels automatically from runs outside the shell.
|
|
66
|
-
- write_report: saves a report that the user asks for as a Markdown file
|
|
66
|
+
- write_report: saves a report that the user asks for as a page (filters, charts, the sources of its data) and a Markdown file. ForgeQE builds its tables from evidence: the results of tool calls in this session, or a read-only query that it runs itself. You write only the summary.
|
|
67
67
|
- capabilities: tells what ForgeQE can do, and whether each capability works in this setup. It gives what to set up, example requests, limits, and the documentation.
|
|
68
68
|
- forgeqe_setup: gives model profiles, the MCP servers that ForgeQE sets up, and the documentation of ForgeQE (docs). It installs or repairs the browser engine. It creates a forgeqe.yaml (init).
|
|
69
69
|
- push_branch: the only way to push. It refuses the default branch, and it never forces.
|
|
@@ -335,7 +335,9 @@ The shell shows replies as markdown in a narrow chat pane. Write them so that th
|
|
|
335
335
|
- You MUST use bold only for the one word or number that is important in a line, such as a status or a count.
|
|
336
336
|
- You SHOULD use a table only for two or three short columns.
|
|
337
337
|
- You MUST keep your own comments short. You MUST give in full the test cases, tests, and reviews that the user asked for.
|
|
338
|
-
- When the user asks for a report, a write-up, or a summary to share, you MUST
|
|
338
|
+
- When the user asks for a report, a write-up, or a summary to share, you MUST save the report with write_report, with its data as datasets. You MUST NOT type rows, counts, or tables into the report yourself.
|
|
339
|
+
- For "all" of something (all open defects, every project), you SHOULD give the dataset a query (a WIQL query or a REST path), so that ForgeQE reads every item itself. Otherwise, name the tool calls that you made in this session (from: the tool name or the evidence id).
|
|
340
|
+
- The summary MUST hold only facts that are in the datasets. After write_report, give the counts that it returned.
|
|
339
341
|
- You MUST then reply with three to five lines of summary. The shell shows the paths of the report as links, so you MUST NOT repeat them or paste the full report.
|
|
340
342
|
- You MUST NOT end a reply with "If you want, I can…" options. Put the follow-ups that the user will probably want in next, 3 or fewer, each with a short label and the request as the user would type it.
|
|
341
343
|
- You MUST end each turn with the structured answer: reply, outcome (done, failed, or needs_input), and next.
|
package/src/brain/index.ts
CHANGED
|
@@ -159,6 +159,10 @@ export async function openBrain(opts: BrainOptions): Promise<Brain> {
|
|
|
159
159
|
[MCP_NAME]: tools.entry,
|
|
160
160
|
},
|
|
161
161
|
hideTool: (tool) => tool.startsWith(`${MCP_NAME}_`),
|
|
162
|
+
// ForgeQE's own tools record their results themselves; this adds the other MCP servers' (Azure DevOps, Jira, …).
|
|
163
|
+
onToolResult: (r) => {
|
|
164
|
+
if (r.ok && !r.tool.startsWith(`${MCP_NAME}_`) && r.tool.includes("_")) tools.server.evidence.record(r.tool, r.input, r.output)
|
|
165
|
+
},
|
|
162
166
|
title: "ForgeQE",
|
|
163
167
|
})
|
|
164
168
|
} catch (err) {
|
|
@@ -317,11 +317,15 @@ export const CAPABILITIES: Capability[] = [
|
|
|
317
317
|
area: "Release",
|
|
318
318
|
title: "Reports you can share",
|
|
319
319
|
summary:
|
|
320
|
-
"Writes up what ForgeQE's agent found, such as
|
|
320
|
+
"Writes up what ForgeQE's agent found, such as open defects across projects, release status, or pipeline health, as a page with filters (project, state, priority, assignee, …), charts, and a sortable table, plus Markdown and CSV. Every table is built from evidence: the results of the tool calls the agent made, or a read-only query ForgeQE runs itself, listed on the page's Sources with each raw result kept beside the report.",
|
|
321
321
|
tools: ["write_report"],
|
|
322
322
|
examples: ["Create a release report for all projects", "Write up the open defects as a report I can share"],
|
|
323
323
|
requires: [{ kind: "live_model" }],
|
|
324
|
-
limits: [
|
|
324
|
+
limits: [
|
|
325
|
+
"Rows come only from tool results in the session or a query ForgeQE runs; the agent writes only the summary, which is not checked figure by figure.",
|
|
326
|
+
"A WIQL query reads up to 1000 work items; the page says when a query matched more.",
|
|
327
|
+
"The page is not published anywhere unless the reports folder is served (notify.report_url).",
|
|
328
|
+
],
|
|
325
329
|
docs: [{ doc: "HOW_TO_USE.md", heading: "Reports from the agent" }],
|
|
326
330
|
},
|
|
327
331
|
{
|
package/src/cases/api.ts
CHANGED
|
@@ -16,37 +16,88 @@ export type ApiQuery = {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const WIQL_TOP = 50
|
|
19
|
+
/** What a report may read in one WIQL search. */
|
|
20
|
+
export const REPORT_WIQL_TOP = 1000
|
|
19
21
|
const WIQL_FIELDS = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.AssignedTo", "System.ChangedDate", "System.IterationPath"]
|
|
22
|
+
/** Read too when the organization has them; a process without one of them falls back to WIQL_FIELDS. */
|
|
23
|
+
const WIQL_MORE = ["System.TeamProject", "Microsoft.VSTS.Common.Priority", "Microsoft.VSTS.Common.Severity", "System.AreaPath", "System.CreatedDate", "System.Tags"]
|
|
24
|
+
const BATCH = 200
|
|
25
|
+
|
|
26
|
+
export type WorkItemRow = {
|
|
27
|
+
id: number
|
|
28
|
+
project?: string
|
|
29
|
+
type?: unknown
|
|
30
|
+
title?: unknown
|
|
31
|
+
state?: unknown
|
|
32
|
+
priority?: unknown
|
|
33
|
+
severity?: unknown
|
|
34
|
+
assignedTo?: string
|
|
35
|
+
area?: unknown
|
|
36
|
+
iteration?: unknown
|
|
37
|
+
tags?: unknown
|
|
38
|
+
created?: unknown
|
|
39
|
+
changed?: unknown
|
|
40
|
+
url: string
|
|
41
|
+
}
|
|
20
42
|
|
|
21
43
|
/**
|
|
22
44
|
* A WIQL search: Azure DevOps answers it with ids only (a POST, but WIQL has no statement that changes anything), so
|
|
23
|
-
* the first
|
|
45
|
+
* the first `top` are read back with their main fields in batch reads.
|
|
24
46
|
*/
|
|
25
|
-
async function
|
|
47
|
+
export async function adoWorkItems(cfg: SourcesConfig, wiql: string, project?: string, top = WIQL_TOP): Promise<{ count: number; total: number; items: WorkItemRow[] }> {
|
|
26
48
|
const { ado, auth } = adoSettings(cfg)
|
|
27
49
|
const org = ado.orgUrl.replace(/\/+$/, "")
|
|
28
50
|
const scope = project || ado.project
|
|
29
51
|
const base = scope ? `${org}/${encodeURIComponent(scope)}` : org
|
|
30
52
|
const { data } = await http<{ workItems?: { id: number }[]; workItemRelations?: { source?: { id: number } | null; target?: { id: number } }[] }>(
|
|
31
|
-
`${base}/_apis/wit/wiql?api-version=7.1&$top=${
|
|
53
|
+
`${base}/_apis/wit/wiql?api-version=7.1&$top=${top}`,
|
|
32
54
|
{ auth, body: { query: wiql } },
|
|
33
55
|
)
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const
|
|
56
|
+
const all = [...new Set([...(data.workItems || []).map((w) => w.id), ...(data.workItemRelations || []).flatMap((r) => [r.source?.id, r.target?.id])])].filter((id): id is number => typeof id === "number")
|
|
57
|
+
const ids = all.slice(0, top)
|
|
58
|
+
const read = async (fields: string[]) => {
|
|
59
|
+
const out: { id: number; fields: Record<string, unknown> }[] = []
|
|
60
|
+
for (let i = 0; i < ids.length; i += BATCH) {
|
|
61
|
+
const batch = await http<{ value: { id: number; fields: Record<string, unknown> }[] }>(`${org}/_apis/wit/workitemsbatch?api-version=7.1`, { auth, body: { ids: ids.slice(i, i + BATCH), fields } })
|
|
62
|
+
out.push(...batch.data.value)
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
const values = ids.length ? await read([...WIQL_FIELDS, ...WIQL_MORE]).catch(() => read(WIQL_FIELDS)) : []
|
|
37
67
|
const name = (v: unknown) => (v && typeof v === "object" ? String((v as { displayName?: string }).displayName || "") : String(v || ""))
|
|
38
68
|
return {
|
|
39
69
|
count: ids.length,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
70
|
+
total: all.length,
|
|
71
|
+
items: values.map((w) => {
|
|
72
|
+
const f = w.fields
|
|
73
|
+
const itemProject = String(f["System.TeamProject"] || scope || "")
|
|
74
|
+
return {
|
|
75
|
+
id: w.id,
|
|
76
|
+
...(f["System.TeamProject"] ? { project: String(f["System.TeamProject"]) } : {}),
|
|
77
|
+
type: f["System.WorkItemType"],
|
|
78
|
+
title: f["System.Title"],
|
|
79
|
+
state: f["System.State"],
|
|
80
|
+
...(f["Microsoft.VSTS.Common.Priority"] !== undefined ? { priority: f["Microsoft.VSTS.Common.Priority"] } : {}),
|
|
81
|
+
...(f["Microsoft.VSTS.Common.Severity"] !== undefined ? { severity: f["Microsoft.VSTS.Common.Severity"] } : {}),
|
|
82
|
+
assignedTo: name(f["System.AssignedTo"]) || undefined,
|
|
83
|
+
...(f["System.AreaPath"] ? { area: f["System.AreaPath"] } : {}),
|
|
84
|
+
iteration: f["System.IterationPath"],
|
|
85
|
+
...(f["System.Tags"] ? { tags: f["System.Tags"] } : {}),
|
|
86
|
+
...(f["System.CreatedDate"] ? { created: f["System.CreatedDate"] } : {}),
|
|
87
|
+
changed: f["System.ChangedDate"],
|
|
88
|
+
url: `${org}/${itemProject ? `${encodeURIComponent(itemProject)}/` : ""}_workitems/edit/${w.id}`,
|
|
89
|
+
}
|
|
90
|
+
}),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function adoWiql(cfg: SourcesConfig, wiql: string, project?: string): Promise<unknown> {
|
|
95
|
+
const { count, items } = await adoWorkItems(cfg, wiql, project, WIQL_TOP)
|
|
96
|
+
if (!count) return { count: 0, items: [] }
|
|
97
|
+
return {
|
|
98
|
+
count,
|
|
99
|
+
items,
|
|
100
|
+
note: `Read each one in full with path <project>/_apis/wit/workitems/<id>?$expand=all.${count === WIQL_TOP ? ` Only the first ${WIQL_TOP} are listed; narrow the query for the rest, or pass the query to write_report, which reads up to ${REPORT_WIQL_TOP}.` : ""}`,
|
|
50
101
|
}
|
|
51
102
|
}
|
|
52
103
|
|
|
@@ -85,31 +136,33 @@ function clipJson(data: unknown): unknown {
|
|
|
85
136
|
* Read-only requests with the credentials from forgeqe.yaml and the environment: GET and WIQL searches for Azure
|
|
86
137
|
* DevOps, GET for Jira (Xray Server/Data Center is under Jira's /rest/raven), GraphQL queries for Xray Cloud.
|
|
87
138
|
*/
|
|
88
|
-
export async function queryApi(cfg: SourcesConfig, q: ApiQuery): Promise<unknown> {
|
|
139
|
+
export async function queryApi(cfg: SourcesConfig, q: ApiQuery, opts: { full?: boolean } = {}): Promise<unknown> {
|
|
140
|
+
// A report reads the whole answer; the agent gets it clipped, so it fits its context.
|
|
141
|
+
const clip = opts.full ? (d: unknown) => d : clipJson
|
|
89
142
|
switch (q.system) {
|
|
90
143
|
case "azure_devops": {
|
|
91
|
-
if (q.wiql?.trim()) return clipJson(await adoWiql(cfg, q.wiql, q.project))
|
|
144
|
+
if (q.wiql?.trim()) return opts.full ? adoWorkItems(cfg, q.wiql, q.project, REPORT_WIQL_TOP) : clipJson(await adoWiql(cfg, q.wiql, q.project))
|
|
92
145
|
if (!q.path) throw new Error("path or wiql is required for azure_devops")
|
|
93
146
|
const { ado, auth } = adoSettings(cfg)
|
|
94
147
|
let url = resolve(q.path, adoBases(ado.orgUrl), "Azure DevOps")
|
|
95
148
|
if (!/[?&]api-version=/.test(url)) url += `${url.includes("?") ? "&" : "?"}api-version=7.1`
|
|
96
|
-
return
|
|
149
|
+
return clip((await http(url, { auth })).data)
|
|
97
150
|
}
|
|
98
151
|
case "jira": {
|
|
99
152
|
if (!q.path) throw new Error("path is required for jira")
|
|
100
153
|
const j = jiraApi(cfg)
|
|
101
|
-
return
|
|
154
|
+
return clip((await http(resolve(q.path, [j.url], "Jira"), { auth: j.auth })).data)
|
|
102
155
|
}
|
|
103
156
|
case "confluence": {
|
|
104
157
|
if (!q.path) throw new Error("path is required for confluence")
|
|
105
158
|
const c = confluenceApi(cfg)
|
|
106
|
-
return
|
|
159
|
+
return clip((await http(resolve(q.path, [c.url], "Confluence"), { auth: c.auth })).data)
|
|
107
160
|
}
|
|
108
161
|
case "xray": {
|
|
109
162
|
if (cfg.jira && !cfg.jira.cloud) throw new Error("Xray Server/Data Center is part of Jira: use system jira with a /rest/raven/1.0/... path.")
|
|
110
163
|
if (!q.query) throw new Error("query (GraphQL) is required for xray")
|
|
111
164
|
if (isMutation(q.query)) throw new Error("query_api only reads; GraphQL mutations are not allowed.")
|
|
112
|
-
return
|
|
165
|
+
return clip(await xrayGraphql(cfg, await xrayCloudToken(cfg), q.query, q.variables || {}))
|
|
113
166
|
}
|
|
114
167
|
default:
|
|
115
168
|
throw new Error(`Unknown system "${String(q.system)}". Use azure_devops, jira, xray, or confluence.`)
|
package/src/code/runtime.ts
CHANGED
|
@@ -539,6 +539,8 @@ export async function openSession<R = AgentReport>(opts: {
|
|
|
539
539
|
mcp?: Record<string, unknown>
|
|
540
540
|
/** Tool calls not to show as ⚙ lines (the caller shows them itself). */
|
|
541
541
|
hideTool?: (tool: string) => boolean
|
|
542
|
+
/** Every finished tool call with what it returned, shown or not: the evidence reports are built from. */
|
|
543
|
+
onToolResult?: (r: { tool: string; input: Record<string, unknown>; output: string; ok: boolean }) => void
|
|
542
544
|
title?: string
|
|
543
545
|
protect?: Protect
|
|
544
546
|
/** Stop the turn when one shell command has failed this many times since the last resetStall. */
|
|
@@ -672,6 +674,7 @@ export async function openSession<R = AgentReport>(opts: {
|
|
|
672
674
|
emit({ type: "code", kind: "start", tool: part.tool, id: part.id, text: toolLine(opts.repo, part.tool, part.state.input || {}, part.state.title) })
|
|
673
675
|
}
|
|
674
676
|
if (part?.type === "tool" && (part.state?.status === "completed" || part.state?.status === "error")) {
|
|
677
|
+
opts.onToolResult?.({ tool: part.tool, input: part.state.input || {}, output: String(part.state.output ?? ""), ok: part.state.status === "completed" })
|
|
675
678
|
if (part.tool === "StructuredOutput" && part.state.status === "completed" && !turn.structured) {
|
|
676
679
|
turn.structured = part.state.input || null
|
|
677
680
|
}
|
package/src/mcp/reporttools.ts
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
|
|
4
|
+
import { queryApi, type ApiQuery } from "../cases/api.ts"
|
|
5
|
+
import { reportPage } from "../report/html.ts"
|
|
6
|
+
import {
|
|
7
|
+
chooseColumns,
|
|
8
|
+
chooseFilters,
|
|
9
|
+
dedupe,
|
|
10
|
+
describeCall,
|
|
11
|
+
EvidenceLog,
|
|
12
|
+
evidenceList,
|
|
13
|
+
pickEvidence,
|
|
14
|
+
rowsOf,
|
|
15
|
+
type Cell,
|
|
16
|
+
type Column,
|
|
17
|
+
type EvidenceEntry,
|
|
18
|
+
type Row,
|
|
19
|
+
} from "../report/evidence.ts"
|
|
4
20
|
import type { McpTool } from "./tools.ts"
|
|
5
21
|
|
|
6
22
|
/**
|
|
7
|
-
* Reports the agent writes from what it found (
|
|
8
|
-
*
|
|
23
|
+
* Reports the agent writes from what it found (open defects, release status, pipeline health). Every table in one is
|
|
24
|
+
* built from evidence: the output of tool calls in this session, or a read-only query ForgeQE runs itself. The model
|
|
25
|
+
* writes only the summary. The report is a page (report-ui, with filters, charts, and the sources it came from) and
|
|
26
|
+
* the same report as Markdown; the raw results are kept beside them.
|
|
9
27
|
*/
|
|
10
28
|
|
|
11
29
|
/** "Release status: all projects" → "release-status-all-projects". */
|
|
@@ -21,7 +39,7 @@ export function reportSlug(title: string): string {
|
|
|
21
39
|
|
|
22
40
|
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
23
41
|
|
|
24
|
-
/**
|
|
42
|
+
/** A Markdown document as a plain page (forgeqe bi's page without Evidence): the Markdown rendered, with raw HTML in it shown as text so a report can never run script. */
|
|
25
43
|
export function reportHtml(title: string, markdown: string, written: Date): string {
|
|
26
44
|
const safe = markdown.replace(/<(?=[A-Za-z/!?])/g, "<")
|
|
27
45
|
const body = Bun.markdown.html(safe)
|
|
@@ -66,36 +84,209 @@ ${body}
|
|
|
66
84
|
`
|
|
67
85
|
}
|
|
68
86
|
|
|
87
|
+
/** The summary as HTML, with raw HTML in it shown as text so a report can never run script. */
|
|
88
|
+
export function summaryHtml(markdown: string): string {
|
|
89
|
+
return Bun.markdown.html(markdown.replace(/<(?=[A-Za-z/!?])/g, "<"))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const MAX_ROWS = 5000
|
|
93
|
+
const MD_ROWS = 200
|
|
94
|
+
|
|
95
|
+
export type DatasetSpec = { name?: string; from?: string; match?: string; query?: ApiQuery; columns?: string[]; filters?: string[] }
|
|
96
|
+
|
|
97
|
+
export type Dataset = { name: string; columns: Column[]; filters: string[]; rows: Row[]; sources: string[]; total: number; note?: string }
|
|
98
|
+
export type Source = { id: string; tool: string; call: string; at: string; rows: number | null; file: string }
|
|
99
|
+
|
|
100
|
+
/** The rows of one dataset, and the evidence they came from. */
|
|
101
|
+
export async function buildDataset(spec: DatasetSpec, log: EvidenceLog, readApi: (q: ApiQuery) => Promise<unknown>): Promise<{ dataset: Dataset; used: EvidenceEntry[] }> {
|
|
102
|
+
const name = String(spec.name || "Data").trim()
|
|
103
|
+
let used: EvidenceEntry[]
|
|
104
|
+
if (spec.query) {
|
|
105
|
+
const q = spec.query
|
|
106
|
+
const out = await readApi(q)
|
|
107
|
+
const entry = log.record("query_api", { ...q }, typeof out === "string" ? out : JSON.stringify(out))
|
|
108
|
+
if (!entry) throw new Error(`Dataset "${name}": the query returned nothing.`)
|
|
109
|
+
used = [entry]
|
|
110
|
+
} else if (spec.from) {
|
|
111
|
+
used = pickEvidence(log.all(), String(spec.from), spec.match ? String(spec.match) : undefined)
|
|
112
|
+
if (!used.length) throw new Error(`Dataset "${name}": no tool result matches "${spec.from}"${spec.match ? ` with "${spec.match}"` : ""}. Results in this session:\n${evidenceList(log.all())}`)
|
|
113
|
+
} else {
|
|
114
|
+
throw new Error(`Dataset "${name}" needs "from" (a tool result of this session, e.g. E3 or the tool's name) or "query" (a read-only query ForgeQE runs).`)
|
|
115
|
+
}
|
|
116
|
+
const parts = used.map((e) => ({ e, rows: rowsOf(e.output) }))
|
|
117
|
+
const noData = parts.filter((p) => p.rows === null)
|
|
118
|
+
if (noData.length === parts.length) {
|
|
119
|
+
throw new Error(`Dataset "${name}": ${noData.map((p) => p.e.id).join(", ")} returned no data (no JSON records). Results in this session:\n${evidenceList(log.all())}`)
|
|
120
|
+
}
|
|
121
|
+
const all = dedupe(parts.flatMap((p) => p.rows || []))
|
|
122
|
+
const rows = all.slice(0, MAX_ROWS)
|
|
123
|
+
const columns = rows.length ? chooseColumns(rows, spec.columns) : []
|
|
124
|
+
const totals = parts.map((p) => {
|
|
125
|
+
const v = (() => {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(p.e.output) as { total?: unknown; count?: unknown }
|
|
128
|
+
} catch {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
})()
|
|
132
|
+
return typeof v?.total === "number" ? v.total : 0
|
|
133
|
+
})
|
|
134
|
+
const total = Math.max(all.length, ...totals)
|
|
135
|
+
const notes = [
|
|
136
|
+
all.length > MAX_ROWS ? `The page holds the first ${MAX_ROWS} of ${all.length} rows.` : "",
|
|
137
|
+
total > all.length ? `The query matched ${total}; the first ${all.length} were read.` : "",
|
|
138
|
+
].filter(Boolean)
|
|
139
|
+
return {
|
|
140
|
+
dataset: { name, columns, filters: chooseFilters(rows, columns, spec.filters), rows, sources: used.map((e) => e.id), total, ...(notes.length ? { note: notes.join(" ") } : {}) },
|
|
141
|
+
used,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const mdCell = (v: Cell | undefined) => (v === null || v === undefined ? "" : String(v).replace(/\|/g, "\\|").replace(/\s+/g, " "))
|
|
146
|
+
const csvCell = (v: Cell | undefined) => {
|
|
147
|
+
const s = v === null || v === undefined ? "" : String(v)
|
|
148
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Counts of each value of a column, most first. */
|
|
152
|
+
export function countBy(rows: Row[], key: string): [string, number][] {
|
|
153
|
+
const m = new Map<string, number>()
|
|
154
|
+
for (const r of rows) {
|
|
155
|
+
const v = r[key] === null || r[key] === undefined || r[key] === "" ? "(none)" : String(r[key])
|
|
156
|
+
m.set(v, (m.get(v) || 0) + 1)
|
|
157
|
+
}
|
|
158
|
+
return [...m].sort((a, b) => b[1] - a[1])
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The report as Markdown: the summary, each dataset's counts and table, and the sources. */
|
|
162
|
+
export function reportMarkdown(title: string, summary: string, datasets: Dataset[], sources: Source[], written: Date): string {
|
|
163
|
+
const out = [`# ${title}`, "", `_Written by ForgeQE on ${written.toISOString().slice(0, 16).replace("T", " ")} UTC, from ${sources.length === 1 ? "1 source" : `${sources.length} sources`}._`, "", summary, ""]
|
|
164
|
+
for (const d of datasets) {
|
|
165
|
+
out.push(`## ${d.name}`, "", `**${d.rows.length}** ${d.rows.length === 1 ? "row" : "rows"}${d.note ? ` (${d.note})` : ""} · from ${d.sources.join(", ")}`, "")
|
|
166
|
+
for (const f of d.filters.slice(0, 3)) out.push(`- By ${f}: ${countBy(d.rows, f).map(([v, n]) => `${v} ${n}`).join(", ")}`)
|
|
167
|
+
if (d.filters.length) out.push("")
|
|
168
|
+
if (d.rows.length) {
|
|
169
|
+
const cols = d.columns.filter((c) => c.key !== "Link")
|
|
170
|
+
const idCol = cols.find((c) => c.key === "ID" || c.key === "Key")
|
|
171
|
+
out.push(`| ${cols.map((c) => c.label).join(" | ")} |`, `| ${cols.map(() => "---").join(" | ")} |`)
|
|
172
|
+
for (const r of d.rows.slice(0, MD_ROWS)) {
|
|
173
|
+
out.push(`| ${cols.map((c) => (c === idCol && r.Link ? `[${mdCell(r[c.key])}](${r.Link})` : mdCell(r[c.key]))).join(" | ")} |`)
|
|
174
|
+
}
|
|
175
|
+
if (d.rows.length > MD_ROWS) out.push("", `The first ${MD_ROWS} rows; the page and ${csvName(d.name)} have all ${d.rows.length}.`)
|
|
176
|
+
out.push("")
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
out.push("## Sources", "", ...sources.map((s) => `- ${s.id} · ${s.call} · ${s.at.slice(0, 16).replace("T", " ")} UTC · ${s.rows === null ? "no data" : `${s.rows} rows`} · [raw](${s.file})`), "")
|
|
180
|
+
return out.join("\n")
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const csvName = (name: string) => `${reportSlug(name)}.csv`
|
|
184
|
+
|
|
69
185
|
export const REPORT_TOOLS: McpTool[] = [
|
|
70
186
|
{
|
|
71
187
|
name: "write_report",
|
|
72
188
|
description:
|
|
73
|
-
'Save a report the user asks for ("create a
|
|
189
|
+
'Save a report the user asks for ("create a defect report for all open defects", "release status for all projects", "a report I can share") as a page with filters, charts, and its sources, and as Markdown. A report is built from evidence: each dataset is the output of tool calls you made in this session (from: the tool\'s name, e.g. wit_get_work_items_batch_by_ids or query_api, or an evidence id like E3; match narrows to calls whose input mentions it), or a read-only query ForgeQE runs itself (query: same fields as query_api; a WIQL query reads up to 1000 work items with project, state, priority, severity, assignee, area, and links, so prefer it for "all" of something). ForgeQE takes the rows from those results, not from you; columns and filters are optional (default: the usual ones in the data, such as Project, State, Priority, Assigned To). Write only the summary yourself: one short paragraph of what the data shows, with no figures the datasets do not hold. The user is shown the report paths as links, so reply with three to five lines of summary only.',
|
|
74
190
|
inputSchema: {
|
|
75
191
|
type: "object",
|
|
76
192
|
properties: {
|
|
77
|
-
title: { type: "string", description: "The report's title, e.g.
|
|
78
|
-
|
|
193
|
+
title: { type: "string", description: "The report's title, e.g. Open defects: all projects, 13 October 2026" },
|
|
194
|
+
summary: { type: "string", description: "One short Markdown paragraph (or a few bullets) on what the data shows; the tables, counts, and charts are made from the datasets" },
|
|
195
|
+
datasets: {
|
|
196
|
+
type: "array",
|
|
197
|
+
minItems: 1,
|
|
198
|
+
items: {
|
|
199
|
+
type: "object",
|
|
200
|
+
properties: {
|
|
201
|
+
name: { type: "string", description: "e.g. Open defects" },
|
|
202
|
+
from: { type: "string", description: "Evidence ids (E3, or E3,E4) or the name of a tool called in this session; every successful call of it is used" },
|
|
203
|
+
match: { type: "string", description: "With a tool name: only its calls whose input contains this text" },
|
|
204
|
+
query: {
|
|
205
|
+
type: "object",
|
|
206
|
+
description: "A read-only query ForgeQE runs for this dataset (instead of from)",
|
|
207
|
+
properties: {
|
|
208
|
+
system: { type: "string", enum: ["azure_devops", "jira", "xray", "confluence"] },
|
|
209
|
+
wiql: { type: "string", description: "azure_devops: e.g. SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType] = 'Bug' AND [System.State] NOT IN ('Closed','Done','Removed'). Across projects, leave out @project" },
|
|
210
|
+
project: { type: "string" },
|
|
211
|
+
path: { type: "string", description: "A REST path, as for query_api (e.g. Jira /rest/api/3/search/jql?jql=...&fields=summary,status,priority,assignee,project)" },
|
|
212
|
+
query: { type: "string", description: "xray: a GraphQL query" },
|
|
213
|
+
variables: { type: "object" },
|
|
214
|
+
},
|
|
215
|
+
required: ["system"],
|
|
216
|
+
},
|
|
217
|
+
columns: { type: "array", items: { type: "string" }, description: "Columns to show, in order (default: the usual ones in the data)" },
|
|
218
|
+
filters: { type: "array", items: { type: "string" }, description: "Columns to filter by (default: Project, State, Priority, Assigned To, … when present)" },
|
|
219
|
+
},
|
|
220
|
+
required: ["name"],
|
|
221
|
+
},
|
|
222
|
+
},
|
|
79
223
|
},
|
|
80
|
-
required: ["title", "
|
|
224
|
+
required: ["title", "datasets"],
|
|
81
225
|
},
|
|
82
226
|
async run(args, ctx) {
|
|
83
227
|
const title = String(args.title || "Report").trim()
|
|
84
|
-
const
|
|
85
|
-
|
|
228
|
+
const summary = String(args.summary ?? args.markdown ?? "").trim()
|
|
229
|
+
const specs = (Array.isArray(args.datasets) ? args.datasets : []) as DatasetSpec[]
|
|
230
|
+
const log = ctx.evidence || new EvidenceLog()
|
|
231
|
+
if (!specs.length) {
|
|
232
|
+
throw new Error(`A report is built from evidence: pass datasets, each from a tool result of this session or a read-only query. Results in this session:\n${evidenceList(log.all())}`)
|
|
233
|
+
}
|
|
234
|
+
const readApi = (q: ApiQuery) => queryApi(ctx.config.sources, q, { full: true })
|
|
235
|
+
const built: Dataset[] = []
|
|
236
|
+
const used = new Map<string, EvidenceEntry>()
|
|
237
|
+
for (const spec of specs) {
|
|
238
|
+
const { dataset, used: entries } = await buildDataset(spec, log, readApi)
|
|
239
|
+
built.push(dataset)
|
|
240
|
+
for (const e of entries) used.set(e.id, e)
|
|
241
|
+
}
|
|
242
|
+
|
|
86
243
|
const written = new Date()
|
|
87
244
|
const stamp = written.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15)
|
|
88
245
|
// Beside the run reports, so the shell links it and outDir publishing (notify.report_url) serves it too.
|
|
89
246
|
const dir = path.resolve(ctx.config.export.outDir, `${reportSlug(title)}-${stamp}`)
|
|
90
|
-
mkdirSync(dir, { recursive: true })
|
|
247
|
+
mkdirSync(path.join(dir, "evidence"), { recursive: true })
|
|
248
|
+
const sources: Source[] = [...used.values()].map((e) => {
|
|
249
|
+
const file = `evidence/${e.id}.json`
|
|
250
|
+
writeFileSync(path.join(dir, file), JSON.stringify({ id: e.id, tool: e.tool, input: e.input, at: e.at, output: e.output }, null, 2))
|
|
251
|
+
const rows = rowsOf(e.output)
|
|
252
|
+
return { id: e.id, tool: e.tool, call: describeCall(e), at: e.at, rows: rows ? rows.length : null, file }
|
|
253
|
+
})
|
|
254
|
+
for (const d of built) {
|
|
255
|
+
const cols = d.columns
|
|
256
|
+
writeFileSync(path.join(dir, csvName(d.name)), [cols.map((c) => csvCell(c.label)).join(","), ...d.rows.map((r) => cols.map((c) => csvCell(r[c.key])).join(","))].join("\n") + "\n")
|
|
257
|
+
}
|
|
91
258
|
const md = path.join(dir, "report.md")
|
|
92
259
|
const html = path.join(dir, "report.html")
|
|
93
|
-
writeFileSync(md,
|
|
94
|
-
writeFileSync(
|
|
260
|
+
writeFileSync(md, reportMarkdown(title, summary, built, sources, written))
|
|
261
|
+
writeFileSync(
|
|
262
|
+
html,
|
|
263
|
+
reportPage(title, {
|
|
264
|
+
kind: "data",
|
|
265
|
+
title,
|
|
266
|
+
id: path.basename(dir),
|
|
267
|
+
startedAt: written.toISOString(),
|
|
268
|
+
ms: 0,
|
|
269
|
+
stopped: false,
|
|
270
|
+
generatedAt: written.toISOString(),
|
|
271
|
+
cases: [],
|
|
272
|
+
posted: [],
|
|
273
|
+
history: [],
|
|
274
|
+
report: { summary, summaryHtml: summary ? summaryHtml(summary) : "", datasets: built.map((d) => ({ ...d, csv: csvName(d.name) })), sources },
|
|
275
|
+
}),
|
|
276
|
+
)
|
|
95
277
|
const show = (p: string) => path.relative(process.cwd(), p) || p
|
|
278
|
+
const rows = built.reduce((n, d) => n + d.rows.length, 0)
|
|
96
279
|
ctx.emit?.({ type: "report", title, html, markdown: md })
|
|
97
|
-
ctx.emit?.({
|
|
98
|
-
|
|
280
|
+
ctx.emit?.({
|
|
281
|
+
type: "card",
|
|
282
|
+
card: { status: "ok", title: `Report: ${title}`, body: [`Page ${show(html)}`, `Markdown ${show(md)}`, `Evidence ${rows} rows from ${sources.map((s) => s.id).join(", ")}`] },
|
|
283
|
+
})
|
|
284
|
+
return {
|
|
285
|
+
html: show(html),
|
|
286
|
+
markdown: show(md),
|
|
287
|
+
datasets: built.map((d) => ({ name: d.name, rows: d.rows.length, total: d.total, columns: d.columns.map((c) => c.key), filters: d.filters, counts: Object.fromEntries(d.filters.map((f) => [f, Object.fromEntries(countBy(d.rows, f).slice(0, 8))])), ...(d.note ? { note: d.note } : {}) })),
|
|
288
|
+
sources: sources.map((s) => `${s.id} ${s.call}`),
|
|
289
|
+
}
|
|
99
290
|
},
|
|
100
291
|
},
|
|
101
292
|
]
|