@capagents/forgeqe 0.0.0-stage → 1.0.1

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.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +781 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +146 -0
  219. package/src/update.ts +311 -0
  220. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
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
+ }