@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,649 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import type { CodeEvent, Usage } from "../types.ts"
6
+ import type { Card } from "../card.ts"
7
+ import { adoSettings } from "../cases/ado.ts"
8
+ import { htmlToText, http } from "../cases/http.ts"
9
+ import { privateEnvNames } from "../childEnv.ts"
10
+ import type { AppConfig } from "../config.ts"
11
+ import { sh } from "../mcp/git.ts"
12
+ import { runDirectory } from "../run.ts"
13
+ import { loadSkills } from "../skills/registry.ts"
14
+ import type { Price } from "../usage.ts"
15
+ import { openSession, type AgentSpec, type OpencodeSession } from "./runtime.ts"
16
+ import { prompt } from "../prompts.ts"
17
+ import { domainNotes } from "../domain.ts"
18
+
19
+ const API = "api-version=7.1"
20
+ /** The diff is shown in the prompt up to this size; the reviewer runs git diff for the rest. */
21
+ const DIFF_CHARS = 60_000
22
+ const ITEM_CHARS = 2_000
23
+ const DESCRIPTION_CHARS = 6_000
24
+
25
+ export type Platform = "azure_devops" | "github"
26
+
27
+ /** Where a pull request lives. Azure DevOps: org URL, project, repository name. GitHub: owner/name in `repo`. */
28
+ export type PrRef = { platform: Platform; number: number; org?: string; project?: string; repo?: string }
29
+
30
+ export type PrInfo = {
31
+ ref: PrRef
32
+ url: string
33
+ title: string
34
+ description: string
35
+ author: string
36
+ state: string
37
+ draft: boolean
38
+ sourceBranch: string
39
+ targetBranch: string
40
+ /** The commit the review read; posting checks the PR still points at it. */
41
+ headSha: string
42
+ /** Work items (Azure DevOps) or issues (GitHub) the PR is linked to. */
43
+ linked: { id: string; title: string; text?: string }[]
44
+ /** Azure DevOps: the linked work items' ids. */
45
+ workItemIds?: number[]
46
+ }
47
+
48
+ export type Severity = "blocker" | "major" | "minor" | "nit"
49
+ export type PrFinding = { file?: string; line?: number; end_line?: number; severity: Severity; title: string; problem: string; suggestion?: string }
50
+ export type Verdict = "approve" | "approve_with_suggestions" | "request_changes"
51
+
52
+ export type PrReview = {
53
+ id: string
54
+ dir: string
55
+ repo: string
56
+ pr: PrInfo
57
+ base: string
58
+ files: { path: string; added: number; removed: number }[]
59
+ verdict: Verdict
60
+ summary: string
61
+ findings: PrFinding[]
62
+ /** Per finding: its line is in the diff, so it can be posted on that line. */
63
+ inline: boolean[]
64
+ model: string
65
+ usage: Usage
66
+ price?: Price
67
+ createdAt: string
68
+ posted?: { at: string; links: string[] }
69
+ }
70
+
71
+ // ---- Which pull request ----
72
+
73
+ type Remote = { name: string; url: string; ref: Omit<PrRef, "number"> }
74
+
75
+ /** The repository a git remote URL points at, for Azure DevOps (https, ssh, visualstudio.com) and GitHub. */
76
+ export function parseRemote(url: string): Omit<PrRef, "number"> | null {
77
+ const u = url.trim().replace(/\.git$/, "")
78
+ const gh = /github\.com[/:]([^/]+)\/([^/]+)$/.exec(u)
79
+ if (gh) return { platform: "github", repo: `${gh[1]}/${gh[2]}` }
80
+ const ssh = /ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(u)
81
+ if (ssh) return { platform: "azure_devops", org: `https://dev.azure.com/${ssh[1]}`, project: decodeURIComponent(ssh[2]), repo: decodeURIComponent(ssh[3]) }
82
+ const vs = /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(u)
83
+ if (vs) return { platform: "azure_devops", org: `https://${vs[1]}.visualstudio.com`, project: decodeURIComponent(vs[2]), repo: decodeURIComponent(vs[3]) }
84
+ const git = /^(https?:\/\/)(?:[^@/]+@)?(.+)\/([^/]+)\/_git\/([^/?#]+)/.exec(u)
85
+ if (git) return { platform: "azure_devops", org: `${git[1]}${git[2]}`, project: decodeURIComponent(git[3]), repo: decodeURIComponent(git[4]) }
86
+ return null
87
+ }
88
+
89
+ /**
90
+ * A pull request link (Azure DevOps .../_git/<repo>/pullrequest/12, GitHub .../pull/12) or a number ("12", "#12",
91
+ * "!12", "PR 12"); a number is a pull request of the repository the working directory is a clone of.
92
+ */
93
+ export function parsePrRef(input: string, remotes: Remote[], platform?: Platform): PrRef {
94
+ const text = input.trim()
95
+ const ado = /^(https?:\/\/)(?:[^@/]+@)?(.+)\/([^/]+)\/_git\/([^/?#]+)\/pullrequest\/(\d+)/i.exec(text)
96
+ if (ado) return { platform: "azure_devops", org: `${ado[1]}${ado[2]}`, project: decodeURIComponent(ado[3]), repo: decodeURIComponent(ado[4]), number: Number(ado[5]) }
97
+ const gh = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i.exec(text)
98
+ if (gh) return { platform: "github", repo: `${gh[1]}/${gh[2]}`, number: Number(gh[3]) }
99
+ const num = /^(?:(?:PR|pull request)\s*)?[#!]?\s*(\d+)$/i.exec(text)
100
+ if (!num) throw new Error(`"${input}" is not a pull request: give its link, or its number in this repository.`)
101
+ const candidates = remotes.filter((r) => !platform || r.ref.platform === platform)
102
+ const remote = candidates.find((r) => r.name === "origin") || candidates[0]
103
+ if (!remote) throw new Error(`Pull request ${num[1]}: the working directory has no ${platform === "github" ? "GitHub" : platform === "azure_devops" ? "Azure DevOps" : "GitHub or Azure DevOps"} remote, so give the pull request's link.`)
104
+ return { ...remote.ref, number: Number(num[1]) }
105
+ }
106
+
107
+ /** The repository's remotes that point at GitHub or Azure DevOps, as written in .git/config. */
108
+ export async function gitRemotes(repo: string): Promise<Remote[]> {
109
+ const res = await sh(["git", "config", "--get-regexp", "^remote\\..*\\.url$"], repo)
110
+ if (res.code !== 0) return []
111
+ return res.out
112
+ .split("\n")
113
+ .map((l) => /^remote\.(.+)\.url\s+(.+)$/.exec(l.trim()))
114
+ .flatMap((m) => {
115
+ const ref = m ? parseRemote(m[2]) : null
116
+ return m && ref ? [{ name: m[1], url: m[2], ref }] : []
117
+ })
118
+ }
119
+
120
+ const sameRepo = (a: Omit<PrRef, "number">, b: Omit<PrRef, "number">) => {
121
+ const low = (s?: string) => (s || "").toLowerCase()
122
+ if (a.platform !== b.platform || low(a.repo) !== low(b.repo)) return false
123
+ return a.platform === "github" || low(a.project) === low(b.project)
124
+ }
125
+
126
+ const describe = (r: Omit<PrRef, "number">) => (r.platform === "github" ? `GitHub ${r.repo}` : `Azure DevOps ${r.project}/${r.repo}`)
127
+
128
+ // ---- Reading the pull request ----
129
+
130
+ export function adoBase(ref: PrRef): string {
131
+ return `${ref.org!.replace(/\/+$/, "")}/${encodeURIComponent(ref.project!)}/_apis/git/repositories/${encodeURIComponent(ref.repo!)}`
132
+ }
133
+
134
+ async function adoPr(ref: PrRef, cfg: AppConfig): Promise<PrInfo> {
135
+ const { auth } = adoSettings(cfg.sources)
136
+ const base = adoBase(ref)
137
+ const { data: pr } = await http<Record<string, any>>(`${base}/pullrequests/${ref.number}?${API}`, { auth })
138
+ const linked: PrInfo["linked"] = []
139
+ const workItemIds: number[] = []
140
+ try {
141
+ const { data } = await http<{ value?: { id: string }[] }>(`${base}/pullRequests/${ref.number}/workitems?${API}`, { auth })
142
+ const ids = (data?.value || []).map((w) => Number(w.id)).filter(Boolean).slice(0, 20)
143
+ workItemIds.push(...ids)
144
+ if (ids.length) {
145
+ const fields = ["System.Title", "System.WorkItemType", "System.Description", "Microsoft.VSTS.Common.AcceptanceCriteria"]
146
+ const { data: items } = await http<{ value?: { id: number; fields: Record<string, unknown> }[] }>(
147
+ `${ref.org!.replace(/\/+$/, "")}/_apis/wit/workitemsbatch?${API}`,
148
+ { auth, body: { ids, fields, errorPolicy: "omit" } },
149
+ )
150
+ for (const w of items?.value || []) {
151
+ const f = w.fields || {}
152
+ const criteria = htmlToText(String(f["Microsoft.VSTS.Common.AcceptanceCriteria"] || ""))
153
+ const text = [htmlToText(String(f["System.Description"] || "")), criteria ? `Acceptance criteria:\n${criteria}` : ""].filter(Boolean).join("\n")
154
+ linked.push({ id: `${f["System.WorkItemType"] || "Work item"} ${w.id}`, title: String(f["System.Title"] || ""), text: text.slice(0, ITEM_CHARS) || undefined })
155
+ }
156
+ }
157
+ } catch {
158
+ // Linked work items are context for the review; the review goes on without them.
159
+ }
160
+ const branch = (r: unknown) => String(r || "").replace(/^refs\/heads\//, "")
161
+ return {
162
+ ref,
163
+ url: `${ref.org!.replace(/\/+$/, "")}/${encodeURIComponent(ref.project!)}/_git/${encodeURIComponent(ref.repo!)}/pullrequest/${ref.number}`,
164
+ title: String(pr.title || ""),
165
+ description: String(pr.description || ""),
166
+ author: String(pr.createdBy?.displayName || ""),
167
+ state: String(pr.status || ""),
168
+ draft: Boolean(pr.isDraft),
169
+ sourceBranch: branch(pr.sourceRefName),
170
+ targetBranch: branch(pr.targetRefName),
171
+ headSha: String(pr.lastMergeSourceCommit?.commitId || ""),
172
+ linked,
173
+ ...(workItemIds.length ? { workItemIds } : {}),
174
+ }
175
+ }
176
+
177
+ export async function gh(args: string[], cwd: string, stdin?: string): Promise<string> {
178
+ const res = await sh(["gh", ...args], cwd, stdin === undefined ? {} : { stdin })
179
+ if (res.code !== 0) {
180
+ const missing = /not found|ENOENT|No such file/i.test(res.out) && !Bun.which("gh")
181
+ throw new Error(missing ? "GitHub pull requests need the gh CLI: install it and run gh auth login." : `gh ${args.slice(0, 2).join(" ")} failed: ${res.out.slice(-1500)}`)
182
+ }
183
+ return res.out
184
+ }
185
+
186
+ async function githubPr(ref: PrRef, cwd: string): Promise<PrInfo> {
187
+ if (!Bun.which("gh")) throw new Error("GitHub pull requests need the gh CLI: install it and run gh auth login.")
188
+ const fields = "number,title,body,author,state,isDraft,baseRefName,headRefName,headRefOid,url,closingIssuesReferences"
189
+ const pr = JSON.parse(await gh(["pr", "view", String(ref.number), "--repo", ref.repo!, "--json", fields], cwd)) as Record<string, any>
190
+ return {
191
+ ref,
192
+ url: String(pr.url || `https://github.com/${ref.repo}/pull/${ref.number}`),
193
+ title: String(pr.title || ""),
194
+ description: String(pr.body || ""),
195
+ author: String(pr.author?.login || ""),
196
+ state: String(pr.state || "").toLowerCase(),
197
+ draft: Boolean(pr.isDraft),
198
+ sourceBranch: String(pr.headRefName || ""),
199
+ targetBranch: String(pr.baseRefName || ""),
200
+ headSha: String(pr.headRefOid || ""),
201
+ linked: ((pr.closingIssuesReferences || []) as { number: number; title?: string }[]).map((i) => ({ id: `#${i.number}`, title: String(i.title || "") })),
202
+ }
203
+ }
204
+
205
+ export function fetchPr(ref: PrRef, cfg: AppConfig, cwd: string): Promise<PrInfo> {
206
+ return ref.platform === "github" ? githubPr(ref, cwd) : adoPr(ref, cfg)
207
+ }
208
+
209
+ // ---- The code ----
210
+
211
+ /** Right-side line numbers (added and context lines) per file in a unified diff: where a comment can sit. */
212
+ export function diffLines(diff: string): Map<string, Set<number>> {
213
+ const map = new Map<string, Set<number>>()
214
+ let file = ""
215
+ let line = 0
216
+ let hunk = false
217
+ for (const l of diff.split("\n")) {
218
+ if (l.startsWith("diff --git ")) {
219
+ hunk = false
220
+ file = ""
221
+ continue
222
+ }
223
+ if (!hunk) {
224
+ if (l.startsWith("+++ ")) {
225
+ file = l === "+++ /dev/null" ? "" : l.slice(4).replace(/^b\//, "")
226
+ if (file && !map.has(file)) map.set(file, new Set())
227
+ }
228
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l)
229
+ if (h) {
230
+ hunk = true
231
+ line = Number(h[1])
232
+ }
233
+ continue
234
+ }
235
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l)
236
+ if (h) line = Number(h[1])
237
+ else if (l.startsWith("+") || l.startsWith(" ")) {
238
+ if (file) map.get(file)!.add(line)
239
+ line++
240
+ }
241
+ }
242
+ return map
243
+ }
244
+
245
+ type Checkout = { tree: string; base: string; diff: string; files: PrReview["files"]; cleanup: () => Promise<void> }
246
+
247
+ /**
248
+ * Fetch the pull request's branches from the clone's remote into refs/forgeqe/pr/<n>/ and check the head out in a
249
+ * temporary worktree, so the reviewer reads the PR's code without touching the user's branch or working tree.
250
+ */
251
+ async function checkout(repo: string, remote: string, pr: PrInfo): Promise<Checkout> {
252
+ const n = pr.ref.number
253
+ const { base, dropRefs } = await fetchPrBranches(repo, remote, pr)
254
+ const git = (args: string[]) => sh(["git", "-c", "core.quotepath=false", ...args], repo)
255
+ const tree = mkdtempSync(path.join(os.tmpdir(), `forgeqe-pr-${n}-`))
256
+ const added = await git(["worktree", "add", "--detach", "--force", tree, pr.headSha])
257
+ if (added.code !== 0) {
258
+ await dropRefs()
259
+ rmSync(tree, { recursive: true, force: true })
260
+ throw new Error(`git worktree add failed:\n${added.out.slice(-1500)}`)
261
+ }
262
+ const range = [base, pr.headSha]
263
+ const diff = (await sh(["git", "-c", "core.quotepath=false", "diff", "--no-color", "--no-ext-diff", "-M", "--src-prefix=a/", "--dst-prefix=b/", ...range], tree)).out
264
+ const numstat = (await git(["diff", "--numstat", "-M", ...range])).out
265
+ const files = numstat
266
+ .split("\n")
267
+ .map((l) => l.split("\t"))
268
+ .filter((p) => p.length >= 3)
269
+ .map(([a, r, ...name]) => ({ path: name.join("\t").replace(/^(.*)\{(.*) => (.*)\}(.*)$/, "$1$3$4").replace(/^.* => /, ""), added: Number(a) || 0, removed: Number(r) || 0 }))
270
+ return {
271
+ tree,
272
+ base,
273
+ diff,
274
+ files,
275
+ cleanup: async () => {
276
+ await git(["worktree", "remove", "--force", tree])
277
+ rmSync(tree, { recursive: true, force: true })
278
+ await git(["worktree", "prune"])
279
+ await dropRefs()
280
+ },
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Fetch a pull request's head and target branch into refs/forgeqe/pr/<n>/ and find their merge base. pr.headSha is
286
+ * set to the commit fetched. dropRefs removes the refs again.
287
+ */
288
+ export async function fetchPrBranches(repo: string, remote: string, pr: PrInfo): Promise<{ base: string; dropRefs: () => Promise<void> }> {
289
+ const n = pr.ref.number
290
+ const head = `refs/forgeqe/pr/${n}/head`
291
+ const target = `refs/forgeqe/pr/${n}/base`
292
+ const source = pr.ref.platform === "github" ? `refs/pull/${n}/head` : `refs/heads/${pr.sourceBranch}`
293
+ const git = (args: string[]) => sh(["git", "-c", "core.quotepath=false", ...args], repo)
294
+ const fetched = await git(["fetch", "--no-tags", "--quiet", remote, `+${source}:${head}`, `+refs/heads/${pr.targetBranch}:${target}`])
295
+ if (fetched.code !== 0) throw new Error(`git fetch of the pull request's branches from ${remote} failed:\n${fetched.out.slice(-1500)}`)
296
+ const dropRefs = async () => {
297
+ await git(["update-ref", "-d", head])
298
+ await git(["update-ref", "-d", target])
299
+ }
300
+ const sha = (await git(["rev-parse", head])).out.trim()
301
+ if (pr.headSha && sha !== pr.headSha) {
302
+ // The branch moved after the API answered, or (Azure DevOps) the PR's source commit is not the branch tip yet.
303
+ const at = await git(["cat-file", "-e", `${pr.headSha}^{commit}`])
304
+ if (at.code === 0) await git(["update-ref", head, pr.headSha])
305
+ }
306
+ pr.headSha = (await git(["rev-parse", head])).out.trim()
307
+ const mb = await git(["merge-base", target, head])
308
+ if (mb.code !== 0) {
309
+ await dropRefs()
310
+ throw new Error(`The pull request's branch has no common history with ${pr.targetBranch}.`)
311
+ }
312
+ return { base: mb.out.trim(), dropRefs }
313
+ }
314
+
315
+ /** A pull request from a link or number, with the clone's remote that holds it; throws when the clone is another repo's. */
316
+ export async function resolvePr(repo: string, input: string, platform?: Platform): Promise<{ root: string; ref: PrRef; remote: string }> {
317
+ const top = await sh(["git", "rev-parse", "--show-toplevel"], repo)
318
+ if (top.code !== 0) throw new Error(`${repo} is not a git clone.`)
319
+ const root = top.out.trim()
320
+ const remotes = await gitRemotes(root)
321
+ const ref = parsePrRef(input, remotes, platform)
322
+ const remote = remotes.find((r) => sameRepo(r.ref, ref))
323
+ if (!remote) throw new Error(`The pull request is in ${describe(ref)}, but ${root} is a clone of ${remotes.map((r) => describe(r.ref)).join(", ") || "no GitHub or Azure DevOps remote"}. Pass repo with the path of a local clone of ${ref.repo}.`)
324
+ return { root, ref, remote: remote.name }
325
+ }
326
+
327
+ // ---- The reviewer ----
328
+
329
+ export const prReviewAgentPrompt = () => prompt("pr-review")
330
+
331
+ export const PR_REVIEW_AGENT: AgentSpec = {
332
+ name: "forgeqe-pr-review",
333
+ description: "Reviews a pull request's changes for bugs, security, requirements, and tests, without changing anything",
334
+ get prompt() {
335
+ return prReviewAgentPrompt()
336
+ },
337
+ permission: {
338
+ edit: "deny",
339
+ bash: {
340
+ "*": "deny",
341
+ "git diff*": "allow",
342
+ "git log*": "allow",
343
+ "git show*": "allow",
344
+ "git blame*": "allow",
345
+ "git status*": "allow",
346
+ "git ls-files*": "allow",
347
+ "ls*": "allow",
348
+ },
349
+ webfetch: "deny",
350
+ task: "deny",
351
+ },
352
+ skills: "scribe",
353
+ }
354
+
355
+ export const PR_REVIEW_SCHEMA = {
356
+ type: "object",
357
+ properties: {
358
+ verdict: { type: "string", enum: ["approve", "approve_with_suggestions", "request_changes"] },
359
+ summary: { type: "string", description: "Two to four sentences: what the pull request does and the verdict's reason" },
360
+ findings: {
361
+ type: "array",
362
+ items: {
363
+ type: "object",
364
+ properties: {
365
+ file: { type: "string", description: "Path relative to the repository root; empty for the change as a whole" },
366
+ line: { type: "number", description: "Line in the pull request's version of the file" },
367
+ end_line: { type: "number", description: "Last line, when the finding spans several" },
368
+ severity: { type: "string", enum: ["blocker", "major", "minor", "nit"] },
369
+ title: { type: "string", description: "A few words" },
370
+ problem: { type: "string", description: "What is wrong and why it matters" },
371
+ suggestion: { type: "string", description: "What to change" },
372
+ },
373
+ required: ["severity", "title", "problem"],
374
+ },
375
+ },
376
+ },
377
+ required: ["verdict", "summary", "findings"],
378
+ } as const
379
+
380
+ type ReviewReport = { verdict?: string; summary?: string; findings?: Partial<PrFinding>[] }
381
+
382
+ const SEVERITIES: Severity[] = ["blocker", "major", "minor", "nit"]
383
+ const VERDICTS: Verdict[] = ["approve", "approve_with_suggestions", "request_changes"]
384
+
385
+ export function reviewPrPrompt(pr: PrInfo, base: string, files: PrReview["files"], diff: string, focus?: string): string {
386
+ const desc = htmlToText(pr.description) || pr.description.trim()
387
+ const shown = diff.length > DIFF_CHARS ? `${diff.slice(0, DIFF_CHARS)}\n… (the rest is not shown here; run git diff ${base} HEAD)` : diff
388
+ return [
389
+ `Review pull request ${pr.ref.platform === "github" ? "#" : "!"}${pr.ref.number}: ${pr.title}`,
390
+ `${pr.sourceBranch} into ${pr.targetBranch}${pr.author ? `, by ${pr.author}` : ""}${pr.draft ? " (draft)" : ""}.`,
391
+ focus ? `The user asks you to focus on: ${focus}` : "",
392
+ "",
393
+ "Description:",
394
+ desc ? (desc.length > DESCRIPTION_CHARS ? `${desc.slice(0, DESCRIPTION_CHARS)}…` : desc) : "(none)",
395
+ ...(pr.linked.length ? ["", "Linked work items:", ...pr.linked.map((w) => [`- ${w.id}: ${w.title}`, ...(w.text ? [w.text.replace(/^/gm, " ")] : [])].join("\n"))] : []),
396
+ "",
397
+ `Merge base: ${base}. The working directory is at the pull request's head, ${pr.headSha}; git diff ${base} HEAD shows the change.`,
398
+ `Files changed (${files.length}): ${files.map((f) => `${f.path} (+${f.added} -${f.removed})`).join(", ") || "(none)"}`,
399
+ "",
400
+ "```diff",
401
+ shown || "(empty diff)",
402
+ "```",
403
+ ]
404
+ .filter((l, i, all) => l !== "" || all[i - 1] !== "")
405
+ .join("\n")
406
+ }
407
+
408
+ function cleanFindings(raw: unknown): PrFinding[] {
409
+ const list = Array.isArray(raw) ? (raw as Partial<PrFinding>[]) : []
410
+ return list
411
+ .filter((f) => f && String(f.problem || "").trim())
412
+ .map((f) => {
413
+ const line = Number(f.line) > 0 ? Math.floor(Number(f.line)) : undefined
414
+ const end = line && Number(f.end_line) > line ? Math.floor(Number(f.end_line)) : undefined
415
+ return {
416
+ ...(f.file ? { file: String(f.file).replace(/^\.?\//, "") } : {}),
417
+ ...(line ? { line } : {}),
418
+ ...(end ? { end_line: end } : {}),
419
+ severity: SEVERITIES.includes(f.severity as Severity) ? (f.severity as Severity) : "minor",
420
+ title: String(f.title || "").trim() || "Finding",
421
+ problem: String(f.problem).trim(),
422
+ ...(f.suggestion?.trim() ? { suggestion: f.suggestion.trim() } : {}),
423
+ }
424
+ })
425
+ .sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity))
426
+ }
427
+
428
+ /**
429
+ * Review a pull request: read it from Azure DevOps or GitHub, check its head out from the local clone in a temporary
430
+ * worktree, and have a read-only Scribe session review the diff. The review is saved as review.json and review.md in a
431
+ * new folder under <export.outDir>/reviews; nothing is posted.
432
+ */
433
+ export async function reviewPullRequest(opts: {
434
+ config: AppConfig
435
+ repo: string
436
+ pr: string
437
+ platform?: Platform
438
+ focus?: string
439
+ signal?: AbortSignal
440
+ onEvent?: (e: CodeEvent) => void
441
+ open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession>
442
+ }): Promise<PrReview> {
443
+ const repo = path.resolve(opts.repo)
444
+ if (!existsSync(repo) || !statSync(repo).isDirectory()) throw new Error(`Repo not found: ${repo}`)
445
+ const top = await sh(["git", "rev-parse", "--show-toplevel"], repo)
446
+ if (top.code !== 0) throw new Error(`${repo} is not a git clone; reviewing a pull request needs a local clone of its repository (pass repo).`)
447
+ const root = top.out.trim()
448
+ const emit = opts.onEvent || (() => {})
449
+ const remotes = await gitRemotes(root)
450
+ const ref = parsePrRef(opts.pr, remotes, opts.platform)
451
+ const remote = remotes.find((r) => sameRepo(r.ref, ref))
452
+ if (!remote) {
453
+ const here = remotes.length ? remotes.map((r) => describe(r.ref)).join(", ") : "no GitHub or Azure DevOps remote"
454
+ throw new Error(`The pull request is in ${describe(ref)}, but ${root} is a clone of ${here}. Pass repo with the path of a local clone of ${ref.repo}.`)
455
+ }
456
+
457
+ emit({ type: "code", kind: "info", text: `Reading pull request ${ref.number} from ${describe(ref)}…` })
458
+ const pr = await fetchPr(ref, opts.config, root)
459
+ if (!pr.sourceBranch || !pr.targetBranch) throw new Error(`Pull request ${ref.number} has no source or target branch.`)
460
+ emit({ type: "code", kind: "info", text: `Fetching ${pr.sourceBranch} and ${pr.targetBranch} from ${remote.name}…` })
461
+ const co = await checkout(root, remote.name, pr)
462
+ try {
463
+ if (!co.diff.trim()) throw new Error(`Pull request ${ref.number} changes nothing against ${pr.targetBranch}.`)
464
+ // code.review_profile: a cheaper llms.json profile for reading and judging; else the session's profile (or code.model).
465
+ const named = opts.config.code.reviewProfile ? opts.config.profiles[opts.config.code.reviewProfile] : undefined
466
+ const profile = named || opts.config.llm
467
+ const open = (opts.open || openSession) as (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<ReviewReport>>
468
+ const session = await open({
469
+ repo: co.tree,
470
+ profile,
471
+ domain: domainNotes(opts.config),
472
+ model: profile === opts.config.llm ? opts.config.code.model : undefined,
473
+ bin: opts.config.code.opencode,
474
+ readDirs: [co.tree],
475
+ hide: privateEnvNames(opts.config),
476
+ role: "reviewer",
477
+ signal: opts.signal,
478
+ onEvent: emit,
479
+ agent: PR_REVIEW_AGENT,
480
+ skills: loadSkills(opts.config.skills, co.tree).skills,
481
+ schema: PR_REVIEW_SCHEMA as unknown as Record<string, unknown>,
482
+ title: `ForgeQE PR review ${ref.number}`,
483
+ })
484
+ try {
485
+ emit({ type: "code", kind: "info", text: `Scribe (${session.model}) is reviewing ${co.files.length} changed file${co.files.length === 1 ? "" : "s"} in "${pr.title}"` })
486
+ let reply = await session.prompt(reviewPrPrompt(pr, co.base, co.files, co.diff, opts.focus))
487
+ if (!reply.report && !reply.error && !opts.signal?.aborted) reply = await session.prompt("Give the structured result: verdict, summary, and findings.")
488
+ if (opts.signal?.aborted) throw new Error("Stopped.")
489
+ const r = reply.report
490
+ if (!r || !VERDICTS.includes(r.verdict as Verdict)) throw new Error(`Scribe gave no review${reply.error ? `: ${reply.error}` : "."}`)
491
+ const findings = cleanFindings(r.findings)
492
+ const lines = diffLines(co.diff)
493
+ const dir = runDirectory(path.join(opts.config.export.outDir, "reviews"))
494
+ const review: PrReview = {
495
+ id: path.basename(dir),
496
+ dir,
497
+ repo: root,
498
+ pr,
499
+ base: co.base,
500
+ files: co.files,
501
+ verdict: r.verdict as Verdict,
502
+ summary: String(r.summary || "").trim(),
503
+ findings,
504
+ inline: findings.map((f) => Boolean(f.file && f.line && lines.get(f.file)?.has(f.line))),
505
+ model: session.model,
506
+ usage: { ...session.usage },
507
+ price: named ? named.price : opts.config.code.model ? undefined : opts.config.llm.price,
508
+ createdAt: new Date().toISOString(),
509
+ }
510
+ saveReview(review)
511
+ return review
512
+ } finally {
513
+ await session.close()
514
+ }
515
+ } finally {
516
+ await co.cleanup()
517
+ }
518
+ }
519
+
520
+ // ---- Saving, showing, posting ----
521
+
522
+ const VERDICT_TEXT: Record<Verdict, string> = { approve: "Approve", approve_with_suggestions: "Approve with suggestions", request_changes: "Changes requested" }
523
+ const where = (f: PrFinding) => (f.file ? `${f.file}${f.line ? `:${f.line}${f.end_line ? `-${f.end_line}` : ""}` : ""}` : "")
524
+
525
+ export function findingMarkdown(f: PrFinding, withPlace = true): string {
526
+ const place = withPlace && where(f) ? ` · \`${where(f)}\`` : ""
527
+ return [`**${f.severity}: ${f.title}**${place}`, "", f.problem, ...(f.suggestion ? ["", `**Suggestion:** ${f.suggestion}`] : [])].join("\n")
528
+ }
529
+
530
+ /** The summary comment: verdict, summary, and every finding that cannot sit on a line of the diff. */
531
+ export function summaryMarkdown(review: PrReview, onlyOffDiff = true): string {
532
+ const rest = review.findings.filter((_, i) => !onlyOffDiff || !review.inline[i])
533
+ const counts = SEVERITIES.map((s) => [s, review.findings.filter((f) => f.severity === s).length] as const).filter(([, n]) => n)
534
+ return [
535
+ `### ForgeQE review: ${VERDICT_TEXT[review.verdict]}`,
536
+ "",
537
+ review.summary,
538
+ ...(counts.length ? ["", counts.map(([s, n]) => `${n} ${s}`).join(" · ")] : []),
539
+ ...(rest.length ? ["", onlyOffDiff ? "Findings not on a changed line:" : "Findings:", "", ...rest.map((f) => `- ${findingMarkdown(f).replace(/\n\n/g, "\n ").replace(/\n(?! )/g, "\n ")}`)] : []),
540
+ ].join("\n")
541
+ }
542
+
543
+ function saveReview(review: PrReview) {
544
+ writeFileSync(path.join(review.dir, "review.json"), JSON.stringify(review, null, 2))
545
+ const md = [
546
+ `# Pull request ${review.pr.ref.number}: ${review.pr.title}`,
547
+ "",
548
+ `${review.pr.url}`,
549
+ `${review.pr.sourceBranch} → ${review.pr.targetBranch}, head ${review.pr.headSha.slice(0, 10)}, reviewed ${review.createdAt} by ${review.model}`,
550
+ "",
551
+ summaryMarkdown(review, false),
552
+ ].join("\n")
553
+ writeFileSync(path.join(review.dir, "review.md"), `${md}\n`)
554
+ }
555
+
556
+ export function loadReview(config: AppConfig, id: string): PrReview {
557
+ const file = path.join(config.export.outDir, "reviews", path.basename(id), "review.json")
558
+ if (!existsSync(file)) throw new Error(`No pull request review "${id}": call review_pull_request first.`)
559
+ return JSON.parse(readFileSync(file, "utf8")) as PrReview
560
+ }
561
+
562
+ const rel = (cwd: string, p: string) => {
563
+ const r = path.relative(cwd, p)
564
+ return r && !r.startsWith("..") && !path.isAbsolute(r) ? r : p
565
+ }
566
+
567
+ const MARK: Record<Severity, string> = { blocker: "●", major: "●", minor: "○", nit: "·" }
568
+
569
+ export function reviewCard(review: PrReview, cwd: string): Card {
570
+ const body: string[] = [review.summary, ""]
571
+ for (const f of review.findings.slice(0, 30)) {
572
+ body.push(`${MARK[f.severity]} ${f.severity.padEnd(7)} ${f.title}${where(f) ? ` ${where(f)}` : ""}`)
573
+ body.push(` ${f.problem}`)
574
+ if (f.suggestion) body.push(` → ${f.suggestion}`)
575
+ }
576
+ if (review.findings.length > 30) body.push(`and ${review.findings.length - 30} more (review.md has all of them)`)
577
+ if (!review.findings.length) body.push("Nothing to fix.")
578
+ body.push("", `PR ${review.pr.url}`, `Saved ${rel(cwd, path.join(review.dir, "review.md"))}`)
579
+ const n = review.findings.length
580
+ return {
581
+ status: review.verdict === "request_changes" ? "fail" : review.verdict === "approve" ? "ok" : "info",
582
+ title: `PR ${review.pr.ref.number} reviewed: ${review.pr.title}`,
583
+ stats: [VERDICT_TEXT[review.verdict], `${n} finding${n === 1 ? "" : "s"}`, `${review.files.length} file${review.files.length === 1 ? "" : "s"}`].join(" · "),
584
+ body,
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Post a saved review to its pull request: one comment per finding on its line of the diff, and one summary comment
590
+ * with the verdict and the findings that have no line. On GitHub it is a review with event COMMENT; it never approves
591
+ * or requests changes in the user's name. Refuses when the pull request has new commits since the review.
592
+ */
593
+ export async function postReview(review: PrReview, config: AppConfig): Promise<string[]> {
594
+ if (review.posted) throw new Error(`This review was already posted on ${review.posted.at}.`)
595
+ const now = await fetchPr(review.pr.ref, config, review.repo)
596
+ if (now.headSha && now.headSha !== review.pr.headSha) {
597
+ throw new Error(`Pull request ${review.pr.ref.number} has new commits since the review (${review.pr.headSha.slice(0, 10)} → ${now.headSha.slice(0, 10)}), so the lines may have moved. Review it again before posting.`)
598
+ }
599
+ const inline = review.findings.filter((_, i) => review.inline[i])
600
+ const links: string[] = []
601
+ if (review.pr.ref.platform === "github") {
602
+ const body = {
603
+ commit_id: review.pr.headSha,
604
+ event: "COMMENT",
605
+ body: summaryMarkdown(review),
606
+ comments: inline.map((f) => ({
607
+ path: f.file,
608
+ side: "RIGHT",
609
+ line: f.end_line || f.line,
610
+ ...(f.end_line ? { start_line: f.line, start_side: "RIGHT" } : {}),
611
+ body: findingMarkdown(f, false),
612
+ })),
613
+ }
614
+ const out = await gh(["api", "-X", "POST", `repos/${review.pr.ref.repo}/pulls/${review.pr.ref.number}/reviews`, "--input", "-"], review.repo, JSON.stringify(body))
615
+ let url = review.pr.url
616
+ try {
617
+ url = String((JSON.parse(out) as { html_url?: string }).html_url || url)
618
+ } catch {}
619
+ links.push(url)
620
+ } else {
621
+ const { auth } = adoSettings(config.sources)
622
+ const threads = `${adoBase(review.pr.ref)}/pullRequests/${review.pr.ref.number}/threads?${API}`
623
+ const post = (content: string, context?: Record<string, unknown>) =>
624
+ http<{ id: number }>(threads, { auth, body: { comments: [{ parentCommentId: 0, content, commentType: 1 }], status: "active", ...(context ? { threadContext: context } : {}) } })
625
+ await post(summaryMarkdown(review))
626
+ for (const f of inline) {
627
+ await post(findingMarkdown(f, false), {
628
+ filePath: `/${f.file}`,
629
+ rightFileStart: { line: f.line, offset: 1 },
630
+ rightFileEnd: { line: f.end_line || f.line, offset: 1 },
631
+ })
632
+ }
633
+ links.push(review.pr.url)
634
+ }
635
+ review.posted = { at: new Date().toISOString(), links }
636
+ saveReview(review)
637
+ return links
638
+ }
639
+
640
+ /** Post one markdown comment on a pull request (an Azure DevOps thread, or a GitHub PR comment). Returns its link. */
641
+ export async function postPrComment(ref: PrRef, config: AppConfig, repo: string, markdown: string): Promise<string> {
642
+ if (ref.platform === "github") {
643
+ const out = await gh(["pr", "comment", String(ref.number), "--repo", ref.repo!, "--body-file", "-"], repo, markdown)
644
+ return out.trim().split("\n").find((l) => l.startsWith("http")) || `https://github.com/${ref.repo}/pull/${ref.number}`
645
+ }
646
+ const { auth } = adoSettings(config.sources)
647
+ await http(`${adoBase(ref)}/pullRequests/${ref.number}/threads?${API}`, { auth, body: { comments: [{ parentCommentId: 0, content: markdown, commentType: 1 }], status: "closed" } })
648
+ return `${ref.org!.replace(/\/+$/, "")}/${encodeURIComponent(ref.project!)}/_git/${encodeURIComponent(ref.repo!)}/pullrequest/${ref.number}`
649
+ }