@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,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
|
+
}
|