@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,284 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_THRESHOLDS, type AppConfig, type ReleaseThresholds } from "../config.ts"
|
|
5
|
+
export { DEFAULT_THRESHOLDS, type ReleaseThresholds }
|
|
6
|
+
import { flakiness, readHistory } from "../code/history.ts"
|
|
7
|
+
import { readMappings } from "../code/traceability.ts"
|
|
8
|
+
import { runDirectory } from "../run.ts"
|
|
9
|
+
import { adoSettings } from "./ado.ts"
|
|
10
|
+
import { http } from "./http.ts"
|
|
11
|
+
import { adoSprints, findSprint } from "./sprints.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Requirement coverage and release readiness for an Azure DevOps scope (a sprint, an area, an epic or feature and
|
|
15
|
+
* everything under it, or given work items): each story with its test cases, whether each is automated, and its
|
|
16
|
+
* last result; the stories nobody tests, the cases nobody automated, the open bugs by severity, and the flaky and
|
|
17
|
+
* quarantined tests; then a verdict, GO, GO WITH RISKS, or NO-GO, with its reasons, from thresholds in forgeqe.yaml.
|
|
18
|
+
*
|
|
19
|
+
* A test case's last result is its newest test point outcome in Azure DevOps; where a plan has none, ForgeQE's own
|
|
20
|
+
* runs: the newest suite report for the case, then the automated test's history.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type Scope = { sprint?: string; area?: string; parent?: number; ids?: number[]; project?: string }
|
|
24
|
+
|
|
25
|
+
export type Outcome = "passed" | "failed" | "blocked" | "not run"
|
|
26
|
+
export type CaseRow = { id: number; title: string; url: string; automated: boolean; test?: string; outcome: Outcome; when?: string; from?: "Azure DevOps" | "ForgeQE"; flaky?: boolean; quarantined?: boolean }
|
|
27
|
+
export type StoryRow = { id: number; type: string; title: string; state: string; url: string; area?: string; cases: CaseRow[] }
|
|
28
|
+
export type BugRow = { id: number; title: string; state: string; severity: number; severityText: string; url: string }
|
|
29
|
+
export type Verdict = "GO" | "GO WITH RISKS" | "NO-GO"
|
|
30
|
+
|
|
31
|
+
export type Readiness = {
|
|
32
|
+
scope: string
|
|
33
|
+
stories: StoryRow[]
|
|
34
|
+
bugs: BugRow[]
|
|
35
|
+
counts: { stories: number; untested: number; cases: number; automated: number; executed: number; passed: number; failed: number; blocked: number; notRun: number; flaky: number; quarantined: number }
|
|
36
|
+
passRate: number | null
|
|
37
|
+
verdict: Verdict
|
|
38
|
+
blockers: string[]
|
|
39
|
+
risks: string[]
|
|
40
|
+
notes: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Item = { id: number; fields: Record<string, unknown>; relations?: { rel: string; url: string }[] }
|
|
44
|
+
|
|
45
|
+
const REQUIREMENT = "[System.WorkItemType] IN GROUP 'Microsoft.RequirementCategory'"
|
|
46
|
+
const OPEN_BUG = "[System.WorkItemType] IN GROUP 'Microsoft.BugCategory' AND [System.State] NOT IN ('Closed', 'Done', 'Removed', 'Resolved')"
|
|
47
|
+
const q = (s: string) => `'${s.replace(/'/g, "''")}'`
|
|
48
|
+
const idOf = (url: string) => Number(url.split("/").pop())
|
|
49
|
+
|
|
50
|
+
/** The verdict from the counts: blockers make it NO-GO, risks GO WITH RISKS. */
|
|
51
|
+
export function verdictOf(r: Pick<Readiness, "stories" | "bugs" | "counts" | "passRate">, t: ReleaseThresholds): { verdict: Verdict; blockers: string[]; risks: string[] } {
|
|
52
|
+
const blockers: string[] = []
|
|
53
|
+
const risks: string[] = []
|
|
54
|
+
const critical = r.bugs.filter((b) => b.severity === 1).length
|
|
55
|
+
const high = r.bugs.filter((b) => b.severity === 2).length
|
|
56
|
+
if (critical > t.maxCritical) blockers.push(`${critical} open critical bug${critical === 1 ? "" : "s"} (allowed: ${t.maxCritical})`)
|
|
57
|
+
if (high > t.maxHigh) blockers.push(`${high} open high-severity bug${high === 1 ? "" : "s"} (allowed: ${t.maxHigh})`)
|
|
58
|
+
const failing = r.stories.filter((s) => s.cases.some((c) => c.outcome === "failed" && !c.quarantined))
|
|
59
|
+
if (failing.length) blockers.push(`${failing.length} stor${failing.length === 1 ? "y has" : "ies have"} a failing test case: ${failing.slice(0, 5).map((s) => `${s.id} ${s.title}`).join("; ")}`)
|
|
60
|
+
if (r.passRate !== null && r.passRate < t.minPassRate) blockers.push(`pass rate ${r.passRate}% is below ${t.minPassRate}%`)
|
|
61
|
+
if (t.requireTests && r.counts.untested) risks.push(`${r.counts.untested} stor${r.counts.untested === 1 ? "y has" : "ies have"} no test case`)
|
|
62
|
+
if (r.counts.notRun) risks.push(`${r.counts.notRun} test case${r.counts.notRun === 1 ? " has" : "s have"} not run`)
|
|
63
|
+
if (r.counts.blocked) risks.push(`${r.counts.blocked} test case${r.counts.blocked === 1 ? " is" : "s are"} blocked`)
|
|
64
|
+
if (r.counts.flaky) risks.push(`${r.counts.flaky} automated test${r.counts.flaky === 1 ? " is" : "s are"} flaky`)
|
|
65
|
+
if (r.counts.quarantined) risks.push(`${r.counts.quarantined} automated test${r.counts.quarantined === 1 ? " is" : "s are"} in quarantine`)
|
|
66
|
+
const other = r.bugs.filter((b) => b.severity > 2).length
|
|
67
|
+
if (other) risks.push(`${other} other open bug${other === 1 ? "" : "s"}`)
|
|
68
|
+
return { verdict: blockers.length ? "NO-GO" : risks.length ? "GO WITH RISKS" : "GO", blockers, risks }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The newest result per test case in ForgeQE's suite reports. */
|
|
72
|
+
function forgeqeResults(suitesDir: string): Map<number, { outcome: Outcome; when: string }> {
|
|
73
|
+
const out = new Map<number, { outcome: Outcome; when: string }>()
|
|
74
|
+
if (!existsSync(suitesDir)) return out
|
|
75
|
+
for (const dir of readdirSync(suitesDir).sort()) {
|
|
76
|
+
try {
|
|
77
|
+
const suite = JSON.parse(readFileSync(path.join(suitesDir, dir, "suite.json"), "utf8")) as { results?: { case: { id: string; ref?: { workItem?: number } }; status: string; finishedAt?: string; failure?: { type: string } }[] }
|
|
78
|
+
for (const r of suite.results || []) {
|
|
79
|
+
const id = Number(r.case.ref?.workItem || /^ADO-(\d+)/.exec(r.case.id)?.[1])
|
|
80
|
+
if (!id || r.status === "stopped") continue
|
|
81
|
+
const outcome: Outcome = r.status === "passed" ? "passed" : r.failure && r.failure.type !== "functional_defect" ? "blocked" : "failed"
|
|
82
|
+
out.set(id, { outcome, when: r.finishedAt || dir })
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function releaseReadiness(cfg: AppConfig, repo: string, scope: Scope, thresholds: ReleaseThresholds = DEFAULT_THRESHOLDS): Promise<Readiness> {
|
|
90
|
+
const { org, auth, project: configured } = adoSettings(cfg.sources)
|
|
91
|
+
const project = scope.project || configured
|
|
92
|
+
if (!project && !scope.ids?.length) throw new Error("Name the Azure DevOps project (project, or sources.azure_devops.project) for a sprint, area, or epic.")
|
|
93
|
+
const notes: string[] = []
|
|
94
|
+
const wiql = async (query: string) => {
|
|
95
|
+
const { data } = await http<{ workItems?: { id: number }[]; workItemRelations?: { target?: { id: number } }[] }>(`${org}/${encodeURIComponent(project!)}/_apis/wit/wiql?api-version=7.1`, { auth, body: { query } })
|
|
96
|
+
return [...new Set([...(data?.workItems || []).map((w) => w.id), ...(data?.workItemRelations || []).map((r) => r.target?.id).filter((n): n is number => Boolean(n))])]
|
|
97
|
+
}
|
|
98
|
+
const items = async (ids: number[]) => {
|
|
99
|
+
const out: Item[] = []
|
|
100
|
+
for (let i = 0; i < ids.length; i += 200) {
|
|
101
|
+
const { data } = await http<{ value?: Item[] }>(`${org}/_apis/wit/workitems?ids=${ids.slice(i, i + 200).join(",")}&$expand=relations&api-version=7.1`, { auth })
|
|
102
|
+
out.push(...(data?.value || []))
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
const urlOf = (it: Item) => `${org}/${encodeURIComponent(String(it.fields["System.TeamProject"] || project || ""))}/_workitems/edit/${it.id}`
|
|
107
|
+
|
|
108
|
+
// The scope: its stories, and the filter its open bugs share.
|
|
109
|
+
let label: string
|
|
110
|
+
let filter = ""
|
|
111
|
+
let storyIds: number[]
|
|
112
|
+
if (scope.ids?.length) {
|
|
113
|
+
label = `work items ${scope.ids.join(", ")}`
|
|
114
|
+
storyIds = scope.ids
|
|
115
|
+
} else if (scope.parent) {
|
|
116
|
+
label = `everything under ${scope.parent}`
|
|
117
|
+
storyIds = (await wiql(`SELECT [System.Id] FROM WorkItemLinks WHERE ([Source].[System.Id] = ${scope.parent}) AND ([System.Links.LinkType] = 'System.LinkTypes.Hierarchy-Forward') AND ([Target].${REQUIREMENT}) MODE (Recursive)`)).filter((id) => id !== scope.parent)
|
|
118
|
+
} else {
|
|
119
|
+
const parts: string[] = []
|
|
120
|
+
if (scope.sprint) {
|
|
121
|
+
const sprints = await adoSprints(cfg.sources)
|
|
122
|
+
const s = scope.sprint.toLowerCase() === "current" ? sprints.find((x) => x.state === "current") : findSprint(sprints, scope.sprint)
|
|
123
|
+
if (!s?.path) throw new Error(`No sprint "${scope.sprint}" with an iteration path in ${project}.`)
|
|
124
|
+
parts.push(`[System.IterationPath] UNDER ${q(s.path)}`)
|
|
125
|
+
label = `sprint ${s.name}${s.start ? ` (${s.start} to ${s.end})` : ""}`
|
|
126
|
+
} else label = ""
|
|
127
|
+
if (scope.area) {
|
|
128
|
+
parts.push(`[System.AreaPath] UNDER ${q(scope.area)}`)
|
|
129
|
+
label = [label, `area ${scope.area}`].filter(Boolean).join(", ")
|
|
130
|
+
}
|
|
131
|
+
if (!parts.length) throw new Error("Give a sprint, an area, a parent epic or feature, or work item ids.")
|
|
132
|
+
filter = parts.join(" AND ")
|
|
133
|
+
storyIds = await wiql(`SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND ${REQUIREMENT} AND [System.State] <> 'Removed' AND ${filter}`)
|
|
134
|
+
}
|
|
135
|
+
const storyItems = await items(storyIds)
|
|
136
|
+
|
|
137
|
+
// Test cases each story is tested by, and their newest outcome.
|
|
138
|
+
const caseIds = [...new Set(storyItems.flatMap((s) => (s.relations || []).filter((r) => r.rel === "Microsoft.VSTS.Common.TestedBy-Forward").map((r) => idOf(r.url))))]
|
|
139
|
+
const caseItems = new Map((await items(caseIds)).map((c) => [c.id, c]))
|
|
140
|
+
const points = new Map<number, { outcome: Outcome; when: string }>()
|
|
141
|
+
if (caseIds.length && project) {
|
|
142
|
+
try {
|
|
143
|
+
for (let i = 0; i < caseIds.length; i += 200) {
|
|
144
|
+
const { data } = await http<{ points?: { testCase?: { id: string | number }; outcome?: string; lastUpdatedDate?: string }[]; value?: { testCase?: { id: string | number }; outcome?: string; lastUpdatedDate?: string }[] }>(
|
|
145
|
+
`${org}/${encodeURIComponent(project)}/_apis/test/points?api-version=7.1-preview.2`,
|
|
146
|
+
{ auth, body: { pointsFilter: { testcaseIds: caseIds.slice(i, i + 200) } } },
|
|
147
|
+
)
|
|
148
|
+
for (const p of data?.points || data?.value || []) {
|
|
149
|
+
const id = Number(p.testCase?.id)
|
|
150
|
+
const o = String(p.outcome || "").toLowerCase()
|
|
151
|
+
if (!id || !o || o === "unspecified" || o === "none") continue
|
|
152
|
+
const outcome: Outcome = o === "passed" ? "passed" : o === "failed" ? "failed" : o === "blocked" ? "blocked" : "not run"
|
|
153
|
+
const when = p.lastUpdatedDate || ""
|
|
154
|
+
if (!points.has(id) || (points.get(id)!.when || "") < when) points.set(id, { outcome, when })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
notes.push(`Test point outcomes could not be read (${(err as Error).message.slice(0, 120)}); ForgeQE's own runs are used.`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const local = forgeqeResults(cfg.suite.outDir)
|
|
162
|
+
const mapping = readMappings(repo).cases
|
|
163
|
+
const history = readHistory(repo).tests
|
|
164
|
+
const flakyAfter = cfg.flaky?.flakyAfter ?? 2
|
|
165
|
+
|
|
166
|
+
const stories: StoryRow[] = storyItems.map((s) => {
|
|
167
|
+
const cases = (s.relations || [])
|
|
168
|
+
.filter((r) => r.rel === "Microsoft.VSTS.Common.TestedBy-Forward")
|
|
169
|
+
.map((r) => caseItems.get(idOf(r.url)))
|
|
170
|
+
.filter((c): c is Item => Boolean(c))
|
|
171
|
+
.map((c): CaseRow => {
|
|
172
|
+
const map = mapping[`ADO-${c.id}`]
|
|
173
|
+
const testName = String(c.fields["Microsoft.VSTS.TCM.AutomatedTestName"] || map?.testName || "")
|
|
174
|
+
const automated = String(c.fields["Microsoft.VSTS.TCM.AutomationStatus"] || "") === "Automated" || Boolean(map && map.status !== "needs_attention")
|
|
175
|
+
const key = Object.keys(history).find((n) => (testName && (n === testName || n.endsWith(testName))) || n.includes(`ADO-${c.id}`))
|
|
176
|
+
const f = key ? flakiness(key, history[key], flakyAfter) : undefined
|
|
177
|
+
const ado = points.get(c.id)
|
|
178
|
+
const mine = local.get(c.id)
|
|
179
|
+
const last = key ? history[key].runs.at(-1) : undefined
|
|
180
|
+
const fromTest = last ? { outcome: (last.status === "passed" ? "passed" : last.status === "failed" ? "failed" : "not run") as Outcome, when: last.at } : undefined
|
|
181
|
+
const best = [ado && { ...ado, from: "Azure DevOps" as const }, mine && { ...mine, from: "ForgeQE" as const }, fromTest && { ...fromTest, from: "ForgeQE" as const }]
|
|
182
|
+
.filter((x): x is { outcome: Outcome; when: string; from: "Azure DevOps" | "ForgeQE" } => Boolean(x))
|
|
183
|
+
.sort((a, b) => (b.when || "").localeCompare(a.when || ""))[0]
|
|
184
|
+
return {
|
|
185
|
+
id: c.id,
|
|
186
|
+
title: String(c.fields["System.Title"] || ""),
|
|
187
|
+
url: urlOf(c),
|
|
188
|
+
automated,
|
|
189
|
+
...(testName ? { test: testName } : map?.testFile ? { test: map.testFile } : {}),
|
|
190
|
+
outcome: best?.outcome || "not run",
|
|
191
|
+
...(best?.when ? { when: best.when.slice(0, 10) } : {}),
|
|
192
|
+
...(best ? { from: best.from } : {}),
|
|
193
|
+
...(f?.flaky ? { flaky: true } : {}),
|
|
194
|
+
...(f?.quarantined ? { quarantined: true } : {}),
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
return { id: s.id, type: String(s.fields["System.WorkItemType"] || ""), title: String(s.fields["System.Title"] || ""), state: String(s.fields["System.State"] || ""), url: urlOf(s), ...(s.fields["System.AreaPath"] ? { area: String(s.fields["System.AreaPath"]) } : {}), cases }
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// Open bugs: in the scope, and those linked to its stories.
|
|
201
|
+
const linked = storyItems.flatMap((s) => (s.relations || []).filter((r) => r.rel === "System.LinkTypes.Hierarchy-Forward" || r.rel === "System.LinkTypes.Related").map((r) => idOf(r.url)))
|
|
202
|
+
const scoped = filter ? await wiql(`SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND ${OPEN_BUG} AND ${filter}`) : []
|
|
203
|
+
const bugItems = (await items([...new Set([...scoped, ...linked])].filter((id) => !storyIds.includes(id)))).filter(
|
|
204
|
+
(b) => /bug/i.test(String(b.fields["System.WorkItemType"] || "")) && !/^(closed|done|removed|resolved)$/i.test(String(b.fields["System.State"] || "")),
|
|
205
|
+
)
|
|
206
|
+
const bugs: BugRow[] = bugItems
|
|
207
|
+
.map((b) => {
|
|
208
|
+
const sev = String(b.fields["Microsoft.VSTS.Common.Severity"] || "")
|
|
209
|
+
return { id: b.id, title: String(b.fields["System.Title"] || ""), state: String(b.fields["System.State"] || ""), severity: Number(/^(\d)/.exec(sev)?.[1] || 3), severityText: sev || "unset", url: urlOf(b) }
|
|
210
|
+
})
|
|
211
|
+
.sort((a, b) => a.severity - b.severity)
|
|
212
|
+
|
|
213
|
+
const all = stories.flatMap((s) => s.cases)
|
|
214
|
+
const executed = all.filter((c) => c.outcome === "passed" || c.outcome === "failed")
|
|
215
|
+
const passed = all.filter((c) => c.outcome === "passed").length
|
|
216
|
+
const counts = {
|
|
217
|
+
stories: stories.length,
|
|
218
|
+
untested: stories.filter((s) => !s.cases.length).length,
|
|
219
|
+
cases: all.length,
|
|
220
|
+
automated: all.filter((c) => c.automated).length,
|
|
221
|
+
executed: executed.length,
|
|
222
|
+
passed,
|
|
223
|
+
failed: all.filter((c) => c.outcome === "failed").length,
|
|
224
|
+
blocked: all.filter((c) => c.outcome === "blocked").length,
|
|
225
|
+
notRun: all.filter((c) => c.outcome === "not run").length,
|
|
226
|
+
flaky: all.filter((c) => c.flaky).length,
|
|
227
|
+
quarantined: all.filter((c) => c.quarantined).length,
|
|
228
|
+
}
|
|
229
|
+
const passRate = executed.length ? Math.round((passed / executed.length) * 1000) / 10 : null
|
|
230
|
+
const v = verdictOf({ stories, bugs, counts, passRate }, thresholds)
|
|
231
|
+
return { scope: label, stories, bugs, counts, passRate, ...v, notes }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const MARK: Record<Outcome, string> = { passed: "✅ passed", failed: "❌ failed", blocked: "⛔ blocked", "not run": "⚪ not run" }
|
|
235
|
+
|
|
236
|
+
/** The report as Markdown, for the wiki and the reports folder. */
|
|
237
|
+
export function readinessMarkdown(r: Readiness, generated = new Date().toISOString().slice(0, 16).replace("T", " ")): string {
|
|
238
|
+
const icon = r.verdict === "GO" ? "🟢" : r.verdict === "NO-GO" ? "🔴" : "🟡"
|
|
239
|
+
const c = r.counts
|
|
240
|
+
const esc = (s: string) => s.replace(/\|/g, "\\|")
|
|
241
|
+
const lines = [
|
|
242
|
+
`# Release readiness: ${r.scope}`,
|
|
243
|
+
"",
|
|
244
|
+
`**${icon} ${r.verdict}** · generated by ForgeQE ${generated} UTC`,
|
|
245
|
+
"",
|
|
246
|
+
...(r.blockers.length ? ["**Blocking:**", ...r.blockers.map((b) => `- ${b}`), ""] : []),
|
|
247
|
+
...(r.risks.length ? ["**Risks:**", ...r.risks.map((b) => `- ${b}`), ""] : []),
|
|
248
|
+
"| Stories | Without test cases | Test cases | Automated | Executed | Pass rate | Open bugs |",
|
|
249
|
+
"|---|---|---|---|---|---|---|",
|
|
250
|
+
`| ${c.stories} | ${c.untested} | ${c.cases} | ${c.automated} (${c.cases ? Math.round((c.automated / c.cases) * 100) : 0}%) | ${c.executed} | ${r.passRate === null ? "–" : `${r.passRate}%`} | ${r.bugs.length} |`,
|
|
251
|
+
"",
|
|
252
|
+
"## Coverage",
|
|
253
|
+
"",
|
|
254
|
+
"| Story | State | Test case | Automated | Last result |",
|
|
255
|
+
"|---|---|---|---|---|",
|
|
256
|
+
]
|
|
257
|
+
for (const s of r.stories) {
|
|
258
|
+
const story = `[${s.id}](${s.url}) ${esc(s.title)}`
|
|
259
|
+
if (!s.cases.length) {
|
|
260
|
+
lines.push(`| ${story} | ${s.state} | ⚠️ no test case | | |`)
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
s.cases.forEach((tc, i) => {
|
|
264
|
+
const extra = tc.quarantined ? " (quarantined)" : tc.flaky ? " (flaky)" : ""
|
|
265
|
+
lines.push(`| ${i ? "" : story} | ${i ? "" : s.state} | [${tc.id}](${tc.url}) ${esc(tc.title)} | ${tc.automated ? "yes" : "no"}${extra} | ${MARK[tc.outcome]}${tc.when ? ` ${tc.when}` : ""} |`)
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
if (r.bugs.length) {
|
|
269
|
+
lines.push("", "## Open bugs", "", "| Bug | Severity | State |", "|---|---|---|", ...r.bugs.map((b) => `| [${b.id}](${b.url}) ${esc(b.title)} | ${b.severityText} | ${b.state} |`))
|
|
270
|
+
}
|
|
271
|
+
const manual = r.stories.flatMap((s) => s.cases.filter((tc) => !tc.automated))
|
|
272
|
+
if (manual.length) lines.push("", `## Not automated (${manual.length})`, "", ...manual.slice(0, 50).map((tc) => `- [${tc.id}](${tc.url}) ${tc.title}`))
|
|
273
|
+
if (r.notes.length) lines.push("", ...r.notes.map((n) => `_${n}_`))
|
|
274
|
+
return `${lines.join("\n")}\n`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Save the report beside ForgeQE's other reports. */
|
|
278
|
+
export function saveReadiness(cfg: AppConfig, r: Readiness): { dir: string; markdown: string } {
|
|
279
|
+
const dir = runDirectory(path.join(cfg.export.outDir, "readiness"))
|
|
280
|
+
const markdown = readinessMarkdown(r)
|
|
281
|
+
writeFileSync(path.join(dir, "readiness.md"), markdown)
|
|
282
|
+
writeFileSync(path.join(dir, "readiness.json"), JSON.stringify(r, null, 2))
|
|
283
|
+
return { dir, markdown }
|
|
284
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { SourcesConfig } from "../config.ts"
|
|
2
|
+
import { adoSettings } from "./ado.ts"
|
|
3
|
+
import { http, textToAdf } from "./http.ts"
|
|
4
|
+
import { jiraApi } from "./jira.ts"
|
|
5
|
+
|
|
6
|
+
/** A test case the agent wrote from requirements, to save in Azure DevOps or Jira. */
|
|
7
|
+
export type NewCase = {
|
|
8
|
+
title: string
|
|
9
|
+
steps: { action: string; expected?: string }[]
|
|
10
|
+
preconditions?: string[]
|
|
11
|
+
description?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One saved case: created now, or found already linked to the story under the same title (never duplicated). */
|
|
15
|
+
export type SavedCase = { title: string; id: string; url: string; created: boolean; error?: string }
|
|
16
|
+
|
|
17
|
+
export type SaveResult = { system: "azure_devops" | "jira"; story: { id: string; title: string; url: string }; cases: SavedCase[] }
|
|
18
|
+
|
|
19
|
+
const API = "api-version=7.1"
|
|
20
|
+
const sameTitle = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
|
|
21
|
+
|
|
22
|
+
const escXml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
23
|
+
/** One step string as Azure DevOps stores it: HTML, escaped again inside the steps XML. */
|
|
24
|
+
const stepHtml = (text: string) => escXml(`<DIV><P>${escXml(text).replace(/\n/g, "<BR/>")}</P></DIV>`)
|
|
25
|
+
|
|
26
|
+
/** Microsoft.VSTS.TCM.Steps for the steps, in the format the test case editor writes (and adoCases reads back). */
|
|
27
|
+
export function adoStepsXml(steps: NewCase["steps"]): string {
|
|
28
|
+
const body = steps
|
|
29
|
+
.map((s, i) => {
|
|
30
|
+
const type = s.expected ? "ValidateStep" : "ActionStep"
|
|
31
|
+
return `<step id="${i + 2}" type="${type}"><parameterizedString isformatted="true">${stepHtml(s.action)}</parameterizedString><parameterizedString isformatted="true">${stepHtml(s.expected || "")}</parameterizedString><description/></step>`
|
|
32
|
+
})
|
|
33
|
+
.join("")
|
|
34
|
+
return `<steps id="0" last="${steps.length + 1}">${body}</steps>`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const html = (lines: string[]) => lines.map((l) => (l ? `<div>${escXml(l)}</div>` : "<br>")).join("")
|
|
38
|
+
|
|
39
|
+
function describe(c: NewCase): string[] {
|
|
40
|
+
const out: string[] = []
|
|
41
|
+
if (c.description?.trim()) out.push(c.description.trim(), "")
|
|
42
|
+
if (c.preconditions?.length) out.push("Preconditions:", ...c.preconditions.map((p) => `- ${p}`), "")
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create each case as an Azure DevOps Test Case linked to the story (Tests / Tested By), in the story's project, area,
|
|
48
|
+
* and iteration. A case whose title matches a test case already linked to the story is returned instead of created.
|
|
49
|
+
*/
|
|
50
|
+
export async function saveAdoCases(cfg: SourcesConfig, storyId: number, cases: NewCase[]): Promise<SaveResult> {
|
|
51
|
+
const { org, auth } = adoSettings(cfg)
|
|
52
|
+
const { data: story } = await http<{ id: number; fields: Record<string, string>; relations?: { rel: string; url: string }[] }>(
|
|
53
|
+
`${org}/_apis/wit/workitems/${storyId}?$expand=relations&${API}`,
|
|
54
|
+
{ auth },
|
|
55
|
+
)
|
|
56
|
+
const project = story.fields["System.TeamProject"]
|
|
57
|
+
const base = `${org}/${encodeURIComponent(project)}`
|
|
58
|
+
const link = (id: number | string) => `${base}/_workitems/edit/${id}`
|
|
59
|
+
const linked = (story.relations || [])
|
|
60
|
+
.filter((r) => r.rel === "Microsoft.VSTS.Common.TestedBy-Forward")
|
|
61
|
+
.map((r) => Number(r.url.split("/").pop()))
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
const existing = linked.length
|
|
64
|
+
? ((await http<{ value: { id: number; fields: Record<string, string> }[] }>(`${org}/_apis/wit/workitemsbatch?${API}`, { auth, body: { ids: linked, fields: ["System.Title"], errorPolicy: "omit" } })).data?.value || []).filter(Boolean)
|
|
65
|
+
: []
|
|
66
|
+
|
|
67
|
+
const out: SavedCase[] = []
|
|
68
|
+
for (const c of cases) {
|
|
69
|
+
const found = existing.find((w) => sameTitle(w.fields["System.Title"] || "", c.title))
|
|
70
|
+
if (found) {
|
|
71
|
+
out.push({ title: c.title, id: String(found.id), url: link(found.id), created: false })
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
const ops: Record<string, unknown>[] = [
|
|
75
|
+
{ op: "add", path: "/fields/System.Title", value: c.title.slice(0, 255) },
|
|
76
|
+
{ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: adoStepsXml(c.steps) },
|
|
77
|
+
{ op: "add", path: "/fields/System.AreaPath", value: story.fields["System.AreaPath"] },
|
|
78
|
+
{ op: "add", path: "/fields/System.IterationPath", value: story.fields["System.IterationPath"] },
|
|
79
|
+
{ op: "add", path: "/relations/-", value: { rel: "Microsoft.VSTS.Common.TestedBy-Reverse", url: `${org}/_apis/wit/workItems/${storyId}` } },
|
|
80
|
+
]
|
|
81
|
+
const text = describe(c)
|
|
82
|
+
if (text.length) ops.push({ op: "add", path: "/fields/System.Description", value: html(text) })
|
|
83
|
+
try {
|
|
84
|
+
const { data } = await http<{ id: number }>(`${base}/_apis/wit/workitems/$Test%20Case?${API}`, { auth, body: ops, contentType: "application/json-patch+json" })
|
|
85
|
+
out.push({ title: c.title, id: String(data.id), url: link(data.id), created: true })
|
|
86
|
+
} catch (err) {
|
|
87
|
+
out.push({ title: c.title, id: "", url: "", created: false, error: (err as Error).message })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { system: "azure_devops", story: { id: String(storyId), title: story.fields["System.Title"] || "", url: link(storyId) }, cases: out }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create each case as a Jira issue (type `issueType`, default Test) in the story's project with its steps in the
|
|
95
|
+
* description, linked to the story. A case whose summary matches an issue already linked to the story is reused.
|
|
96
|
+
*/
|
|
97
|
+
export async function saveJiraCases(cfg: SourcesConfig, storyKey: string, cases: NewCase[], opts: { issueType?: string; linkType?: string } = {}): Promise<SaveResult> {
|
|
98
|
+
const j = jiraApi(cfg, "Saving test cases in Jira")
|
|
99
|
+
const { data: story } = await http<{ key: string; fields: { summary?: string; project?: { key: string }; issuelinks?: { inwardIssue?: { key: string; fields?: { summary?: string } }; outwardIssue?: { key: string; fields?: { summary?: string } } }[] } }>(
|
|
100
|
+
`${j.api}/issue/${encodeURIComponent(storyKey)}?fields=summary,project,issuelinks`,
|
|
101
|
+
{ auth: j.auth },
|
|
102
|
+
)
|
|
103
|
+
const link = (key: string) => `${j.url}/browse/${key}`
|
|
104
|
+
const existing = (story.fields.issuelinks || []).flatMap((l) => [l.inwardIssue, l.outwardIssue]).filter((i): i is { key: string; fields?: { summary?: string } } => Boolean(i))
|
|
105
|
+
const out: SavedCase[] = []
|
|
106
|
+
for (const c of cases) {
|
|
107
|
+
const found = existing.find((i) => sameTitle(i.fields?.summary || "", c.title))
|
|
108
|
+
if (found) {
|
|
109
|
+
out.push({ title: c.title, id: found.key, url: link(found.key), created: false })
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const lines = [...describe(c), "Steps:", ...c.steps.map((s, i) => `${i + 1}. ${s.action}${s.expected ? `\n Expected: ${s.expected}` : ""}`)]
|
|
113
|
+
const text = lines.join("\n")
|
|
114
|
+
try {
|
|
115
|
+
const { data } = await http<{ key: string }>(`${j.api}/issue`, {
|
|
116
|
+
auth: j.auth,
|
|
117
|
+
body: { fields: { project: { key: story.fields.project?.key || storyKey.split("-")[0] }, issuetype: { name: opts.issueType || "Test" }, summary: c.title.slice(0, 255), description: j.cloud ? textToAdf(text) : text } },
|
|
118
|
+
})
|
|
119
|
+
let error: string | undefined
|
|
120
|
+
try {
|
|
121
|
+
await http(`${j.api}/issueLink`, { auth: j.auth, body: { type: { name: opts.linkType || "Relates" }, inwardIssue: { key: data.key }, outwardIssue: { key: story.key } } })
|
|
122
|
+
} catch (err) {
|
|
123
|
+
error = `created but not linked to ${story.key}: ${(err as Error).message}`
|
|
124
|
+
}
|
|
125
|
+
out.push({ title: c.title, id: data.key, url: link(data.key), created: true, ...(error ? { error } : {}) })
|
|
126
|
+
} catch (err) {
|
|
127
|
+
out.push({ title: c.title, id: "", url: "", created: false, error: (err as Error).message })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { system: "jira", story: { id: story.key, title: story.fields.summary || "", url: link(story.key) }, cases: out }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Replace an Azure DevOps Test Case's steps (and title, when given), and say why in its history. Returns its link.
|
|
135
|
+
* The previous steps stay in the work item's history, so the change can be undone there.
|
|
136
|
+
*/
|
|
137
|
+
export async function updateAdoCase(cfg: SourcesConfig, id: number, change: { title?: string; steps: NewCase["steps"]; reason: string }): Promise<string> {
|
|
138
|
+
const { org, auth } = adoSettings(cfg)
|
|
139
|
+
const { data: item } = await http<{ fields: Record<string, string> }>(`${org}/_apis/wit/workitems/${id}?fields=System.TeamProject,System.WorkItemType&${API}`, { auth })
|
|
140
|
+
if (item.fields["System.WorkItemType"] && item.fields["System.WorkItemType"] !== "Test Case") throw new Error(`Work item ${id} is a ${item.fields["System.WorkItemType"]}, not a Test Case.`)
|
|
141
|
+
const base = `${org}/${encodeURIComponent(item.fields["System.TeamProject"])}`
|
|
142
|
+
const ops: Record<string, unknown>[] = [
|
|
143
|
+
{ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: adoStepsXml(change.steps) },
|
|
144
|
+
{ op: "add", path: "/fields/System.History", value: html([`Updated by ForgeQE: ${change.reason}`]) },
|
|
145
|
+
]
|
|
146
|
+
if (change.title?.trim()) ops.push({ op: "add", path: "/fields/System.Title", value: change.title.trim() })
|
|
147
|
+
await http(`${base}/_apis/wit/workitems/${id}?${API}`, { auth, method: "PATCH", body: ops, contentType: "application/json-patch+json" })
|
|
148
|
+
return `${base}/_workitems/edit/${id}`
|
|
149
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { SourcesConfig } from "../config.ts"
|
|
2
|
+
import { adoProjectNames, adoSettings } from "./ado.ts"
|
|
3
|
+
import { http } from "./http.ts"
|
|
4
|
+
import { jiraApi } from "./jira.ts"
|
|
5
|
+
|
|
6
|
+
export type Sprint = {
|
|
7
|
+
system: "azure_devops" | "jira"
|
|
8
|
+
name: string
|
|
9
|
+
/** YYYY-MM-DD; empty when the sprint has no dates yet. */
|
|
10
|
+
start: string
|
|
11
|
+
end: string
|
|
12
|
+
state: "current" | "past" | "future"
|
|
13
|
+
/** Azure DevOps team or Jira board the sprint belongs to. */
|
|
14
|
+
where: string
|
|
15
|
+
/** Azure DevOps iteration path ("Shop\\Sprint 12"), for queries. */
|
|
16
|
+
path?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const day = (d: unknown) => (typeof d === "string" ? d.slice(0, 10) : "")
|
|
20
|
+
|
|
21
|
+
type AdoIteration = { name: string; path?: string; attributes?: { startDate?: string; finishDate?: string; timeFrame?: string } }
|
|
22
|
+
|
|
23
|
+
/** A team's iterations. Without a team, the default team of the configured project, or of every project. */
|
|
24
|
+
export async function adoSprints(cfg: SourcesConfig, team?: string): Promise<Sprint[]> {
|
|
25
|
+
const { ado, auth, org } = adoSettings(cfg)
|
|
26
|
+
if (team && !ado.project) throw new Error("Name sources.azure_devops.project when asking for one team's sprints, or omit the team to read every project's default team.")
|
|
27
|
+
const projects = ado.project ? [ado.project] : await adoProjectNames(cfg)
|
|
28
|
+
const out: Sprint[] = []
|
|
29
|
+
const failed: string[] = []
|
|
30
|
+
for (const project of projects) {
|
|
31
|
+
try {
|
|
32
|
+
out.push(...(await projectSprints(org, project, ado.project ? team : undefined, auth)))
|
|
33
|
+
} catch (err) {
|
|
34
|
+
failed.push(`${project}: ${err instanceof Error ? err.message : String(err)}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!out.length && failed.length) throw new Error(failed[0])
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function projectSprints(org: string, project: string, team: string | undefined, auth: string): Promise<Sprint[]> {
|
|
42
|
+
const url = `${org}/${encodeURIComponent(project)}${team ? `/${encodeURIComponent(team)}` : ""}/_apis/work/teamsettings/iterations?api-version=7.1`
|
|
43
|
+
const { data } = await http<{ value?: AdoIteration[] }>(url, { auth })
|
|
44
|
+
const where = team || `${project} default team`
|
|
45
|
+
return (data.value || []).map((it) => {
|
|
46
|
+
const frame = it.attributes?.timeFrame
|
|
47
|
+
return {
|
|
48
|
+
system: "azure_devops" as const,
|
|
49
|
+
name: it.name,
|
|
50
|
+
start: day(it.attributes?.startDate),
|
|
51
|
+
end: day(it.attributes?.finishDate),
|
|
52
|
+
state: frame === "current" ? "current" : frame === "future" ? "future" : "past",
|
|
53
|
+
where,
|
|
54
|
+
...(it.path ? { path: it.path } : {}),
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type JiraBoard = { id: number; name: string; location?: { projectKey?: string } }
|
|
60
|
+
type JiraSprint = { name: string; state?: string; startDate?: string; endDate?: string; completeDate?: string }
|
|
61
|
+
|
|
62
|
+
const MAX_BOARDS = 5
|
|
63
|
+
const MAX_PAGES = 10
|
|
64
|
+
|
|
65
|
+
/** Sprints of the scrum boards (of a project, or one board by id), up to five boards. */
|
|
66
|
+
export async function jiraSprints(cfg: SourcesConfig, opts: { project?: string; board?: string } = {}): Promise<Sprint[]> {
|
|
67
|
+
const j = jiraApi(cfg, "Jira sprints")
|
|
68
|
+
const agile = `${j.url}/rest/agile/1.0`
|
|
69
|
+
let boards: JiraBoard[]
|
|
70
|
+
if (opts.board && /^\d+$/.test(opts.board)) {
|
|
71
|
+
boards = [(await http<JiraBoard>(`${agile}/board/${opts.board}`, { auth: j.auth })).data]
|
|
72
|
+
} else {
|
|
73
|
+
const q = new URLSearchParams({ type: "scrum", maxResults: "50" })
|
|
74
|
+
if (opts.project) q.set("projectKeyOrId", opts.project)
|
|
75
|
+
if (opts.board) q.set("name", opts.board)
|
|
76
|
+
boards = (await http<{ values?: JiraBoard[] }>(`${agile}/board?${q}`, { auth: j.auth })).data.values || []
|
|
77
|
+
}
|
|
78
|
+
const out: Sprint[] = []
|
|
79
|
+
for (const b of boards.slice(0, MAX_BOARDS)) {
|
|
80
|
+
for (let page = 0, startAt = 0; page < MAX_PAGES; page++) {
|
|
81
|
+
const q = new URLSearchParams({ state: "active,closed,future", maxResults: "50", startAt: String(startAt) })
|
|
82
|
+
const { data } = await http<{ values?: JiraSprint[]; isLast?: boolean }>(`${agile}/board/${b.id}/sprint?${q}`, { auth: j.auth })
|
|
83
|
+
const values = data.values || []
|
|
84
|
+
for (const s of values) {
|
|
85
|
+
out.push({
|
|
86
|
+
system: "jira",
|
|
87
|
+
name: s.name,
|
|
88
|
+
start: day(s.startDate),
|
|
89
|
+
end: day(s.endDate || s.completeDate),
|
|
90
|
+
state: s.state === "active" ? "current" : s.state === "future" ? "future" : "past",
|
|
91
|
+
where: b.location?.projectKey ? `${b.name} (${b.location.projectKey})` : b.name,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
if (data.isLast !== false || !values.length) break
|
|
95
|
+
startAt += values.length
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Sprints from every configured system; a system that fails is reported in `errors` instead of failing the rest. */
|
|
102
|
+
export async function listSprints(cfg: SourcesConfig, opts: { team?: string; project?: string; board?: string } = {}): Promise<{ sprints: Sprint[]; errors: string[] }> {
|
|
103
|
+
if (!cfg.ado && !cfg.jira) throw new Error("No sprint source: set up Azure DevOps (sources.azure_devops) or Jira (sources.jira) in forgeqe.yaml, or give a date range instead.")
|
|
104
|
+
const sprints: Sprint[] = []
|
|
105
|
+
const errors: string[] = []
|
|
106
|
+
const tries: [string, () => Promise<Sprint[]>][] = []
|
|
107
|
+
if (cfg.ado) tries.push(["Azure DevOps", () => adoSprints(cfg, opts.team)])
|
|
108
|
+
if (cfg.jira) tries.push(["Jira", () => jiraSprints(cfg, { project: opts.project, board: opts.board })])
|
|
109
|
+
for (const [name, get] of tries) {
|
|
110
|
+
try {
|
|
111
|
+
sprints.push(...(await get()))
|
|
112
|
+
} catch (err) {
|
|
113
|
+
errors.push(`${name}: ${err instanceof Error ? err.message : String(err)}`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { sprints, errors }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The sprints worth offering: current ones, then the most recent past ones, then the next one, per team or board. */
|
|
120
|
+
export function recentSprints(sprints: Sprint[], past = 3): Sprint[] {
|
|
121
|
+
const groups = new Map<string, Sprint[]>()
|
|
122
|
+
for (const s of sprints) {
|
|
123
|
+
const key = `${s.system}|${s.where}`
|
|
124
|
+
groups.set(key, [...(groups.get(key) || []), s])
|
|
125
|
+
}
|
|
126
|
+
const out: Sprint[] = []
|
|
127
|
+
for (const list of groups.values()) {
|
|
128
|
+
const byStart = (a: Sprint, b: Sprint) => a.start.localeCompare(b.start)
|
|
129
|
+
out.push(...list.filter((s) => s.state === "current"))
|
|
130
|
+
out.push(...list.filter((s) => s.state === "past").sort(byStart).slice(-past).reverse())
|
|
131
|
+
out.push(...list.filter((s) => s.state === "future").sort(byStart).slice(0, 1))
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function sprintLabel(s: Sprint): string {
|
|
137
|
+
const dates = s.start || s.end ? `${s.start || "?"} to ${s.end || "?"}` : "no dates"
|
|
138
|
+
return `${s.name} (${s.state}, ${dates}, ${s.system === "jira" ? "Jira" : "Azure DevOps"} ${s.where})`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The sprint a name refers to: an exact match first, then the only one containing it. */
|
|
142
|
+
export function findSprint(sprints: Sprint[], name: string): Sprint | undefined {
|
|
143
|
+
const n = name.trim().toLowerCase()
|
|
144
|
+
const exact = sprints.filter((s) => s.name.toLowerCase() === n)
|
|
145
|
+
if (exact.length) return exact.find((s) => s.state === "current") || exact[0]
|
|
146
|
+
const partial = sprints.filter((s) => s.name.toLowerCase().includes(n))
|
|
147
|
+
return partial.length === 1 ? partial[0] : undefined
|
|
148
|
+
}
|